From d4f23051aad079690df9e0a54cbd7e201fa4894d Mon Sep 17 00:00:00 2001 From: Dave Date: Wed, 25 Feb 2026 16:08:57 +0000 Subject: [PATCH] feat(matrix): render bot messages with HTML formatting Add HTML formatted_body to Matrix bot messages so that markdown-style formatting (code blocks, bold, italic, lists) renders properly in Matrix clients. Uses the pulldown-cmark crate to convert markdown to HTML and sets the message format to org.matrix.custom.html. Story: 188_story_render_matrix_bot_messages_with_html_formatting --- Cargo.lock | 19 +++++++++++++ Cargo.toml | 1 + server/Cargo.toml | 1 + server/src/matrix/bot.rs | 61 +++++++++++++++++++++++++++++++++++++++- 4 files changed, 81 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index f2de15f..7be0a2e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2766,6 +2766,24 @@ dependencies = [ "syn 2.0.116", ] +[[package]] +name = "pulldown-cmark" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f86ba2052aebccc42cbbb3ed234b8b13ce76f75c3551a303cb2bcffcff12bb14" +dependencies = [ + "bitflags 2.11.0", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + [[package]] name = "quick-xml" version = "0.36.2" @@ -3771,6 +3789,7 @@ dependencies = [ "poem", "poem-openapi", "portable-pty", + "pulldown-cmark", "reqwest 0.13.2", "rust-embed", "serde", diff --git a/Cargo.toml b/Cargo.toml index 25b6049..8ce18a2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,3 +33,4 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [ "native-tls", "sqlite", ] } +pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] } diff --git a/server/Cargo.toml b/server/Cargo.toml index 52e7805..2655059 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -29,6 +29,7 @@ toml = { workspace = true } uuid = { workspace = true, features = ["v4", "serde"] } walkdir = { workspace = true } matrix-sdk = { workspace = true } +pulldown-cmark = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/server/src/matrix/bot.rs b/server/src/matrix/bot.rs index 213ddec..e6c1f23 100644 --- a/server/src/matrix/bot.rs +++ b/server/src/matrix/bot.rs @@ -1,5 +1,6 @@ use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult}; use crate::slog; +use pulldown_cmark::{Options, Parser, html}; use matrix_sdk::{ Client, config::SyncSettings, @@ -293,8 +294,9 @@ async fn handle_message( let post_room = room.clone(); let post_task = tokio::spawn(async move { while let Some(chunk) = msg_rx.recv().await { + let html = markdown_to_html(&chunk); let _ = post_room - .send(RoomMessageEventContent::text_plain(chunk)) + .send(RoomMessageEventContent::text_html(chunk, html)) .await; } }); @@ -388,6 +390,26 @@ async fn handle_message( } } +// --------------------------------------------------------------------------- +// Markdown rendering helper +// --------------------------------------------------------------------------- + +/// Convert a Markdown string to an HTML string using pulldown-cmark. +/// +/// Enables the standard extension set (tables, footnotes, strikethrough, +/// tasklists) so that common Markdown constructs render correctly in Matrix +/// clients such as Element. +pub fn markdown_to_html(markdown: &str) -> String { + let options = Options::ENABLE_TABLES + | Options::ENABLE_FOOTNOTES + | Options::ENABLE_STRIKETHROUGH + | Options::ENABLE_TASKLISTS; + let parser = Parser::new_ext(markdown, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + html_output +} + // --------------------------------------------------------------------------- // Paragraph buffering helper // --------------------------------------------------------------------------- @@ -417,6 +439,43 @@ pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec { mod tests { use super::*; + // -- markdown_to_html --------------------------------------------------- + + #[test] + fn markdown_to_html_bold() { + let html = markdown_to_html("**bold**"); + assert!(html.contains("bold"), "expected : {html}"); + } + + #[test] + fn markdown_to_html_unordered_list() { + let html = markdown_to_html("- item one\n- item two"); + assert!(html.contains("
    "), "expected
      : {html}"); + assert!(html.contains("
    • item one
    • "), "expected list item: {html}"); + } + + #[test] + fn markdown_to_html_inline_code() { + let html = markdown_to_html("`inline_code()`"); + assert!(html.contains("inline_code()"), "expected : {html}"); + } + + #[test] + fn markdown_to_html_code_block() { + let html = markdown_to_html("```rust\nfn main() {}\n```"); + assert!(html.contains("
      "), "expected 
      : {html}");
      +        assert!(html.contains(" inside pre: {html}");
      +        assert!(html.contains("fn main() {}"), "expected code content: {html}");
      +    }
      +
      +    #[test]
      +    fn markdown_to_html_plain_text_passthrough() {
      +        let html = markdown_to_html("Hello, world!");
      +        assert!(html.contains("Hello, world!"), "expected plain text passthrough: {html}");
      +    }
      +
      +    // -- bot_context_is_clone -----------------------------------------------
      +
           #[test]
           fn bot_context_is_clone() {
               // BotContext must be Clone for the Matrix event handler injection.