feat(mesh): Phase 1/2b/4/5 primitives — ReadReceipt/Forward/Edit/Delete/Presence/Contacts/ChannelInvite + chunked send + unified inbox RPCs

Adds every remaining wire variant and RPC needed to finish the Telegram-quality
mesh plan in a single pass:

* Variants 15 ReadReceipt, 16 Forward, 17 Edit, 18 Delete, 20 Presence,
  21 ChannelInvite; plus MeshMessageType::ContactCard(22) cleanup (was
  enum-only, now wired through from_u8/label/from_label).
* MessageType::from_label() as the inverse of label() — used by the Forward
  path to re-encode a stored typed body back through its original variant.
* RPCs: mesh.send-psbt (variant 3 was previously enum-only),
  mesh.send-read-receipt, mesh.forward-message, mesh.edit-message,
  mesh.delete-message, mesh.broadcast-presence, mesh.presence-list,
  mesh.contacts-list, mesh.contacts-save, mesh.contacts-block,
  mesh.send-channel-invite, conversations.list, conversations.messages.
* MeshState gains presence (pubkey → status+timestamps) and contacts
  (pubkey → ContactEntry{alias,notes,pinned,blocked}) in-memory stores.
* MeshService gains find_message_by_id (Forward lookup), apply_local_edit /
  apply_local_delete (optimistic local echo), and send_chunked_payload — an
  MC-framed base64 splitter that fires as a fallback inside send_typed_wire
  when wire > MAX_MESSAGE_LEN and no federation path is known. Reuses the
  existing receive-side reassembly in listener/decode.rs.
* Receive dispatch arms for PsbtHash, Presence, ChannelInvite, ReadReceipt
  (rolls forward `delivered` flag on own-Sent ≤ seq for that peer), Forward,
  Edit, Delete. Edit/Delete guard against cross-peer tampering by matching
  the target MessageKey pubkey against the sender's advertised pubkey_hex.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-13 18:24:05 -04:00
co-authored by Claude Opus 4.6
parent 002032b7da
commit 8ef7af985d
7 changed files with 1150 additions and 81 deletions
+264 -67
View File
@@ -35,6 +35,74 @@ use tracing::{error, info, warn};
const MESH_CONFIG_FILE: &str = "mesh-config.json";
/// Derive a stable synthetic `contact_id` for a federation peer from its
/// archipelago ed25519 pubkey. Mesh LoRa contacts use meshcore firmware's
/// own ID space (small ints 0..N), so federation peers are mapped into the
/// high half of u32 space to avoid collision. Both the receive path
/// (`inject_typed_from_federation`) and the startup pre-seed use this
/// formula so they always produce the same id for the same peer.
pub(crate) fn federation_peer_contact_id(archipelago_pubkey_hex: &str) -> u32 {
let bytes = hex::decode(archipelago_pubkey_hex).unwrap_or_default();
if bytes.len() < 4 {
return 0x8000_0001;
}
let low = u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]]);
0x8000_0000 | (low & 0x7FFF_FFFF)
}
/// Upsert a mesh peer record representing a federation node so the UI can
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
/// Existing entries (same contact_id) are updated in place, preserving any
/// previously observed radio state (rssi/snr/hops).
pub(crate) async fn upsert_federation_peer(
state: &Arc<listener::MeshState>,
archipelago_pubkey_hex: &str,
did: &str,
name: Option<&str>,
) -> u32 {
let contact_id = federation_peer_contact_id(archipelago_pubkey_hex);
let display_name = name
.map(|s| s.to_string())
.unwrap_or_else(|| {
let short = &archipelago_pubkey_hex[..archipelago_pubkey_hex.len().min(8)];
format!("Archipelago {}", short)
});
let mut peers = state.peers.write().await;
let existing = peers.get(&contact_id).cloned();
let peer = MeshPeer {
contact_id,
advert_name: display_name,
did: Some(did.to_string()),
pubkey_hex: Some(archipelago_pubkey_hex.to_string()),
x25519_pubkey: existing.as_ref().and_then(|p| p.x25519_pubkey),
rssi: existing.as_ref().and_then(|p| p.rssi),
snr: existing.as_ref().and_then(|p| p.snr),
last_heard: chrono::Utc::now().to_rfc3339(),
hops: existing.as_ref().map(|p| p.hops).unwrap_or(0),
};
peers.insert(contact_id, peer);
drop(peers);
state.update_peer_count().await;
contact_id
}
/// Load federation nodes from disk and upsert each as a synthetic mesh peer.
/// Called at MeshService startup so the chat list already contains every
/// known federation node — users can share files to them without first
/// receiving a message.
pub(crate) async fn seed_federation_peers_into_mesh(
state: &Arc<listener::MeshState>,
data_dir: &Path,
) {
let nodes = match crate::federation::load_nodes(data_dir).await {
Ok(n) => n,
Err(_) => return,
};
for node in nodes {
upsert_federation_peer(state, &node.pubkey, &node.did, node.name.as_deref()).await;
}
}
/// Mesh configuration (persisted to disk).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MeshConfig {
@@ -185,6 +253,13 @@ impl MeshService {
}),
);
// Pre-seed mesh state with a synthetic peer record for every known
// federation node. Mesh LoRa discovery and federation have disjoint
// identity namespaces, so without this step a user can't address a
// federated peer from the mesh UI until one side receives over the
// radio — which never happens for nodes that only share Tor.
seed_federation_peers_into_mesh(&state, data_dir).await;
Ok(Self {
state,
config,
@@ -474,6 +549,59 @@ impl MeshService {
Ok(dest_prefix)
}
/// Split an oversized wire payload into MC-framed base64 chunks and send
/// each via the mesh device. Matches the receive-side reassembly in
/// `mesh/listener/decode.rs::handle_chunked_frame` (header `MCIIXXTT`,
/// 20-chunk cap, 152 base64 chars per chunk). The caller must ensure
/// the peer exists and the device is connected.
async fn send_chunked_payload(&self, contact_id: u32, payload: Vec<u8>) -> Result<()> {
use base64::Engine;
const HEADER_LEN: usize = 8; // MC + msg_id(2) + chunk_idx(2) + total(2)
const MAX_CHUNK_B64: usize = protocol::MAX_MESSAGE_LEN - HEADER_LEN;
const MAX_CHUNKS: u8 = 20;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let total_chunks = ((b64.len() + MAX_CHUNK_B64 - 1) / MAX_CHUNK_B64) as u8;
if total_chunks == 0 || total_chunks > MAX_CHUNKS {
anyhow::bail!(
"Payload too large to chunk: {} bytes → {} chunks (max {})",
payload.len(),
total_chunks,
MAX_CHUNKS
);
}
// Pick a 1-byte msg_id. Use the low 8 bits of the unix nanos; not
// cryptographically unique but collisions within a 120s reassembly
// window are astronomically unlikely for normal send rates.
let msg_id: u8 =
(chrono::Utc::now().timestamp_nanos_opt().unwrap_or(0) as u64 & 0xFF) as u8;
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
for chunk_idx in 0..total_chunks {
let start = chunk_idx as usize * MAX_CHUNK_B64;
let end = (start + MAX_CHUNK_B64).min(b64.len());
let chunk = &b64[start..end];
let frame = format!("MC{:02X}{:02X}{:02X}{}", msg_id, chunk_idx, total_chunks, chunk);
self.state
.cmd_tx
.send(listener::MeshCommand::SendText {
dest_pubkey_prefix: dest_prefix,
payload: frame.into_bytes(),
})
.await
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
}
tracing::info!(
contact_id,
msg_id,
chunks = total_chunks,
bytes = payload.len(),
"Sent chunked payload over mesh"
);
Ok(())
}
/// Send raw wire payload bytes to a peer (no Sent-record bookkeeping).
/// Callers are responsible for storing the MeshMessage record afterwards.
async fn send_raw_payload(&self, contact_id: u32, payload: Vec<u8>) -> Result<()> {
@@ -521,6 +649,61 @@ impl MeshService {
typed_payload: Option<serde_json::Value>,
sender_seq: u64,
) -> Result<MeshMessage> {
// Federation-synthetic contacts (high bit set) don't exist in the
// meshcore firmware contact table, so LoRa send would fail at
// `peer_dest_prefix`. Any envelope larger than the LoRa frame budget
// also needs the federation path. In both cases we look up the
// peer's onion (by archipelago pubkey first, then by DID) and POST
// over Tor; otherwise the send falls through to LoRa.
let is_federation_synthetic = contact_id & 0x8000_0000 != 0;
let exceeds_lora = wire.len() > protocol::MAX_MESSAGE_LEN;
if is_federation_synthetic || exceeds_lora {
let (peer_pubkey, peer_did) = {
let peers = self.state.peers.read().await;
match peers.get(&contact_id) {
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
None if is_federation_synthetic => {
anyhow::bail!("Unknown federation peer {}", contact_id);
}
None => (None, None),
}
};
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
let onion = peer_pubkey
.as_ref()
.and_then(|pk| nodes.iter().find(|n| &n.pubkey == pk).map(|n| n.onion.clone()))
.or_else(|| {
peer_did.as_ref().and_then(|d| {
nodes.iter().find(|n| &n.did == d).map(|n| n.onion.clone())
})
});
if let Some(onion) = onion {
return self
.send_typed_wire_via_federation(
contact_id,
&onion,
wire,
type_label,
display_text,
typed_payload,
sender_seq,
)
.await;
}
if exceeds_lora {
// No federation path — fall back to send-side chunking. Receive
// side already handles MC-framed base64 reassembly for up to 20
// chunks (~3KB) per message, which is plenty for ContentRef or
// long replies when the peer is LoRa-only.
self.send_chunked_payload(contact_id, wire).await?;
return Ok(self
.record_sent_typed(contact_id, type_label, display_text, typed_payload, sender_seq)
.await);
}
// Fall through: federation-synthetic case handled above, shouldn't reach here.
}
self.send_raw_payload(contact_id, wire).await?;
Ok(self
.record_sent_typed(contact_id, type_label, display_text, typed_payload, sender_seq)
@@ -601,42 +784,39 @@ impl MeshService {
wire: Vec<u8>,
) -> Result<()> {
let envelope = crate::mesh::message_types::TypedEnvelope::from_wire(&wire)?;
// The sender's `from_pubkey_hex` is their archipelago identity key,
// which differs from the mesh peer's LoRa advert pubkey. Resolve
// identity → DID → mesh contact_id via federation/nodes.json (the
// DID is the only stable cross-transport key).
let federation_did = {
// Federation and mesh have disjoint identity namespaces: a LoRa
// mesh contact carries meshcore's firmware-issued pubkey, not the
// archipelago ed25519 key. So we cannot rely on matching pubkeys
// across transports. Instead, every federation peer has a stable
// synthetic mesh contact_id derived from its archipelago pubkey,
// and is upserted into mesh state the first time we hear from them.
let (federation_did, federation_name) = {
let nodes = crate::federation::load_nodes(&self.data_dir)
.await
.unwrap_or_default();
nodes
.into_iter()
.find(|n| n.pubkey == from_pubkey_hex)
.map(|n| n.did)
.map(|n| (Some(n.did), n.name))
.unwrap_or((None, None))
};
let contact_id = {
let peers = self.state.peers.read().await;
peers
.iter()
.find_map(|(cid, p)| {
let did_match = federation_did
.as_ref()
.zip(p.did.as_ref())
.map(|(a, b)| a == b)
.unwrap_or(false);
let pk_match = p.pubkey_hex.as_deref() == Some(from_pubkey_hex);
if did_match || pk_match { Some(*cid) } else { None }
})
.unwrap_or_else(|| {
let bytes = hex::decode(from_pubkey_hex).unwrap_or_default();
if bytes.len() >= 4 {
u32::from_le_bytes([bytes[0], bytes[1], bytes[2], bytes[3]])
} else {
0
}
})
};
let display_name = from_name.unwrap_or("federation peer").to_string();
// If the federation list knows this sender, use its DID; otherwise
// fall back to `from_name` (the sender's self-reported DID from the
// envelope body, signature-verified upstream in handle_mesh_typed_relay).
let effective_did = federation_did
.or_else(|| from_name.map(|s| s.to_string()))
.unwrap_or_else(|| format!("did:unknown:{}", &from_pubkey_hex[..from_pubkey_hex.len().min(16)]));
let display_name = federation_name
.clone()
.or_else(|| from_name.map(|s| s.to_string()))
.unwrap_or_else(|| "federation peer".to_string());
let contact_id = upsert_federation_peer(
&self.state,
from_pubkey_hex,
&effective_did,
Some(&display_name),
)
.await;
listener::dispatch::handle_typed_envelope_direct(
&self.state,
contact_id,
@@ -654,6 +834,51 @@ impl MeshService {
self.state.next_send_seq(target).await
}
/// Look up a stored MeshMessage by its local `id`. Used by the Forward
/// RPC to pull an existing record's typed payload for re-encoding.
pub async fn find_message_by_id(&self, id: u64) -> Option<MeshMessage> {
let messages = self.state.messages.read().await;
messages.iter().find(|m| m.id == id).cloned()
}
/// Apply an Edit locally to any own-Sent message matching `sender_seq`
/// (sender_pubkey is implicit = self). Rewrites `plaintext` and appends
/// an `edited_at` marker on `typed_payload` so the UI can show "(edited)".
/// Best-effort: missing target is silently ignored.
pub async fn apply_local_edit(&self, target_seq: u64, new_text: &str, edited_at: u32) {
let mut messages = self.state.messages.write().await;
for m in messages.iter_mut() {
if m.sender_seq == Some(target_seq)
&& matches!(m.direction, crate::mesh::types::MessageDirection::Sent)
{
m.plaintext = new_text.to_string();
let mut obj = match m.typed_payload.take() {
Some(serde_json::Value::Object(o)) => o,
_ => serde_json::Map::new(),
};
obj.insert("edited_at".to_string(), serde_json::json!(edited_at));
obj.insert("text".to_string(), serde_json::json!(new_text));
m.typed_payload = Some(serde_json::Value::Object(obj));
break;
}
}
}
/// Apply a Delete tombstone locally to an own-Sent message.
pub async fn apply_local_delete(&self, target_seq: u64) {
let mut messages = self.state.messages.write().await;
for m in messages.iter_mut() {
if m.sender_seq == Some(target_seq)
&& matches!(m.direction, crate::mesh::types::MessageDirection::Sent)
{
m.plaintext = "🗑 message deleted".to_string();
m.typed_payload = Some(serde_json::json!({ "deleted": true }));
m.message_type = "delete".to_string();
break;
}
}
}
/// Broadcast a typed envelope wire payload on a mesh channel and record a
/// rich Sent MeshMessage. Bytes are sent directly — do NOT utf8_lossy-encode
/// binary envelope bytes before handing them here.
@@ -714,45 +939,17 @@ impl MeshService {
Ok(msg)
}
/// Send a message to a peer by contact_id.
/// Routes through the background listener which owns the serial port.
/// Send a text message to a peer. Wraps the text in a typed `Text`
/// envelope (variant 0) with an allocated `sender_seq`, so the resulting
/// MeshMessage carries a stable MessageKey — this is what makes replies
/// and reactions addressable against plain text bubbles.
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
let payload = text.as_bytes().to_vec();
let encrypted = false;
self.send_raw_payload(contact_id, payload).await?;
let msg_id = self.state.next_id().await;
let peer_name = self
.state
.peers
.read()
.await
.get(&contact_id)
.map(|p| p.advert_name.clone());
let msg = MeshMessage {
id: msg_id,
direction: MessageDirection::Sent,
peer_contact_id: contact_id,
peer_name,
plaintext: text.to_string(),
timestamp: chrono::Utc::now().to_rfc3339(),
delivered: false,
encrypted,
message_type: "text".to_string(),
typed_payload: None,
sender_pubkey: None,
sender_seq: None,
};
self.state.store_message(msg.clone()).await;
{
let mut status = self.state.status.write().await;
status.messages_sent += 1;
}
Ok(msg)
use crate::mesh::message_types::{MeshMessageType, TypedEnvelope};
let seq = self.state.next_send_seq(contact_id).await;
let envelope = TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec())
.with_seq(seq);
let wire = envelope.to_wire()?;
self.send_typed_wire(contact_id, wire, "text", text, None, seq).await
}
/// Record a Sent MeshMessage for a typed envelope that has already been