feat: EXT-007 P2P message history gossip

After hello verification, the connecting peer sends history_request to
the first peer it meets (one per room, no fan-out). The responder queries
SQLite and replies with a history_chunk. Received history is stored via
INSERT OR IGNORE (mid dedup) and emitted as history_loaded IPC events.

- proto: MsgHistoryRequest/Chunk types, HistoryEntry, EvtHistoryLoaded,
  ComputeMsgID (sha256 content-addressed ID), MsgID field on ChatMessage
- store: ALTER TABLE ADD COLUMN msg_id + unique index migration (idempotent);
  RecentMessagesSince query (msg_id IS NOT NULL filter); msg_id persisted on save
- mesh: RequestHistoryFrom, HandleHistoryRequest, HandleHistoryChunk methods;
  historyRequested/historyFirstPeer state to ensure single-peer requests
- peer: dispatch history_request/history_chunk; RequestHistoryFrom after hello;
  stamp MsgID on incoming chat messages
- ipc: stamp MsgID on outgoing group chat messages
- EXTENSIONS.md: EXT-007 documented

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Fredrik Johansson
2026-06-28 23:08:17 +02:00
parent 1c73f1b1ef
commit 7c3cedc549
6 changed files with 322 additions and 35 deletions

View File

@@ -3,7 +3,11 @@
// Binary data (keys, signatures) is hex-encoded; signaling boxes are base64.
package proto
import "time"
import (
"crypto/sha256"
"fmt"
"time"
)
// ── Identity ──────────────────────────────────────────────────────────────────
@@ -43,8 +47,10 @@ const (
MsgFileAccept MsgType = "file-accept"
MsgFileCancel MsgType = "file-cancel"
MsgFileDone MsgType = "file-done"
MsgPing MsgType = "ping"
MsgPong MsgType = "pong"
MsgPing MsgType = "ping"
MsgPong MsgType = "pong"
MsgHistoryRequest MsgType = "history_request"
MsgHistoryChunk MsgType = "history_chunk"
)
// PmMessage is a private message sent directly over a single peer link (§8 "pm").
@@ -83,17 +89,43 @@ type PeerMessage struct {
Reason string `json:"reason,omitempty"` // file-cancel
Seq *uint64 `json:"seq,omitempty"` // ping/pong
// history_request fields
Since int64 `json:"since,omitempty"` // Unix ms; 0 = no lower bound
Limit int `json:"limit,omitempty"`
// history_chunk fields
History []HistoryEntry `json:"history,omitempty"`
HistoryDone bool `json:"history_done,omitempty"`
}
// HistoryEntry is one message in a history_chunk response.
type HistoryEntry struct {
Mid string `json:"mid"`
From string `json:"from"` // peer ID hex
FromAlias string `json:"from_alias"` // advisory
Text string `json:"text"`
Ts int64 `json:"ts"` // Unix ms
}
// ChatMessage is a group chat message (wire type "chat", §8).
// Also used internally for persisting PMs after they are received.
type ChatMessage struct {
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
Room string `json:"room"`
Text string `json:"text"`
Ts int64 `json:"ts"` // Unix milliseconds
Mid string `json:"mid,omitempty"` // optional dedup id (required when relay hops > 0)
MsgID string `json:"msg_id,omitempty"` // EXT-007: content-addressed gossip ID
From PeerID `json:"from,omitempty"` // set by receiver from DC context; not on wire for pm
To *PeerID `json:"to,omitempty"` // internal only — not transmitted; set for DMs
Room string `json:"room"`
Text string `json:"text"`
Ts int64 `json:"ts"` // Unix milliseconds
}
// ComputeMsgID returns the EXT-007 content-addressed ID for a message.
// sha256(fromID \x00 room \x00 ts_decimal \x00 text)
func ComputeMsgID(fromID PeerID, room string, ts int64, text string) string {
h := sha256.New()
fmt.Fprintf(h, "%s\x00%s\x00%d\x00%s", string(fromID), room, ts, text)
return fmt.Sprintf("sha256:%x", h.Sum(nil))
}
// PeerGossip shares known peer addresses.
@@ -259,6 +291,7 @@ const (
EvtIdentityImported IpcMsgType = "identity_imported"
EvtSharesList IpcMsgType = "shares_list"
EvtRoomCreated IpcMsgType = "room_created" // field: room (name)
EvtHistoryLoaded IpcMsgType = "history_loaded" // fields: room, messages
)
// NetworkInfo summarises one joined network for state_snapshot and network_joined events.
@@ -312,7 +345,8 @@ type IpcMessage struct {
Networks []NetworkInfo `json:"networks,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
InviteGenerated string `json:"invite,omitempty"`
Files []FileEntry `json:"files,omitempty"`
Files []FileEntry `json:"files,omitempty"`
Messages []ChatMessage `json:"messages,omitempty"` // history_loaded
Shares []ShareEntry `json:"shares,omitempty"`
ShareNetworks []string `json:"network_ids,omitempty"` // for add_share command: scope to specific network IDs, or ["*"] for global
// export_identity / import_identity