feat(mesh): Telegram primitives pass + attachment transport router

Bundles the Phase 2b/3/4/5 work that accumulated across prior sessions
and the new attachment chunking router from this session. Everything
ships in one shot so the full mesh surface stays coherent on-wire.

Telegram primitives (variants 13–18, 20–22):
- Reply / Reaction / ReadReceipt / Forward / Edit / Delete
- Presence heartbeat + last-seen tracking
- ChannelInvite + ContactCard payload types
- MessageKey (sender_pubkey, sender_seq) as cross-transport identity
- Action menu, reply banner, edit banner, tombstones, (edited) marker
- Debounced auto-read-receipts on scroll + message arrival

Activated prototypes (Phase 4):
- PsbtHash send RPC
- Contacts CRUD (in-memory alias/notes/pinned/blocked)
- Outbox 📤 badge, rotate-prekeys button
- Chunked send fallback (MCIIXXTT framing) as auto-failover inside
  send_typed_wire when a typed wire exceeds the LoRa per-frame budget

Unified inbox (Phase 1):
- conversations.list + conversations.messages RPCs (UI collapse deferred)

Attachment transport router (new this session):
- ContentInline variant 23 + ContentInlinePayload carrying file bytes
  directly in the envelope for small files with no Tor path
- mesh.send-content-inline RPC — mirrors to local BlobStore, rides
  send_typed_wire which auto-chunks over MCIIXXTT framing (~2.3 KB cap)
- mesh.transport-advice RPC as single source of truth for tier
  decisions: auto-mesh / choose / tor-only / impossible
- Receive arm writes inline bytes to local BlobStore so the existing
  content_ref card renderer handles both transports uniformly
- MeshState.blob_store field + order-independent propagation from
  RpcHandler::set_blob_store / set_mesh_service
- Frontend handleAttachFile calls advice first, branches into silent
  auto-send, transport-chooser modal, Tor-only path, or red error
- Transport modal with 📡 mesh / 🧅 Tor options + ETA + disabled
  state when peer has no Tor reachability

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-14 20:40:19 -04:00
co-authored by Claude Opus 4.6
parent 5616bb74e6
commit 6760d11a57
16 changed files with 789 additions and 153 deletions
@@ -59,9 +59,7 @@ impl RpcHandler {
{
use base64::Engine;
let b64 = base64::engine::general_purpose::STANDARD.encode(&payload);
let _ = shared_state
.cmd_tx
.send(crate::mesh::listener::MeshCommand::BroadcastChannel {
let _ = shared_state.send_cmd(crate::mesh::listener::MeshCommand::BroadcastChannel {
channel: 0,
payload: b64.into_bytes(),
})
@@ -95,9 +93,7 @@ impl RpcHandler {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
let _ = svc.shared_state().send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
@@ -243,9 +239,7 @@ impl RpcHandler {
wire.clone()
};
let _ = svc.shared_state()
.cmd_tx
.send(crate::mesh::listener::MeshCommand::SendRaw {
let _ = svc.shared_state().send_cmd(crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload,
})
+1 -1
View File
@@ -185,7 +185,7 @@ impl RpcHandler {
if pk_bytes.len() >= 6 {
let mut prefix = [0u8; 6];
prefix.copy_from_slice(&pk_bytes[..6]);
let _ = svc.shared_state().cmd_tx.send(
let _ = svc.shared_state().send_cmd(
crate::mesh::listener::MeshCommand::SendRaw {
dest_pubkey_prefix: prefix,
payload: wire,
@@ -1,9 +1,9 @@
use super::super::RpcHandler;
use crate::blobs::DEFAULT_CAP_TTL_SECS;
use crate::mesh::message_types::{
self, AlertPayload, AlertType, ChannelInvitePayload, ContentRefPayload, Coordinate,
DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MessageKey, MeshMessageType,
PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload,
self, AlertPayload, AlertType, ChannelInvitePayload, ContentInlinePayload, ContentRefPayload,
Coordinate, DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MessageKey,
MeshMessageType, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload,
TypedEnvelope,
};
use anyhow::Result;
@@ -352,6 +352,201 @@ impl RpcHandler {
}))
}
/// mesh.send-content-inline — Carry file bytes directly in a typed envelope.
/// Params: { contact_id, mime, filename?, caption?, bytes_b64 }. The
/// underlying `send_typed_wire` auto-chunks via MCIIXXTT framing when the
/// envelope exceeds the LoRa per-frame budget. Sender also writes the
/// blob to its own BlobStore so the chat history renders identically to
/// ContentRef on both sides.
pub(in crate::api::rpc) async fn handle_mesh_send_content_inline(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let mime = params["mime"]
.as_str()
.unwrap_or("application/octet-stream")
.to_string();
let filename = params["filename"].as_str().map(|s| s.to_string());
let caption = params["caption"].as_str().map(|s| s.to_string());
let bytes_b64 = params["bytes_b64"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing bytes_b64"))?;
let bytes = B64
.decode(bytes_b64)
.map_err(|e| anyhow::anyhow!("Invalid base64: {}", e))?;
// Hard ceiling matching the chunked-send capacity (~20 chunks * 152
// b64 chars after MCIIXXTT framing). Anything larger must go via
// ContentRef over Tor.
const INLINE_HARD_MAX: usize = 2300;
if bytes.len() > INLINE_HARD_MAX {
anyhow::bail!(
"Payload {} bytes exceeds inline max {} — use mesh.send-content (ContentRef) instead",
bytes.len(),
INLINE_HARD_MAX
);
}
// Mirror to local BlobStore so the Sent record renders the same
// attachment card as the receiver's.
let blob_store = {
let guard = self.blob_store.read().await;
guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Blob store not initialised"))?
.clone()
};
let meta = blob_store
.put(&bytes, &mime, filename.clone(), None)
.await?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let content = ContentInlinePayload {
mime: mime.clone(),
filename: filename.clone(),
caption: caption.clone(),
bytes,
};
let seq = svc.next_send_seq(contact_id).await;
let payload = message_types::encode_payload(&content)?;
let envelope = TypedEnvelope::new(MeshMessageType::ContentInline, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let display = match (&filename, &caption) {
(Some(f), Some(c)) => format!("📎 {}{}", f, c),
(Some(f), None) => format!("📎 {}", f),
(None, Some(c)) => format!("📎 {}", c),
(None, None) => format!("📎 {} ({} bytes)", mime, meta.size),
};
// Render as a content_ref card on the sender side (UI already knows
// how to draw it from cid + mime + filename + size).
let typed_json = serde_json::json!({
"cid": meta.cid,
"size": meta.size,
"mime": mime,
"filename": filename,
"caption": caption,
"inline": true,
});
let msg = svc
.send_typed_wire(
contact_id,
wire,
"content_ref",
&display,
Some(typed_json),
seq,
)
.await?;
info!(
contact_id,
size = meta.size,
cid = %meta.cid,
"Sent content_inline over mesh"
);
Ok(serde_json::json!({
"sent": true,
"message_id": msg.id,
"cid": meta.cid,
"size": meta.size,
}))
}
/// mesh.transport-advice — Recommend how to send an attachment of a given
/// size to a given peer. Single source of truth for the frontend tier
/// router. Params: { contact_id, size }. Returns:
/// { tier, est_seconds, has_tor, reason }
/// where tier ∈ "auto-mesh" | "choose" | "tor-only" | "impossible".
pub(in crate::api::rpc) async fn handle_mesh_transport_advice(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let contact_id = params["contact_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing contact_id"))? as u32;
let size = params["size"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing size"))?;
// Knobs — keep in sync with the frontend modal copy.
const MESH_AUTO_MAX: u64 = 1024;
const MESH_HARD_MAX: u64 = 2300;
const TOR_LARGE_WARN: u64 = 5 * 1024 * 1024;
const LORA_BYTES_PER_SEC: u64 = 50;
// Resolve peer Tor reachability via federation node list.
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let state = svc.shared_state();
let (peer_pubkey_hex, peer_did) = {
let peers = state.peers.read().await;
match peers.get(&contact_id) {
Some(p) => (p.pubkey_hex.clone(), p.did.clone()),
None => (None, None),
}
};
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
let has_tor = peer_pubkey_hex
.as_ref()
.map(|pk| nodes.iter().any(|n| &n.pubkey == pk))
.unwrap_or(false)
|| peer_did
.as_ref()
.map(|d| nodes.iter().any(|n| &n.did == d))
.unwrap_or(false);
let est_seconds = (size.saturating_add(LORA_BYTES_PER_SEC - 1) / LORA_BYTES_PER_SEC).max(1);
let (tier, reason) = if size <= MESH_AUTO_MAX {
("auto-mesh", "Small enough to send inline over mesh")
} else if size <= MESH_HARD_MAX {
if has_tor {
("choose", "Fits over mesh (slow) or Tor (instant)")
} else {
("auto-mesh", "No Tor path — sending inline over mesh")
}
} else if size <= TOR_LARGE_WARN {
if has_tor {
("tor-only", "Too large for mesh — Tor only")
} else {
("impossible", "Too large for mesh, and peer has no Tor path")
}
} else {
if has_tor {
("tor-only", "Large file — receiver fetch may be slow")
} else {
("impossible", "Too large, and peer has no Tor path")
}
};
Ok(serde_json::json!({
"tier": tier,
"est_seconds": est_seconds,
"has_tor": has_tor,
"reason": reason,
"size": size,
"mesh_auto_max": MESH_AUTO_MAX,
"mesh_hard_max": MESH_HARD_MAX,
}))
}
/// mesh.send-reply — Send a text reply targeted at an earlier message.
/// Params: { contact_id, target_pubkey, target_seq, text }. The target
/// MessageKey identifies the message being replied to; it does NOT need