fix(telegram): send responses as rich markdown (#11062)

Co-authored-by: zq <zhouquan1511@163.com>
This commit is contained in:
zcxGGmu
2026-08-10 12:13:28 +08:00
committed by GitHub
parent 4ae4017f65
commit e282631cbc
5 changed files with 96 additions and 306 deletions
Generated
-12
View File
@@ -4986,7 +4986,6 @@ dependencies = [
"pctx_code_mode",
"pem 4.0.0",
"process-wrap",
"pulldown-cmark",
"rand 0.10.2",
"rayon",
"rcgen",
@@ -8480,17 +8479,6 @@ dependencies = [
"psl-types",
]
[[package]]
name = "pulldown-cmark"
version = "0.13.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e"
dependencies = [
"bitflags 2.13.0",
"memchr",
"unicase",
]
[[package]]
name = "pulp"
version = "0.21.5"
-1
View File
@@ -201,7 +201,6 @@ tree-sitter-rust = { workspace = true }
tree-sitter-swift = { workspace = true }
tree-sitter-typescript = { workspace = true }
which = { workspace = true }
pulldown-cmark = { version = "0.13", default-features = false }
pastey = { version = "0.2", default-features = false }
shell-words = { workspace = true }
goose-acp-macros = { path = "../goose-acp-macros", default-features = false }
-1
View File
@@ -2,7 +2,6 @@ pub mod handler;
pub mod manager;
pub mod pairing;
pub mod telegram;
pub mod telegram_format;
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
+96 -13
View File
@@ -3,7 +3,7 @@ use super::{
};
use async_trait::async_trait;
use reqwest::Client;
use serde::Deserialize;
use serde::{Deserialize, Serialize};
use tokio_util::sync::CancellationToken;
const TELEGRAM_API_BASE: &str = "https://api.telegram.org";
@@ -16,6 +16,18 @@ const MAX_VOICE_FILE_SIZE: i64 = 20 * 1024 * 1024;
pub struct TelegramGateway {
bot_token: String,
client: Client,
api_base: String,
}
#[derive(Debug, Serialize)]
struct SendRichMessageRequest<'a> {
chat_id: i64,
rich_message: InputRichMessage<'a>,
}
#[derive(Debug, Serialize)]
struct InputRichMessage<'a> {
markdown: &'a str,
}
#[derive(Debug, Deserialize)]
@@ -106,11 +118,15 @@ impl TelegramGateway {
.http1_only()
.build()?;
Ok(Self { bot_token, client })
Ok(Self {
bot_token,
client,
api_base: TELEGRAM_API_BASE.to_string(),
})
}
fn api_url(&self, method: &str) -> String {
format!("{}/bot{}/{}", TELEGRAM_API_BASE, self.bot_token, method)
format!("{}/bot{}/{}", self.api_base, self.bot_token, method)
}
async fn get_updates(&self, offset: Option<i64>) -> anyhow::Result<Vec<TelegramUpdate>> {
@@ -141,16 +157,15 @@ impl TelegramGateway {
}
async fn send_text(&self, chat_id: i64, text: &str) -> anyhow::Result<()> {
let html = super::telegram_format::markdown_to_telegram_html(text);
for chunk in split_message(&html, MAX_MESSAGE_LENGTH) {
let chunks = split_message(text, MAX_MESSAGE_LENGTH);
for (index, chunk) in chunks.iter().enumerate() {
let resp = self
.client
.post(self.api_url("sendMessage"))
.json(&serde_json::json!({
"chat_id": chat_id,
"text": chunk,
"parse_mode": "HTML",
}))
.post(self.api_url("sendRichMessage"))
.json(&SendRichMessageRequest {
chat_id,
rich_message: InputRichMessage { markdown: chunk },
})
.send()
.await?;
@@ -158,9 +173,9 @@ impl TelegramGateway {
if !body.ok {
tracing::warn!(
error = body.description.as_deref().unwrap_or("unknown"),
"Telegram rejected HTML, falling back to plain text"
"Telegram rejected rich markdown, falling back to plain text"
);
for plain_chunk in split_message(text, MAX_MESSAGE_LENGTH) {
for plain_chunk in &chunks[index..] {
let plain_resp = self
.client
.post(self.api_url("sendMessage"))
@@ -592,6 +607,74 @@ fn split_message(text: &str, max_len: usize) -> Vec<String> {
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{body_json, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn test_gateway(api_base: String) -> TelegramGateway {
TelegramGateway {
bot_token: "test-token".to_string(),
client: Client::builder().no_proxy().build().unwrap(),
api_base,
}
}
#[tokio::test]
async fn send_text_uses_rich_markdown() {
let server = MockServer::start().await;
let markdown = "| Tool | Status |\n|---|---|\n| **MCP** | `ready` |";
Mock::given(method("POST"))
.and(path("/bottest-token/sendRichMessage"))
.and(body_json(serde_json::json!({
"chat_id": 123,
"rich_message": { "markdown": markdown },
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {},
})))
.expect(1)
.mount(&server)
.await;
test_gateway(server.uri())
.send_text(123, markdown)
.await
.unwrap();
}
#[tokio::test]
async fn send_text_falls_back_from_rejected_rich_markdown_chunk() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/bottest-token/sendRichMessage"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": false,
"description": "invalid rich markdown",
})))
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/bottest-token/sendMessage"))
.and(body_json(serde_json::json!({
"chat_id": 123,
"text": "broken **markdown",
})))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"ok": true,
"result": {},
})))
.expect(1)
.mount(&server)
.await;
test_gateway(server.uri())
.send_text(123, "broken **markdown")
.await
.unwrap();
}
#[test]
fn split_short_message() {
-279
View File
@@ -1,279 +0,0 @@
use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
/// Convert markdown into Telegram-compatible HTML.
///
/// Telegram supports only a minimal HTML subset with no attributes beyond
/// `href` on `<a>`. Unsupported tags or attributes (e.g. `class`) cause the
/// API to reject the message outright.
pub fn markdown_to_telegram_html(markdown: &str) -> String {
let options = Options::ENABLE_STRIKETHROUGH | Options::ENABLE_TABLES;
let parser = Parser::new_ext(markdown, options);
let mut output = String::with_capacity(markdown.len());
let mut list_number: Option<u64> = None;
for event in parser {
match event {
Event::Start(tag) => match tag {
Tag::Paragraph => {}
Tag::Heading { .. } => output.push_str("<b>"),
Tag::Strong => output.push_str("<b>"),
Tag::Emphasis => output.push_str("<i>"),
Tag::Strikethrough => output.push_str("<s>"),
Tag::CodeBlock(_) => output.push_str("<pre><code>"),
Tag::Link { dest_url, .. } => {
output.push_str(&format!("<a href=\"{}\">", escape_html(&dest_url)));
}
Tag::List(start) => {
list_number = start;
}
Tag::Item => {
if let Some(n) = list_number.as_mut() {
output.push_str(&format!("{}. ", n));
*n += 1;
} else {
output.push_str("");
}
}
Tag::BlockQuote(_) => output.push_str("<blockquote>"),
_ => {}
},
Event::End(tag_end) => match tag_end {
TagEnd::Paragraph => output.push('\n'),
TagEnd::Heading(_) => output.push_str("</b>\n"),
TagEnd::Strong => output.push_str("</b>"),
TagEnd::Emphasis => output.push_str("</i>"),
TagEnd::Strikethrough => output.push_str("</s>"),
TagEnd::CodeBlock => output.push_str("</code></pre>\n"),
TagEnd::Link => output.push_str("</a>"),
TagEnd::List(_) => {
list_number = None;
}
TagEnd::Item => output.push('\n'),
TagEnd::BlockQuote(_) => output.push_str("</blockquote>\n"),
_ => {}
},
Event::Text(text) => output.push_str(&escape_html(&text)),
Event::Code(code) => {
output.push_str("<code>");
output.push_str(&escape_html(&code));
output.push_str("</code>");
}
Event::SoftBreak | Event::HardBreak => output.push('\n'),
Event::Rule => output.push_str("———\n"),
_ => {}
}
}
let collapsed = collapse_newlines(&output);
collapsed.trim().to_string()
}
/// Collapse runs of 3+ newlines down to 2, preserving whitespace inside `<pre>` blocks.
fn collapse_newlines(text: &str) -> String {
let mut result = String::with_capacity(text.len());
let mut in_pre = false;
let mut chars = text.chars().peekable();
while let Some(ch) = chars.next() {
if ch == '<' {
let rest: String = chars.clone().take(4).collect();
if !in_pre && (rest.starts_with("pre>") || rest.starts_with("pre ")) {
in_pre = true;
}
if in_pre && rest.starts_with("/pre") {
in_pre = false;
}
result.push(ch);
} else if !in_pre && ch == '\n' {
let mut count = 1;
while chars.peek() == Some(&'\n') {
chars.next();
count += 1;
}
for _ in 0..count.min(2) {
result.push('\n');
}
} else {
result.push(ch);
}
}
result
}
fn escape_html(text: &str) -> String {
text.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn plain_text_unchanged() {
assert_eq!(markdown_to_telegram_html("Hello world"), "Hello world");
}
#[test]
fn bold_and_italic() {
assert_eq!(
markdown_to_telegram_html("This is **bold** and *italic*"),
"This is <b>bold</b> and <i>italic</i>"
);
}
#[test]
fn inline_code() {
assert_eq!(
markdown_to_telegram_html("Use `cargo build` to compile"),
"Use <code>cargo build</code> to compile"
);
}
#[test]
fn code_block_no_class_attribute() {
let html = markdown_to_telegram_html("```rust\nfn main() {}\n```");
assert!(
!html.contains("class="),
"Telegram rejects class attributes: {html}"
);
assert!(html.contains("<pre><code>"));
assert!(html.contains("fn main() {}"));
assert!(html.contains("</code></pre>"));
}
#[test]
fn code_block_no_language() {
let html = markdown_to_telegram_html("```\nhello\n```");
assert!(html.contains("<pre><code>"));
assert!(html.contains("hello"));
}
#[test]
fn heading() {
assert_eq!(markdown_to_telegram_html("# Title"), "<b>Title</b>");
}
#[test]
fn unordered_list() {
let html = markdown_to_telegram_html("- one\n- two\n- three");
assert!(html.contains("• one"));
assert!(html.contains("• two"));
assert!(html.contains("• three"));
}
#[test]
fn ordered_list() {
let html = markdown_to_telegram_html("1. first\n2. second\n3. third");
assert!(html.contains("1. first"));
assert!(html.contains("2. second"));
assert!(html.contains("3. third"));
}
#[test]
fn link() {
let html = markdown_to_telegram_html("Visit [Rust](https://rust-lang.org) docs");
assert!(html.contains("<a href=\"https://rust-lang.org\">Rust</a>"));
}
#[test]
fn html_entities_escaped() {
assert_eq!(
markdown_to_telegram_html("1 < 2 & 3 > 0"),
"1 &lt; 2 &amp; 3 &gt; 0"
);
}
#[test]
fn strikethrough() {
assert_eq!(markdown_to_telegram_html("~~deleted~~"), "<s>deleted</s>");
}
#[test]
fn blockquote() {
let html = markdown_to_telegram_html("> This is a quote");
assert!(html.contains("<blockquote>"));
assert!(html.contains("This is a quote"));
assert!(html.contains("</blockquote>"));
}
#[test]
fn horizontal_rule() {
let html = markdown_to_telegram_html("above\n\n---\n\nbelow");
assert!(html.contains("———"));
}
#[test]
fn complex_llm_response() {
let md = r#"# Summary
Here's what I found:
1. **First item** - this is important
2. **Second item** - also relevant
```python
print("hello")
```
For more info, visit [docs](https://example.com).
> Note: this is a blockquote
That's all!"#;
let html = markdown_to_telegram_html(md);
assert!(!html.contains("class="), "no class attributes: {html}");
assert!(html.contains("<b>Summary</b>"));
assert!(html.contains("<b>First item</b>"));
assert!(html.contains("1. "));
assert!(html.contains("2. "));
assert!(html.contains("<pre><code>"));
assert!(html.contains("print(&quot;hello&quot;)"));
assert!(html.contains("<a href="));
assert!(html.contains("<blockquote>"));
assert!(html.contains("That's all!"));
}
#[test]
fn no_trailing_whitespace() {
let html = markdown_to_telegram_html("Hello\n\n");
assert!(!html.ends_with('\n'));
assert!(!html.ends_with(' '));
}
#[test]
fn no_excessive_newlines() {
let md = "Paragraph one.\n\n\n\nParagraph two.\n\n\n\n\nParagraph three.";
let html = markdown_to_telegram_html(md);
assert!(!html.contains("\n\n\n"));
}
#[test]
fn list_to_paragraph_spacing() {
let md = "- one\n- two\n\nNext paragraph.";
let html = markdown_to_telegram_html(md);
assert!(!html.contains("\n\n\n"));
}
#[test]
fn code_block_preserves_internal_newlines() {
let md = "```\nline1\n\n\nline2\n```";
let html = markdown_to_telegram_html(md);
assert!(html.contains("line1\n\n\nline2"));
}
#[test]
fn compact_output() {
let md = "Sure! Here's a quick summary:\n\n## Key Points\n\n- Point one\n- Point two\n\nThat's it!";
let html = markdown_to_telegram_html(md);
let newline_count = html.chars().filter(|c| *c == '\n').count();
assert!(
newline_count <= 7,
"too many newlines ({newline_count}): {html}"
);
}
}