diff --git a/matrix/src/main.rs b/matrix/src/main.rs index 9c17419..25d28df 100644 --- a/matrix/src/main.rs +++ b/matrix/src/main.rs @@ -55,6 +55,7 @@ impl BridgeState { if s.last_rx_time.is_none() { s.last_rx_time = s.last_checked_at; } + s.last_checked_at = None; Ok(s) } @@ -147,6 +148,7 @@ async fn poll_once( continue; } + state.update_with(msg); // persist after each processed message if let Err(e) = state.save(state_path) { error!("Error saving state: {:?}", e); @@ -214,16 +216,18 @@ async fn handle_message( .unwrap_or_else(|| node.long_name.clone()); let preset_short = modem_preset_short(&msg.modem_preset); - let body = format!( - "[{freq}][{preset_short}][{channel}][{short}] {text}", + let prefix = format!( + "[{freq}][{preset_short}][{channel}][{short}]", freq = msg.lora_freq, preset_short = preset_short, channel = msg.channel_name, short = short, - text = msg.text, ); + let (body, formatted_body) = format_message_bodies(&prefix, &msg.text); - matrix.send_text_message_as(&user_id, &body).await?; + matrix + .send_formatted_message_as(&user_id, &body, &formatted_body) + .await?; state.update_with(msg); Ok(()) @@ -242,6 +246,29 @@ fn modem_preset_short(preset: &str) -> String { } } +/// Build plain text + HTML message bodies with inline-code metadata. +fn format_message_bodies(prefix: &str, text: &str) -> (String, String) { + let body = format!("`{}` {}", prefix, text); + let formatted_body = format!("{} {}", escape_html(prefix), escape_html(text)); + (body, formatted_body) +} + +/// Minimal HTML escaping for Matrix formatted_body payloads. +fn escape_html(input: &str) -> String { + let mut escaped = String::with_capacity(input.len()); + for ch in input.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + #[cfg(test)] mod tests { use super::*; @@ -276,6 +303,18 @@ mod tests { assert_eq!(modem_preset_short("MediumFast"), "MF"); } + #[test] + fn format_message_bodies_escape_html() { + let (body, formatted) = format_message_bodies("[868][LF]", "Hello <&>"); + assert_eq!(body, "`[868][LF]` Hello <&>"); + assert_eq!(formatted, "[868][LF] Hello <&>"); + } + + #[test] + fn escape_html_escapes_quotes() { + assert_eq!(escape_html("a\"b'c"), "a"b'c"); + } + #[test] fn bridge_state_initially_forwards_all() { let state = BridgeState::default(); @@ -619,6 +658,10 @@ mod tests { .as_str(), ) .match_query(format!("user_id={}&access_token=AS_TOKEN", encoded_user).as_str()) + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "msgtype": "m.text", + "format": "org.matrix.custom.html", + }))) .with_status(200) .create(); diff --git a/matrix/src/matrix.rs b/matrix/src/matrix.rs index 050e309..f0ece04 100644 --- a/matrix/src/matrix.rs +++ b/matrix/src/matrix.rs @@ -165,12 +165,19 @@ impl MatrixAppserviceClient { } } - /// Send a plain text message into the configured room as puppet user_id. - pub async fn send_text_message_as(&self, user_id: &str, body_text: &str) -> anyhow::Result<()> { + /// Send a text message with HTML formatting into the configured room as puppet user_id. + pub async fn send_formatted_message_as( + &self, + user_id: &str, + body_text: &str, + formatted_body: &str, + ) -> anyhow::Result<()> { #[derive(Serialize)] struct MsgContent<'a> { msgtype: &'a str, body: &'a str, + format: &'a str, + formatted_body: &'a str, } let txn_id = self.txn_counter.fetch_add(1, Ordering::SeqCst); @@ -189,24 +196,23 @@ impl MatrixAppserviceClient { let content = MsgContent { msgtype: "m.text", body: body_text, + format: "org.matrix.custom.html", + formatted_body, }; let resp = self.http.put(&url).json(&content).send().await?; if !resp.status().is_success() { let status = resp.status(); - // optional: pull a short body snippet for debugging let body_snip = resp.text().await.unwrap_or_default(); - // Log for observability tracing::warn!( - "Failed to send message as {}: status {}, body: {}", + "Failed to send formatted message as {}: status {}, body: {}", user_id, status, body_snip ); - // Propagate an error so callers know this message was NOT delivered return Err(anyhow::anyhow!( "Matrix send failed for {} with status {}", user_id, @@ -441,7 +447,7 @@ mod tests { } #[tokio::test] - async fn test_send_text_message_as_success() { + async fn test_send_formatted_message_as_success() { let mut server = mockito::Server::new_async().await; let user_id = "@test:example.org"; let room_id = "!roomid:example.org"; @@ -464,45 +470,20 @@ mod tests { let mock = server .mock("PUT", path.as_str()) .match_query(query.as_str()) + .match_body(mockito::Matcher::PartialJson(serde_json::json!({ + "msgtype": "m.text", + "body": "`[meta]` hello", + "format": "org.matrix.custom.html", + "formatted_body": "[meta] hello", + }))) .with_status(200) .create(); - let result = client.send_text_message_as(user_id, "hello").await; + let result = client + .send_formatted_message_as(user_id, "`[meta]` hello", "[meta] hello") + .await; mock.assert(); assert!(result.is_ok()); } - - #[tokio::test] - async fn test_send_text_message_as_fail() { - let mut server = mockito::Server::new_async().await; - let user_id = "@test:example.org"; - let room_id = "!roomid:example.org"; - let encoded_user = urlencoding::encode(user_id); - let encoded_room = urlencoding::encode(room_id); - - let client = { - let mut cfg = dummy_cfg(); - cfg.homeserver = server.url(); - cfg.room_id = room_id.to_string(); - MatrixAppserviceClient::new(reqwest::Client::new(), cfg) - }; - let txn_id = client.txn_counter.load(Ordering::SeqCst); - let query = format!("user_id={}&access_token=AS_TOKEN", encoded_user); - let path = format!( - "/_matrix/client/v3/rooms/{}/send/m.room.message/{}", - encoded_room, txn_id - ); - - let mock = server - .mock("PUT", path.as_str()) - .match_query(query.as_str()) - .with_status(500) - .create(); - - let result = client.send_text_message_as(user_id, "hello").await; - - mock.assert(); - assert!(result.is_err()); - } }