feat(chat): message model, in-memory store, and receive service
Phase 1 Steps 4-6 (the receive pipeline, minus the App/sync wiring which lands next). - chat_message: the in-memory ChatMessage model (direction, kind, txid, cid, peer zaddr/pubkey, body, timestamp, payload_position). No libsodium. - chat_store: threads messages by conversation_id and dedups by (txid, payload_position) so re-scanning the chain never double-inserts. - chat_service: owns the long-lived chat identity keypair (move-disabled, wiped on destruction/clear) and the store. ingest() decrypts each Message (drops undecryptable ones silently — no plaintext/memo logging), passes a ContactRequest's plaintext through, and threads the result. No-op without an identity. Tests: a metadata batch decrypts into a threaded conversation; re-ingest dedups; a contact request carries through; a wrong identity decrypts nothing; no-identity ingest is a no-op. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
26
src/chat/chat_message.h
Normal file
26
src/chat/chat_message.h
Normal file
@@ -0,0 +1,26 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat in-memory message model (no libsodium, no persistence).
|
||||
// Phase 1 keeps decrypted messages in memory only; sqlite persistence is Phase 2.
|
||||
|
||||
#include <cstdint>
|
||||
#include <string>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
enum class ChatDirection { Incoming, Outgoing };
|
||||
enum class ChatMessageKind { Message, ContactRequest };
|
||||
|
||||
struct ChatMessage {
|
||||
ChatDirection direction = ChatDirection::Incoming;
|
||||
ChatMessageKind kind = ChatMessageKind::Message;
|
||||
std::string txid;
|
||||
std::string conversation_id; // cid — the conversation thread key
|
||||
std::string peer_zaddr; // header "z": peer's reply z-address
|
||||
std::string peer_public_key_hex; // header "p": peer's crypto_kx public key
|
||||
std::string body; // decrypted plaintext (Message) or request text (ContactRequest)
|
||||
std::int64_t timestamp = 0; // tx time in seconds; set by the ingesting caller
|
||||
std::size_t payload_position = 0; // together with txid, the dedup key
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
56
src/chat/chat_service.cpp
Normal file
56
src/chat/chat_service.cpp
Normal file
@@ -0,0 +1,56 @@
|
||||
// DragonX Wallet - HushChat service (implementation).
|
||||
|
||||
#include "chat_service.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
ChatService::~ChatService() {
|
||||
clearIdentity();
|
||||
}
|
||||
|
||||
void ChatService::setIdentity(const ChatKeyPair& keys) {
|
||||
identity_ = keys;
|
||||
has_identity_ = true;
|
||||
}
|
||||
|
||||
void ChatService::clearIdentity() {
|
||||
wipeChatKeyPair(identity_);
|
||||
has_identity_ = false;
|
||||
}
|
||||
|
||||
int ChatService::ingest(const std::vector<HushChatTransactionMetadata>& metadata,
|
||||
std::int64_t txTimestamp) {
|
||||
if (!has_identity_) return 0;
|
||||
|
||||
int added = 0;
|
||||
for (const auto& meta : metadata) {
|
||||
ChatMessage message;
|
||||
message.direction = ChatDirection::Incoming;
|
||||
message.txid = meta.txid;
|
||||
message.conversation_id = meta.conversation_id;
|
||||
message.peer_zaddr = meta.reply_zaddr;
|
||||
message.peer_public_key_hex = meta.sender_public_key_hex;
|
||||
message.timestamp = txTimestamp;
|
||||
message.payload_position = meta.payload_position;
|
||||
|
||||
if (meta.type == HushChatHeaderType::ContactRequest) {
|
||||
message.kind = ChatMessageKind::ContactRequest;
|
||||
message.body = meta.payload_memo; // plaintext request text
|
||||
} else {
|
||||
message.kind = ChatMessageKind::Message;
|
||||
std::string plaintext;
|
||||
const ChatCryptoStatus status = decryptIncoming(
|
||||
identity_, meta.sender_public_key_hex, meta.secretstream_header_hex,
|
||||
meta.payload_memo, plaintext);
|
||||
if (status != ChatCryptoStatus::Ok) continue; // drop undecryptable silently
|
||||
message.body = std::move(plaintext);
|
||||
}
|
||||
|
||||
if (store_.append(message)) ++added;
|
||||
}
|
||||
return added;
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
47
src/chat/chat_service.h
Normal file
47
src/chat/chat_service.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat service: turns harvested memo metadata into decrypted, threaded
|
||||
// messages. Owns the long-lived chat identity keypair (a secret) + the in-memory store.
|
||||
// Move-disabled (the secret stays pinned); wipes the secret on destruction/clear.
|
||||
// Not thread-safe — drive from the main thread (where refresh results are applied).
|
||||
|
||||
#include "chat_crypto.h" // ChatKeyPair
|
||||
#include "chat_protocol.h" // HushChatTransactionMetadata
|
||||
#include "chat_store.h"
|
||||
|
||||
#include <cstdint>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
class ChatService {
|
||||
public:
|
||||
ChatService() = default;
|
||||
~ChatService();
|
||||
ChatService(const ChatService&) = delete;
|
||||
ChatService& operator=(const ChatService&) = delete;
|
||||
ChatService(ChatService&&) = delete;
|
||||
ChatService& operator=(ChatService&&) = delete;
|
||||
|
||||
// Provision (or replace) the chat identity. Copies the keypair — the caller should wipe
|
||||
// its own copy afterwards (see chat_identity.h ownership note).
|
||||
void setIdentity(const ChatKeyPair& keys);
|
||||
bool hasIdentity() const { return has_identity_; }
|
||||
void clearIdentity(); // wipes the held secret key
|
||||
|
||||
// Decrypt/record each metadata entry (a Message is decrypted; a ContactRequest carries its
|
||||
// plaintext through) and thread it into the store. `txTimestamp` is stamped onto every
|
||||
// message in this batch. Returns the number of NEW messages added. Returns 0 with no
|
||||
// identity. Undecryptable Messages are dropped silently (no logging of memo/plaintext).
|
||||
int ingest(const std::vector<HushChatTransactionMetadata>& metadata, std::int64_t txTimestamp = 0);
|
||||
|
||||
const ChatStore& store() const { return store_; }
|
||||
ChatStore& store() { return store_; }
|
||||
|
||||
private:
|
||||
ChatKeyPair identity_{};
|
||||
bool has_identity_ = false;
|
||||
ChatStore store_;
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
39
src/chat/chat_store.cpp
Normal file
39
src/chat/chat_store.cpp
Normal file
@@ -0,0 +1,39 @@
|
||||
// DragonX Wallet - HushChat in-memory message store (implementation).
|
||||
|
||||
#include "chat_store.h"
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
std::string ChatStore::dedupKey(const ChatMessage& message) {
|
||||
return message.txid + ":" + std::to_string(message.payload_position);
|
||||
}
|
||||
|
||||
bool ChatStore::append(const ChatMessage& message) {
|
||||
if (!seen_.insert(dedupKey(message)).second) return false;
|
||||
messages_.push_back(message);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::vector<ChatMessage> ChatStore::conversation(const std::string& conversationId) const {
|
||||
std::vector<ChatMessage> out;
|
||||
for (const auto& message : messages_) {
|
||||
if (message.conversation_id == conversationId) out.push_back(message);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::vector<std::string> ChatStore::conversationIds() const {
|
||||
std::vector<std::string> ids;
|
||||
std::unordered_set<std::string> seenIds;
|
||||
for (const auto& message : messages_) {
|
||||
if (seenIds.insert(message.conversation_id).second) ids.push_back(message.conversation_id);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
void ChatStore::clear() {
|
||||
messages_.clear();
|
||||
seen_.clear();
|
||||
}
|
||||
|
||||
} // namespace dragonx::chat
|
||||
37
src/chat/chat_store.h
Normal file
37
src/chat/chat_store.h
Normal file
@@ -0,0 +1,37 @@
|
||||
#pragma once
|
||||
|
||||
// DragonX Wallet - HushChat in-memory message store (Phase 1; sqlite persistence is Phase 2).
|
||||
|
||||
#include "chat_message.h"
|
||||
|
||||
#include <string>
|
||||
#include <unordered_set>
|
||||
#include <vector>
|
||||
|
||||
namespace dragonx::chat {
|
||||
|
||||
// Threads messages by conversation_id (cid) and deduplicates by (txid, payload_position) so
|
||||
// re-scanning the chain never double-inserts. Not thread-safe — drive from the main thread.
|
||||
class ChatStore {
|
||||
public:
|
||||
// Returns true if newly inserted, false if a duplicate was ignored.
|
||||
bool append(const ChatMessage& message);
|
||||
|
||||
// Messages in a conversation, in insertion order.
|
||||
std::vector<ChatMessage> conversation(const std::string& conversationId) const;
|
||||
|
||||
// Distinct conversation ids, in first-seen order.
|
||||
std::vector<std::string> conversationIds() const;
|
||||
|
||||
std::size_t size() const { return messages_.size(); }
|
||||
bool empty() const { return messages_.empty(); }
|
||||
void clear();
|
||||
|
||||
private:
|
||||
static std::string dedupKey(const ChatMessage& message);
|
||||
|
||||
std::vector<ChatMessage> messages_;
|
||||
std::unordered_set<std::string> seen_;
|
||||
};
|
||||
|
||||
} // namespace dragonx::chat
|
||||
Reference in New Issue
Block a user