diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index a5c8dc5..ce9407a 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -562,10 +562,23 @@ Allowlist Telegram username (without '@') or numeric user ID.", .and_then(serde_json::Value::as_i64) .unwrap_or(0); + // Extract thread/topic ID for forum support + let thread_id = message + .get("message_thread_id") + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + // reply_target: chat_id or chat_id:thread_id format + let reply_target = if let Some(tid) = thread_id { + format!("{}:{}", chat_id, tid) + } else { + chat_id.clone() + }; + Some(ChannelMessage { id: format!("telegram_{chat_id}_{message_id}"), sender: sender_identity, - reply_target: chat_id, + reply_target, content: text.to_string(), channel: "telegram".to_string(), timestamp: std::time::SystemTime::now() @@ -575,7 +588,12 @@ Allowlist Telegram username (without '@') or numeric user ID.", }) } - async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> { + async fn send_text_chunks( + &self, + message: &str, + chat_id: &str, + thread_id: Option<&str>, + ) -> anyhow::Result<()> { let chunks = split_message_for_telegram(message); for (index, chunk) in chunks.iter().enumerate() { @@ -591,12 +609,17 @@ Allowlist Telegram username (without '@') or numeric user ID.", chunk.to_string() }; - let markdown_body = serde_json::json!({ + let mut markdown_body = serde_json::json!({ "chat_id": chat_id, "text": text, "parse_mode": "Markdown" }); + // Add message_thread_id for forum topic support + if let Some(tid) = thread_id { + markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + let markdown_resp = self .client .post(self.api_url("sendMessage")) @@ -618,10 +641,15 @@ Allowlist Telegram username (without '@') or numeric user ID.", "Telegram sendMessage with Markdown failed; retrying without parse_mode" ); - let plain_body = serde_json::json!({ + let mut plain_body = serde_json::json!({ "chat_id": chat_id, "text": text, }); + + // Add message_thread_id for forum topic support + if let Some(tid) = thread_id { + plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } let plain_resp = self .client .post(self.api_url("sendMessage")) @@ -654,6 +682,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", method: &str, media_field: &str, chat_id: &str, + thread_id: Option<&str>, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -662,6 +691,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", }); body[media_field] = serde_json::Value::String(url.to_string()); + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if let Some(cap) = caption { body["caption"] = serde_json::Value::String(cap.to_string()); } @@ -685,6 +718,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", async fn send_attachment( &self, chat_id: &str, + thread_id: Option<&str>, attachment: &TelegramAttachment, ) -> anyhow::Result<()> { let target = attachment.target.trim(); @@ -692,19 +726,24 @@ Allowlist Telegram username (without '@') or numeric user ID.", if is_http_url(target) { return match attachment.kind { TelegramAttachmentKind::Image => { - self.send_photo_by_url(chat_id, target, None).await + self.send_photo_by_url(chat_id, thread_id, target, None) + .await } TelegramAttachmentKind::Document => { - self.send_document_by_url(chat_id, target, None).await + self.send_document_by_url(chat_id, thread_id, target, None) + .await } TelegramAttachmentKind::Video => { - self.send_video_by_url(chat_id, target, None).await + self.send_video_by_url(chat_id, thread_id, target, None) + .await } TelegramAttachmentKind::Audio => { - self.send_audio_by_url(chat_id, target, None).await + self.send_audio_by_url(chat_id, thread_id, target, None) + .await } TelegramAttachmentKind::Voice => { - self.send_voice_by_url(chat_id, target, None).await + self.send_voice_by_url(chat_id, thread_id, target, None) + .await } }; } @@ -715,11 +754,13 @@ Allowlist Telegram username (without '@') or numeric user ID.", } match attachment.kind { - TelegramAttachmentKind::Image => self.send_photo(chat_id, path, None).await, - TelegramAttachmentKind::Document => self.send_document(chat_id, path, None).await, - TelegramAttachmentKind::Video => self.send_video(chat_id, path, None).await, - TelegramAttachmentKind::Audio => self.send_audio(chat_id, path, None).await, - TelegramAttachmentKind::Voice => self.send_voice(chat_id, path, None).await, + TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Document => { + self.send_document(chat_id, thread_id, path, None).await + } + TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await, + TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await, } } @@ -727,6 +768,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_document( &self, chat_id: &str, + thread_id: Option<&str>, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -742,6 +784,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("document", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -766,6 +812,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_document_bytes( &self, chat_id: &str, + thread_id: Option<&str>, file_bytes: Vec, file_name: &str, caption: Option<&str>, @@ -776,6 +823,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("document", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -800,6 +851,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_photo( &self, chat_id: &str, + thread_id: Option<&str>, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -815,6 +867,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("photo", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -839,6 +895,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_photo_bytes( &self, chat_id: &str, + thread_id: Option<&str>, file_bytes: Vec, file_name: &str, caption: Option<&str>, @@ -849,6 +906,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("photo", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -873,6 +934,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_video( &self, chat_id: &str, + thread_id: Option<&str>, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -888,6 +950,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("video", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -912,6 +978,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_audio( &self, chat_id: &str, + thread_id: Option<&str>, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -927,6 +994,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("audio", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -951,6 +1022,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_voice( &self, chat_id: &str, + thread_id: Option<&str>, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -966,6 +1038,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", .text("chat_id", chat_id.to_string()) .part("voice", part); + if let Some(tid) = thread_id { + form = form.text("message_thread_id", tid.to_string()); + } + if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } @@ -990,6 +1066,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_document_by_url( &self, chat_id: &str, + thread_id: Option<&str>, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -998,6 +1075,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", "document": url }); + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if let Some(cap) = caption { body["caption"] = serde_json::Value::String(cap.to_string()); } @@ -1022,6 +1103,7 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_photo_by_url( &self, chat_id: &str, + thread_id: Option<&str>, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { @@ -1030,6 +1112,10 @@ Allowlist Telegram username (without '@') or numeric user ID.", "photo": url }); + if let Some(tid) = thread_id { + body["message_thread_id"] = serde_json::Value::String(tid.to_string()); + } + if let Some(cap) = caption { body["caption"] = serde_json::Value::String(cap.to_string()); } @@ -1054,10 +1140,11 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_video_by_url( &self, chat_id: &str, + thread_id: Option<&str>, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { - self.send_media_by_url("sendVideo", "video", chat_id, url, caption) + self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption) .await } @@ -1065,10 +1152,11 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_audio_by_url( &self, chat_id: &str, + thread_id: Option<&str>, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { - self.send_media_by_url("sendAudio", "audio", chat_id, url, caption) + self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption) .await } @@ -1076,10 +1164,11 @@ Allowlist Telegram username (without '@') or numeric user ID.", pub async fn send_voice_by_url( &self, chat_id: &str, + thread_id: Option<&str>, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { - self.send_media_by_url("sendVoice", "voice", chat_id, url, caption) + self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption) .await } } @@ -1094,28 +1183,34 @@ impl Channel for TelegramChannel { // Strip tool_call tags before processing to prevent Markdown parsing failures let content = strip_tool_call_tags(&message.content); + // Parse recipient: "chat_id" or "chat_id:thread_id" format + let (chat_id, thread_id) = match message.recipient.split_once(':') { + Some((chat, thread)) => (chat, Some(thread)), + None => (message.recipient.as_str(), None), + }; + let (text_without_markers, attachments) = parse_attachment_markers(&content); if !attachments.is_empty() { if !text_without_markers.is_empty() { - self.send_text_chunks(&text_without_markers, &message.recipient) + self.send_text_chunks(&text_without_markers, chat_id, thread_id) .await?; } for attachment in &attachments { - self.send_attachment(&message.recipient, attachment).await?; + self.send_attachment(chat_id, thread_id, attachment).await?; } return Ok(()); } if let Some(attachment) = parse_path_only_attachment(&content) { - self.send_attachment(&message.recipient, &attachment) + self.send_attachment(chat_id, thread_id, &attachment) .await?; return Ok(()); } - self.send_text_chunks(&content, &message.recipient).await + self.send_text_chunks(&content, chat_id, thread_id).await } async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { @@ -1453,6 +1548,35 @@ mod tests { assert_eq!(msg.reply_target, "12345"); } + #[test] + fn parse_update_message_extracts_thread_id_for_forum_topic() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()]); + let update = serde_json::json!({ + "update_id": 3, + "message": { + "message_id": 42, + "text": "hello from topic", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": -100_200_300 + }, + "message_thread_id": 789 + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("message with thread_id should parse"); + + assert_eq!(msg.sender, "alice"); + assert_eq!(msg.reply_target, "-100200300:789"); + assert_eq!(msg.content, "hello from topic"); + assert_eq!(msg.id, "telegram_-100200300_42"); + } + // ── File sending API URL tests ────────────────────────────────── #[test] @@ -1511,7 +1635,7 @@ mod tests { // The actual API call will fail (no real server), but we verify the method exists // and handles the input correctly up to the network call let result = ch - .send_document_bytes("123456", file_bytes, "test.txt", Some("Test caption")) + .send_document_bytes("123456", None, file_bytes, "test.txt", Some("Test caption")) .await; // Should fail with network error, not a panic or type error @@ -1531,7 +1655,7 @@ mod tests { let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; let result = ch - .send_photo_bytes("123456", file_bytes, "test.png", None) + .send_photo_bytes("123456", None, file_bytes, "test.png", None) .await; assert!(result.is_err()); @@ -1542,7 +1666,12 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let result = ch - .send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc")) + .send_document_by_url( + "123456", + None, + "https://example.com/file.pdf", + Some("PDF doc"), + ) .await; assert!(result.is_err()); @@ -1553,7 +1682,7 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let result = ch - .send_photo_by_url("123456", "https://example.com/image.jpg", None) + .send_photo_by_url("123456", None, "https://example.com/image.jpg", None) .await; assert!(result.is_err()); @@ -1566,7 +1695,7 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/file.txt"); - let result = ch.send_document("123456", path, None).await; + let result = ch.send_document("123456", None, path, None).await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); @@ -1582,7 +1711,7 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/photo.jpg"); - let result = ch.send_photo("123456", path, None).await; + let result = ch.send_photo("123456", None, path, None).await; assert!(result.is_err()); } @@ -1592,7 +1721,7 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/video.mp4"); - let result = ch.send_video("123456", path, None).await; + let result = ch.send_video("123456", None, path, None).await; assert!(result.is_err()); } @@ -1602,7 +1731,7 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/audio.mp3"); - let result = ch.send_audio("123456", path, None).await; + let result = ch.send_audio("123456", None, path, None).await; assert!(result.is_err()); } @@ -1612,7 +1741,7 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/voice.ogg"); - let result = ch.send_voice("123456", path, None).await; + let result = ch.send_voice("123456", None, path, None).await; assert!(result.is_err()); } @@ -1702,13 +1831,19 @@ mod tests { // With caption let result = ch - .send_document_bytes("123456", file_bytes.clone(), "test.txt", Some("My caption")) + .send_document_bytes( + "123456", + None, + file_bytes.clone(), + "test.txt", + Some("My caption"), + ) .await; assert!(result.is_err()); // Network error expected // Without caption let result = ch - .send_document_bytes("123456", file_bytes, "test.txt", None) + .send_document_bytes("123456", None, file_bytes, "test.txt", None) .await; assert!(result.is_err()); // Network error expected } @@ -1722,6 +1857,7 @@ mod tests { let result = ch .send_photo_bytes( "123456", + None, file_bytes.clone(), "test.png", Some("Photo caption"), @@ -1731,7 +1867,7 @@ mod tests { // Without caption let result = ch - .send_photo_bytes("123456", file_bytes, "test.png", None) + .send_photo_bytes("123456", None, file_bytes, "test.png", None) .await; assert!(result.is_err()); } @@ -1744,7 +1880,7 @@ mod tests { let file_bytes: Vec = vec![]; let result = ch - .send_document_bytes("123456", file_bytes, "empty.txt", None) + .send_document_bytes("123456", None, file_bytes, "empty.txt", None) .await; // Should not panic, will fail at API level @@ -1756,7 +1892,9 @@ mod tests { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let file_bytes = b"content".to_vec(); - let result = ch.send_document_bytes("123456", file_bytes, "", None).await; + let result = ch + .send_document_bytes("123456", None, file_bytes, "", None) + .await; // Should not panic assert!(result.is_err()); @@ -1768,7 +1906,7 @@ mod tests { let file_bytes = b"content".to_vec(); let result = ch - .send_document_bytes("", file_bytes, "test.txt", None) + .send_document_bytes("", None, file_bytes, "test.txt", None) .await; // Should not panic