diff --git a/src/chat/chat_outgoing.cpp b/src/chat/chat_outgoing.cpp index 3350d0c..e535642 100644 --- a/src/chat/chat_outgoing.cpp +++ b/src/chat/chat_outgoing.cpp @@ -17,16 +17,18 @@ std::string buildHeaderMemo(const std::string& replyZaddr, const char* type, const std::string& streamHeaderHex, const std::string& publicKeyHex, - std::int64_t sentAt) + std::int64_t sentAt, + const std::string& op = "") { nlohmann::json header; header["h"] = 1; // header number (>= 1) header["v"] = kHushChatSupportedVersion; // 0 header["z"] = replyZaddr; // where the peer should reply (my address) header["cid"] = conversationId; - header["t"] = type; // "Memo" or "Cont" - header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont) + header["t"] = type; // "Memo" / "Cont" / "Ctrl" + header["e"] = streamHeaderHex; // 48-hex secretstream header (Memo) / "" (Cont, Ctrl) header["p"] = publicKeyHex; // my 64-hex crypto_kx public key + if (!op.empty()) header["op"] = op; // Control only; alphabetically sorts to cid,e,h,op,p,t,v,z if (sentAt > 0) header["ts"] = sentAt; // optional sender compose time (Unix s) — receiver shows this return header.dump(); } @@ -109,4 +111,33 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex, return ChatComposeStatus::Ok; } +ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex, + const std::string& selfZaddr, + const std::string& conversationId, + const std::string& op, + const std::string& label, + OutgoingChatMemos& out) +{ + // A control signal is addressed to SELF: only this wallet (any device holding the seed) harvests + // it, and it never reaches the peer. op carries the action; the label is a plaintext hint (peer + // address / display name) so a fresh import can show the conversation it refers to. + if (label.empty()) return ChatComposeStatus::EmptyBody; + if (label.front() == '{') return ChatComposeStatus::BadRequestText; // would parse as a header + if (!present(op) || !present(myPublicKeyHex) || !present(selfZaddr) || !present(conversationId)) { + return ChatComposeStatus::MissingField; + } + + OutgoingChatMemos memos; + memos.recipientZaddr = selfZaddr; + memos.headerMemo = buildHeaderMemo(selfZaddr, conversationId, "Ctrl", "", myPublicKeyHex, + static_cast(std::time(nullptr)), op); + memos.payloadMemo = label; + if (memos.headerMemo.size() > kHushChatMemoByteLimit || + memos.payloadMemo.size() > kHushChatMemoByteLimit) { + return ChatComposeStatus::TooLong; + } + out = std::move(memos); + return ChatComposeStatus::Ok; +} + } // namespace dragonx::chat diff --git a/src/chat/chat_outgoing.h b/src/chat/chat_outgoing.h index 42644ae..3af039c 100644 --- a/src/chat/chat_outgoing.h +++ b/src/chat/chat_outgoing.h @@ -63,4 +63,15 @@ ChatComposeStatus buildOutgoingContactRequest(const std::string& myPublicKeyHex, const std::string& requestText, OutgoingChatMemos& out); +// Build a self-addressed control signal (delete / restore / mark-sensitive). It is sent to your OWN +// z-address, so only this wallet's seed can harvest it and it never reaches the peer. `op` is the +// action ("del"/"res"/...); `label` is a plaintext hint (peer address or name) carried as the payload +// so a fresh seed import can name the conversation the signal refers to. +ChatComposeStatus buildOutgoingControl(const std::string& myPublicKeyHex, + const std::string& selfZaddr, + const std::string& conversationId, + const std::string& op, + const std::string& label, + OutgoingChatMemos& out); + } // namespace dragonx::chat diff --git a/tests/test_phase4.cpp b/tests/test_phase4.cpp index 7a50302..2719c90 100644 --- a/tests/test_phase4.cpp +++ b/tests/test_phase4.cpp @@ -15049,6 +15049,30 @@ void testHushChatControlMemo() tx3.outputs.push_back(HushChatMemoOutput{0, unknown}); tx3.outputs.push_back(HushChatMemoOutput{1, "x"}); EXPECT_EQ((int)extractHushChatTransactionMetadata(tx3, true).metadata.size(), 0); + + // Compose round-trip: buildOutgoingControl -> extract yields the same op/cid/label, self-addressed. + OutgoingChatMemos cm; + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "conv-42", "del", "Bob", cm) + == ChatComposeStatus::Ok); + EXPECT_EQ(cm.recipientZaddr, std::string("zs-self")); + HushChatTransactionInput rt; rt.txid = "txid-ctrl-rt"; + rt.outputs.push_back({0, cm.headerMemo}); + rt.outputs.push_back({1, cm.payloadMemo}); + auto rx = extractHushChatTransactionMetadata(rt, true); + EXPECT_EQ((int)rx.metadata.size(), 1); + EXPECT_TRUE(rx.metadata[0].type == HushChatHeaderType::Control); + EXPECT_EQ(rx.metadata[0].op, std::string("del")); + EXPECT_EQ(rx.metadata[0].conversation_id, std::string("conv-42")); + EXPECT_EQ(rx.metadata[0].payload_memo, std::string("Bob")); + + // Compose guards: empty op -> MissingField, empty label -> EmptyBody, '{' label -> BadRequestText. + OutgoingChatMemos dummy; + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "c", "", "Bob", dummy) + == ChatComposeStatus::MissingField); + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "c", "del", "", dummy) + == ChatComposeStatus::EmptyBody); + EXPECT_TRUE(buildOutgoingControl(r.public_key_hex, "zs-self", "c", "del", "{x", dummy) + == ChatComposeStatus::BadRequestText); } // ChatService: metadata batch -> decrypt -> threaded, deduped in-memory store.