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:
parent
18284e1592
commit
3ed9243c50
@ -17,6 +17,7 @@ impl ApiHandler {
|
|||||||
}
|
}
|
||||||
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
let incoming: Incoming = serde_json::from_slice(&body).unwrap_or(Incoming {
|
||||||
from_pubkey: None,
|
from_pubkey: None,
|
||||||
|
from_name: None,
|
||||||
message: None,
|
message: None,
|
||||||
signature: None,
|
signature: None,
|
||||||
encrypted: false,
|
encrypted: false,
|
||||||
|
|||||||
@ -38,8 +38,15 @@ impl RpcHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
let display = format!(
|
||||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
"Invoice: {} sats{}",
|
||||||
|
amount_sats,
|
||||||
|
memo.as_ref().map(|m| format!(" — {}", m)).unwrap_or_default()
|
||||||
|
);
|
||||||
|
let typed_json = serde_json::to_value(&invoice).ok();
|
||||||
|
let msg = svc
|
||||||
|
.send_typed_wire(contact_id, wire, "invoice", &display, typed_json)
|
||||||
|
.await?;
|
||||||
|
|
||||||
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
info!(contact_id, amount_sats, "Sent invoice over mesh");
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@ -77,8 +84,16 @@ impl RpcHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
let display = format!(
|
||||||
let msg = svc.send_message(contact_id, &wire_str).await?;
|
"Location: {:.6}, {:.6}{}",
|
||||||
|
coord.lat_degrees(),
|
||||||
|
coord.lng_degrees(),
|
||||||
|
coord.label.as_ref().map(|l| format!(" ({})", l)).unwrap_or_default()
|
||||||
|
);
|
||||||
|
let typed_json = serde_json::to_value(&coord).ok();
|
||||||
|
let msg = svc
|
||||||
|
.send_typed_wire(contact_id, wire, "coordinate", &display, typed_json)
|
||||||
|
.await?;
|
||||||
|
|
||||||
info!(contact_id, "Sent coordinate over mesh");
|
info!(contact_id, "Sent coordinate over mesh");
|
||||||
Ok(serde_json::json!({
|
Ok(serde_json::json!({
|
||||||
@ -153,13 +168,23 @@ impl RpcHandler {
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
.ok_or_else(|| anyhow::anyhow!("Mesh service not running"))?;
|
||||||
|
|
||||||
let wire_str = String::from_utf8_lossy(&wire).to_string();
|
let display = alert.message.clone();
|
||||||
|
let typed_json = serde_json::to_value(&alert).ok();
|
||||||
if broadcast {
|
if broadcast {
|
||||||
// Send on channel (all peers)
|
// Send on public channel (all peers) as raw bytes so the binary
|
||||||
svc.send_message(0, &wire_str).await?;
|
// envelope is not corrupted by utf8 conversion.
|
||||||
|
svc.send_channel_typed_wire(0, wire, "alert", &display, typed_json.clone())
|
||||||
|
.await?;
|
||||||
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
info!(alert_type = alert_type_str, "Broadcast alert over mesh");
|
||||||
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
} else if let Some(contact_id) = params["contact_id"].as_u64() {
|
||||||
svc.send_message(contact_id as u32, &wire_str).await?;
|
svc.send_typed_wire(
|
||||||
|
contact_id as u32,
|
||||||
|
wire,
|
||||||
|
"alert",
|
||||||
|
&display,
|
||||||
|
typed_json,
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
info!(contact_id, alert_type = alert_type_str, "Sent alert to peer");
|
||||||
} else {
|
} else {
|
||||||
anyhow::bail!("Must specify contact_id or broadcast: true");
|
anyhow::bail!("Must specify contact_id or broadcast: true");
|
||||||
|
|||||||
@ -276,6 +276,8 @@ pub(super) async fn store_plain_message(
|
|||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: true,
|
delivered: true,
|
||||||
encrypted: false,
|
encrypted: false,
|
||||||
|
message_type: "text".to_string(),
|
||||||
|
typed_payload: None,
|
||||||
};
|
};
|
||||||
state.store_message(msg.clone()).await;
|
state.store_message(msg.clone()).await;
|
||||||
state.status.write().await.messages_received += 1;
|
state.status.write().await.messages_received += 1;
|
||||||
@ -438,6 +440,8 @@ pub(super) async fn handle_received_message(
|
|||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: true,
|
delivered: true,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
message_type: "text".to_string(),
|
||||||
|
typed_payload: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.store_message(msg.clone()).await;
|
state.store_message(msg.clone()).await;
|
||||||
|
|||||||
@ -8,13 +8,14 @@ use super::super::types::*;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tracing::{debug, info, warn};
|
use tracing::{debug, info, warn};
|
||||||
|
|
||||||
/// Store a typed message with a type label for UI rendering.
|
/// Store a typed message with a type label and structured JSON payload for UI rendering.
|
||||||
async fn store_typed_message(
|
async fn store_typed_message(
|
||||||
state: &Arc<MeshState>,
|
state: &Arc<MeshState>,
|
||||||
contact_id: u32,
|
contact_id: u32,
|
||||||
peer_name: &str,
|
peer_name: &str,
|
||||||
text: &str,
|
display_text: &str,
|
||||||
type_label: &str,
|
type_label: &str,
|
||||||
|
typed_payload: Option<serde_json::Value>,
|
||||||
) {
|
) {
|
||||||
let msg_id = state.next_id().await;
|
let msg_id = state.next_id().await;
|
||||||
let msg = MeshMessage {
|
let msg = MeshMessage {
|
||||||
@ -22,16 +23,24 @@ async fn store_typed_message(
|
|||||||
direction: MessageDirection::Received,
|
direction: MessageDirection::Received,
|
||||||
peer_contact_id: contact_id,
|
peer_contact_id: contact_id,
|
||||||
peer_name: Some(peer_name.to_string()),
|
peer_name: Some(peer_name.to_string()),
|
||||||
plaintext: format!("[{}] {}", type_label, text),
|
plaintext: display_text.to_string(),
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: true,
|
delivered: true,
|
||||||
encrypted: false,
|
encrypted: false,
|
||||||
|
message_type: type_label.to_string(),
|
||||||
|
typed_payload,
|
||||||
};
|
};
|
||||||
state.store_message(msg.clone()).await;
|
state.store_message(msg.clone()).await;
|
||||||
state.status.write().await.messages_received += 1;
|
state.status.write().await.messages_received += 1;
|
||||||
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
let _ = state.event_tx.send(MeshEvent::MessageReceived(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serialize a decoded payload to JSON for the UI layer.
|
||||||
|
/// Falls back to `None` on serialization failure (shouldn't happen for our serde types).
|
||||||
|
fn payload_to_json<T: serde::Serialize>(v: &T) -> Option<serde_json::Value> {
|
||||||
|
serde_json::to_value(v).ok()
|
||||||
|
}
|
||||||
|
|
||||||
/// Handle a typed message envelope (0x02 prefix).
|
/// Handle a typed message envelope (0x02 prefix).
|
||||||
/// Dispatches to type-specific handlers: BlockHeader, Alert, TxRelay, etc.
|
/// Dispatches to type-specific handlers: BlockHeader, Alert, TxRelay, etc.
|
||||||
pub(super) async fn handle_typed_message(
|
pub(super) async fn handle_typed_message(
|
||||||
@ -105,12 +114,14 @@ pub(super) async fn handle_typed_message(
|
|||||||
"Alert received via mesh: {}",
|
"Alert received via mesh: {}",
|
||||||
alert.message
|
alert.message
|
||||||
);
|
);
|
||||||
|
let json = payload_to_json(&alert);
|
||||||
store_typed_message(
|
store_typed_message(
|
||||||
state,
|
state,
|
||||||
sender_contact_id,
|
sender_contact_id,
|
||||||
sender_name,
|
sender_name,
|
||||||
&alert.message,
|
&alert.message,
|
||||||
"alert",
|
"alert",
|
||||||
|
json,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = state.event_tx.send(MeshEvent::AlertReceived {
|
let _ = state.event_tx.send(MeshEvent::AlertReceived {
|
||||||
@ -136,12 +147,14 @@ pub(super) async fn handle_typed_message(
|
|||||||
tx_len = relay.tx_hex.len(),
|
tx_len = relay.tx_hex.len(),
|
||||||
"TX relay request received — broadcasting to Bitcoin network"
|
"TX relay request received — broadcasting to Bitcoin network"
|
||||||
);
|
);
|
||||||
|
let json = payload_to_json(&relay);
|
||||||
store_typed_message(
|
store_typed_message(
|
||||||
state,
|
state,
|
||||||
sender_contact_id,
|
sender_contact_id,
|
||||||
sender_name,
|
sender_name,
|
||||||
&format!("TX relay request #{} ({} hex chars)", relay.request_id, relay.tx_hex.len()),
|
&format!("TX relay request #{} ({} hex chars)", relay.request_id, relay.tx_hex.len()),
|
||||||
"tx_relay",
|
"tx_relay",
|
||||||
|
json,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
@ -170,12 +183,14 @@ pub(super) async fn handle_typed_message(
|
|||||||
amount_sats = relay.amount_sats,
|
amount_sats = relay.amount_sats,
|
||||||
"Lightning relay request received"
|
"Lightning relay request received"
|
||||||
);
|
);
|
||||||
|
let json = payload_to_json(&relay);
|
||||||
store_typed_message(
|
store_typed_message(
|
||||||
state,
|
state,
|
||||||
sender_contact_id,
|
sender_contact_id,
|
||||||
sender_name,
|
sender_name,
|
||||||
&format!("Lightning relay: {} sats", relay.amount_sats),
|
&format!("Lightning relay: {} sats", relay.amount_sats),
|
||||||
"lightning_relay",
|
"lightning_relay",
|
||||||
|
json,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
// Will be wired to LND in Week 9
|
// Will be wired to LND in Week 9
|
||||||
@ -201,7 +216,8 @@ pub(super) async fn handle_typed_message(
|
|||||||
} else {
|
} else {
|
||||||
format!("Lightning failed: {}", resp.error.as_deref().unwrap_or("unknown"))
|
format!("Lightning failed: {}", resp.error.as_deref().unwrap_or("unknown"))
|
||||||
};
|
};
|
||||||
store_typed_message(state, sender_contact_id, sender_name, &text, "lightning_relay_response").await;
|
let json = payload_to_json(&resp);
|
||||||
|
store_typed_message(state, sender_contact_id, sender_name, &text, "lightning_relay_response", json).await;
|
||||||
let _ = state.event_tx.send(MeshEvent::LightningRelayCompleted {
|
let _ = state.event_tx.send(MeshEvent::LightningRelayCompleted {
|
||||||
request_id: resp.request_id,
|
request_id: resp.request_id,
|
||||||
payment_hash: resp.payment_hash,
|
payment_hash: resp.payment_hash,
|
||||||
@ -220,7 +236,8 @@ pub(super) async fn handle_typed_message(
|
|||||||
invoice.amount_sats,
|
invoice.amount_sats,
|
||||||
invoice.memo.as_ref().map(|m| format!(" — {}", m)).unwrap_or_default()
|
invoice.memo.as_ref().map(|m| format!(" — {}", m)).unwrap_or_default()
|
||||||
);
|
);
|
||||||
store_typed_message(state, sender_contact_id, sender_name, &text, "invoice").await;
|
let json = payload_to_json(&invoice);
|
||||||
|
store_typed_message(state, sender_contact_id, sender_name, &text, "invoice", json).await;
|
||||||
}
|
}
|
||||||
Err(e) => warn!("Failed to decode invoice payload: {}", e),
|
Err(e) => warn!("Failed to decode invoice payload: {}", e),
|
||||||
}
|
}
|
||||||
@ -235,7 +252,8 @@ pub(super) async fn handle_typed_message(
|
|||||||
coord.lng_degrees(),
|
coord.lng_degrees(),
|
||||||
coord.label.as_ref().map(|l| format!(" ({})", l)).unwrap_or_default()
|
coord.label.as_ref().map(|l| format!(" ({})", l)).unwrap_or_default()
|
||||||
);
|
);
|
||||||
store_typed_message(state, sender_contact_id, sender_name, &text, "coordinate").await;
|
let json = payload_to_json(&coord);
|
||||||
|
store_typed_message(state, sender_contact_id, sender_name, &text, "coordinate", json).await;
|
||||||
}
|
}
|
||||||
Err(e) => warn!("Failed to decode coordinate payload: {}", e),
|
Err(e) => warn!("Failed to decode coordinate payload: {}", e),
|
||||||
}
|
}
|
||||||
@ -291,7 +309,7 @@ async fn dispatch_block_header(
|
|||||||
timestamp,
|
timestamp,
|
||||||
announced_by: sender_name.to_string(),
|
announced_by: sender_name.to_string(),
|
||||||
};
|
};
|
||||||
let _ = state.block_header_cache.store_header(header_payload).await;
|
let _ = state.block_header_cache.store_header(header_payload.clone()).await;
|
||||||
|
|
||||||
let text = format!(
|
let text = format!(
|
||||||
"Block #{} — {}...{}",
|
"Block #{} — {}...{}",
|
||||||
@ -299,12 +317,14 @@ async fn dispatch_block_header(
|
|||||||
&hash_hex[..8.min(hash_hex.len())],
|
&hash_hex[..8.min(hash_hex.len())],
|
||||||
&hash_hex[hash_hex.len().saturating_sub(8)..]
|
&hash_hex[hash_hex.len().saturating_sub(8)..]
|
||||||
);
|
);
|
||||||
|
let json = payload_to_json(&header_payload);
|
||||||
store_typed_message(
|
store_typed_message(
|
||||||
state,
|
state,
|
||||||
sender_contact_id,
|
sender_contact_id,
|
||||||
sender_name,
|
sender_name,
|
||||||
&text,
|
&text,
|
||||||
"block_header",
|
"block_header",
|
||||||
|
json,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
let _ = state.event_tx.send(MeshEvent::BlockHeaderReceived {
|
let _ = state.event_tx.send(MeshEvent::BlockHeaderReceived {
|
||||||
@ -339,7 +359,8 @@ async fn dispatch_tx_relay_response(
|
|||||||
} else {
|
} else {
|
||||||
format!("TX relay failed: {}", resp.error.as_deref().unwrap_or("unknown"))
|
format!("TX relay failed: {}", resp.error.as_deref().unwrap_or("unknown"))
|
||||||
};
|
};
|
||||||
store_typed_message(state, sender_contact_id, sender_name, &text, "tx_relay_response").await;
|
let json = payload_to_json(&resp);
|
||||||
|
store_typed_message(state, sender_contact_id, sender_name, &text, "tx_relay_response", json).await;
|
||||||
// Store result for frontend polling
|
// Store result for frontend polling
|
||||||
if let Some(ref tracker) = state.relay_tracker {
|
if let Some(ref tracker) = state.relay_tracker {
|
||||||
tracker.store_result(super::super::bitcoin_relay::RelayResult {
|
tracker.store_result(super::super::bitcoin_relay::RelayResult {
|
||||||
@ -380,7 +401,8 @@ async fn dispatch_tx_confirmation(
|
|||||||
block_height = conf.block_height,
|
block_height = conf.block_height,
|
||||||
"TX confirmation update received"
|
"TX confirmation update received"
|
||||||
);
|
);
|
||||||
store_typed_message(state, sender_contact_id, sender_name, &status_text, "tx_confirmation").await;
|
let json = payload_to_json(&conf);
|
||||||
|
store_typed_message(state, sender_contact_id, sender_name, &status_text, "tx_confirmation", json).await;
|
||||||
// Store confirmation for frontend polling
|
// Store confirmation for frontend polling
|
||||||
if let Some(ref tracker) = state.relay_tracker {
|
if let Some(ref tracker) = state.relay_tracker {
|
||||||
tracker.store_result(super::super::bitcoin_relay::RelayResult {
|
tracker.store_result(super::super::bitcoin_relay::RelayResult {
|
||||||
|
|||||||
@ -29,6 +29,17 @@ const SYNC_INTERVAL: Duration = Duration::from_secs(10);
|
|||||||
/// Maximum stored messages (circular buffer).
|
/// Maximum stored messages (circular buffer).
|
||||||
const MAX_MESSAGES: usize = 100;
|
const MAX_MESSAGES: usize = 100;
|
||||||
|
|
||||||
|
/// Check if two ISO8601 timestamps are within N seconds of each other.
|
||||||
|
fn within_seconds_iso(ts1: &str, ts2: &str, secs: i64) -> bool {
|
||||||
|
use chrono::DateTime;
|
||||||
|
let a = DateTime::parse_from_rfc3339(ts1).ok();
|
||||||
|
let b = DateTime::parse_from_rfc3339(ts2).ok();
|
||||||
|
match (a, b) {
|
||||||
|
(Some(a), Some(b)) => (a - b).num_seconds().unsigned_abs() < secs as u64,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Initial delay before reconnection attempt after device disconnect.
|
/// Initial delay before reconnection attempt after device disconnect.
|
||||||
const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
|
const RECONNECT_DELAY_INIT: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
@ -129,6 +140,16 @@ impl MeshState {
|
|||||||
|
|
||||||
pub async fn store_message(&self, msg: MeshMessage) {
|
pub async fn store_message(&self, msg: MeshMessage) {
|
||||||
let mut messages = self.messages.write().await;
|
let mut messages = self.messages.write().await;
|
||||||
|
// Deduplicate: skip if we already have a message with the same text,
|
||||||
|
// peer, and timestamp within 30 seconds (prevents echo-back doubles)
|
||||||
|
let dominated = messages.iter().rev().take(20).any(|m| {
|
||||||
|
m.peer_contact_id == msg.peer_contact_id
|
||||||
|
&& m.plaintext == msg.plaintext
|
||||||
|
&& within_seconds_iso(&m.timestamp, &msg.timestamp, 30)
|
||||||
|
});
|
||||||
|
if dominated {
|
||||||
|
return;
|
||||||
|
}
|
||||||
messages.push_back(msg);
|
messages.push_back(msg);
|
||||||
if messages.len() > MAX_MESSAGES {
|
if messages.len() > MAX_MESSAGES {
|
||||||
messages.pop_front();
|
messages.pop_front();
|
||||||
|
|||||||
@ -432,16 +432,8 @@ impl MeshService {
|
|||||||
messages.iter().skip(skip).cloned().collect()
|
messages.iter().skip(skip).cloned().collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Send a message to a peer by contact_id.
|
/// Resolve a peer's 6-byte public-key prefix for mesh addressing.
|
||||||
/// Routes through the background listener which owns the serial port.
|
async fn peer_dest_prefix(&self, contact_id: u32) -> Result<[u8; 6]> {
|
||||||
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
|
|
||||||
let peers = self.state.peers.read().await;
|
let peers = self.state.peers.read().await;
|
||||||
let peer = peers
|
let peer = peers
|
||||||
.get(&contact_id)
|
.get(&contact_id)
|
||||||
@ -457,10 +449,17 @@ impl MeshService {
|
|||||||
}
|
}
|
||||||
let mut dest_prefix = [0u8; 6];
|
let mut dest_prefix = [0u8; 6];
|
||||||
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
|
dest_prefix.copy_from_slice(&pubkey_bytes[..6]);
|
||||||
drop(peers);
|
Ok(dest_prefix)
|
||||||
|
}
|
||||||
|
|
||||||
let payload = text.as_bytes().to_vec();
|
/// Send raw wire payload bytes to a peer (no Sent-record bookkeeping).
|
||||||
let encrypted = false;
|
/// 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 {
|
if payload.len() > protocol::MAX_MESSAGE_LEN {
|
||||||
anyhow::bail!(
|
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
|
self.state
|
||||||
.cmd_tx
|
.cmd_tx
|
||||||
.send(listener::MeshCommand::SendText {
|
.send(listener::MeshCommand::SendText {
|
||||||
@ -479,6 +479,90 @@ impl MeshService {
|
|||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
.map_err(|_| anyhow::anyhow!("Mesh listener not running"))?;
|
.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 msg_id = self.state.next_id().await;
|
||||||
let peer_name = self
|
let peer_name = self
|
||||||
@ -498,6 +582,8 @@ impl MeshService {
|
|||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: false,
|
delivered: false,
|
||||||
encrypted,
|
encrypted,
|
||||||
|
message_type: "text".to_string(),
|
||||||
|
typed_payload: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.state.store_message(msg.clone()).await;
|
self.state.store_message(msg.clone()).await;
|
||||||
@ -509,6 +595,45 @@ impl MeshService {
|
|||||||
Ok(msg)
|
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).
|
/// Send a message to a mesh channel (broadcast).
|
||||||
/// Routes through the background listener which owns the serial port.
|
/// Routes through the background listener which owns the serial port.
|
||||||
pub async fn send_channel_message(&self, channel: u8, text: &str) -> Result<MeshMessage> {
|
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(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
delivered: false,
|
delivered: false,
|
||||||
encrypted: false,
|
encrypted: false,
|
||||||
|
message_type: "text".to_string(),
|
||||||
|
typed_payload: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
self.state.store_message(msg.clone()).await;
|
self.state.store_message(msg.clone()).await;
|
||||||
|
|||||||
@ -64,13 +64,24 @@ pub struct MeshMessage {
|
|||||||
pub peer_contact_id: u32,
|
pub peer_contact_id: u32,
|
||||||
/// Peer name (for display).
|
/// Peer name (for display).
|
||||||
pub peer_name: Option<String>,
|
pub peer_name: Option<String>,
|
||||||
/// Decrypted plaintext content.
|
/// Human-readable rendering — for text messages this is the raw text,
|
||||||
|
/// for typed messages a short summary used as a fallback in lists.
|
||||||
pub plaintext: String,
|
pub plaintext: String,
|
||||||
pub timestamp: String,
|
pub timestamp: String,
|
||||||
/// Whether delivery was confirmed via ACK.
|
/// Whether delivery was confirmed via ACK.
|
||||||
pub delivered: bool,
|
pub delivered: bool,
|
||||||
/// Whether the message was end-to-end encrypted.
|
/// Whether the message was end-to-end encrypted.
|
||||||
pub encrypted: bool,
|
pub encrypted: bool,
|
||||||
|
/// Typed-envelope label ("text", "invoice", "alert", "coordinate", ...).
|
||||||
|
#[serde(default = "default_message_type")]
|
||||||
|
pub message_type: String,
|
||||||
|
/// Structured payload as JSON — populated for non-text typed messages.
|
||||||
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
|
pub typed_payload: Option<serde_json::Value>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_message_type() -> String {
|
||||||
|
"text".to_string()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Overall mesh subsystem status.
|
/// Overall mesh subsystem status.
|
||||||
|
|||||||
@ -112,6 +112,7 @@ pub fn store_sent(message: &str) {
|
|||||||
guard.messages.push(IncomingMessage {
|
guard.messages.push(IncomingMessage {
|
||||||
from_pubkey: "me".to_string(),
|
from_pubkey: "me".to_string(),
|
||||||
from_onion: None,
|
from_onion: None,
|
||||||
|
from_name: None,
|
||||||
message: message.to_string(),
|
message: message.to_string(),
|
||||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
direction: "sent".to_string(),
|
direction: "sent".to_string(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user