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:
19
Cargo.lock
generated
19
Cargo.lock
generated
@@ -2766,6 +2766,24 @@ dependencies = [
|
|||||||
"syn 2.0.116",
|
"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]]
|
[[package]]
|
||||||
name = "quick-xml"
|
name = "quick-xml"
|
||||||
version = "0.36.2"
|
version = "0.36.2"
|
||||||
@@ -3771,6 +3789,7 @@ dependencies = [
|
|||||||
"poem",
|
"poem",
|
||||||
"poem-openapi",
|
"poem-openapi",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
|
"pulldown-cmark",
|
||||||
"reqwest 0.13.2",
|
"reqwest 0.13.2",
|
||||||
"rust-embed",
|
"rust-embed",
|
||||||
"serde",
|
"serde",
|
||||||
|
|||||||
@@ -33,3 +33,4 @@ matrix-sdk = { version = "0.16.0", default-features = false, features = [
|
|||||||
"native-tls",
|
"native-tls",
|
||||||
"sqlite",
|
"sqlite",
|
||||||
] }
|
] }
|
||||||
|
pulldown-cmark = { version = "0.12", default-features = false, features = ["html"] }
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ toml = { workspace = true }
|
|||||||
uuid = { workspace = true, features = ["v4", "serde"] }
|
uuid = { workspace = true, features = ["v4", "serde"] }
|
||||||
walkdir = { workspace = true }
|
walkdir = { workspace = true }
|
||||||
matrix-sdk = { workspace = true }
|
matrix-sdk = { workspace = true }
|
||||||
|
pulldown-cmark = { workspace = true }
|
||||||
|
|
||||||
[dev-dependencies]
|
[dev-dependencies]
|
||||||
tempfile = { workspace = true }
|
tempfile = { workspace = true }
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
use crate::llm::providers::claude_code::{ClaudeCodeProvider, ClaudeCodeResult};
|
||||||
use crate::slog;
|
use crate::slog;
|
||||||
|
use pulldown_cmark::{Options, Parser, html};
|
||||||
use matrix_sdk::{
|
use matrix_sdk::{
|
||||||
Client,
|
Client,
|
||||||
config::SyncSettings,
|
config::SyncSettings,
|
||||||
@@ -293,8 +294,9 @@ async fn handle_message(
|
|||||||
let post_room = room.clone();
|
let post_room = room.clone();
|
||||||
let post_task = tokio::spawn(async move {
|
let post_task = tokio::spawn(async move {
|
||||||
while let Some(chunk) = msg_rx.recv().await {
|
while let Some(chunk) = msg_rx.recv().await {
|
||||||
|
let html = markdown_to_html(&chunk);
|
||||||
let _ = post_room
|
let _ = post_room
|
||||||
.send(RoomMessageEventContent::text_plain(chunk))
|
.send(RoomMessageEventContent::text_html(chunk, html))
|
||||||
.await;
|
.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
|
// Paragraph buffering helper
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -417,6 +439,43 @@ pub fn drain_complete_paragraphs(buffer: &mut String) -> Vec<String> {
|
|||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
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]
|
#[test]
|
||||||
fn bot_context_is_clone() {
|
fn bot_context_is_clone() {
|
||||||
// BotContext must be Clone for the Matrix event handler injection.
|
// BotContext must be Clone for the Matrix event handler injection.
|
||||||
|
|||||||
Reference in New Issue
Block a user