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
This commit is contained in:
Dave
2026-02-25 16:08:57 +00:00
parent cda7915e12
commit d4f23051aa
4 changed files with 81 additions and 1 deletions

19
Cargo.lock generated
View File

@@ -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",

View File

@@ -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"] }

View File

@@ -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 }

View File

@@ -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<String> {
mod tests {
use super::*;
// -- markdown_to_html ---------------------------------------------------
#[test]
fn markdown_to_html_bold() {
let html = markdown_to_html("**bold**");
assert!(html.contains("<strong>bold</strong>"), "expected <strong>: {html}");
}
#[test]
fn markdown_to_html_unordered_list() {
let html = markdown_to_html("- item one\n- item two");
assert!(html.contains("<ul>"), "expected <ul>: {html}");
assert!(html.contains("<li>item one</li>"), "expected list item: {html}");
}
#[test]
fn markdown_to_html_inline_code() {
let html = markdown_to_html("`inline_code()`");
assert!(html.contains("<code>inline_code()</code>"), "expected <code>: {html}");
}
#[test]
fn markdown_to_html_code_block() {
let html = markdown_to_html("```rust\nfn main() {}\n```");
assert!(html.contains("<pre>"), "expected <pre>: {html}");
assert!(html.contains("<code"), "expected <code> 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.