From cb6e6909d4021f0951abd6f2bf224df5a33853ce Mon Sep 17 00:00:00 2001 From: JingyiQiu <140409989+QiuYi111@users.noreply.github.com> Date: Sun, 15 Mar 2026 11:01:45 +0800 Subject: [PATCH] telegram formatting --- .gitignore | 1 + crates/openfang-channels/src/formatter.rs | 293 ++++++++++++++++++---- 2 files changed, 246 insertions(+), 48 deletions(-) diff --git a/.gitignore b/.gitignore index 78b7238c..c64c84f4 100644 --- a/.gitignore +++ b/.gitignore @@ -34,6 +34,7 @@ BUILD_LOG.md # OS .DS_Store +._* Thumbs.db # IDE & tools diff --git a/crates/openfang-channels/src/formatter.rs b/crates/openfang-channels/src/formatter.rs index 0ffface7..ac5957fe 100644 --- a/crates/openfang-channels/src/formatter.rs +++ b/crates/openfang-channels/src/formatter.rs @@ -19,19 +19,162 @@ pub fn format_for_channel(text: &str, format: OutputFormat) -> String { /// Convert Markdown to Telegram HTML subset. /// -/// Supported tags: ``, ``, ``, `
`, ``.
+/// Supported tags: ``, ``, ``, `
`, ``, `
`. fn markdown_to_telegram_html(text: &str) -> String { - // Escape HTML special characters first so agent names and other text - // don't get interpreted as HTML tags by Telegram's parser. - let mut result = text - .replace('&', "&") - .replace('<', "<") - .replace('>', ">"); + let normalized = text.replace("\r\n", "\n").replace('\r', "\n"); + let mut blocks = Vec::new(); + let lines: Vec<&str> = normalized.lines().collect(); + let mut i = 0; + + while i < lines.len() { + let line = lines[i]; + let trimmed = line.trim(); + + if trimmed.is_empty() { + i += 1; + continue; + } + + // Fenced code block + if let Some(fence) = fence_delimiter(trimmed) { + i += 1; + let mut code_lines = Vec::new(); + while i < lines.len() { + let candidate = lines[i].trim(); + if candidate.starts_with(fence) { + i += 1; + break; + } + code_lines.push(lines[i]); + i += 1; + } + let code = escape_html(&code_lines.join("\n")); + blocks.push(format!("
{}
", code)); + continue; + } + + // ATX heading (#, ##, ...) + if let Some(content) = heading_text(trimmed) { + blocks.push(format!("{}", render_inline_markdown(content.trim()))); + i += 1; + continue; + } + + // Blockquote + if trimmed.starts_with('>') { + let mut quote_lines = Vec::new(); + while i < lines.len() { + let current = lines[i].trim(); + if current.is_empty() || !current.starts_with('>') { + break; + } + let content = current + .strip_prefix('>') + .unwrap_or(current) + .trim_start(); + quote_lines.push(render_inline_markdown(content)); + i += 1; + } + blocks.push(format!("
{}
", quote_lines.join("\n"))); + continue; + } + + // Unordered list + if let Some(item) = unordered_list_item(trimmed) { + let mut items = vec![format!("• {}", render_inline_markdown(item.trim()))]; + i += 1; + while i < lines.len() { + let current = lines[i].trim(); + if let Some(next_item) = unordered_list_item(current) { + items.push(format!("• {}", render_inline_markdown(next_item.trim()))); + i += 1; + } else if current.is_empty() { + i += 1; + break; + } else { + break; + } + } + blocks.push(items.join("\n")); + continue; + } + + // Ordered list + if let Some(item) = ordered_list_item(trimmed) { + let mut items = vec![format!("1. {}", render_inline_markdown(item.trim()))]; + let mut counter = 2; + i += 1; + while i < lines.len() { + let current = lines[i].trim(); + if let Some(next_item) = ordered_list_item(current) { + items.push(format!("{}. {}", counter, render_inline_markdown(next_item.trim()))); + counter += 1; + i += 1; + } else if current.is_empty() { + i += 1; + break; + } else { + break; + } + } + blocks.push(items.join("\n")); + continue; + } + + // Paragraph + let mut paragraph_lines = vec![trimmed]; + i += 1; + while i < lines.len() { + let current = lines[i].trim(); + if current.is_empty() + || fence_delimiter(current).is_some() + || heading_text(current).is_some() + || current.starts_with('>') + || unordered_list_item(current).is_some() + || ordered_list_item(current).is_some() + { + break; + } + paragraph_lines.push(current); + i += 1; + } + let joined = paragraph_lines.join("\n"); + blocks.push(render_inline_markdown(&joined)); + } + + blocks.join("\n\n") +} + +fn render_inline_markdown(text: &str) -> String { + let mut result = escape_html(text); + + // Links: [text](url) →
text + while let Some(bracket_start) = result.find('[') { + if let Some(bracket_end_rel) = result[bracket_start..].find("](") { + let bracket_end = bracket_start + bracket_end_rel; + if let Some(paren_end_rel) = result[bracket_end + 2..].find(')') { + let paren_end = bracket_end + 2 + paren_end_rel; + let link_text = result[bracket_start + 1..bracket_end].to_string(); + let url = result[bracket_end + 2..paren_end].to_string(); + result = format!( + "{}{}{}", + &result[..bracket_start], + url, + link_text, + &result[paren_end + 1..] + ); + } else { + break; + } + } else { + break; + } + } // Bold: **text** → text while let Some(start) = result.find("**") { - if let Some(end) = result[start + 2..].find("**") { - let end = start + 2 + end; + if let Some(end_rel) = result[start + 2..].find("**") { + let end = start + 2 + end_rel; let inner = result[start + 2..end].to_string(); result = format!("{}{}{}", &result[..start], inner, &result[end + 2..]); } else { @@ -39,8 +182,23 @@ fn markdown_to_telegram_html(text: &str) -> String { } } - // Italic: *text* → text (but not inside bold tags) - // Simple heuristic: match single * not preceded/followed by * + // Inline code: `text` → text + while let Some(start) = result.find('`') { + if let Some(end_rel) = result[start + 1..].find('`') { + let end = start + 1 + end_rel; + let inner = result[start + 1..end].to_string(); + result = format!( + "{}{}{}", + &result[..start], + inner, + &result[end + 1..] + ); + } else { + break; + } + } + + // Italic: *text* → text (single star only) let mut out = String::with_capacity(result.len()); let chars: Vec = result.chars().collect(); let mut i = 0; @@ -61,48 +219,57 @@ fn markdown_to_telegram_html(text: &str) -> String { } i += 1; } - result = out; - // Inline code: `text` → text - while let Some(start) = result.find('`') { - if let Some(end) = result[start + 1..].find('`') { - let end = start + 1 + end; - let inner = result[start + 1..end].to_string(); - result = format!( - "{}{}{}", - &result[..start], - inner, - &result[end + 1..] - ); - } else { - break; + out +} + +fn escape_html(text: &str) -> String { + text.replace('&', "&") + .replace('<', "<") + .replace('>', ">") +} + +fn fence_delimiter(line: &str) -> Option<&'static str> { + if line.starts_with("```") { + Some("```") + } else if line.starts_with("~~~") { + Some("~~~") + } else { + None + } +} + +fn heading_text(line: &str) -> Option<&str> { + let hashes = line.chars().take_while(|c| *c == '#').count(); + if (1..=6).contains(&hashes) && line.chars().nth(hashes) == Some(' ') { + Some(&line[hashes + 1..]) + } else { + None + } +} + +fn unordered_list_item(line: &str) -> Option<&str> { + for prefix in ["- ", "* ", "+ "] { + if let Some(rest) = line.strip_prefix(prefix) { + return Some(rest); } } + None +} - // Links: [text](url) → text - while let Some(bracket_start) = result.find('[') { - if let Some(bracket_end) = result[bracket_start..].find("](") { - let bracket_end = bracket_start + bracket_end; - if let Some(paren_end) = result[bracket_end + 2..].find(')') { - let paren_end = bracket_end + 2 + paren_end; - let link_text = &result[bracket_start + 1..bracket_end]; - let url = &result[bracket_end + 2..paren_end]; - result = format!( - "{}{}{}", - &result[..bracket_start], - url, - link_text, - &result[paren_end + 1..] - ); - } else { - break; - } - } else { - break; - } +fn ordered_list_item(line: &str) -> Option<&str> { + let digit_count = line.chars().take_while(|c| c.is_ascii_digit()).count(); + if digit_count == 0 { + return None; + } + let rest = &line[digit_count..]; + if let Some(item) = rest.strip_prefix(". ") { + Some(item) + } else if let Some(item) = rest.strip_prefix(") ") { + Some(item) + } else { + None } - - result } /// Convert Markdown to Slack mrkdwn format. @@ -231,6 +398,36 @@ mod tests { assert_eq!(result, "click here"); } + #[test] + fn test_telegram_html_heading() { + let result = markdown_to_telegram_html("## Result"); + assert_eq!(result, "Result"); + } + + #[test] + fn test_telegram_html_unordered_list() { + let result = markdown_to_telegram_html("- alpha\n- beta"); + assert_eq!(result, "• alpha\n• beta"); + } + + #[test] + fn test_telegram_html_ordered_list() { + let result = markdown_to_telegram_html("1. alpha\n2. beta"); + assert_eq!(result, "1. alpha\n2. beta"); + } + + #[test] + fn test_telegram_html_fenced_code_block() { + let result = markdown_to_telegram_html("```rust\nfn main() {}\n```"); + assert_eq!(result, "
fn main() {}
"); + } + + #[test] + fn test_telegram_html_blockquote() { + let result = markdown_to_telegram_html("> note\n> second line"); + assert_eq!(result, "
note\nsecond line
"); + } + #[test] fn test_slack_mrkdwn_bold() { let result = markdown_to_slack_mrkdwn("Hello **world**!");