feat(chat): composer + contact requests — outgoing construction (Phase 4)
Add the outgoing side of HushChat: compose messages and start conversations.
The wire construction is the byte-exact inverse of the receive parser and is
proven by a self-consistent round-trip (build → parse → decrypt).
- src/chat/chat_outgoing.{h,cpp} (pure): buildOutgoingMessage encrypts via
encryptOutgoing and buildOutgoingContactRequest carries plaintext; both emit
the header memo JSON ({h,v,z,cid,t,e,p}, which nlohmann serializes
alphabetically to match SilentDragonXLite) + the payload memo, validating the
512-byte memo limit, the 64-hex peer key, and the "no leading '{'" rule for
request text. My public key goes in header "p"; the peer's key is the
encryption recipient.
- ChatService: composeMessage/composeContactRequest (encrypt with the held
identity) + recordOutgoing (echo an Outgoing ChatMessage into the store + DB —
we never harvest our own sends, so this is the only local record).
- App: sendChatMessage(cid,text) sources the peer z-addr + public key from the
conversation, composes, and records a random-id echo; startChatConversation
(zaddr,text) mints a random cid + composes a plaintext contact request;
chatReplyZaddr() picks a spendable z-addr. broadcastChatMemos() is the Phase-5
transport seam (network delivery + real-SDXL interop verification land there).
- Chat tab: a message composer (shown once the peer's key is known, else a
"waiting for reply" hint) + a "New conversation" modal that sends a contact
request to a z-address.
- Tests: outgoing round-trip through the receive parser + decrypt, contact-request
passthrough, validation guards, and ChatService compose + recordOutgoing echo.
Adversarially reviewed (crypto/interop, secret hygiene, logic, UI) — 0 findings.
Gated by DRAGONX_ENABLE_CHAT (default OFF). Verified: Linux + Windows(mingw)
build with chat ON, ctest 100%, hygiene clean; caches restored to the OFF default.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -2468,6 +2468,113 @@ void App::maybeProvisionChatIdentity()
|
||||
}
|
||||
}
|
||||
|
||||
// A stable wallet z-address for outgoing chat headers (the "z" reply field + the send-from address).
|
||||
// Prefers a spendable one so replies land somewhere we control and fees can be paid.
|
||||
std::string App::chatReplyZaddr() const
|
||||
{
|
||||
for (const auto& addr : state_.z_addresses)
|
||||
if (addr.has_spending_key && !addr.address.empty()) return addr.address;
|
||||
if (!state_.z_addresses.empty()) return state_.z_addresses.front().address;
|
||||
return {};
|
||||
}
|
||||
|
||||
// A unique opaque id (hex), used for the local echo id (we never harvest our own sends) and for
|
||||
// new conversation ids. Random — collisions are astronomically unlikely.
|
||||
std::string App::generateChatLocalId(const char* prefix, int numBytes) const
|
||||
{
|
||||
if (numBytes < 1) numBytes = 8;
|
||||
std::vector<unsigned char> buf(static_cast<std::size_t>(numBytes));
|
||||
randombytes_buf(buf.data(), buf.size());
|
||||
static const char* kHex = "0123456789abcdef";
|
||||
std::string id = prefix ? prefix : "";
|
||||
for (unsigned char b : buf) { id.push_back(kHex[b >> 4]); id.push_back(kHex[b & 0x0F]); }
|
||||
return id;
|
||||
}
|
||||
|
||||
// Phase-5 seam: broadcast the header + payload as two 0-value memo outputs to memos.recipientZaddr
|
||||
// (full-node z_sendmany via submitZSendMany with a two-recipient array; lite via the controller).
|
||||
// Not wired yet — the composed message is recorded locally so the UI reflects it; over-the-wire
|
||||
// delivery + real-SDXL interop verification land in the next phase.
|
||||
void App::broadcastChatMemos(const chat::OutgoingChatMemos& memos)
|
||||
{
|
||||
(void)memos;
|
||||
}
|
||||
|
||||
void App::sendChatMessage(const std::string& conversationId, const std::string& text)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (text.empty() || conversationId.empty()) return;
|
||||
|
||||
// The peer's z-address + public key come from a message we already have in this conversation.
|
||||
std::string peerZaddr;
|
||||
std::string peerPubKey;
|
||||
for (const auto& m : chat_service_.store().conversation(conversationId)) {
|
||||
if (!m.peer_zaddr.empty()) peerZaddr = m.peer_zaddr;
|
||||
if (!m.peer_public_key_hex.empty()) peerPubKey = m.peer_public_key_hex;
|
||||
}
|
||||
if (peerPubKey.empty()) {
|
||||
ui::Notifications::instance().info("Waiting for the contact to reply before you can message them.");
|
||||
return;
|
||||
}
|
||||
const std::string myReply = chatReplyZaddr();
|
||||
if (myReply.empty()) {
|
||||
ui::Notifications::instance().error("No z-address available to send chat from.");
|
||||
return;
|
||||
}
|
||||
|
||||
chat::OutgoingChatMemos memos;
|
||||
if (chat_service_.composeMessage(myReply, peerPubKey, peerZaddr, conversationId, text, memos)
|
||||
!= chat::ChatComposeStatus::Ok) {
|
||||
ui::Notifications::instance().error("Could not compose the message (too long?).");
|
||||
return;
|
||||
}
|
||||
broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::Message;
|
||||
echo.conversation_id = conversationId;
|
||||
echo.peer_zaddr = peerZaddr;
|
||||
echo.peer_public_key_hex = peerPubKey;
|
||||
echo.body = text;
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
}
|
||||
|
||||
void App::startChatConversation(const std::string& peerZaddr, const std::string& text)
|
||||
{
|
||||
if (!chat::hushChatFeatureEnabledAtBuild() || !chat_service_.hasIdentity()) return;
|
||||
if (peerZaddr.empty() || text.empty()) return;
|
||||
const std::string myReply = chatReplyZaddr();
|
||||
if (myReply.empty()) {
|
||||
ui::Notifications::instance().error("No z-address available to send chat from.");
|
||||
return;
|
||||
}
|
||||
|
||||
const std::string cid = generateChatLocalId("", 16); // opaque per-conversation id
|
||||
chat::OutgoingChatMemos memos;
|
||||
if (chat_service_.composeContactRequest(myReply, peerZaddr, cid, text, memos)
|
||||
!= chat::ChatComposeStatus::Ok) {
|
||||
ui::Notifications::instance().error("Could not compose the contact request (invalid address / text?).");
|
||||
return;
|
||||
}
|
||||
broadcastChatMemos(memos);
|
||||
|
||||
chat::ChatMessage echo;
|
||||
echo.direction = chat::ChatDirection::Outgoing;
|
||||
echo.kind = chat::ChatMessageKind::ContactRequest;
|
||||
echo.conversation_id = cid;
|
||||
echo.peer_zaddr = peerZaddr;
|
||||
echo.body = text;
|
||||
echo.timestamp = std::time(nullptr);
|
||||
echo.txid = generateChatLocalId("out:", 8);
|
||||
echo.payload_position = 0;
|
||||
chat_service_.recordOutgoing(echo);
|
||||
ui::Notifications::instance().success("Contact request queued.");
|
||||
}
|
||||
|
||||
void App::exportAllKeys(std::function<void(const std::string&, int, int)> callback)
|
||||
{
|
||||
if (!state_.connected || !rpc_) {
|
||||
|
||||
Reference in New Issue
Block a user