fix(mesh): collapse cross-transport twin contacts into one conversation (#12)
A node reachable both over LoRa and federation has two MeshPeer rows (radio twin: low contact_id + firmware key; federation twin: high contact_id + archipelago key), and messages key by peer_contact_id split across the two ids — so opening one twin shows an empty thread (the .120->.89 symptom). - backend: new group_peer_twins() helper groups peers by arch_pubkey_hex (set on BOTH twins by bind_federation_twins), keeps the radio id as the mesh-first send target, and unions messages across all twin ids. Wired into conversations.list / conversations.messages / mesh.contacts-list. +3 unit tests. - frontend: the live chat list merges client-side (mergedPeers) and matched twins by the "Archy-z6Mk..." advert prefix, which the Meshtastic device rename broke (radio now advertises the server name). Merge by arch_pubkey_hex instead, which the backend reliably sets on both twins. Expose arch_pubkey_hex on MeshPeer. - fix unrelated stale test: EcashTransaction test missing the new `kind` field. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f7e8dca80
commit
f92e442bfc
@ -95,12 +95,17 @@ impl RpcHandler {
|
|||||||
if let Some(svc) = service.as_ref() {
|
if let Some(svc) = service.as_ref() {
|
||||||
let peers = svc.peers().await;
|
let peers = svc.peers().await;
|
||||||
let messages = svc.messages(None).await;
|
let messages = svc.messages(None).await;
|
||||||
// Per-peer last message.
|
// Collapse radio/federation twins into one conversation per identity
|
||||||
for peer in &peers {
|
// so a node reachable both ways shows once, with its messages unioned
|
||||||
|
// across both twin contact_ids (#12).
|
||||||
|
let groups = mesh::group_peer_twins(&peers);
|
||||||
|
for group in &groups {
|
||||||
|
let peer = &group.canonical;
|
||||||
|
// Newest message across ALL twin contact_ids in this group.
|
||||||
let last = messages
|
let last = messages
|
||||||
.iter()
|
.iter()
|
||||||
.rev()
|
.rev()
|
||||||
.find(|m| m.peer_contact_id == peer.contact_id);
|
.find(|m| group.contact_ids.contains(&m.peer_contact_id));
|
||||||
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
let is_federation = peer.contact_id & 0x8000_0000 != 0;
|
||||||
conversations.push(serde_json::json!({
|
conversations.push(serde_json::json!({
|
||||||
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
"id": format!("{}:{}", if is_federation { "federation" } else { "mesh" }, peer.contact_id),
|
||||||
@ -163,8 +168,16 @@ impl RpcHandler {
|
|||||||
let filtered: Vec<_> = match kind {
|
let filtered: Vec<_> = match kind {
|
||||||
"mesh" | "federation" => {
|
"mesh" | "federation" => {
|
||||||
let contact_id: u32 = rest.parse().unwrap_or(0);
|
let contact_id: u32 = rest.parse().unwrap_or(0);
|
||||||
|
// Resolve this id's twin group and union messages across all of
|
||||||
|
// its contact_ids, so opening either twin shows the full thread
|
||||||
|
// (federation-injected + radio messages) (#12).
|
||||||
|
let ids: Vec<u32> = mesh::group_peer_twins(&svc.peers().await)
|
||||||
|
.into_iter()
|
||||||
|
.find(|g| g.contact_ids.contains(&contact_id))
|
||||||
|
.map(|g| g.contact_ids)
|
||||||
|
.unwrap_or_else(|| vec![contact_id]);
|
||||||
all.into_iter()
|
all.into_iter()
|
||||||
.filter(|m| m.peer_contact_id == contact_id)
|
.filter(|m| ids.contains(&m.peer_contact_id))
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
"channel" => {
|
"channel" => {
|
||||||
|
|||||||
@ -1133,9 +1133,13 @@ impl RpcHandler {
|
|||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
let state = svc.shared_state();
|
let state = svc.shared_state();
|
||||||
let contacts = state.contacts.read().await;
|
let contacts = state.contacts.read().await;
|
||||||
let peers = state.peers.read().await;
|
let peer_vec: Vec<_> = state.peers.read().await.values().cloned().collect();
|
||||||
|
// Collapse radio/federation twins so a node reachable both ways shows as
|
||||||
|
// one contact instead of two (#12).
|
||||||
|
let groups = crate::mesh::group_peer_twins(&peer_vec);
|
||||||
let mut out: Vec<serde_json::Value> = Vec::new();
|
let mut out: Vec<serde_json::Value> = Vec::new();
|
||||||
for peer in peers.values() {
|
for group in &groups {
|
||||||
|
let peer = &group.canonical;
|
||||||
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
if let Some(pk) = peer.pubkey_hex.as_ref() {
|
||||||
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
let entry = contacts.get(pk).cloned().unwrap_or_default();
|
||||||
out.push(serde_json::json!({
|
out.push(serde_json::json!({
|
||||||
|
|||||||
@ -127,6 +127,91 @@ pub(crate) fn bind_federation_twins(peers: &mut std::collections::HashMap<u32, M
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// One logical contact after collapsing cross-transport twins (see
|
||||||
|
/// [`group_peer_twins`]). A node reachable both over LoRa and over federation
|
||||||
|
/// has two `MeshPeer` rows (different `contact_id`s) but is one conversation.
|
||||||
|
pub(crate) struct PeerGroup {
|
||||||
|
/// The peer row the UI should address. The radio twin when one exists (so
|
||||||
|
/// `send_typed_wire` stays mesh-first — LoRa if reachable, else federation
|
||||||
|
/// via the bound arch key), otherwise the federation row. Gap-healed so its
|
||||||
|
/// name / `arch_pubkey_hex` / `did` are populated from whichever twin had
|
||||||
|
/// them, and `reachable` is the OR across the group.
|
||||||
|
pub canonical: MeshPeer,
|
||||||
|
/// Every `contact_id` in the group. The conversation's messages are the
|
||||||
|
/// union of those keyed by any of these ids — federation-injected messages
|
||||||
|
/// land on the federation twin's id, radio messages on the radio twin's.
|
||||||
|
pub contact_ids: Vec<u32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Collapse cross-transport twin peers into one conversation per identity.
|
||||||
|
///
|
||||||
|
/// The same node commonly appears twice in the peer table: a radio twin (low
|
||||||
|
/// `contact_id`, firmware routing key) and a federation twin (high
|
||||||
|
/// `contact_id`, archipelago key), correlated by [`bind_federation_twins`]
|
||||||
|
/// which copies `arch_pubkey_hex` onto the radio twin but leaves both rows.
|
||||||
|
/// Messages are keyed by `peer_contact_id`, so they split across the two ids:
|
||||||
|
/// a federation-injected message sits on the federation row while the user may
|
||||||
|
/// open the radio row and see an empty thread (the `.120`→`.89` symptom).
|
||||||
|
///
|
||||||
|
/// Group peers by `arch_pubkey_hex` when set, else treat each peer as its own
|
||||||
|
/// singleton group keyed by `contact_id`. Grouping is done ONLY here at surface
|
||||||
|
/// time — never re-keyed at bind time — so outbound routing keeps the distinct
|
||||||
|
/// per-twin `contact_id`s and stays mesh-first. First-seen order is preserved
|
||||||
|
/// for stable downstream sorting.
|
||||||
|
pub(crate) fn group_peer_twins(peers: &[MeshPeer]) -> Vec<PeerGroup> {
|
||||||
|
let mut order: Vec<String> = Vec::new();
|
||||||
|
let mut groups: std::collections::HashMap<String, Vec<MeshPeer>> =
|
||||||
|
std::collections::HashMap::new();
|
||||||
|
for p in peers {
|
||||||
|
let key = match p.arch_pubkey_hex.as_deref() {
|
||||||
|
Some(arch) if !arch.is_empty() => format!("arch:{}", arch.to_ascii_lowercase()),
|
||||||
|
_ => format!("cid:{}", p.contact_id),
|
||||||
|
};
|
||||||
|
if !groups.contains_key(&key) {
|
||||||
|
order.push(key.clone());
|
||||||
|
}
|
||||||
|
groups.entry(key).or_default().push(p.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut out = Vec::with_capacity(order.len());
|
||||||
|
for key in order {
|
||||||
|
let members = match groups.remove(&key) {
|
||||||
|
Some(m) if !m.is_empty() => m,
|
||||||
|
_ => continue,
|
||||||
|
};
|
||||||
|
let contact_ids: Vec<u32> = members.iter().map(|m| m.contact_id).collect();
|
||||||
|
// Canonical = the radio twin (lowest id below the federation base) when
|
||||||
|
// one exists, else the lowest id overall (a federation-only peer).
|
||||||
|
let canonical_src = members
|
||||||
|
.iter()
|
||||||
|
.filter(|m| m.contact_id < FEDERATION_CONTACT_ID_BASE)
|
||||||
|
.min_by_key(|m| m.contact_id)
|
||||||
|
.or_else(|| members.iter().min_by_key(|m| m.contact_id))
|
||||||
|
.expect("non-empty members");
|
||||||
|
let mut canonical = canonical_src.clone();
|
||||||
|
// Heal gaps from the twin: a radio row may lack the advert name, arch
|
||||||
|
// identity, or did that only the federation row carries.
|
||||||
|
if canonical.advert_name.trim().is_empty() {
|
||||||
|
if let Some(named) = members.iter().find(|m| !m.advert_name.trim().is_empty()) {
|
||||||
|
canonical.advert_name = named.advert_name.clone();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if canonical.arch_pubkey_hex.is_none() {
|
||||||
|
canonical.arch_pubkey_hex = members.iter().find_map(|m| m.arch_pubkey_hex.clone());
|
||||||
|
}
|
||||||
|
if canonical.did.is_none() {
|
||||||
|
canonical.did = members.iter().find_map(|m| m.did.clone());
|
||||||
|
}
|
||||||
|
// Reachable if ANY twin is reachable (radio path or off-radio federation).
|
||||||
|
canonical.reachable = members.iter().any(|m| m.reachable);
|
||||||
|
out.push(PeerGroup {
|
||||||
|
canonical,
|
||||||
|
contact_ids,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
/// Upsert a mesh peer record representing a federation node so the UI can
|
/// Upsert a mesh peer record representing a federation node so the UI can
|
||||||
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
|
/// address it as a chat and `mesh.send-content` can route ContentRef to it.
|
||||||
/// Existing entries (same contact_id) are updated in place, preserving any
|
/// Existing entries (same contact_id) are updated in place, preserving any
|
||||||
@ -1760,6 +1845,57 @@ async fn bitcoin_rpc_getblockheader_by_height(
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
|
fn mk_peer(contact_id: u32, name: &str, arch: Option<&str>, reachable: bool) -> MeshPeer {
|
||||||
|
MeshPeer {
|
||||||
|
contact_id,
|
||||||
|
advert_name: name.to_string(),
|
||||||
|
did: None,
|
||||||
|
pubkey_hex: Some(format!("fw{contact_id}")),
|
||||||
|
arch_pubkey_hex: arch.map(|s| s.to_string()),
|
||||||
|
x25519_pubkey: None,
|
||||||
|
rssi: None,
|
||||||
|
snr: None,
|
||||||
|
last_heard: String::new(),
|
||||||
|
hops: 0,
|
||||||
|
last_advert: 0,
|
||||||
|
reachable,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_group_peer_twins_collapses_radio_and_federation() {
|
||||||
|
let radio = mk_peer(42, "Archy-X250-EXP", Some("ABCD"), false);
|
||||||
|
let fed = mk_peer(0x8000_0001, "Archy-X250-EXP", Some("abcd"), true);
|
||||||
|
let groups = group_peer_twins(&[radio, fed]);
|
||||||
|
assert_eq!(groups.len(), 1, "twins must collapse to one conversation");
|
||||||
|
let g = &groups[0];
|
||||||
|
// Canonical = the radio twin (mesh-first send), but reachability is the
|
||||||
|
// OR across twins (federation is reachable off-radio).
|
||||||
|
assert_eq!(g.canonical.contact_id, 42);
|
||||||
|
assert!(g.canonical.reachable);
|
||||||
|
// Both ids retained so messages can be unioned across them.
|
||||||
|
assert!(g.contact_ids.contains(&42) && g.contact_ids.contains(&0x8000_0001));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_group_peer_twins_keeps_distinct_identities_and_unbound_radio() {
|
||||||
|
// Two different identities + one radio peer that was never bound to a
|
||||||
|
// federation twin (arch = None) → three separate conversations.
|
||||||
|
let a = mk_peer(1, "Alice", Some("aa"), false);
|
||||||
|
let b = mk_peer(2, "Bob", Some("bb"), true);
|
||||||
|
let lonely = mk_peer(3, "Carol-radio", None, false);
|
||||||
|
let groups = group_peer_twins(&[a, b, lonely]);
|
||||||
|
assert_eq!(groups.len(), 3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_group_peer_twins_federation_only_uses_federation_id() {
|
||||||
|
let fed = mk_peer(0x8000_00ff, "Arch Dev", Some("dead"), true);
|
||||||
|
let groups = group_peer_twins(&[fed]);
|
||||||
|
assert_eq!(groups.len(), 1);
|
||||||
|
assert_eq!(groups[0].canonical.contact_id, 0x8000_00ff);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_mesh_config_default() {
|
fn test_mesh_config_default() {
|
||||||
let config = MeshConfig::default();
|
let config = MeshConfig::default();
|
||||||
|
|||||||
@ -1507,6 +1507,7 @@ mod tests {
|
|||||||
description: "test".into(),
|
description: "test".into(),
|
||||||
mint_url: String::new(),
|
mint_url: String::new(),
|
||||||
peer: String::new(),
|
peer: String::new(),
|
||||||
|
kind: default_tx_kind(),
|
||||||
};
|
};
|
||||||
let json = serde_json::to_string(&tx).unwrap();
|
let json = serde_json::to_string(&tx).unwrap();
|
||||||
assert!(json.contains("\"streamingpayment\""));
|
assert!(json.contains("\"streamingpayment\""));
|
||||||
|
|||||||
@ -26,6 +26,12 @@ export interface MeshPeer {
|
|||||||
advert_name: string
|
advert_name: string
|
||||||
did: string | null
|
did: string | null
|
||||||
pubkey_hex: string | null
|
pubkey_hex: string | null
|
||||||
|
/** Verified archipelago ed25519 identity key. The backend binds this onto
|
||||||
|
* BOTH a node's twins — the federation peer natively and the radio twin via
|
||||||
|
* `bind_federation_twins` — so it's the most reliable key for collapsing the
|
||||||
|
* cross-transport duplicate (survives the device rename, unlike the advert
|
||||||
|
* name). Absent on radio peers that were never matched to a federation twin. */
|
||||||
|
arch_pubkey_hex?: string | null
|
||||||
rssi: number | null
|
rssi: number | null
|
||||||
snr: number | null
|
snr: number | null
|
||||||
last_heard: string
|
last_heard: string
|
||||||
|
|||||||
@ -560,6 +560,20 @@ function fedDidKeySuffix(did: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
|
function mergeKeyForPeer(peer: MeshPeer): { key: string; matchedFed: FedNodeInfo | null } {
|
||||||
|
// Strongest signal: the verified archipelago identity key. The backend binds
|
||||||
|
// it onto BOTH twins (federation peer natively, radio twin via
|
||||||
|
// `bind_federation_twins`), so grouping by it collapses the cross-transport
|
||||||
|
// duplicate regardless of advert name — this is what survives the Meshtastic
|
||||||
|
// device rename, which broke the `Archy-z6Mk…` did-prefix match below. Prefer
|
||||||
|
// the matching federation node's `did:` key so this stays consistent with the
|
||||||
|
// federation-only placeholder pass (which dedups on `did:<did>`); fall back to
|
||||||
|
// an `arch:` key only when no federation entry is known for the identity.
|
||||||
|
if (peer.arch_pubkey_hex) {
|
||||||
|
for (const fed of fedNodesByDid.value.values()) {
|
||||||
|
if (fed.pubkey === peer.arch_pubkey_hex) return { key: `did:${fed.did}`, matchedFed: fed }
|
||||||
|
}
|
||||||
|
return { key: `arch:${peer.arch_pubkey_hex.toLowerCase()}`, matchedFed: null }
|
||||||
|
}
|
||||||
if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
|
if (peer.did) return { key: `did:${peer.did}`, matchedFed: fedNodesByDid.value.get(peer.did) ?? null }
|
||||||
// pubkey cross-ref: a federation node may share the archipelago pubkey
|
// pubkey cross-ref: a federation node may share the archipelago pubkey
|
||||||
// with this radio peer if it's the same physical node (rare today, since
|
// with this radio peer if it's the same physical node (rare today, since
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user