feat(mesh): rich typed Sent records and echo dedup
Adds message_type + typed_payload (JSON) to MeshMessage so the UI can render invoice/alert/coordinate/tx/lightning messages as structured cards in both directions instead of showing raw wire bytes on the Sent side. RPC handlers now route through send_typed_wire / send_channel_typed_wire which transmit the binary envelope directly (no utf8_lossy corruption) and record a rich Sent MeshMessage. Also: store_message deduplicates echo-back doubles (20-msg lookback, 30s window), from_name is plumbed through the federation Incoming path, and peer_dest_prefix / send_raw_payload are factored out of send_message. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,16 +432,8 @@ impl MeshService {
|
||||
messages.iter().skip(skip).cloned().collect()
|
||||
}
|
||||
|
||||
/// Send a message to a peer by contact_id.
|
||||
/// Routes through the background listener which owns the serial port.
|
||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
// Look up the peer's public key to get the 6-byte prefix for addressing
|
||||
/// Resolve a peer's 6-byte public-key prefix for mesh addressing.
|
||||
async fn peer_dest_prefix(&self, contact_id: u32) -> Result<[u8; 6]> {
|
||||
let peers = self.state.peers.read().await;
|
||||
let peer = peers
|
||||
.get(&contact_id)
|
||||
@@ -457,10 +449,17 @@ impl MeshService {
|
||||
}
|
||||
let mut dest_prefix = [0u8; 6];
|
||||
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
|
||||
drop(peers);
|
||||
Ok(dest_prefix)
|
||||
}
|
||||
|
||||
let payload = text.as_bytes().to_vec();
|
||||
let encrypted = false;
|
||||
/// Send raw wire payload bytes to a peer (no Sent-record bookkeeping).
|
||||
/// Callers are responsible for storing the MeshMessage record afterwards.
|
||||
async fn send_raw_payload(&self, contact_id: u32, payload: Vec<u8>) -> Result<()> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
if payload.len() > protocol::MAX_MESSAGE_LEN {
|
||||
anyhow::bail!(
|
||||
@@ -470,7 +469,8 @@ impl MeshService {
|
||||
);
|
||||
}
|
||||
|
||||
// Send through the listener's command channel
|
||||
let dest_prefix = self.peer_dest_prefix(contact_id).await?;
|
||||
|
||||
self.state
|
||||
.cmd_tx
|
||||
.send(listener::MeshCommand::SendText {
|
||||
@@ -479,6 +479,90 @@ impl MeshService {
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Send a typed envelope wire payload and record a rich Sent MeshMessage.
|
||||
/// Used by RPC handlers that transmit invoice/coordinate/alert/etc. so the
|
||||
/// UI sees a proper rich Sent card instead of garbage wire-byte plaintext.
|
||||
pub async fn send_typed_wire(
|
||||
&self,
|
||||
contact_id: u32,
|
||||
wire: Vec<u8>,
|
||||
type_label: &str,
|
||||
display_text: &str,
|
||||
typed_payload: Option<serde_json::Value>,
|
||||
) -> Result<MeshMessage> {
|
||||
self.send_raw_payload(contact_id, wire).await?;
|
||||
Ok(self
|
||||
.record_sent_typed(contact_id, type_label, display_text, typed_payload)
|
||||
.await)
|
||||
}
|
||||
|
||||
/// Broadcast a typed envelope wire payload on a mesh channel and record a
|
||||
/// rich Sent MeshMessage. Bytes are sent directly — do NOT utf8_lossy-encode
|
||||
/// binary envelope bytes before handing them here.
|
||||
pub async fn send_channel_typed_wire(
|
||||
&self,
|
||||
channel: u8,
|
||||
wire: Vec<u8>,
|
||||
type_label: &str,
|
||||
display_text: &str,
|
||||
typed_payload: Option<serde_json::Value>,
|
||||
) -> Result<MeshMessage> {
|
||||
let status = self.state.status.read().await;
|
||||
if !status.device_connected {
|
||||
anyhow::bail!("No mesh device connected");
|
||||
}
|
||||
drop(status);
|
||||
|
||||
if wire.len() > protocol::MAX_MESSAGE_LEN {
|
||||
anyhow::bail!(
|
||||
"Message too large for LoRa: {} bytes (max {})",
|
||||
wire.len(),
|
||||
protocol::MAX_MESSAGE_LEN
|
||||
);
|
||||
}
|
||||
|
||||
self.state
|
||||
.cmd_tx
|
||||
.send(listener::MeshCommand::BroadcastChannel {
|
||||
channel,
|
||||
payload: wire,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
||||
|
||||
let chan_contact_id = u32::MAX - (channel as u32);
|
||||
let chan_name = format!("Channel {}", channel);
|
||||
let msg_id = self.state.next_id().await;
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Sent,
|
||||
peer_contact_id: chan_contact_id,
|
||||
peer_name: Some(chan_name),
|
||||
plaintext: display_text.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: false,
|
||||
encrypted: false,
|
||||
message_type: type_label.to_string(),
|
||||
typed_payload,
|
||||
};
|
||||
self.state.store_message(msg.clone()).await;
|
||||
{
|
||||
let mut status = self.state.status.write().await;
|
||||
status.messages_sent += 1;
|
||||
}
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// Send a message to a peer by contact_id.
|
||||
/// Routes through the background listener which owns the serial port.
|
||||
pub async fn send_message(&self, contact_id: u32, text: &str) -> Result<MeshMessage> {
|
||||
let payload = text.as_bytes().to_vec();
|
||||
let encrypted = false;
|
||||
|
||||
self.send_raw_payload(contact_id, payload).await?;
|
||||
|
||||
let msg_id = self.state.next_id().await;
|
||||
let peer_name = self
|
||||
@@ -498,6 +582,8 @@ impl MeshService {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: false,
|
||||
encrypted,
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
};
|
||||
|
||||
self.state.store_message(msg.clone()).await;
|
||||
@@ -509,6 +595,45 @@ impl MeshService {
|
||||
Ok(msg)
|
||||
}
|
||||
|
||||
/// 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
|
||||
/// instead of a Sent record containing the raw wire bytes as plaintext.
|
||||
pub async fn record_sent_typed(
|
||||
&self,
|
||||
contact_id: u32,
|
||||
type_label: &str,
|
||||
display_text: &str,
|
||||
typed_payload: Option<serde_json::Value>,
|
||||
) -> MeshMessage {
|
||||
let msg_id = self.state.next_id().await;
|
||||
let peer_name = self
|
||||
.state
|
||||
.peers
|
||||
.read()
|
||||
.await
|
||||
.get(&contact_id)
|
||||
.map(|p| p.advert_name.clone());
|
||||
let msg = MeshMessage {
|
||||
id: msg_id,
|
||||
direction: MessageDirection::Sent,
|
||||
peer_contact_id: contact_id,
|
||||
peer_name,
|
||||
plaintext: display_text.to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: false,
|
||||
encrypted: false,
|
||||
message_type: type_label.to_string(),
|
||||
typed_payload,
|
||||
};
|
||||
self.state.store_message(msg.clone()).await;
|
||||
{
|
||||
let mut status = self.state.status.write().await;
|
||||
status.messages_sent += 1;
|
||||
}
|
||||
msg
|
||||
}
|
||||
|
||||
/// Send a message to a mesh channel (broadcast).
|
||||
/// Routes through the background listener which owns the serial port.
|
||||
pub async fn send_channel_message(&self, channel: u8, text: &str) -> Result<MeshMessage> {
|
||||
@@ -551,6 +676,8 @@ impl MeshService {
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
delivered: false,
|
||||
encrypted: false,
|
||||
message_type: "text".to_string(),
|
||||
typed_payload: None,
|
||||
};
|
||||
|
||||
self.state.store_message(msg.clone()).await;
|
||||
|
||||
Reference in New Issue
Block a user