fix(mesh): DM-via-channel tunnel + disable presence spam

Meshcore direct unicast silently drops between our two Archy nodes
(firmware reports flood sends with resp_code=6 but nothing arrives).
Wrap DMs as channel-1 broadcasts with a [0xD1][dest_prefix(6)][inner]
header; receivers filter by prefix and dispatch the inner payload
through the existing typed/base64/chunk ladder. Shrink chunk body to
125B so the wrapper still fits the 160B LoRa budget. Auto-heal
routing: CMD_RESET_PATH (0x0D) any type-1 contact with path_len=0 on
refresh so floods take over. send_text now returns the firmware's
flood/direct mode flag for diagnostics.

Disable the 120s presence heartbeat broadcaster — its CBOR payload
was being re-echoed as plaintext by the shared repeater, spamming
every visible node with garbled "Archy-…: av�…fstatusfonline…"
messages on channel 0. mesh.broadcast-presence RPC stays registered
but no longer transmits. Re-enable only once presence moves off the
shared broadcast path.

Also: MeshState.cmd_tx behind RwLock so stop()→start() cycles don't
fail with "command channel already consumed"; MeshService.send_cmd
helper; drop_message_by_id for control envelopes that shouldn't
appear as Sent bubbles; self_advert_name reflected into MeshStatus
after set; path_len/flags parsed out of RESP_CONTACT.

Frontend: unified inbox merges mesh peers with federation nodes by
DID/pubkey/name; hide presence/read_receipt/edit/channel_invite/
contact_card from chat stream; publicChannel index → 1 to match the
new DM-via-channel routing.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-14 10:24:27 -04:00
co-authored by Claude Opus 4.6
parent bdacc06a2b
commit d514e0e5e4
10 changed files with 771 additions and 111 deletions
@@ -3,7 +3,7 @@ use crate::blobs::DEFAULT_CAP_TTL_SECS;
use crate::mesh::message_types::{
self, AlertPayload, AlertType, ChannelInvitePayload, ContentRefPayload, Coordinate,
DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MessageKey, MeshMessageType,
PresencePayload, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload,
PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload,
TypedEnvelope,
};
use anyhow::Result;
@@ -647,6 +647,10 @@ impl RpcHandler {
let msg = svc
.send_typed_wire(contact_id, wire, "read_receipt", &display, typed_json, seq)
.await?;
// Read receipts are control envelopes; the receiver uses them to
// roll the ✓✓ marker forward on the matching outgoing bubble. They
// must not surface as standalone bubbles in our own chat history.
svc.drop_message_by_id(msg.id).await;
info!(contact_id, seq, "Sent read receipt over mesh");
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
@@ -776,6 +780,10 @@ impl RpcHandler {
let msg = svc
.send_typed_wire(contact_id, wire, "edit", &new_text, typed_json, seq)
.await?;
// Edits are control envelopes — they mutate the target bubble in
// apply_local_edit below, so the standalone Sent record has no UI
// value and would just clutter the chat.
svc.drop_message_by_id(msg.id).await;
// Best-effort: apply the edit to our own local copy too, so the UI
// updates without waiting for an echo.
@@ -824,6 +832,10 @@ impl RpcHandler {
let msg = svc
.send_typed_wire(contact_id, wire, "delete", "(deleted)", typed_json, seq)
.await?;
// Delete is a control envelope — apply_local_delete below tombstones
// the target bubble in place, so the standalone Sent record is just
// noise in the chat history.
svc.drop_message_by_id(msg.id).await;
svc.apply_local_delete(target_seq).await;
@@ -836,35 +848,14 @@ impl RpcHandler {
/// Params: `{ channel?, status? }`. Defaults: channel 0, status "online".
pub(in crate::api::rpc) async fn handle_mesh_broadcast_presence(
&self,
params: Option<serde_json::Value>,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.unwrap_or(serde_json::json!({}));
let channel = params["channel"].as_u64().unwrap_or(0) as u8;
let status = params["status"].as_str().unwrap_or("online").to_string();
let presence = PresencePayload {
status: status.clone(),
last_active: chrono::Utc::now().timestamp() as u32,
};
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let seq = svc.next_send_seq(0).await;
let payload = message_types::encode_payload(&presence)?;
let envelope = TypedEnvelope::new(MeshMessageType::Presence, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let typed_json = serde_json::to_value(&presence).ok();
// Best-effort: if the mesh device isn't connected, skip silently —
// presence heartbeats don't deserve a user-visible error.
match svc
.send_channel_typed_wire(channel, wire, "presence", &status, typed_json, seq)
.await
{
Ok(_) => Ok(serde_json::json!({ "sent": true, "sender_seq": seq })),
Err(e) => Ok(serde_json::json!({ "sent": false, "reason": e.to_string() })),
}
// DISABLED: presence broadcasts were spamming the public channel
// with malformed CBOR bytes (repeaters re-echoed our
// PresencePayload as plaintext, producing "av…fstatusfonline…").
// The RPC stays registered so frontends that still call it don't
// hard-fail, but it no longer transmits anything.
Ok(serde_json::json!({ "sent": false, "reason": "presence disabled" }))
}
/// mesh.presence-list — return the in-memory presence map (pubkey → status+timestamps).