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());
- }
}