fix(telegram): send responses as rich markdown (#11062)
Co-authored-by: zq <zhouquan1511@163.com>
This commit is contained in:
Generated
-12
@@ -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"
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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('&', "&")
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
.replace('"', """)
|
||||
}
|
||||
|
||||
#[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 < 2 & 3 > 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("hello")"));
|
||||
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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user