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
parent 5f7ebf145e
commit c4e0ae0a70
7 changed files with 1150 additions and 81 deletions

View File

@@ -299,6 +299,19 @@ impl RpcHandler {
"mesh.fetch-content" => self.handle_mesh_fetch_content(params).await,
"mesh.send-reply" => self.handle_mesh_send_reply(params).await,
"mesh.send-reaction" => self.handle_mesh_send_reaction(params).await,
"mesh.send-read-receipt" => self.handle_mesh_send_read_receipt(params).await,
"mesh.forward-message" => self.handle_mesh_forward_message(params).await,
"mesh.edit-message" => self.handle_mesh_edit_message(params).await,
"mesh.delete-message" => self.handle_mesh_delete_message(params).await,
"mesh.send-psbt" => self.handle_mesh_send_psbt(params).await,
"mesh.broadcast-presence" => self.handle_mesh_broadcast_presence(params).await,
"mesh.presence-list" => self.handle_mesh_presence_list(params).await,
"mesh.contacts-list" => self.handle_mesh_contacts_list(params).await,
"mesh.contacts-save" => self.handle_mesh_contacts_save(params).await,
"mesh.contacts-block" => self.handle_mesh_contacts_block(params).await,
"mesh.send-channel-invite" => self.handle_mesh_send_channel_invite(params).await,
"conversations.list" => self.handle_conversations_list(params).await,
"conversations.messages" => self.handle_conversations_messages(params).await,
"mesh.outbox" => self.handle_mesh_outbox(params).await,
"mesh.session-status" => self.handle_mesh_session_status(params).await,
"mesh.rotate-prekeys" => self.handle_mesh_rotate_prekeys().await,

View File

@@ -70,6 +70,91 @@ impl RpcHandler {
}
}
/// conversations.list — Unified inbox across mesh peers, mesh channels,
/// and federation nodes. Each conversation returns its latest message
/// timestamp + snippet + transport tag so the UI can render one sorted list.
pub(in crate::api::rpc) async fn handle_conversations_list(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let mut conversations: Vec<serde_json::Value> = Vec::new();
let service = self.mesh_service.read().await;
if let Some(svc) = service.as_ref() {
let peers = svc.peers().await;
let messages = svc.messages(None).await;
// Per-peer last message.
for peer in &peers {
let last = messages
.iter()
.rev()
.find(|m| m.peer_contact_id == peer.contact_id);
let is_federation = peer.contact_id & 0x8000_0000 != 0;
conversations.push(serde_json::json!({
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
"transport": if is_federation { "federation" } else { "mesh" },
"contact_id": peer.contact_id,
"name": peer.advert_name,
"pubkey": peer.pubkey_hex,
"last_text": last.map(|m| m.plaintext.clone()),
"last_timestamp": last.map(|m| m.timestamp.clone()),
"last_direction": last.map(|m| format!("{:?}", m.direction).to_lowercase()),
}));
}
// Channel 0 ("Archipelago") as a synthetic conversation.
let channel_last = messages
.iter()
.rev()
.find(|m| m.message_type == "text" && m.peer_contact_id == 0);
conversations.push(serde_json::json!({
"id": "channel:0",
"transport": "channel",
"channel": 0,
"name": "Archipelago",
"last_text": channel_last.map(|m| m.plaintext.clone()),
"last_timestamp": channel_last.map(|m| m.timestamp.clone()),
}));
}
// Sort by last_timestamp desc (missing timestamps sink).
conversations.sort_by(|a, b| {
let at = a.get("last_timestamp").and_then(|v| v.as_str()).unwrap_or("");
let bt = b.get("last_timestamp").and_then(|v| v.as_str()).unwrap_or("");
bt.cmp(at)
});
Ok(serde_json::json!({ "conversations": conversations }))
}
/// conversations.messages — Return messages for a ConversationId string
/// (format: `mesh:<contact_id>` | `federation:<contact_id>` | `channel:<u8>`).
pub(in crate::api::rpc) async fn handle_conversations_messages(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let id = params["id"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing id"))?;
let (kind, rest) = id
.split_once(':')
.ok_or_else(|| anyhow::anyhow!("Invalid conversation id"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
let all = svc.messages(None).await;
let filtered: Vec<_> = match kind {
"mesh" | "federation" => {
let contact_id: u32 = rest.parse().unwrap_or(0);
all.into_iter().filter(|m| m.peer_contact_id == contact_id).collect()
}
"channel" => {
// For now the channel bucket keeps contact_id = 0.
all.into_iter().filter(|m| m.peer_contact_id == 0).collect()
}
_ => Vec::new(),
};
Ok(serde_json::json!({ "messages": filtered }))
}
/// mesh.debug-dump — Full in-memory state snapshot for debugging.
/// Returns peers, all messages, status, shared-secret peer ids, encrypt_relay
/// flag, and stego mode. Intended for smoke tests and bug investigation.

View File

@@ -1,8 +1,10 @@
use super::super::RpcHandler;
use crate::blobs::DEFAULT_CAP_TTL_SECS;
use crate::mesh::message_types::{
self, AlertPayload, AlertType, ContentRefPayload, Coordinate, InvoicePayload, MessageKey,
MeshMessageType, ReactionPayload, ReplyPayload, TypedEnvelope,
self, AlertPayload, AlertType, ChannelInvitePayload, ContentRefPayload, Coordinate,
DeletePayload, EditPayload, ForwardPayload, InvoicePayload, MessageKey, MeshMessageType,
PresencePayload, PsbtHashPayload, ReactionPayload, ReadReceiptPayload, ReplyPayload,
TypedEnvelope,
};
use anyhow::Result;
use tracing::info;
@@ -302,21 +304,28 @@ impl RpcHandler {
// budget (cid alone is 64 hex chars, plus onion + cap). Route via
// federation when the peer has a known onion; fall back to LoRa
// only for tiny envelopes that could theoretically fit.
// Match mesh peer → federation node by master DID, NOT by pubkey.
// Mesh adverts carry a LoRa-local ed25519 key that differs from the
// archipelago node's identity key in federation/nodes.json; the DID
// is the only stable identifier the two transports share.
//
// Federation peers are pre-seeded into mesh state with their
// archipelago pubkey as `pubkey_hex` and DID populated — so a direct
// lookup on either key finds the onion. The `explicit_peer_onion`
// frontend override and the DID path remain as fallbacks for the
// transitional case where a mesh-discovered LoRa contact also
// happens to be federated.
let federation_onion = if let Some(onion) = explicit_peer_onion {
Some(onion)
} else {
let nodes = crate::federation::load_nodes(&self.config.data_dir)
.await
.unwrap_or_default();
if let Some(did) = peer_did.as_ref() {
nodes.into_iter().find(|n| &n.did == did).map(|n| n.onion)
} else {
None
}
nodes
.iter()
.find(|n| n.pubkey == peer_pubkey_hex)
.map(|n| n.onion.clone())
.or_else(|| {
peer_did.as_ref().and_then(|did| {
nodes.iter().find(|n| &n.did == did).map(|n| n.onion.clone())
})
})
};
let msg = if let Some(onion) = federation_onion {
svc.send_typed_wire_via_federation(
@@ -560,4 +569,449 @@ impl RpcHandler {
"local_url": local_url,
}))
}
/// mesh.send-psbt — share a PSBT sign-request with a mesh peer.
/// Params: `{ contact_id, psbt_hash, description, amount_sats }`. The payload
/// carries only the SHA-256 hash; actual PSBT bytes travel out-of-band via
/// a ContentRef or federation if needed.
pub(in crate::api::rpc) async fn handle_mesh_send_psbt(
&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 psbt_hash = params["psbt_hash"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing psbt_hash"))?
.to_string();
let description = params["description"].as_str().unwrap_or("PSBT sign request").to_string();
let amount_sats = params["amount_sats"].as_u64().unwrap_or(0);
let payload = PsbtHashPayload {
psbt_hash: psbt_hash.clone(),
description: description.clone(),
amount_sats,
};
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(contact_id).await;
let body = message_types::encode_payload(&payload)?;
let envelope = TypedEnvelope::new(MeshMessageType::PsbtHash, body).with_seq(seq);
let wire = envelope.to_wire()?;
let display = format!("PSBT {} sats — {}", amount_sats, description);
let typed_json = serde_json::to_value(&payload).ok();
let msg = svc
.send_typed_wire(contact_id, wire, "psbt_hash", &display, typed_json, seq)
.await?;
info!(contact_id, psbt_hash = %psbt_hash, "Sent PSBT hash over mesh");
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
/// mesh.send-read-receipt — "I've seen everything from `target_pubkey` up to `target_seq`."
/// Params: `{ contact_id, target_pubkey, target_seq }`. The peer uses this to roll
/// forward the ✓✓ marker on its local Sent bubbles.
pub(in crate::api::rpc) async fn handle_mesh_send_read_receipt(
&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 target_pubkey = params["target_pubkey"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing target_pubkey"))?
.to_string();
let target_seq = params["target_seq"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing target_seq"))?;
let receipt = ReadReceiptPayload {
up_to: MessageKey { sender_pubkey: target_pubkey, sender_seq: target_seq },
};
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(contact_id).await;
let payload = message_types::encode_payload(&receipt)?;
let envelope = TypedEnvelope::new(MeshMessageType::ReadReceipt, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let display = format!("seen ≤ #{}", receipt.up_to.sender_seq);
let typed_json = serde_json::to_value(&receipt).ok();
let msg = svc
.send_typed_wire(contact_id, wire, "read_receipt", &display, typed_json, seq)
.await?;
info!(contact_id, seq, "Sent read receipt over mesh");
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
/// mesh.forward-message — Re-broadcast an existing local message to another peer,
/// preserving original sender attribution. Params: `{ contact_id, source_message_id }`.
/// We look up the source by local id, pull its typed_payload (or plaintext for Text),
/// and wrap it in a Forward envelope with the original MessageKey + timestamp.
pub(in crate::api::rpc) async fn handle_mesh_forward_message(
&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 source_id = params["source_message_id"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing source_message_id"))?;
let service = self.mesh_service.read().await;
let svc = service
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
// Pull the source message from MeshState.
let source = svc
.find_message_by_id(source_id)
.await
.ok_or_else(|| anyhow::anyhow!("Source message {} not found", source_id))?;
// Forwarding a message without a stable MessageKey is meaningless —
// the receiver can't attribute it.
let orig_pubkey = source.sender_pubkey.clone().ok_or_else(|| {
anyhow::anyhow!("Source message has no sender_pubkey — cannot forward")
})?;
let orig_seq = source
.sender_seq
.ok_or_else(|| anyhow::anyhow!("Source message has no sender_seq — cannot forward"))?;
// Re-encode the original body. For typed messages we serialize the
// existing typed_payload JSON back through CBOR via the original type;
// for plain text we forward the plaintext as Text.
let (body_type, body): (u8, Vec<u8>) = match source.typed_payload.as_ref() {
Some(json) => {
let type_label = source.message_type.as_str();
let t = MeshMessageType::from_label(type_label)
.unwrap_or(MeshMessageType::Text) as u8;
let mut buf = Vec::new();
ciborium::into_writer(json, &mut buf)
.map_err(|e| anyhow::anyhow!("re-encode body failed: {}", e))?;
(t, buf)
}
None => (MeshMessageType::Text as u8, source.plaintext.clone().into_bytes()),
};
let forward = ForwardPayload {
orig: MessageKey { sender_pubkey: orig_pubkey, sender_seq: orig_seq },
orig_ts: source
.timestamp
.parse::<chrono::DateTime<chrono::Utc>>()
.map(|dt| dt.timestamp() as u32)
.unwrap_or(chrono::Utc::now().timestamp() as u32),
orig_name: source.peer_name.clone(),
body_type,
body,
};
let seq = svc.next_send_seq(contact_id).await;
let payload = message_types::encode_payload(&forward)?;
let envelope = TypedEnvelope::new(MeshMessageType::Forward, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let display = format!(
"Forwarded: {}",
if source.plaintext.is_empty() { "(attachment)" } else { source.plaintext.as_str() }
);
let typed_json = serde_json::to_value(&forward).ok();
let msg = svc
.send_typed_wire(contact_id, wire, "forward", &display, typed_json, seq)
.await?;
info!(contact_id, seq, source_id, "Forwarded message over mesh");
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
/// mesh.edit-message — In-place edit of an earlier message's text. The target
/// must have been sent by this node (own MessageKey). Params:
/// `{ contact_id, target_seq, new_text }`. `target_pubkey` is implicit (self).
pub(in crate::api::rpc) async fn handle_mesh_edit_message(
&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 target_seq = params["target_seq"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing target_seq"))?;
let new_text = params["new_text"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing new_text"))?
.to_string();
let self_pubkey = {
let guard = self.self_pubkey_hex.read().await;
guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Self pubkey not set"))?
.clone()
};
let edit = EditPayload {
target: MessageKey { sender_pubkey: self_pubkey, sender_seq: target_seq },
new_text: new_text.clone(),
edited_at: 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(contact_id).await;
let payload = message_types::encode_payload(&edit)?;
let envelope = TypedEnvelope::new(MeshMessageType::Edit, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let typed_json = serde_json::to_value(&edit).ok();
let msg = svc
.send_typed_wire(contact_id, wire, "edit", &new_text, typed_json, seq)
.await?;
// Best-effort: apply the edit to our own local copy too, so the UI
// updates without waiting for an echo.
svc.apply_local_edit(target_seq, &new_text, edit.edited_at).await;
info!(contact_id, seq, target_seq, "Sent edit over mesh");
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
/// mesh.delete-message — Tombstone an earlier own-message. Params:
/// `{ contact_id, target_seq }`. Applied locally immediately; wire form is
/// informational for peers who already have the bytes.
pub(in crate::api::rpc) async fn handle_mesh_delete_message(
&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 target_seq = params["target_seq"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing target_seq"))?;
let self_pubkey = {
let guard = self.self_pubkey_hex.read().await;
guard
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Self pubkey not set"))?
.clone()
};
let del = DeletePayload {
target: MessageKey { sender_pubkey: self_pubkey, sender_seq: target_seq },
};
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(contact_id).await;
let payload = message_types::encode_payload(&del)?;
let envelope = TypedEnvelope::new(MeshMessageType::Delete, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let typed_json = serde_json::to_value(&del).ok();
let msg = svc
.send_typed_wire(contact_id, wire, "delete", "(deleted)", typed_json, seq)
.await?;
svc.apply_local_delete(target_seq).await;
info!(contact_id, seq, target_seq, "Sent delete over mesh");
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
/// mesh.broadcast-presence — emit a PresencePayload heartbeat on the
/// channel so online peers can update their presence table.
/// 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>,
) -> 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() })),
}
}
/// mesh.presence-list — return the in-memory presence map (pubkey → status+timestamps).
pub(in crate::api::rpc) async fn handle_mesh_presence_list(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
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 presence = state.presence.read().await;
let list: Vec<_> = presence
.iter()
.map(|(pk, (status, last_active, received_at))| {
serde_json::json!({
"pubkey": pk,
"status": status,
"last_active": last_active,
"received_at": received_at,
})
})
.collect();
Ok(serde_json::json!({ "presence": list }))
}
/// mesh.contacts-list — return the contacts store merged with the peer list.
pub(in crate::api::rpc) async fn handle_mesh_contacts_list(
&self,
_params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
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 contacts = state.contacts.read().await;
let peers = state.peers.read().await;
let mut out: Vec<serde_json::Value> = Vec::new();
for peer in peers.values() {
if let Some(pk) = peer.pubkey_hex.as_ref() {
let entry = contacts.get(pk).cloned().unwrap_or_default();
out.push(serde_json::json!({
"pubkey": pk,
"contact_id": peer.contact_id,
"name": peer.advert_name,
"alias": entry.alias,
"notes": entry.notes,
"pinned": entry.pinned,
"blocked": entry.blocked,
}));
}
}
Ok(serde_json::json!({ "contacts": out }))
}
/// mesh.contacts-save — create/update a contact entry (alias/notes/pinned).
/// Params: `{ pubkey, alias?, notes?, pinned? }`.
pub(in crate::api::rpc) async fn handle_mesh_contacts_save(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let pubkey = params["pubkey"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?
.to_string();
let alias = params["alias"].as_str().map(|s| s.to_string());
let notes = params["notes"].as_str().map(|s| s.to_string());
let pinned = params["pinned"].as_bool();
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 mut contacts = state.contacts.write().await;
let entry = contacts.entry(pubkey.clone()).or_default();
if alias.is_some() { entry.alias = alias; }
if notes.is_some() { entry.notes = notes; }
if let Some(p) = pinned { entry.pinned = p; }
let saved = entry.clone();
Ok(serde_json::json!({
"saved": true,
"pubkey": pubkey,
"alias": saved.alias,
"notes": saved.notes,
"pinned": saved.pinned,
"blocked": saved.blocked,
}))
}
/// mesh.contacts-block — toggle the blocked flag on a contact.
/// Params: `{ pubkey, blocked }`.
pub(in crate::api::rpc) async fn handle_mesh_contacts_block(
&self,
params: Option<serde_json::Value>,
) -> Result<serde_json::Value> {
let params = params.ok_or_else(|| anyhow::anyhow!("Missing params"))?;
let pubkey = params["pubkey"]
.as_str()
.ok_or_else(|| anyhow::anyhow!("Missing pubkey"))?
.to_string();
let blocked = params["blocked"].as_bool().unwrap_or(true);
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 mut contacts = state.contacts.write().await;
let entry = contacts.entry(pubkey.clone()).or_default();
entry.blocked = blocked;
Ok(serde_json::json!({ "pubkey": pubkey, "blocked": blocked }))
}
/// mesh.send-channel-invite — share a channel invite with a direct peer.
/// Params: `{ contact_id, channel, name, key? }`.
pub(in crate::api::rpc) async fn handle_mesh_send_channel_invite(
&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 channel = params["channel"]
.as_u64()
.ok_or_else(|| anyhow::anyhow!("Missing channel"))? as u8;
let name = params["name"].as_str().unwrap_or("").to_string();
let key = params["key"].as_str().map(|s| s.to_string());
let invite = ChannelInvitePayload { channel, name: name.clone(), key };
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(contact_id).await;
let payload = message_types::encode_payload(&invite)?;
let envelope = TypedEnvelope::new(MeshMessageType::ChannelInvite, payload).with_seq(seq);
let wire = envelope.to_wire()?;
let display = format!("Channel invite: {} ({})", channel, name);
let typed_json = serde_json::to_value(&invite).ok();
let msg = svc
.send_typed_wire(contact_id, wire, "channel_invite", &display, typed_json, seq)
.await?;
Ok(serde_json::json!({ "sent": true, "message_id": msg.id, "sender_seq": seq }))
}
}