Render Telegram output as HTML
Build Claw Telegram / build (push) Successful in 4m27s
Build Claw Telegram / cleanup (push) Successful in 1s

This commit is contained in:
Wylabb
2026-04-05 07:11:56 +02:00
parent 83e313ec6d
commit 51b47c93d2
+200 -1
View File
@@ -6,6 +6,8 @@ use reqwest::multipart::{Form, Part};
use serde::{Deserialize, Serialize};
use serde_json::json;
const TELEGRAM_PARSE_MODE_HTML: &str = "HTML";
#[derive(Clone)]
pub struct TelegramApi {
client: reqwest::Client,
@@ -52,9 +54,11 @@ impl TelegramApi {
text: &str,
reply_markup: Option<&InlineKeyboardMarkup>,
) -> Result<Message, TelegramApiError> {
let text = render_telegram_html(text);
let mut body = serde_json::Map::new();
body.insert("chat_id".to_string(), json!(chat_id));
body.insert("text".to_string(), json!(text));
body.insert("parse_mode".to_string(), json!(TELEGRAM_PARSE_MODE_HTML));
body.insert("disable_web_page_preview".to_string(), json!(true));
if let Some(reply_markup) = reply_markup {
body.insert(
@@ -73,10 +77,12 @@ impl TelegramApi {
text: &str,
reply_markup: Option<&InlineKeyboardMarkup>,
) -> Result<(), TelegramApiError> {
let text = render_telegram_html(text);
let mut body = serde_json::Map::new();
body.insert("chat_id".to_string(), json!(chat_id));
body.insert("message_id".to_string(), json!(message_id));
body.insert("text".to_string(), json!(text));
body.insert("parse_mode".to_string(), json!(TELEGRAM_PARSE_MODE_HTML));
body.insert("disable_web_page_preview".to_string(), json!(true));
if let Some(reply_markup) = reply_markup {
body.insert(
@@ -234,7 +240,9 @@ impl TelegramApi {
.text("chat_id", chat_id.to_string())
.part(field_name.to_string(), part);
if let Some(caption) = caption {
form = form.text("caption", caption.to_string());
form = form
.text("caption", render_telegram_html(caption))
.text("parse_mode", TELEGRAM_PARSE_MODE_HTML.to_string());
}
self.post_multipart(method, form).await
}
@@ -313,6 +321,166 @@ impl TelegramApi {
}
}
fn render_telegram_html(text: &str) -> String {
let normalized = text.replace("\r\n", "\n");
let mut rendered = String::new();
let mut prose_lines = Vec::new();
let mut code_lines = Vec::new();
let mut in_code_block = false;
for line in normalized.lines() {
if let Some(fence) = line.trim_start().strip_prefix("```") {
if in_code_block {
flush_code_block(&mut rendered, &code_lines);
code_lines.clear();
in_code_block = false;
} else {
flush_prose_block(&mut rendered, &prose_lines);
prose_lines.clear();
in_code_block = true;
let language = fence.trim();
if !language.is_empty() {
code_lines.push(language.to_string());
}
}
continue;
}
if in_code_block {
code_lines.push(line.to_string());
} else {
prose_lines.push(line.to_string());
}
}
if in_code_block {
flush_code_block(&mut rendered, &code_lines);
} else {
flush_prose_block(&mut rendered, &prose_lines);
}
rendered
}
fn flush_prose_block(rendered: &mut String, prose_lines: &[String]) {
if prose_lines.is_empty() {
return;
}
if !rendered.is_empty() {
rendered.push('\n');
}
for (index, line) in prose_lines.iter().enumerate() {
if index > 0 {
rendered.push('\n');
}
rendered.push_str(&render_prose_line(line));
}
}
fn flush_code_block(rendered: &mut String, code_lines: &[String]) {
if code_lines.is_empty() {
return;
}
if !rendered.is_empty() {
rendered.push('\n');
}
rendered.push_str("<pre>");
for (index, line) in code_lines.iter().enumerate() {
if index > 0 {
rendered.push('\n');
}
rendered.push_str(&escape_html(line));
}
rendered.push_str("</pre>");
}
fn render_prose_line(line: &str) -> String {
let trimmed = line.trim_start();
let indent = &line[..line.len().saturating_sub(trimmed.len())];
if trimmed.is_empty() {
return String::new();
}
if let Some(heading) = parse_heading(trimmed) {
return format!("{}<b>{}</b>", escape_html(indent), escape_html(&strip_simple_markdown(heading)));
}
if let Some(item) = trimmed.strip_prefix("- ").or_else(|| trimmed.strip_prefix("* ")) {
return format!("{}{}", escape_html(indent), render_inline_markdownish(item));
}
format!("{}{}", escape_html(indent), render_inline_markdownish(trimmed))
}
fn parse_heading(line: &str) -> Option<&str> {
let count = line.chars().take_while(|&ch| ch == '#').count();
if count == 0 || count > 6 {
return None;
}
let remainder = &line[count..];
remainder
.strip_prefix(' ')
.map(str::trim)
.filter(|value| !value.is_empty())
}
fn strip_simple_markdown(text: &str) -> String {
text.replace("**", "").replace("__", "").replace('`', "")
}
fn render_inline_markdownish(text: &str) -> String {
let mut rendered = String::new();
let bytes = text.as_bytes();
let mut index = 0;
while index < bytes.len() {
if bytes[index..].starts_with(b"**") {
if let Some(end) = text[index + 2..].find("**") {
let content = &text[index + 2..index + 2 + end];
rendered.push_str("<b>");
rendered.push_str(&escape_html(content));
rendered.push_str("</b>");
index += 2 + end + 2;
continue;
}
}
if bytes[index] == b'`' {
if let Some(end) = text[index + 1..].find('`') {
let content = &text[index + 1..index + 1 + end];
rendered.push_str("<code>");
rendered.push_str(&escape_html(content));
rendered.push_str("</code>");
index += 1 + end + 1;
continue;
}
}
let next = text[index..]
.char_indices()
.nth(1)
.map_or(text.len(), |(offset, _)| index + offset);
rendered.push_str(&escape_html(&text[index..next]));
index = next;
}
rendered
}
fn escape_html(text: &str) -> String {
let mut escaped = String::with_capacity(text.len());
for ch in text.chars() {
match ch {
'&' => escaped.push_str("&amp;"),
'<' => escaped.push_str("&lt;"),
'>' => escaped.push_str("&gt;"),
'"' => escaped.push_str("&quot;"),
_ => escaped.push(ch),
}
}
escaped
}
#[derive(Debug)]
pub enum TelegramApiError {
Http(reqwest::Error),
@@ -455,3 +623,34 @@ struct TelegramEnvelope<T> {
#[serde(default)]
description: Option<String>,
}
#[cfg(test)]
mod tests {
use super::render_telegram_html;
#[test]
fn renders_headings_bullets_and_bold_for_telegram_html() {
let rendered = render_telegram_html(
"## 🛠️ **Code & Files**\n- **Read, write, and edit** files in the workspace",
);
assert!(rendered.contains("<b>🛠️ Code &amp; Files</b>"));
assert!(rendered.contains("• <b>Read, write, and edit</b> files in the workspace"));
assert!(!rendered.contains("##"));
assert!(!rendered.contains("**"));
}
#[test]
fn renders_fenced_code_blocks_as_preformatted_html() {
let rendered = render_telegram_html("```rust\nfn main() {}\n```");
assert_eq!(rendered, "<pre>rust\nfn main() {}</pre>");
}
#[test]
fn renders_inline_code_and_escapes_html() {
let rendered = render_telegram_html("Use `cargo test` on <main>.");
assert_eq!(
rendered,
"Use <code>cargo test</code> on &lt;main&gt;."
);
}
}