Skip to content

Commit a0c899a

Browse files
author
超渡法師
committed
feat(gateway): implement Telegram Rich Messages (Bot API 10.1)
- Add sendRichMessage() for structured content (tables, code, headings) - Add sendRichMessageDraft() for future AI streaming support - Add is_complex_markdown() classifier to route complex replies - Feature-gated via TELEGRAM_RICH_MESSAGES=true env var (default: off) - Falls back to sendMessage on sendRichMessage failure When enabled, replies containing tables, fenced code blocks, headings, or content >4096 chars will use sendRichMessage with InputRichMessage markdown format. This passes agent markdown directly (GFM-compatible) without needing any conversion layer.
1 parent 860ff5f commit a0c899a

4 files changed

Lines changed: 89 additions & 0 deletions

File tree

gateway/src/adapters/line.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -643,6 +643,7 @@ mod tests {
643643
let state = Arc::new(crate::AppState {
644644
telegram_bot_token: None,
645645
telegram_secret_token: None,
646+
telegram_rich_messages: false,
646647
line_channel_secret: None,
647648
line_access_token: None,
648649
teams: None,

gateway/src/adapters/teams.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -654,6 +654,7 @@ mod tests {
654654
Arc::new(crate::AppState {
655655
telegram_bot_token: None,
656656
telegram_secret_token: None,
657+
telegram_rich_messages: false,
657658
line_channel_secret: None,
658659
line_access_token: None,
659660
teams: Some(TeamsAdapter::new(make_config(vec![]))),

gateway/src/adapters/telegram.rs

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -220,6 +220,66 @@ fn is_markdown_parse_error(description: &str) -> bool {
220220
|| desc_lower.contains("parse entities")
221221
}
222222

223+
/// Returns true if the content is complex enough to benefit from sendRichMessage.
224+
fn is_complex_markdown(text: &str) -> bool {
225+
text.contains("|---|")
226+
|| text.contains("```")
227+
|| text.starts_with("# ")
228+
|| text.contains("\n# ")
229+
|| text.contains("\n## ")
230+
|| text.contains("\n### ")
231+
|| text.len() > 4096
232+
}
233+
234+
/// Send a rich message via Bot API 10.1 sendRichMessage.
235+
async fn send_rich_message(
236+
client: &reqwest::Client,
237+
bot_token: &str,
238+
chat_id: &str,
239+
thread_id: &Option<String>,
240+
text: &str,
241+
) -> Result<serde_json::Value, String> {
242+
let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendRichMessage");
243+
let body = serde_json::json!({
244+
"chat_id": chat_id,
245+
"message_thread_id": thread_id,
246+
"rich_message": { "markdown": text },
247+
});
248+
let resp = client.post(&url).json(&body).send().await.map_err(|e| e.to_string())?;
249+
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
250+
if json["ok"].as_bool() == Some(true) {
251+
Ok(json)
252+
} else {
253+
Err(json["description"].as_str().unwrap_or("unknown error").to_string())
254+
}
255+
}
256+
257+
/// Stream a partial rich message via sendRichMessageDraft.
258+
#[allow(dead_code)]
259+
async fn send_rich_message_draft(
260+
client: &reqwest::Client,
261+
bot_token: &str,
262+
chat_id: &str,
263+
thread_id: &Option<String>,
264+
draft_id: i64,
265+
text: &str,
266+
) -> Result<(), String> {
267+
let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendRichMessageDraft");
268+
let body = serde_json::json!({
269+
"chat_id": chat_id,
270+
"message_thread_id": thread_id,
271+
"draft_id": draft_id,
272+
"rich_message": { "markdown": text },
273+
});
274+
let resp = client.post(&url).json(&body).send().await.map_err(|e| e.to_string())?;
275+
let json: serde_json::Value = resp.json().await.map_err(|e| e.to_string())?;
276+
if json["ok"].as_bool() == Some(true) {
277+
Ok(())
278+
} else {
279+
Err(json["description"].as_str().unwrap_or("unknown error").to_string())
280+
}
281+
}
282+
223283
// --- Reply handler ---
224284

225285
pub async fn handle_reply(
@@ -228,6 +288,7 @@ pub async fn handle_reply(
228288
client: &reqwest::Client,
229289
event_tx: &tokio::sync::broadcast::Sender<String>,
230290
reaction_state: &Arc<Mutex<HashMap<String, Vec<String>>>>,
291+
rich_messages: bool,
231292
) {
232293
// Handle create_topic command
233294
if reply.command.as_deref() == Some("create_topic") {
@@ -338,6 +399,15 @@ pub async fn handle_reply(
338399
thread_id = ?reply.channel.thread_id,
339400
"gateway → telegram"
340401
);
402+
403+
// Try sendRichMessage for complex content when enabled
404+
if rich_messages && is_complex_markdown(&reply.content.text) {
405+
match send_rich_message(client, bot_token, &reply.channel.id, &reply.channel.thread_id, &reply.content.text).await {
406+
Ok(_) => return,
407+
Err(e) => warn!("sendRichMessage failed ({e}), falling back to sendMessage"),
408+
}
409+
}
410+
341411
let url = format!("{TELEGRAM_API_BASE}/bot{bot_token}/sendMessage");
342412
let resp = client
343413
.post(&url)
@@ -541,4 +611,14 @@ mod tests {
541611
assert!(!is_markdown_parse_error("Unauthorized"));
542612
assert!(!is_markdown_parse_error("Bad Request: chat not found"));
543613
}
614+
615+
#[test]
616+
fn test_is_complex_markdown() {
617+
assert!(is_complex_markdown("| Col1 | Col2 |\n|---|---|\n| a | b |"));
618+
assert!(is_complex_markdown("```rust\nfn main() {}\n```"));
619+
assert!(is_complex_markdown("# Heading\n\nSome text"));
620+
assert!(is_complex_markdown(&"x".repeat(4097)));
621+
assert!(!is_complex_markdown("Hello world"));
622+
assert!(!is_complex_markdown("*bold* and _italic_"));
623+
}
544624
}

gateway/src/main.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,8 @@ pub struct AppState {
4545
pub telegram_bot_token: Option<String>,
4646
/// Telegram webhook secret token for request validation
4747
pub telegram_secret_token: Option<String>,
48+
/// Use sendRichMessage for complex content (Bot API 10.1+)
49+
pub telegram_rich_messages: bool,
4850
/// LINE channel secret for signature validation
4951
pub line_channel_secret: Option<String>,
5052
/// LINE channel access token for reply API
@@ -138,6 +140,7 @@ async fn handle_oab_connection(state: Arc<AppState>, socket: axum::extract::ws::
138140
&client,
139141
&state_for_recv.event_tx,
140142
&reaction_state,
143+
state_for_recv.telegram_rich_messages,
141144
)
142145
.await;
143146
} else {
@@ -241,6 +244,9 @@ async fn main() -> Result<()> {
241244
// Telegram adapter
242245
let telegram_bot_token = std::env::var("TELEGRAM_BOT_TOKEN").ok();
243246
let telegram_secret_token = std::env::var("TELEGRAM_SECRET_TOKEN").ok();
247+
let telegram_rich_messages = std::env::var("TELEGRAM_RICH_MESSAGES")
248+
.map(|v| v == "1" || v.eq_ignore_ascii_case("true"))
249+
.unwrap_or(false);
244250
if telegram_bot_token.is_some() {
245251
let webhook_path =
246252
std::env::var("TELEGRAM_WEBHOOK_PATH").unwrap_or_else(|_| "/webhook/telegram".into());
@@ -378,6 +384,7 @@ async fn main() -> Result<()> {
378384
let state = Arc::new(AppState {
379385
telegram_bot_token,
380386
telegram_secret_token,
387+
telegram_rich_messages,
381388
line_channel_secret,
382389
line_access_token,
383390
teams,

0 commit comments

Comments
 (0)