diff --git a/core/archipelago/src/api/rpc/mesh/status.rs b/core/archipelago/src/api/rpc/mesh/status.rs index 59f61a98..66dfa8be 100644 --- a/core/archipelago/src/api/rpc/mesh/status.rs +++ b/core/archipelago/src/api/rpc/mesh/status.rs @@ -258,43 +258,45 @@ impl RpcHandler { if let Some(svc) = service.as_ref() { let state = svc.state(); - // Snapshot the firmware pubkeys we currently know about, then - // add them to the radio-contact blocklist. MeshCore's on-device - // contact table is persistent and reads back stale rows on the - // next refresh_contacts, so without this step `clear-all` only - // wipes the app view for a few seconds before the old entries - // reappear. The blocklist is also saved to disk so the filter - // survives a restart. - let firmware_pubkeys: Vec = state + // NOTE: `clear-all` intentionally does NOT build a radio-contact + // blocklist. Permanently ignoring firmware contacts meant a cleared + // peer could never return even when it re-advertised (it also broke + // re-pairing a phone after a clear). Real per-contact blocking will + // be a separate, explicit feature. Here we just wipe the app-side + // view and ALSO clear any blocklist left over from older builds, so + // previously-hidden contacts can re-appear when next heard. The + // firmware's own contact table is the source of truth on refresh. + { + let mut set = state.radio_contact_blocklist.write().await; + set.clear(); + } + let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &[]).await; + + // Actually DELETE each radio contact from the firmware table (via + // CMD_REMOVE_CONTACT) so wiped peers don't just reappear on the next + // refresh. They come back only when they re-advertise (reachable). + // Federation-synthetic peers (high contact_id bit) aren't firmware + // contacts, so skip those. + let firmware_pubkeys: Vec<[u8; 32]> = state .peers .read() .await .values() - .filter_map(|p| { - // Federation-synthetic peers have their contact_id in the - // high half of u32 and carry the archipelago key — those - // aren't firmware contacts and must not go on the list. - if p.contact_id & 0x8000_0000 != 0 { - None - } else { - p.pubkey_hex.clone() - } + .filter(|p| p.contact_id & 0x8000_0000 == 0) + .filter_map(|p| p.pubkey_hex.as_deref()) + .filter_map(|h| hex::decode(h).ok()) + .filter(|b| b.len() == 32) + .map(|b| { + let mut k = [0u8; 32]; + k.copy_from_slice(&b); + k }) .collect(); - { - let mut set = state.radio_contact_blocklist.write().await; - for pk in &firmware_pubkeys { - set.insert(pk.clone()); - } + for pk in firmware_pubkeys { + let _ = state + .send_cmd(crate::mesh::listener::MeshCommand::RemoveContact { pubkey: pk }) + .await; } - let persisted: Vec = state - .radio_contact_blocklist - .read() - .await - .iter() - .cloned() - .collect(); - let _ = crate::mesh::save_ignored_radio_contacts(&data_dir, &persisted).await; state.peers.write().await.clear(); state.messages.write().await.clear(); diff --git a/core/archipelago/src/mesh/listener/assist.rs b/core/archipelago/src/mesh/listener/assist.rs index b6433eed..dd8fa705 100644 --- a/core/archipelago/src/mesh/listener/assist.rs +++ b/core/archipelago/src/mesh/listener/assist.rs @@ -48,6 +48,11 @@ pub(super) enum AssistReply { /// direct message, so the answer lands inline in that same conversation /// (encrypted, peer-addressed) rather than as a separate widget. ChatText { contact_id: u32 }, + /// Plain-text NATIVE direct message back to the asker's radio contact — + /// the bare `!ai` path for a stock meshcore client (e.g. a phone). The + /// answer goes as a real unicast DM (not a public-channel broadcast), so + /// only the asker sees it and a stock client can read it. + RadioDm { dest_prefix: [u8; 6] }, } /// Entry point: gate the query, run the model, send the answer back via the @@ -224,6 +229,15 @@ async fn send_reply(state: &Arc, reply: &AssistReply, req_id: u64, an let (text, _) = cap_reply(answer); send_chat_text(state, *contact_id, &text).await; } + AssistReply::RadioDm { dest_prefix } => { + let text = cap_channel(answer); + let _ = state + .send_cmd(MeshCommand::SendNativeText { + dest_pubkey_prefix: *dest_prefix, + payload: text.into_bytes(), + }) + .await; + } } } @@ -246,6 +260,14 @@ async fn send_failure(state: &Arc, reply: &AssistReply, req_id: u64, AssistReply::ChatText { contact_id } => { send_chat_text(state, *contact_id, &format!("AI: {msg}")).await; } + AssistReply::RadioDm { dest_prefix } => { + let _ = state + .send_cmd(MeshCommand::SendNativeText { + dest_pubkey_prefix: *dest_prefix, + payload: format!("AI: {msg}").into_bytes(), + }) + .await; + } } } diff --git a/core/archipelago/src/mesh/listener/decode.rs b/core/archipelago/src/mesh/listener/decode.rs index beca4951..a7d77c49 100644 --- a/core/archipelago/src/mesh/listener/decode.rs +++ b/core/archipelago/src/mesh/listener/decode.rs @@ -353,28 +353,37 @@ pub(super) async fn store_plain_message( state.status.write().await.messages_received += 1; let _ = state.event_tx.send(MeshEvent::MessageReceived(msg)); - // Mesh-AI assistant (issue #50): a plain `!ai`/`!ask ` on the - // channel is answered by this node's local model when the assistant is on. - // Reply goes back as plain channel text so bare (non-archipelago) clients - // see it. The trust/rate gate lives in run_assist. + // Mesh-AI assistant (issue #50): a plain `!ai`/`!ask ` is answered + // by this node's local model when the assistant is on. The trust/rate gate + // lives in run_assist. The reply goes back as a private NATIVE DM to the + // asker whenever we know its radio pubkey (so it does NOT land on the public + // channel and a stock meshcore client can read it); we only fall back to a + // channel reply if the sender has no resolvable pubkey (rare). if state.assistant.read().await.enabled { if let Some(prompt) = strip_ai_trigger(text) { if !prompt.is_empty() { + let reply = { + let peers = state.peers.read().await; + peers + .get(&contact_id) + .and_then(|p| p.pubkey_hex.clone()) + .filter(|h| h.len() >= 12) + .and_then(|h| hex::decode(&h[..12]).ok()) + .filter(|b| b.len() == 6) + .map(|b| { + let mut pre = [0u8; 6]; + pre.copy_from_slice(&b); + super::assist::AssistReply::RadioDm { dest_prefix: pre } + }) + .unwrap_or(super::assist::AssistReply::ChannelText { channel: 0 }) + }; let req_id = state.next_id().await; let prompt = prompt.to_string(); let name = peer_name.to_string(); let st = Arc::clone(state); tokio::spawn(async move { - super::assist::run_assist( - prompt, - None, - req_id, - contact_id, - name, - super::assist::AssistReply::ChannelText { channel: 0 }, - st, - ) - .await; + super::assist::run_assist(prompt, None, req_id, contact_id, name, reply, st) + .await; }); } } @@ -480,6 +489,9 @@ pub(super) async fn handle_identity_received( snr: None, last_heard: chrono::Utc::now().to_rfc3339(), hops: 0, + last_advert: 0, + // We just heard this peer's identity advert, so it's reachable. + reachable: true, }; let is_new = { diff --git a/core/archipelago/src/mesh/listener/frames.rs b/core/archipelago/src/mesh/listener/frames.rs index 1d0b60b9..1a7002a6 100644 --- a/core/archipelago/src/mesh/listener/frames.rs +++ b/core/archipelago/src/mesh/listener/frames.rs @@ -22,8 +22,33 @@ pub(super) async fn handle_frame( protocol::PUSH_NEW_CONTACT | protocol::PUSH_CONTACT_ADVERT => { info!( code = frame.code, + data_len = frame.data.len(), "Contact discovery event — refreshing contacts" ); + // Auto-import: a PUSH_CONTACT_ADVERT (0x80) carries the 32-byte + // pubkey of a node we just heard. If it isn't already a contact, + // add it to the firmware table so it shows up immediately — no + // flood-advert dance required. (PUSH_NEW_CONTACT/0x8A is already + // added by the firmware, so we skip it.) + if frame.code == protocol::PUSH_CONTACT_ADVERT && frame.data.len() >= 32 { + let mut pubkey = [0u8; 32]; + pubkey.copy_from_slice(&frame.data[..32]); + let pk_hex = hex::encode(pubkey); + let known = state + .peers + .read() + .await + .values() + .any(|p| p.pubkey_hex.as_deref() == Some(pk_hex.as_str())); + if !known { + let _ = state + .send_cmd(super::MeshCommand::AddContact { + pubkey, + name: String::new(), + }) + .await; + } + } return true; // Signal caller to fetch contacts } diff --git a/core/archipelago/src/mesh/listener/mod.rs b/core/archipelago/src/mesh/listener/mod.rs index 528a5fdb..71277ca7 100644 --- a/core/archipelago/src/mesh/listener/mod.rs +++ b/core/archipelago/src/mesh/listener/mod.rs @@ -63,6 +63,14 @@ pub enum MeshCommand { dest_pubkey_prefix: [u8; 6], payload: Vec, }, + /// Send PLAIN text as one or more native meshcore DMs to a stock client + /// (e.g. a phone). Long text is split into multiple readable plain messages + /// — never MC-chunked — because stock clients can't reassemble archy's + /// chunk framing. Used for chat/AI replies to non-archipelago contacts. + SendNativeText { + dest_pubkey_prefix: [u8; 6], + payload: Vec, + }, /// Broadcast pre-encoded binary on a mesh channel. BroadcastChannel { channel: u8, @@ -71,6 +79,16 @@ pub enum MeshCommand { SendAdvert, /// Re-fetch contact list from the radio device. RefreshContacts, + /// Delete a contact from the firmware table (clear-all / unreachable wipe). + RemoveContact { + pubkey: [u8; 32], + }, + /// Import/add a heard advert as a firmware contact so it shows up without + /// needing a flood advert. Name may be empty (firmware fills from advert). + AddContact { + pubkey: [u8; 32], + name: String, + }, } /// Shared state for the mesh listener, accessible from RPC handlers. diff --git a/core/archipelago/src/mesh/listener/session.rs b/core/archipelago/src/mesh/listener/session.rs index ba3a393e..47a60a67 100644 --- a/core/archipelago/src/mesh/listener/session.rs +++ b/core/archipelago/src/mesh/listener/session.rs @@ -53,6 +53,43 @@ impl MeshRadioDevice { } } + async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], payload: &[u8]) -> Result<()> { + match self { + Self::Meshcore(device) => device.send_text_msg(dest_pubkey_prefix, payload).await, + Self::Meshtastic(device) => device.send_text_msg(dest_pubkey_prefix, payload).await, + } + } + + async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> { + match self { + Self::Meshcore(device) => device.remove_contact(pubkey).await, + Self::Meshtastic(device) => device.remove_contact(pubkey).await, + } + } + + async fn add_contact( + &mut self, + pubkey: &[u8; 32], + contact_type: u8, + flags: u8, + out_path_len: u8, + name: &str, + last_advert: u32, + ) -> Result<()> { + match self { + Self::Meshcore(device) => { + device + .add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert) + .await + } + Self::Meshtastic(device) => { + device + .add_contact(pubkey, contact_type, flags, out_path_len, name, last_advert) + .await + } + } + } + async fn get_contacts(&mut self) -> Result> { match self { Self::Meshcore(device) => device.get_contacts().await, @@ -151,6 +188,7 @@ pub(super) const DM_V1_MARKER: &str = "@DM:"; /// route inbound DMs to the correct contact_id thread. pub(super) const DM_V2_MARKER: &str = "@DM2:"; +#[allow(dead_code)] // legacy @DM2-over-channel wrapper; kept for reference now that DMs are native unicast fn wrap_dm_for_channel( dest_pubkey_prefix: &[u8; 6], sender_arch_prefix: &[u8; 6], @@ -169,6 +207,7 @@ fn wrap_dm_for_channel( /// `[0u8; 6]` if the stored hex is malformed (which would only happen if a /// caller constructed `MeshState` with a bad value — empty string yields /// all-zero, which won't match any real peer on the receiver side). +#[allow(dead_code)] // was used by the @DM2 wrapper; native unicast doesn't need it fn our_sender_prefix(state: &Arc) -> [u8; 6] { let mut out = [0u8; 6]; if state.our_ed_pubkey_hex.len() >= 12 { @@ -195,39 +234,42 @@ async fn send_dm_via_channel( consecutive_write_failures: &mut u32, ) { use base64::Engine; - let sender_prefix = our_sender_prefix(state); - // First try a single frame with the raw payload directly wrapped. - // This keeps small plain-text messages at minimal overhead. - let single = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, payload); - if single.len() <= 140 { - match device.send_channel_text(0, single.as_bytes()).await { + let _ = state; // native unicast carries no separate sender prefix + // NATIVE meshcore unicast (CMD_SEND_TXT_MSG): a real direct message to the + // contact, NOT a broadcast on the shared public channel. This is the fix + // for the long-standing public-channel pollution — archy used to tunnel + // every DM/relay/receipt as an `@DM2:` blob on channel 0, which (a) every + // mesh participant saw as spam and (b) stock meshcore clients (e.g. a + // phone) couldn't decode. A native DM is private and decodes everywhere. + // The receive side handles these via the existing RESP_CONTACT_MSG path. + // + // Small payloads send in one frame; larger ones are base64 + MC-chunked + // and reassembled by the receiver (try_chunk_reassemble). + if payload.len() <= 140 { + match device.send_text_msg(dest_pubkey_prefix, payload).await { Ok(()) => { *consecutive_write_failures = 0; info!( dest = %hex::encode(dest_pubkey_prefix), len = payload.len(), - wire_len = single.len(), - "Sent mesh message (DM via channel)" + "Sent mesh DM (native unicast)" ); } Err(e) => { *consecutive_write_failures += 1; warn!( failures = *consecutive_write_failures, - "Failed to send DM via channel: {}", e + "Failed to send native DM: {}", e ); } } return; } - // Payload too large for one wrap — base64 then MC-chunk. Receiver - // reassembles base64 chunks and routes the decoded bytes back through - // the typed-envelope ladder in handle_channel_payload. let encoded = base64::engine::general_purpose::STANDARD.encode(payload); static CHUNK_MSG_ID: std::sync::atomic::AtomicU8 = std::sync::atomic::AtomicU8::new(0); let msg_id = CHUNK_MSG_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed); - let chunk_data_size = 80; + let chunk_data_size = 100; let chunks: Vec<&str> = encoded .as_bytes() .chunks(chunk_data_size) @@ -239,18 +281,20 @@ async fn send_dm_via_channel( raw_len = payload.len(), b64_len = encoded.len(), chunks = total, - "Sending chunked mesh message (DM via channel)" + "Sending chunked mesh DM (native unicast)" ); let mut any_err = false; for (idx, chunk) in chunks.iter().enumerate() { let frame = format!("MC{:02x}{:02x}{:02x}{}", msg_id, idx as u8, total, chunk); - let wrapped = wrap_dm_for_channel(dest_pubkey_prefix, &sender_prefix, frame.as_bytes()); - if let Err(e) = device.send_channel_text(0, wrapped.as_bytes()).await { + if let Err(e) = device + .send_text_msg(dest_pubkey_prefix, frame.as_bytes()) + .await + { *consecutive_write_failures += 1; warn!( failures = *consecutive_write_failures, chunk = idx, - "Chunk DM-via-channel send failed: {}", + "Chunk native DM send failed: {}", e ); any_err = true; @@ -263,20 +307,72 @@ async fn send_dm_via_channel( } } +/// Send PLAIN text to a stock meshcore client as one or more native DMs. +/// Unlike `send_dm_via_channel`, this never uses MC-chunk framing (stock +/// clients can't reassemble it) — if the text exceeds one LoRa frame it is +/// split into multiple readable plain messages on UTF-8 char boundaries. +async fn send_plain_native_text( + device: &mut MeshRadioDevice, + dest_pubkey_prefix: &[u8; 6], + text: &[u8], + consecutive_write_failures: &mut u32, +) { + // Split on char boundaries so we never break a multi-byte UTF-8 sequence. + const FRAME: usize = 150; // under MAX_MESSAGE_LEN (160), leaves header room + let s = String::from_utf8_lossy(text); + let mut parts: Vec = Vec::new(); + let mut cur = String::new(); + for ch in s.chars() { + if cur.len() + ch.len_utf8() > FRAME { + parts.push(std::mem::take(&mut cur)); + } + cur.push(ch); + } + if !cur.is_empty() || parts.is_empty() { + parts.push(cur); + } + let total = parts.len(); + for (idx, part) in parts.iter().enumerate() { + match device + .send_text_msg(dest_pubkey_prefix, part.as_bytes()) + .await + { + Ok(()) => { + *consecutive_write_failures = 0; + info!( + dest = %hex::encode(dest_pubkey_prefix), + part = idx + 1, + total, + "Sent plain native DM" + ); + } + Err(e) => { + *consecutive_write_failures += 1; + warn!( + failures = *consecutive_write_failures, + "Plain native DM send failed: {}", e + ); + break; + } + } + if total > 1 { + tokio::time::sleep(Duration::from_millis(400)).await; + } + } +} + /// Fetch the contacts list from the device and update the peer cache. async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) { match device.get_contacts().await { Ok(contacts) => { - // Skip firmware contacts the user has explicitly wiped via - // mesh.clear-all. MeshCore keeps its own persistent contact - // table the app can't remove from, so we filter on read to - // keep cleared entries out of the chat list. - let blocklist = state.radio_contact_blocklist.read().await.clone(); + // Contact blocking is intentionally NOT applied here. A read-time + // blocklist meant a wiped/re-paired contact could never come back + // even when it re-advertised (it broke phone re-pairing after a + // clear). Per-contact blocking will return later as an explicit, + // user-controlled feature; until then every firmware contact is + // surfaced. `radio_contact_blocklist` is retained but unused. let mut peers = state.peers.write().await; for (idx, contact) in contacts.iter().enumerate() { - if blocklist.contains(&contact.public_key_hex) { - continue; - } let contact_id = idx as u32; let existing = peers.get(&contact_id); let peer = super::super::types::MeshPeer { @@ -289,6 +385,10 @@ async fn refresh_contacts(device: &mut MeshRadioDevice, state: &Arc) snr: None, last_heard: chrono::Utc::now().to_rfc3339(), hops: 0, + last_advert: contact.last_advert, + // A non-zero path_len means the firmware has a route (direct + // or flood) to this contact — i.e. we can deliver to it. + reachable: contact.path_len != 0, }; peers.insert(contact_id, peer); } @@ -555,6 +655,18 @@ async fn handle_send_command( ) .await; } + MeshCommand::SendNativeText { + dest_pubkey_prefix, + payload, + } => { + send_plain_native_text( + device, + &dest_pubkey_prefix, + &payload, + consecutive_write_failures, + ) + .await; + } MeshCommand::SendRaw { dest_pubkey_prefix, payload, @@ -608,5 +720,22 @@ async fn handle_send_command( MeshCommand::RefreshContacts => { refresh_contacts(device, state).await; } + MeshCommand::RemoveContact { pubkey } => { + if let Err(e) = device.remove_contact(&pubkey).await { + warn!(pubkey = %hex::encode(pubkey), "remove_contact failed: {}", e); + } else { + info!(pubkey = %hex::encode(&pubkey[..6]), "Removed firmware contact"); + } + } + MeshCommand::AddContact { pubkey, name } => { + // type=1 (chat/user), flags=0, out_path_len=0 (firmware will flood + // until a path is learned). last_advert=0 lets the firmware keep its + // own advert timestamp. + if let Err(e) = device.add_contact(&pubkey, 1, 0, 0, &name, 0).await { + warn!(pubkey = %hex::encode(&pubkey[..6]), "add_contact failed: {}", e); + } else { + info!(pubkey = %hex::encode(&pubkey[..6]), "Imported advert as contact"); + } + } } } diff --git a/core/archipelago/src/mesh/meshtastic.rs b/core/archipelago/src/mesh/meshtastic.rs index a092b087..9754a927 100644 --- a/core/archipelago/src/mesh/meshtastic.rs +++ b/core/archipelago/src/mesh/meshtastic.rs @@ -150,6 +150,32 @@ impl MeshtasticDevice { .await } + /// Meshtastic addresses by numeric node-id, not a meshcore pubkey prefix, + /// so there's no direct unicast mapping here. Best-effort fallback to a + /// channel send keeps the device interface uniform; native unicast is only + /// meaningful on the Meshcore transport. + pub async fn send_text_msg(&mut self, _dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> { + self.send_channel_text(0, msg).await + } + + /// Meshtastic has no meshcore-style contact table; these are no-ops so the + /// device interface stays uniform. + pub async fn remove_contact(&mut self, _pubkey: &[u8; 32]) -> Result<()> { + Ok(()) + } + + pub async fn add_contact( + &mut self, + _pubkey: &[u8; 32], + _contact_type: u8, + _flags: u8, + _out_path_len: u8, + _name: &str, + _last_advert: u32, + ) -> Result<()> { + Ok(()) + } + pub async fn get_contacts(&mut self) -> Result> { if self.contacts.is_empty() { self.send_to_radio(&encode_want_config()).await?; diff --git a/core/archipelago/src/mesh/mod.rs b/core/archipelago/src/mesh/mod.rs index 1fb565f3..f204b90a 100644 --- a/core/archipelago/src/mesh/mod.rs +++ b/core/archipelago/src/mesh/mod.rs @@ -82,6 +82,9 @@ pub(crate) async fn upsert_federation_peer( 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), + last_advert: existing.as_ref().map(|p| p.last_advert).unwrap_or(0), + // Federation peers are reachable off-radio (Tor/FIPS), so always true. + reachable: true, }; peers.insert(contact_id, peer); drop(peers); @@ -584,6 +587,7 @@ impl MeshService { let mut interval = tokio::time::interval(Duration::from_secs(30)); interval.tick().await; // skip first let mut last_announced_height: u64 = 0; + let mut last_announce_at: Option = None; let client = match reqwest::Client::builder() .timeout(Duration::from_secs(10)) .build() @@ -601,6 +605,18 @@ impl MeshService { // Poll Bitcoin Core for latest block match bitcoin_rpc_getblockcount(&client).await { Ok(height) if height > last_announced_height => { + // Advance the tip baseline immediately so a fast Bitcoin + // catch-up (a new block every poll) doesn't re-fire each tick. + last_announced_height = height; + // Throttle: at most one announcement per ~9 min. Real ~10 min + // blocks still propagate, but a rapid catch-up can no longer + // flood the shared LoRa channel. + if last_announce_at + .map(|t| t.elapsed() < Duration::from_secs(540)) + .unwrap_or(false) + { + continue; + } if let Ok(header) = bitcoin_rpc_getblockheader_by_height(&client, height).await { // Store in cache let payload = message_types::BlockHeaderPayload { @@ -646,30 +662,15 @@ impl MeshService { } } } - // Second pass: any peer if no Archy nodes found - if sent == 0 { - for peer in peers.values() { - if sent >= max_peers { break; } - if let Some(ref pk) = peer.pubkey_hex { - if let Ok(pk_bytes) = hex::decode(pk) { - if pk_bytes.len() >= 6 { - let mut prefix = [0u8; 6]; - prefix.copy_from_slice(&pk_bytes[..6]); - let _ = bha_state.send_cmd( - listener::MeshCommand::SendRaw { - dest_pubkey_prefix: prefix, - payload: wire.clone(), - }, - ).await; - sent += 1; - } - } - } - } - } + // NOTE: intentionally NO fallback to arbitrary + // peers. Block headers go ONLY to known Archy + // (federated) nodes — never to random meshcore + // devices on the shared public channel. drop(peers); - last_announced_height = height; - info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers"); + if sent > 0 { + last_announce_at = Some(std::time::Instant::now()); + info!(height, hash = %header.hash, peers = sent, "Announced block header to Archy peers"); + } } Err(e) => warn!("Failed to build block announcement: {}", e), } @@ -1273,6 +1274,24 @@ impl MeshService { pub async fn send_message(&self, contact_id: u32, text: &str) -> Result { use crate::mesh::message_types::{MeshMessageType, TypedEnvelope}; let seq = self.state.next_send_seq(contact_id).await; + // Stock (non-archipelago) radio contacts — e.g. a phone running the + // MeshCore app — can't decode our typed envelope and would render it as + // garbled bytes. Send them the raw text as a plain native DM instead. + // Archipelago peers still get the typed envelope (seq/reply/reaction + // addressing + encryption). + if !self.is_archy_peer(contact_id).await { + let dest_prefix = self.peer_dest_prefix(contact_id).await?; + self.state + .send_cmd(listener::MeshCommand::SendNativeText { + dest_pubkey_prefix: dest_prefix, + payload: text.as_bytes().to_vec(), + }) + .await + .map_err(|_| anyhow::anyhow!("Mesh listener not running"))?; + return Ok(self + .record_sent_typed(contact_id, "text", text, None, seq) + .await); + } let envelope = TypedEnvelope::new(MeshMessageType::Text, text.as_bytes().to_vec()).with_seq(seq); let wire = envelope.to_wire()?; @@ -1280,6 +1299,22 @@ impl MeshService { .await } + /// Whether `contact_id` is an archipelago peer (vs a stock meshcore client). + /// Federation-synthetic ids are always archy; radio contacts count as archy + /// only once we've learned their archipelago identity (DID or x25519 key, + /// from federation seeding or an identity exchange). Stock clients have + /// neither, so we send them plain text rather than typed envelopes. + async fn is_archy_peer(&self, contact_id: u32) -> bool { + if contact_id & 0x8000_0000 != 0 { + return true; + } + let peers = self.state.peers.read().await; + peers + .get(&contact_id) + .map(|p| p.did.is_some() || p.x25519_pubkey.is_some()) + .unwrap_or(false) + } + /// Record a Sent MeshMessage for a typed envelope that has already been /// transmitted by the caller. Used by the RPC layer after sending /// invoice/coordinate/alert/etc. so the UI gets a proper rich Sent card diff --git a/core/archipelago/src/mesh/protocol.rs b/core/archipelago/src/mesh/protocol.rs index 6f288c5e..3ccafbd9 100644 --- a/core/archipelago/src/mesh/protocol.rs +++ b/core/archipelago/src/mesh/protocol.rs @@ -30,6 +30,13 @@ pub const CMD_SYNC_NEXT_MESSAGE: u8 = 0x0A; /// known" — without this, the firmware silently drops outbound TXT_MSG /// frames to such contacts. pub const CMD_RESET_PATH: u8 = 0x0D; +/// CMD_ADD_UPDATE_CONTACT (0x09): add or update a contact in the firmware +/// table. 144-byte frame (see `build_add_contact`). +pub const CMD_ADD_UPDATE_CONTACT: u8 = 0x09; +/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]` — delete a contact from the +/// firmware's persistent table (used by clear-all so wiped contacts actually +/// go away and only return when they re-advertise). +pub const CMD_REMOVE_CONTACT: u8 = 0x0F; pub const CMD_SET_RADIO_PARAMS: u8 = 0x0B; pub const CMD_SET_RADIO_TX_POWER: u8 = 0x0C; pub const CMD_SET_TUNING_PARAMS: u8 = 0x15; @@ -258,6 +265,45 @@ pub fn build_reset_path(pubkey: &[u8; 32]) -> Vec { encode_frame(&data) } +/// CMD_REMOVE_CONTACT (0x0F): `[0x0F][pub_key:32]`. Removes the contact from +/// the firmware's persistent contact table. +pub fn build_remove_contact(pubkey: &[u8; 32]) -> Vec { + let mut data = vec![CMD_REMOVE_CONTACT]; + data.extend_from_slice(pubkey); + encode_frame(&data) +} + +/// CMD_ADD_UPDATE_CONTACT (0x09): add/update a contact. 144-byte body: +/// `[0x09][pub_key:32][type:1][flags:1][out_path_len:1][out_path:64][name:32] +/// [last_advert:4 LE][adv_lat:4 LE][adv_lon:4 LE]`. +/// `name` is zero-padded to 32 bytes (the firmware fills it from the heard +/// advert on its side too, so an empty name still resolves on get-contacts). +pub fn build_add_contact( + pubkey: &[u8; 32], + contact_type: u8, + flags: u8, + out_path_len: u8, + name: &str, + last_advert: u32, +) -> Vec { + let mut data = Vec::with_capacity(144); + data.push(CMD_ADD_UPDATE_CONTACT); + data.extend_from_slice(pubkey); // 32 + data.push(contact_type); // 1 + data.push(flags); // 1 + data.push(out_path_len); // 1 + data.extend_from_slice(&[0u8; 64]); // out_path (64) + let mut name_buf = [0u8; 32]; + let nb = name.as_bytes(); + let n = nb.len().min(32); + name_buf[..n].copy_from_slice(&nb[..n]); + data.extend_from_slice(&name_buf); // name (32) + data.extend_from_slice(&last_advert.to_le_bytes()); // last_advert (4) + data.extend_from_slice(&0i32.to_le_bytes()); // adv_lat (4) + data.extend_from_slice(&0i32.to_le_bytes()); // adv_lon (4) + encode_frame(&data) +} + /// CMD_SYNC_NEXT_MESSAGE (0x0A): Retrieve the next queued message. pub fn build_sync_next_message() -> Vec { encode_frame(&[CMD_SYNC_NEXT_MESSAGE]) diff --git a/core/archipelago/src/mesh/serial.rs b/core/archipelago/src/mesh/serial.rs index f390e7c4..5eaac130 100644 --- a/core/archipelago/src/mesh/serial.rs +++ b/core/archipelago/src/mesh/serial.rs @@ -206,6 +206,24 @@ impl MeshcoreDevice { Ok(()) } + /// Send a NATIVE meshcore direct message (CMD_SEND_TXT_MSG) to a contact, + /// addressed by the first 6 bytes of its public key. Unlike the + /// `@DM2`-over-channel path, this is a real unicast — it does not appear on + /// the public channel, and a stock meshcore client receives it as a normal + /// DM. The contact must already exist in the firmware table (with a path). + pub async fn send_text_msg(&mut self, dest_pubkey_prefix: &[u8; 6], msg: &[u8]) -> Result<()> { + let frame_data = protocol::build_send_text(dest_pubkey_prefix, msg)?; + self.send_raw(&frame_data).await?; + let frame = self.recv_frame_timeout(READ_TIMEOUT).await?; + if frame.code == protocol::RESP_ERR { + anyhow::bail!( + "Direct text send failed: {}", + protocol::parse_error(&frame.data) + ); + } + Ok(()) + } + /// Clear the stored routing path for a contact so the firmware flood- /// routes future messages instead of dropping them when path_len=0. pub async fn reset_contact_path(&mut self, pubkey: &[u8; 32]) -> Result<()> { @@ -217,6 +235,47 @@ impl MeshcoreDevice { Ok(()) } + /// Delete a contact from the firmware's persistent contact table. + pub async fn remove_contact(&mut self, pubkey: &[u8; 32]) -> Result<()> { + self.send_raw(&protocol::build_remove_contact(pubkey)) + .await?; + let frame = self.recv_frame_timeout(READ_TIMEOUT).await?; + if frame.code == protocol::RESP_ERR { + anyhow::bail!( + "Remove contact failed: {}", + protocol::parse_error(&frame.data) + ); + } + Ok(()) + } + + /// Add/update a contact in the firmware table (CMD_ADD_UPDATE_CONTACT). + /// Used to import a heard advert so it shows up as a contact immediately. + pub async fn add_contact( + &mut self, + pubkey: &[u8; 32], + contact_type: u8, + flags: u8, + out_path_len: u8, + name: &str, + last_advert: u32, + ) -> Result<()> { + self.send_raw(&protocol::build_add_contact( + pubkey, + contact_type, + flags, + out_path_len, + name, + last_advert, + )) + .await?; + let frame = self.recv_frame_timeout(READ_TIMEOUT).await?; + if frame.code == protocol::RESP_ERR { + anyhow::bail!("Add contact failed: {}", protocol::parse_error(&frame.data)); + } + Ok(()) + } + /// Get the list of known contacts from the device. /// Protocol: CMD_GET_CONTACTS -> CONTACT_START(count) -> N×CONTACT -> CONTACT_END pub async fn get_contacts(&mut self) -> Result> { diff --git a/core/archipelago/src/mesh/types.rs b/core/archipelago/src/mesh/types.rs index 6517874f..7d65a7e4 100644 --- a/core/archipelago/src/mesh/types.rs +++ b/core/archipelago/src/mesh/types.rs @@ -45,6 +45,15 @@ pub struct MeshPeer { pub last_heard: String, /// Number of hops to reach this peer. pub hops: u8, + /// Firmware advert timestamp (unix secs) of the contact's last advert, or + /// 0 if unknown. Used to gauge reachability/recency in the UI. + #[serde(default)] + pub last_advert: u32, + /// Best-effort "currently reachable" flag: the radio has a route to this + /// contact (or it's a federation/identity peer reachable off-radio). A + /// contact with no path and no recent advert is shown as unreachable. + #[serde(default)] + pub reachable: bool, } /// Direction of a mesh message. diff --git a/neode-ui/src/stores/mesh.ts b/neode-ui/src/stores/mesh.ts index 416a24d3..87db9bf4 100644 --- a/neode-ui/src/stores/mesh.ts +++ b/neode-ui/src/stores/mesh.ts @@ -30,6 +30,8 @@ export interface MeshPeer { snr: number | null last_heard: string hops: number + last_advert?: number + reachable?: boolean } export interface MeshChannel { diff --git a/neode-ui/src/views/Mesh.vue b/neode-ui/src/views/Mesh.vue index 79d6e140..ad9e8378 100644 --- a/neode-ui/src/views/Mesh.vue +++ b/neode-ui/src/views/Mesh.vue @@ -501,6 +501,7 @@ interface MergedPeer { primary_pubkey_hex: string | null primary_rssi: number | null is_archy: boolean + reachable: boolean // The original active-chat marker uses contact_id equality, so keep a // representative MeshPeer for the rest of the codepaths that still want // a single object (peer header rssi, prekey rotation, etc). @@ -622,6 +623,7 @@ const mergedPeers = computed(() => { primary_pubkey_hex: peer.pubkey_hex, primary_rssi: peer.rssi, is_archy: isArchyNode(peer) || !!matchedFed, + reachable: peer.reachable ?? true, primary: peer, }) } @@ -671,12 +673,27 @@ const mergedPeers = computed(() => { primary_pubkey_hex: fed.pubkey, primary_rssi: null, is_archy: true, + reachable: true, primary: placeholder, }) } return Array.from(groups.values()) }) +// Contact search — filters the Peers list by name, DID, npub, or pubkey. +const peerSearch = ref('') +const displayedPeers = computed(() => { + const q = peerSearch.value.trim().toLowerCase() + if (!q) return mergedPeers.value + return mergedPeers.value.filter((mp) => + mp.display_name.toLowerCase().includes(q) || + (mp.short_did?.toLowerCase().includes(q) ?? false) || + (mp.did?.toLowerCase().includes(q) ?? false) || + (mp.npub?.toLowerCase().includes(q) ?? false) || + (mp.primary_pubkey_hex?.toLowerCase().includes(q) ?? false), + ) +}) + // Mirror of the backend's `federation_peer_contact_id` (mesh/mod.rs): take the // first 4 bytes of the archipelago pubkey as a little-endian u32, clear the top // bit, then set it as the federation marker. Producing the SAME id here means a @@ -1417,6 +1434,25 @@ function isImageMime(mime?: string): boolean { + +
+ + +
+
No peers discovered yet.
@@ -1454,8 +1490,11 @@ function isImageMime(mime?: string): boolean { {{ mesh.unreadCounts[channelContactId(0)] }} +
+ No contacts match “{{ peerSearch.trim() }}”. +
+
diff --git a/neode-ui/src/views/mesh/MeshAssistantPanel.vue b/neode-ui/src/views/mesh/MeshAssistantPanel.vue index d32ed598..8eb71496 100644 --- a/neode-ui/src/views/mesh/MeshAssistantPanel.vue +++ b/neode-ui/src/views/mesh/MeshAssistantPanel.vue @@ -62,6 +62,32 @@ function toggleAllowed(pubkey: string) { apply({ allowed_contacts: allowedContacts.value }) } +// Manually pasting a raw ed25519 pubkey (hex) — for an allowed asker that +// isn't in the contact list yet (e.g. a phone/meshcore device). +const newPubkey = ref('') +const pubkeyError = ref('') +// Allowlisted keys that aren't one of our known contacts (manually added). +const extraAllowed = computed(() => + allowedContacts.value.filter( + (k) => !contactOptions.value.some((c) => c.pubkey.toLowerCase() === k.toLowerCase()), + ), +) +function addPubkey() { + const pk = newPubkey.value.trim().toLowerCase() + pubkeyError.value = '' + if (!/^[0-9a-f]{64}$/.test(pk)) { + pubkeyError.value = 'Enter a 64-character hex ed25519 public key.' + return + } + if (allowedContacts.value.some((k) => k.toLowerCase() === pk)) { + newPubkey.value = '' + return + } + allowedContacts.value = [...allowedContacts.value, pk] + newPubkey.value = '' + apply({ allowed_contacts: allowedContacts.value }) +} + onMounted(() => { mesh.fetchAssistantStatus() }) @@ -206,7 +232,28 @@ function onPolicy() { {{ c.name }} + +
+ + +
+ + +
+

{{ pubkeyError }}

diff --git a/neode-ui/src/views/mesh/mesh-styles.css b/neode-ui/src/views/mesh/mesh-styles.css index 477a5ca4..ec0f71e9 100644 --- a/neode-ui/src/views/mesh/mesh-styles.css +++ b/neode-ui/src/views/mesh/mesh-styles.css @@ -34,7 +34,7 @@ .mesh-error { color: #ef4444; font-size: 0.85rem; padding: 8px 12px; background: rgba(239, 68, 68, 0.1); border-radius: 8px; border: 1px solid rgba(239, 68, 68, 0.2); flex-shrink: 0; } .mesh-columns { display: flex; gap: 16px; flex: 1; min-height: 0; overflow: hidden; } .mesh-left { width: 380px; flex-shrink: 0; display: flex; flex-direction: column; gap: 12px; min-height: 0; overflow-y: auto; overscroll-behavior: contain; } -.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; } +.mesh-right { flex: 1; min-width: 0; min-height: 0; display: flex; flex-direction: column; gap: 12px; overflow: hidden; overscroll-behavior: contain; } .mesh-tools-wrapper { display: contents; } .mesh-tools-tab-bar { display: none; } .mesh-columns-wide { display: grid; grid-template-columns: minmax(300px, 340px) minmax(420px, 1.1fr) minmax(360px, 0.9fr); gap: 16px; } @@ -85,7 +85,16 @@ .mesh-peer-row { display: flex; align-items: center; gap: 10px; padding: 10px; border-radius: 8px; cursor: pointer; transition: background 0.15s; } .mesh-peer-row:hover { background: rgba(255, 255, 255, 0.06); } .mesh-peer-row.active { background: rgba(251, 146, 60, 0.1); border: 1px solid rgba(251, 146, 60, 0.2); } -.mesh-peer-avatar { width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; } +.mesh-peer-avatar { position: relative; width: 36px; height: 36px; border-radius: 50%; background: rgba(255, 255, 255, 0.08); display: flex; align-items: center; justify-content: center; font-size: 0.9rem; color: rgba(255, 255, 255, 0.6); flex-shrink: 0; font-weight: 600; } +.mesh-peer-search-wrap { position: relative; margin-bottom: 10px; flex-shrink: 0; } +.mesh-peer-search { width: 100%; box-sizing: border-box; padding: 7px 30px 7px 10px; font-size: 0.85rem; border-radius: 8px; border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.25); color: rgba(255,255,255,0.9); outline: none; } +.mesh-peer-search::placeholder { color: rgba(255,255,255,0.35); } +.mesh-peer-search:focus { border-color: rgba(251,146,60,0.4); } +.mesh-peer-search-clear { position: absolute; top: 50%; right: 6px; transform: translateY(-50%); width: 20px; height: 20px; line-height: 18px; text-align: center; border: none; border-radius: 50%; background: rgba(255,255,255,0.12); color: rgba(255,255,255,0.7); font-size: 16px; cursor: pointer; padding: 0; } +.mesh-peer-search-clear:hover { background: rgba(255,255,255,0.22); color: #fff; } +.mesh-peer-reach { position: absolute; bottom: -1px; right: -1px; width: 10px; height: 10px; border-radius: 50%; border: 2px solid #11131a; } +.mesh-peer-reach.is-reachable { background: #34d399; } +.mesh-peer-reach.is-unreachable { background: rgba(255,255,255,0.25); } .mesh-peer-avatar.archy { background: rgba(251, 146, 60, 0.15); padding: 0; overflow: hidden; } .mesh-peer-avatar.archy :deep(> div) { width: 26px; height: 26px; border-radius: 50%; overflow: hidden; } .mesh-peer-avatar.channel { background: rgba(59, 130, 246, 0.15); color: #3b82f6; font-weight: 700; font-size: 1.1rem; } @@ -236,6 +245,8 @@ .mesh-assistant-allow-row { display: flex; align-items: center; gap: 8px; padding: 6px 8px; border-radius: 8px; cursor: pointer; font-size: 0.85rem; color: rgba(255,255,255,0.85); } .mesh-assistant-allow-row:hover { background: rgba(255,255,255,0.06); } .mesh-assistant-allow-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.mesh-assistant-addkey { display: flex; gap: 6px; margin-top: 6px; } +.mesh-assistant-addkey input { flex: 1; min-width: 0; } .mesh-panel-title { font-size: 1rem; font-weight: 700; color: rgba(255,255,255,0.95); margin: 0; } .mesh-panel-sub { font-size: 0.8rem; color: rgba(255,255,255,0.45); margin: -4px 0 0; } .mesh-bitcoin-section { display: flex; flex-direction: column; gap: 8px; }