diff --git a/Cargo.lock b/Cargo.lock index 2b6aabcf8..ba9bc3b1f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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" diff --git a/crates/goose/Cargo.toml b/crates/goose/Cargo.toml index ae7a2bf70..cef1da9d8 100644 --- a/crates/goose/Cargo.toml +++ b/crates/goose/Cargo.toml @@ -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 } diff --git a/crates/goose/src/gateway/mod.rs b/crates/goose/src/gateway/mod.rs index 6438d9489..6f4c50f43 100644 --- a/crates/goose/src/gateway/mod.rs +++ b/crates/goose/src/gateway/mod.rs @@ -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}; diff --git a/crates/goose/src/gateway/telegram.rs b/crates/goose/src/gateway/telegram.rs index 8b764bf7a..00f51185a 100644 --- a/crates/goose/src/gateway/telegram.rs +++ b/crates/goose/src/gateway/telegram.rs @@ -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) -> anyhow::Result> { @@ -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 { #[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() { diff --git a/crates/goose/src/gateway/telegram_format.rs b/crates/goose/src/gateway/telegram_format.rs deleted file mode 100644 index 1fa3ec851..000000000 --- a/crates/goose/src/gateway/telegram_format.rs +++ /dev/null @@ -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 ``. 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 = None; - - for event in parser { - match event { - Event::Start(tag) => match tag { - Tag::Paragraph => {} - Tag::Heading { .. } => output.push_str(""), - Tag::Strong => output.push_str(""), - Tag::Emphasis => output.push_str(""), - Tag::Strikethrough => output.push_str(""), - Tag::CodeBlock(_) => output.push_str("
"),
-                Tag::Link { dest_url, .. } => {
-                    output.push_str(&format!("", 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("
"), - _ => {} - }, - Event::End(tag_end) => match tag_end { - TagEnd::Paragraph => output.push('\n'), - TagEnd::Heading(_) => output.push_str("\n"), - TagEnd::Strong => output.push_str(""), - TagEnd::Emphasis => output.push_str(""), - TagEnd::Strikethrough => output.push_str(""), - TagEnd::CodeBlock => output.push_str("
\n"), - TagEnd::Link => output.push_str(""), - TagEnd::List(_) => { - list_number = None; - } - TagEnd::Item => output.push('\n'), - TagEnd::BlockQuote(_) => output.push_str("\n"), - _ => {} - }, - Event::Text(text) => output.push_str(&escape_html(&text)), - Event::Code(code) => { - output.push_str(""); - output.push_str(&escape_html(&code)); - output.push_str(""); - } - 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 `
` 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 bold and italic"
-        );
-    }
-
-    #[test]
-    fn inline_code() {
-        assert_eq!(
-            markdown_to_telegram_html("Use `cargo build` to compile"),
-            "Use cargo build 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("
"));
-        assert!(html.contains("fn main() {}"));
-        assert!(html.contains("
")); - } - - #[test] - fn code_block_no_language() { - let html = markdown_to_telegram_html("```\nhello\n```"); - assert!(html.contains("
"));
-        assert!(html.contains("hello"));
-    }
-
-    #[test]
-    fn heading() {
-        assert_eq!(markdown_to_telegram_html("# Title"), "Title");
-    }
-
-    #[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("Rust"));
-    }
-
-    #[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~~"), "deleted");
-    }
-
-    #[test]
-    fn blockquote() {
-        let html = markdown_to_telegram_html("> This is a quote");
-        assert!(html.contains("
")); - assert!(html.contains("This is a quote")); - assert!(html.contains("
")); - } - - #[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("Summary")); - assert!(html.contains("First item")); - assert!(html.contains("1. ")); - assert!(html.contains("2. ")); - assert!(html.contains("
"));
-        assert!(html.contains("print("hello")"));
-        assert!(html.contains(""));
-        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}"
-        );
-    }
-}