From 2d2c2ac9e6a19c73e1e8e831844cb3f8321bd579 Mon Sep 17 00:00:00 2001 From: Argenis Date: Sun, 22 Mar 2026 14:36:31 -0400 Subject: [PATCH] feat(telegram): support forwarded messages with attribution (#4265) Parse forward_from, forward_from_chat, and forward_sender_name fields from Telegram message updates. Prepend forwarding attribution to message content so the LLM has context about the original sender. Closes #4118 --- src/channels/telegram.rs | 203 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 203 insertions(+) diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index 31e3ab7a5..9175e1b07 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -1140,6 +1140,11 @@ Allowlist Telegram username (without '@') or numeric user ID.", content = format!("{quote}\n\n{content}"); } + // Prepend forwarding attribution when the message was forwarded + if let Some(attr) = Self::format_forward_attribution(message) { + content = format!("{attr}{content}"); + } + Some(ChannelMessage { id: format!("telegram_{chat_id}_{message_id}"), sender: sender_identity, @@ -1263,6 +1268,13 @@ Allowlist Telegram username (without '@') or numeric user ID.", format!("[Voice] {text}") }; + // Prepend forwarding attribution when the message was forwarded + let content = if let Some(attr) = Self::format_forward_attribution(message) { + format!("{attr}{content}") + } else { + content + }; + Some(ChannelMessage { id: format!("telegram_{chat_id}_{message_id}"), sender: sender_identity, @@ -1299,6 +1311,41 @@ Allowlist Telegram username (without '@') or numeric user ID.", (username, sender_id, sender_identity) } + /// Build a forwarding attribution prefix from Telegram forward fields. + /// + /// Returns `Some("[Forwarded from ...] ")` when the message is forwarded, + /// `None` otherwise. + fn format_forward_attribution(message: &serde_json::Value) -> Option { + if let Some(from_chat) = message.get("forward_from_chat") { + // Forwarded from a channel or group + let title = from_chat + .get("title") + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown channel"); + Some(format!("[Forwarded from channel: {title}] ")) + } else if let Some(from_user) = message.get("forward_from") { + // Forwarded from a user (privacy allows identity) + let label = from_user + .get("username") + .and_then(serde_json::Value::as_str) + .map(|u| format!("@{u}")) + .or_else(|| { + from_user + .get("first_name") + .and_then(serde_json::Value::as_str) + .map(String::from) + }) + .unwrap_or_else(|| "unknown".to_string()); + Some(format!("[Forwarded from {label}] ")) + } else { + // Forwarded from a user who hides their identity + message + .get("forward_sender_name") + .and_then(serde_json::Value::as_str) + .map(|name| format!("[Forwarded from {name}] ")) + } + } + /// Extract reply context from a Telegram `reply_to_message`, if present. fn extract_reply_context(&self, message: &serde_json::Value) -> Option { let reply = message.get("reply_to_message")?; @@ -1420,6 +1467,13 @@ Allowlist Telegram username (without '@') or numeric user ID.", content }; + // Prepend forwarding attribution when the message was forwarded + let content = if let Some(attr) = Self::format_forward_attribution(message) { + format!("{attr}{content}") + } else { + content + }; + // Exit voice-chat mode when user switches back to typing if let Ok(mut vc) = self.voice_chats.lock() { vc.remove(&reply_target); @@ -4871,4 +4925,153 @@ mod tests { TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(true); assert!(ch.ack_reactions); } + + // ── Forwarded message tests ───────────────────────────────────── + + #[test] + fn parse_update_message_forwarded_from_user_with_username() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 100, + "message": { + "message_id": 50, + "text": "Check this out", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 999 }, + "forward_from": { + "id": 42, + "first_name": "Bob", + "username": "bob" + }, + "forward_date": 1_700_000_000 + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("forwarded message should parse"); + assert_eq!(msg.content, "[Forwarded from @bob] Check this out"); + } + + #[test] + fn parse_update_message_forwarded_from_channel() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 101, + "message": { + "message_id": 51, + "text": "Breaking news", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 999 }, + "forward_from_chat": { + "id": -1_001_234_567_890_i64, + "title": "Daily News", + "username": "dailynews", + "type": "channel" + }, + "forward_date": 1_700_000_000 + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("channel-forwarded message should parse"); + assert_eq!( + msg.content, + "[Forwarded from channel: Daily News] Breaking news" + ); + } + + #[test] + fn parse_update_message_forwarded_hidden_sender() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 102, + "message": { + "message_id": 52, + "text": "Secret tip", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 999 }, + "forward_sender_name": "Hidden User", + "forward_date": 1_700_000_000 + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("hidden-sender forwarded message should parse"); + assert_eq!(msg.content, "[Forwarded from Hidden User] Secret tip"); + } + + #[test] + fn parse_update_message_non_forwarded_unaffected() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 103, + "message": { + "message_id": 53, + "text": "Normal message", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 999 } + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("non-forwarded message should parse"); + assert_eq!(msg.content, "Normal message"); + } + + #[test] + fn parse_update_message_forwarded_from_user_no_username() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + let update = serde_json::json!({ + "update_id": 104, + "message": { + "message_id": 54, + "text": "Hello there", + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 999 }, + "forward_from": { + "id": 77, + "first_name": "Charlie" + }, + "forward_date": 1_700_000_000 + } + }); + + let msg = ch + .parse_update_message(&update) + .expect("forwarded message without username should parse"); + assert_eq!(msg.content, "[Forwarded from Charlie] Hello there"); + } + + #[test] + fn forwarded_photo_attachment_has_attribution() { + // Verify that format_forward_attribution produces correct prefix + // for a photo message (the actual download is async, so we test the + // helper directly with a photo-bearing message structure). + let message = serde_json::json!({ + "message_id": 60, + "from": { "id": 1, "username": "alice" }, + "chat": { "id": 999 }, + "photo": [ + { "file_id": "abc123", "file_unique_id": "u1", "width": 320, "height": 240 } + ], + "forward_from": { + "id": 42, + "username": "bob" + }, + "forward_date": 1_700_000_000 + }); + + let attr = + TelegramChannel::format_forward_attribution(&message).expect("should detect forward"); + assert_eq!(attr, "[Forwarded from @bob] "); + + // Simulate what try_parse_attachment_message does after building content + let photo_content = "[IMAGE:/tmp/photo.jpg]".to_string(); + let content = format!("{attr}{photo_content}"); + assert_eq!(content, "[Forwarded from @bob] [IMAGE:/tmp/photo.jpg]"); + } }