Render Telegram output as HTML
This commit is contained in:
@@ -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("&"),
|
||||
'<' => escaped.push_str("<"),
|
||||
'>' => escaped.push_str(">"),
|
||||
'"' => escaped.push_str("""),
|
||||
_ => 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 & 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 <main>."
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user