feat(mesh): native-unicast DMs, contact import/remove, reachability, contact search

- DMs now use native meshcore unicast (CMD_SEND_TXT_MSG) instead of @DM2 channel
  broadcasts: private (E2E-encrypted to the recipient pubkey by firmware), off the
  public channel, and decodable by stock clients. Plain text (split, not MC-chunked)
  to non-archipelago contacts; typed envelopes to archy peers.
- !ai replies now DM the asker privately (RadioDm) instead of broadcasting on ch0.
- Auto contact-import: a heard advert (PUSH_CONTACT_ADVERT/0x80, 32-byte pubkey) is
  added via CMD_ADD_UPDATE_CONTACT (0x09) so contacts appear without a flood advert.
- clear-all now DELETES firmware contacts via CMD_REMOVE_CONTACT (0x0F) instead of
  blocklisting; blocking filter removed entirely. Wiped contacts return when reachable.
- Contact reachability: MeshPeer carries last_advert + reachable (path-based); UI shows
  a reachability dot.
- Peers list: contact search box (filter by name/DID/npub/pubkey) with a clear button.
- send_message routes stock contacts as plain native text (fixes garbled envelopes).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
archipelago 2026-06-18 08:08:52 -04:00
parent 9f2edf6b7a
commit f0fdc23cc9
15 changed files with 578 additions and 95 deletions

View File

@ -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<String> = 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<String> = 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();

View File

@ -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<MeshState>, 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<MeshState>, 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;
}
}
}

View File

@ -353,27 +353,36 @@ 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 <question>` 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 <question>` 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,
)
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 = {

View File

@ -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
}

View File

@ -63,6 +63,14 @@ pub enum MeshCommand {
dest_pubkey_prefix: [u8; 6],
payload: Vec<u8>,
},
/// 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<u8>,
},
/// 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.

View File

@ -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<Vec<super::super::protocol::ParsedContact>> {
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<MeshState>) -> [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<String> = 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<MeshState>) {
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<MeshState>)
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");
}
}
}
}

View File

@ -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<Vec<ParsedContact>> {
if self.contacts.is_empty() {
self.send_to_radio(&encode_want_config()).await?;

View File

@ -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<std::time::Instant> = 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,31 +662,16 @@ 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;
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<MeshMessage> {
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

View File

@ -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<u8> {
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<u8> {
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<u8> {
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<u8> {
encode_frame(&[CMD_SYNC_NEXT_MESSAGE])

View File

@ -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<Vec<protocol::ParsedContact>> {

View File

@ -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.

View File

@ -30,6 +30,8 @@ export interface MeshPeer {
snr: number | null
last_heard: string
hops: number
last_advert?: number
reachable?: boolean
}
export interface MeshChannel {

View File

@ -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<MergedPeer[]>(() => {
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<MergedPeer[]>(() => {
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<MergedPeer[]>(() => {
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 {
<button class="text-xs text-white/40 hover:text-red-400 transition-colors px-2 py-1" @click="clearAllMesh" title="Clear all peers, messages, and chat history">Clear All</button>
</div>
<!-- Contact search: filters the list below by name / DID / npub / pubkey -->
<div class="mesh-peer-search-wrap">
<input
v-model="peerSearch"
type="text"
class="mesh-peer-search"
placeholder="Search contacts…"
aria-label="Search contacts"
/>
<button
v-if="peerSearch"
type="button"
class="mesh-peer-search-clear"
aria-label="Clear search"
title="Clear search"
@click="peerSearch = ''"
>&times;</button>
</div>
<div v-if="mesh.peers.length === 0 && !mesh.status?.device_connected" class="mesh-empty">
No peers discovered yet.
</div>
@ -1454,8 +1490,11 @@ function isImageMime(mime?: string): boolean {
</div>
<span v-if="mesh.unreadCounts[channelContactId(0)]" class="ml-auto text-[10px] px-1.5 py-0.5 rounded-full bg-orange-500/30 text-orange-300">{{ mesh.unreadCounts[channelContactId(0)] }}</span>
</div>
<div v-if="displayedPeers.length === 0 && peerSearch.trim()" class="mesh-empty">
No contacts match {{ peerSearch.trim() }}.
</div>
<div
v-for="mp in mergedPeers" :key="mp.key"
v-for="mp in displayedPeers" :key="mp.key"
class="mesh-peer-row"
:class="{ active: mp.contact_ids.includes(activeChatPeer?.contact_id ?? -1), 'is-archy': mp.is_archy }"
tabindex="0"
@ -1466,6 +1505,7 @@ function isImageMime(mime?: string): boolean {
<div class="mesh-peer-avatar" :class="{ archy: mp.is_archy }">
<AnimatedLogo v-if="mp.is_archy" size="sm" />
<template v-else>{{ mp.display_name.charAt(0).toUpperCase() }}</template>
<span class="mesh-peer-reach" :class="mp.reachable ? 'is-reachable' : 'is-unreachable'" :title="mp.reachable ? 'Reachable' : 'Not currently reachable'"></span>
</div>
<div class="mesh-peer-info">
<div class="mesh-peer-name">

View File

@ -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() {
<input type="checkbox" :checked="isAllowed(c.pubkey)" @change="toggleAllowed(c.pubkey)" />
<span class="mesh-assistant-allow-name">{{ c.name }}</span>
</label>
<!-- Manually-added pubkeys not in the contact list -->
<label
v-for="pk in extraAllowed"
:key="pk"
class="mesh-assistant-allow-row"
>
<input type="checkbox" checked @change="toggleAllowed(pk)" />
<span class="mesh-assistant-allow-name" :title="pk">{{ pk.slice(0, 10) }} (added)</span>
</label>
</div>
<!-- Add an arbitrary pubkey directly -->
<div class="mesh-assistant-addkey">
<input
v-model="newPubkey"
class="mesh-bitcoin-input mesh-bitcoin-input-sm"
placeholder="Paste an ed25519 pubkey (64 hex) to allow"
@keyup.enter="addPubkey"
/>
<button type="button" class="glass-button mesh-bitcoin-input-sm" @click="addPubkey">Add</button>
</div>
<p v-if="pubkeyError" class="text-xs mt-1" style="color:#f87171">{{ pubkeyError }}</p>
</div>
<p class="text-xs text-white/50 mt-2">

View File

@ -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; }