From e8dc42a7c3caf51e213e3a6d368a08f8bba4fb50 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Thu, 28 May 2026 11:13:33 +0000 Subject: [PATCH 1/4] fix(gateway/telegram): fallback to plain text on Markdown parse failure Closes #871. When Telegram rejects a message due to Markdown parse errors (unescaped special characters in URLs, Chinese text, code), the adapter now detects the API-level failure (ok: false) and retries without parse_mode. Previously, `let _ =` discarded the response entirely, causing messages to silently disappear while the bot still showed success reactions. --- gateway/src/adapters/telegram.rs | 34 ++++++++++++++++++++++++++++++-- 1 file changed, 32 insertions(+), 2 deletions(-) diff --git a/gateway/src/adapters/telegram.rs b/gateway/src/adapters/telegram.rs index ee5d08dd1..3cfd181ba 100644 --- a/gateway/src/adapters/telegram.rs +++ b/gateway/src/adapters/telegram.rs @@ -332,7 +332,7 @@ pub async fn handle_reply( "gateway → telegram" ); let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage"); - let _ = client + let retry_plain = match client .post(&url) .json(&serde_json::json!({ "chat_id": reply.channel.id, @@ -342,7 +342,37 @@ pub async fn handle_reply( })) .send() .await - .map_err(|e| error!("telegram send error: {e}")); + { + Ok(resp) => { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + if body["ok"].as_bool() != Some(true) { + warn!( + desc = %body["description"].as_str().unwrap_or("unknown"), + "telegram Markdown send failed, retrying as plain text" + ); + true + } else { + false + } + } + Err(e) => { + error!("telegram send error: {e}"); + false + } + }; + + if retry_plain { + let _ = client + .post(&url) + .json(&serde_json::json!({ + "chat_id": reply.channel.id, + "text": reply.content.text, + "message_thread_id": reply.channel.thread_id, + })) + .send() + .await + .map_err(|e| error!("telegram plain text send error: {e}")); + } } /// Download media from Telegram via getFile → store to filesystem (colocate mode). From c19af3182d8e402318dbb764612524c34a943590 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Thu, 28 May 2026 11:15:50 +0000 Subject: [PATCH 2/4] fix: only retry on parse errors, log plain text fallback result Address review findings: - F1: Only retry as plain text when description contains 'parse' or 'entities'. Other errors (429 rate limit, 403 forbidden) are logged but not retried to avoid wasting API calls. - F2: Plain text fallback now checks response and logs error if it also fails, improving observability. --- gateway/src/adapters/telegram.rs | 30 +++++++++++++++++++++++------- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/gateway/src/adapters/telegram.rs b/gateway/src/adapters/telegram.rs index 3cfd181ba..758197f32 100644 --- a/gateway/src/adapters/telegram.rs +++ b/gateway/src/adapters/telegram.rs @@ -346,11 +346,16 @@ pub async fn handle_reply( Ok(resp) => { let body: serde_json::Value = resp.json().await.unwrap_or_default(); if body["ok"].as_bool() != Some(true) { - warn!( - desc = %body["description"].as_str().unwrap_or("unknown"), - "telegram Markdown send failed, retrying as plain text" - ); - true + let desc = body["description"].as_str().unwrap_or("unknown"); + // Only retry as plain text for parse-related errors; other failures + // (rate limit, forbidden, etc.) should not trigger a second request. + if desc.contains("parse") || desc.contains("entities") { + warn!(desc = %desc, "telegram Markdown parse failed, retrying as plain text"); + true + } else { + error!(desc = %desc, "telegram send failed (non-parse error)"); + false + } } else { false } @@ -362,7 +367,7 @@ pub async fn handle_reply( }; if retry_plain { - let _ = client + match client .post(&url) .json(&serde_json::json!({ "chat_id": reply.channel.id, @@ -371,7 +376,18 @@ pub async fn handle_reply( })) .send() .await - .map_err(|e| error!("telegram plain text send error: {e}")); + { + Ok(resp) => { + let body: serde_json::Value = resp.json().await.unwrap_or_default(); + if body["ok"].as_bool() != Some(true) { + error!( + desc = %body["description"].as_str().unwrap_or("unknown"), + "telegram plain text fallback also failed" + ); + } + } + Err(e) => error!("telegram plain text send error: {e}"), + } } } From 7b037136b10f5b865e1a6cba16810fba380732dc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E8=B6=85=E6=B8=A1=E6=B3=95=E5=B8=AB?= Date: Thu, 28 May 2026 11:17:44 +0000 Subject: [PATCH 3/4] fix: use error_code 400 for retry decision, warn on JSON decode failure MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address 普渡法師 review: - F2: Replace fragile desc.contains() string matching with error_code == 400 check. Telegram 400 = Bad Request (parse errors); 429/403/etc are not retried. - F3: Handle JSON decode failure explicitly with warn log instead of unwrap_or_default() which could silently trigger false retries. - F1 (MarkdownV2): Intentionally deferred — switching parse_mode is a behavior change beyond this bug fix scope. MarkdownV2 requires more escaping and would cause more failures without a full escape pipeline. - F4 (tests): No test framework exists for gateway telegram adapter. Tracked as follow-up. --- gateway/src/adapters/telegram.rs | 44 +++++++++++++++++++------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/gateway/src/adapters/telegram.rs b/gateway/src/adapters/telegram.rs index 758197f32..cf6057cca 100644 --- a/gateway/src/adapters/telegram.rs +++ b/gateway/src/adapters/telegram.rs @@ -344,20 +344,25 @@ pub async fn handle_reply( .await { Ok(resp) => { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - if body["ok"].as_bool() != Some(true) { - let desc = body["description"].as_str().unwrap_or("unknown"); - // Only retry as plain text for parse-related errors; other failures - // (rate limit, forbidden, etc.) should not trigger a second request. - if desc.contains("parse") || desc.contains("entities") { - warn!(desc = %desc, "telegram Markdown parse failed, retrying as plain text"); + match resp.json::().await { + Ok(body) if body["ok"].as_bool() != Some(true) => { + let desc = body["description"].as_str().unwrap_or("unknown"); + let code = body["error_code"].as_u64().unwrap_or(0); + // Retry as plain text only for 400 Bad Request (parse/entity errors). + // Other failures (429 rate limit, 403 forbidden) should not retry. + if code == 400 { + warn!(desc = %desc, "telegram Markdown send rejected (400), retrying as plain text"); + true + } else { + error!(code, desc = %desc, "telegram send failed"); + false + } + } + Ok(_) => false, + Err(e) => { + warn!("telegram response not valid JSON, retrying as plain text: {e}"); true - } else { - error!(desc = %desc, "telegram send failed (non-parse error)"); - false } - } else { - false } } Err(e) => { @@ -378,12 +383,15 @@ pub async fn handle_reply( .await { Ok(resp) => { - let body: serde_json::Value = resp.json().await.unwrap_or_default(); - if body["ok"].as_bool() != Some(true) { - error!( - desc = %body["description"].as_str().unwrap_or("unknown"), - "telegram plain text fallback also failed" - ); + match resp.json::().await { + Ok(body) if body["ok"].as_bool() != Some(true) => { + error!( + desc = %body["description"].as_str().unwrap_or("unknown"), + "telegram plain text fallback also failed" + ); + } + Err(e) => warn!("telegram plain text fallback response not valid JSON: {e}"), + _ => {} } } Err(e) => error!("telegram plain text send error: {e}"), From 89c8a8a4aff500da728d00313b6b20f9a8055035 Mon Sep 17 00:00:00 2001 From: shaun-agent Date: Sun, 31 May 2026 17:45:21 +0000 Subject: [PATCH 4/4] fix(gateway/telegram): narrow markdown fallback retry --- gateway/src/adapters/telegram.rs | 56 ++++++++++++++++++++++++++++---- 1 file changed, 50 insertions(+), 6 deletions(-) diff --git a/gateway/src/adapters/telegram.rs b/gateway/src/adapters/telegram.rs index cf6057cca..5ce433007 100644 --- a/gateway/src/adapters/telegram.rs +++ b/gateway/src/adapters/telegram.rs @@ -348,10 +348,8 @@ pub async fn handle_reply( Ok(body) if body["ok"].as_bool() != Some(true) => { let desc = body["description"].as_str().unwrap_or("unknown"); let code = body["error_code"].as_u64().unwrap_or(0); - // Retry as plain text only for 400 Bad Request (parse/entity errors). - // Other failures (429 rate limit, 403 forbidden) should not retry. - if code == 400 { - warn!(desc = %desc, "telegram Markdown send rejected (400), retrying as plain text"); + if should_retry_plain_text_fallback(code, desc) { + warn!(code, desc = %desc, "telegram Markdown parse failed, retrying as plain text"); true } else { error!(code, desc = %desc, "telegram send failed"); @@ -360,8 +358,8 @@ pub async fn handle_reply( } Ok(_) => false, Err(e) => { - warn!("telegram response not valid JSON, retrying as plain text: {e}"); - true + warn!("telegram response not valid JSON: {e}"); + false } } } @@ -399,6 +397,15 @@ pub async fn handle_reply( } } +fn should_retry_plain_text_fallback(code: u64, description: &str) -> bool { + if code != 400 { + return false; + } + + let description = description.to_ascii_lowercase(); + description.contains("parse") || description.contains("entities") +} + /// Download media from Telegram via getFile → store to filesystem (colocate mode). async fn download_telegram_media( client: &reqwest::Client, @@ -537,3 +544,40 @@ async fn download_telegram_document( path: Some(path), }) } + +#[cfg(test)] +mod tests { + use super::should_retry_plain_text_fallback; + + #[test] + fn retries_markdown_parse_errors() { + assert!(should_retry_plain_text_fallback( + 400, + "Bad Request: can't parse entities: Can't find end of the entity" + )); + } + + #[test] + fn retries_entity_errors() { + assert!(should_retry_plain_text_fallback( + 400, + "Bad Request: unsupported start tag in entities" + )); + } + + #[test] + fn does_not_retry_unrelated_bad_requests() { + assert!(!should_retry_plain_text_fallback( + 400, + "Bad Request: message text is empty" + )); + } + + #[test] + fn does_not_retry_non_bad_request_errors() { + assert!(!should_retry_plain_text_fallback( + 429, + "Too Many Requests: retry after 10" + )); + } +}