From c0a80ad6569e82dff155d38596ee7ad43b196f91 Mon Sep 17 00:00:00 2001 From: ZeroClaw Contributor Date: Wed, 18 Feb 2026 12:57:38 +0300 Subject: [PATCH] feat(channel): add mention_only option for Telegram groups Adds mention_only config option to Telegram channel, allowing the bot to only respond to messages that @-mention the bot in group chats. Direct messages are always processed regardless of this setting. Behavior: - When mention_only = true: Bot only responds to group messages containing @botname - When mention_only = false (default): Bot responds to all allowed messages - DM/private chats always work regardless of mention_only setting Implementation: - Fetch and cache bot username from Telegram API on startup - Check for @botname mention in group messages - Strip mention from message content before processing Config example: [channels.telegram] bot_token = "your_token" mention_only = true Changes: - src/config/schema.rs: Add mention_only to TelegramConfig - src/channels/telegram.rs: Implement mention_only logic + 6 new tests - src/channels/mod.rs: Update factory calls - src/cron/scheduler.rs: Update constructor call - src/onboard/wizard.rs: Update wizard config - src/daemon/mod.rs: Update test config - src/integrations/registry.rs: Update test config - TESTING_TELEGRAM.md: Add mention_only test section - CHANGELOG.md: Document feature Risk: medium Backward compatible: Yes (default: false) --- CHANGELOG.md | 3 + TESTING_TELEGRAM.md | 19 ++- src/channels/mod.rs | 16 ++- src/channels/telegram.rs | 264 ++++++++++++++++++++++++++++------- src/config/mod.rs | 1 + src/config/schema.rs | 6 + src/cron/scheduler.rs | 6 +- src/daemon/mod.rs | 1 + src/integrations/registry.rs | 1 + src/onboard/wizard.rs | 1 + 10 files changed, 264 insertions(+), 54 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 79e1712..4944885 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -18,6 +18,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 value if the input used the legacy `enc:` format - `SecretStore::needs_migration()` — Check if a value uses the legacy `enc:` format - `SecretStore::is_secure_encrypted()` — Check if a value uses the secure `enc2:` format +- **Telegram mention_only mode** — New config option `mention_only` for Telegram channel. + When enabled, bot only responds to messages that @-mention the bot in group chats. + Direct messages always work regardless of this setting. Default: `false`. ### Deprecated - `enc:` prefix for encrypted secrets — Use `enc2:` (ChaCha20-Poly1305) instead. diff --git a/TESTING_TELEGRAM.md b/TESTING_TELEGRAM.md index 128ff76..d1cfe98 100644 --- a/TESTING_TELEGRAM.md +++ b/TESTING_TELEGRAM.md @@ -101,7 +101,22 @@ After running automated tests, perform these manual checks: - Verify: No "Too Many Requests" errors - Verify: Responses have delays -5. **Error logging** +5. **Mention-only mode (group chats)** + + ```toml + # Edit ~/.zeroclaw/config.toml + [channels.telegram] + mention_only = true + ``` + + - Add bot to a group chat + - Send message without @botname mention + - Verify: Bot does not respond + - Send message with @botname mention + - Verify: Bot responds and mention is stripped + - DM/private chat should always work regardless of mention_only + +6. **Error logging** ```bash RUST_LOG=debug zeroclaw channel start @@ -225,7 +240,7 @@ Expected values after all fixes: | Message split overhead | <50ms | Check logs for timing | | Memory usage | <10MB | `ps aux \| grep zeroclaw` | | Binary size | ~3-4MB | `ls -lh target/release/zeroclaw` | -| Unit test coverage | 24/24 pass | `cargo test telegram --lib` | +| Unit test coverage | 61/61 pass | `cargo test telegram --lib` | ## 🐛 Debugging Failed Tests diff --git a/src/channels/mod.rs b/src/channels/mod.rs index f88913e..9fd9381 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -942,8 +942,12 @@ pub async fn doctor_channels(config: Config) -> Result<()> { channels.push(( "Telegram", Arc::new( - TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone()) - .with_streaming(tg.stream_mode, tg.draft_update_interval_ms), + TelegramChannel::new( + tg.bot_token.clone(), + tg.allowed_users.clone(), + tg.mention_only, + ) + .with_streaming(tg.stream_mode, tg.draft_update_interval_ms), ), )); } @@ -1262,8 +1266,12 @@ pub async fn start_channels(config: Config) -> Result<()> { if let Some(ref tg) = config.channels_config.telegram { channels.push(Arc::new( - TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone()) - .with_streaming(tg.stream_mode, tg.draft_update_interval_ms), + TelegramChannel::new( + tg.bot_token.clone(), + tg.allowed_users.clone(), + tg.mention_only, + ) + .with_streaming(tg.stream_mode, tg.draft_update_interval_ms), )); } diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index 6cd71ee..77ba2e7 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -305,10 +305,12 @@ pub struct TelegramChannel { stream_mode: StreamMode, draft_update_interval_ms: u64, last_draft_edit: Mutex>, + mention_only: bool, + bot_username: Mutex>, } impl TelegramChannel { - pub fn new(bot_token: String, allowed_users: Vec) -> Self { + pub fn new(bot_token: String, allowed_users: Vec, mention_only: bool) -> Self { let normalized_allowed = Self::normalize_allowed_users(allowed_users); let pairing = if normalized_allowed.is_empty() { let guard = PairingGuard::new(true, &[]); @@ -330,6 +332,8 @@ impl TelegramChannel { draft_update_interval_ms: 1000, last_draft_edit: Mutex::new(std::collections::HashMap::new()), typing_handle: Mutex::new(None), + mention_only, + bot_username: Mutex::new(None), } } @@ -443,6 +447,70 @@ impl TelegramChannel { format!("https://api.telegram.org/bot{}/{method}", self.bot_token) } + async fn fetch_bot_username(&self) -> anyhow::Result { + let resp = self.client.get(self.api_url("getMe")).send().await?; + + if !resp.status().is_success() { + anyhow::bail!("Failed to fetch bot info: {}", resp.status()); + } + + let data: serde_json::Value = resp.json().await?; + let username = data + .get("result") + .and_then(|r| r.get("username")) + .and_then(|u| u.as_str()) + .context("Bot username not found in response")?; + + Ok(username.to_string()) + } + + async fn get_bot_username(&self) -> Option { + { + let cache = self.bot_username.lock(); + if let Some(ref username) = *cache { + return Some(username.clone()); + } + } + + match self.fetch_bot_username().await { + Ok(username) => { + let mut cache = self.bot_username.lock(); + *cache = Some(username.clone()); + Some(username) + } + Err(e) => { + tracing::warn!("Failed to fetch bot username: {e}"); + None + } + } + } + + fn contains_bot_mention(text: &str, bot_username: &str) -> bool { + let mention = format!("@{}", bot_username); + text.contains(&mention) + } + + fn normalize_incoming_content(text: &str, bot_username: &str) -> Option { + let mention = format!("@{}", bot_username); + let normalized = text.replace(&mention, " "); + let normalized = normalized.split_whitespace().collect::>().join(" "); + + if normalized.is_empty() { + None + } else { + Some(normalized) + } + } + + fn is_group_message(message: &serde_json::Value) -> bool { + message + .get("chat") + .and_then(|c| c.get("type")) + .and_then(|t| t.as_str()) + .map(|t| t == "group" || t == "supergroup") + .unwrap_or(false) + } + fn is_user_allowed(&self, username: &str) -> bool { let identity = Self::normalize_identity(username); self.allowed_users @@ -645,6 +713,18 @@ Allowlist Telegram username (without '@') or numeric user ID.", return None; } + let is_group = Self::is_group_message(message); + if self.mention_only && is_group { + let bot_username = self.bot_username.lock(); + if let Some(ref bot_username) = *bot_username { + if !Self::contains_bot_mention(text, bot_username) { + return None; + } + } else { + return None; + } + } + let chat_id = message .get("chat") .and_then(|chat| chat.get("id")) @@ -669,11 +749,23 @@ Allowlist Telegram username (without '@') or numeric user ID.", chat_id.clone() }; + let content = if self.mention_only && is_group { + let bot_username = self.bot_username.lock(); + if let Some(ref bot_username) = *bot_username { + Self::normalize_incoming_content(text, bot_username) + .unwrap_or_else(|| text.to_string()) + } else { + text.to_string() + } + } else { + text.to_string() + }; + Some(ChannelMessage { id: format!("telegram_{chat_id}_{message_id}"), sender: sender_identity, reply_target, - content: text.to_string(), + content, channel: "telegram".to_string(), timestamp: std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) @@ -1522,6 +1614,10 @@ impl Channel for TelegramChannel { async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { let mut offset: i64 = 0; + if self.mention_only { + let _ = self.get_bot_username().await; + } + tracing::info!("Telegram channel listening for messages..."); loop { @@ -1672,20 +1768,20 @@ mod tests { #[test] fn telegram_channel_name() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); assert_eq!(ch.name(), "telegram"); } #[test] fn typing_handle_starts_as_none() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let guard = ch.typing_handle.lock(); assert!(guard.is_none()); } #[tokio::test] async fn stop_typing_clears_handle() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); // Manually insert a dummy handle { @@ -1704,7 +1800,7 @@ mod tests { #[tokio::test] async fn start_typing_replaces_previous_handle() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); // Insert a dummy handle first { @@ -1723,10 +1819,10 @@ mod tests { #[test] fn supports_draft_updates_respects_stream_mode() { - let off = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let off = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); assert!(!off.supports_draft_updates()); - let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()]) + let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) .with_streaming(StreamMode::Partial, 750); assert!(partial.supports_draft_updates()); assert_eq!(partial.draft_update_interval_ms, 750); @@ -1734,7 +1830,7 @@ mod tests { #[tokio::test] async fn send_draft_returns_none_when_stream_mode_off() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let id = ch .send_draft(&SendMessage::new("draft", "123")) .await @@ -1744,7 +1840,7 @@ mod tests { #[tokio::test] async fn update_draft_rate_limit_short_circuits_network() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]) + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) .with_streaming(StreamMode::Partial, 60_000); ch.last_draft_edit .lock() @@ -1756,7 +1852,7 @@ mod tests { #[tokio::test] async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]) + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) .with_streaming(StreamMode::Partial, 0); let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20); @@ -1770,7 +1866,7 @@ mod tests { #[tokio::test] async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]) + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false) .with_streaming(StreamMode::Partial, 0); let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64); @@ -1782,7 +1878,7 @@ mod tests { #[test] fn telegram_api_url() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); assert_eq!( ch.api_url("getMe"), "https://api.telegram.org/bot123:ABC/getMe" @@ -1791,32 +1887,32 @@ mod tests { #[test] fn telegram_user_allowed_wildcard() { - let ch = TelegramChannel::new("t".into(), vec!["*".into()]); + let ch = TelegramChannel::new("t".into(), vec!["*".into()], false); assert!(ch.is_user_allowed("anyone")); } #[test] fn telegram_user_allowed_specific() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], false); assert!(ch.is_user_allowed("alice")); assert!(!ch.is_user_allowed("eve")); } #[test] fn telegram_user_allowed_with_at_prefix_in_config() { - let ch = TelegramChannel::new("t".into(), vec!["@alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["@alice".into()], false); assert!(ch.is_user_allowed("alice")); } #[test] fn telegram_user_denied_empty() { - let ch = TelegramChannel::new("t".into(), vec![]); + let ch = TelegramChannel::new("t".into(), vec![], false); assert!(!ch.is_user_allowed("anyone")); } #[test] fn telegram_user_exact_match_not_substring() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); assert!(!ch.is_user_allowed("alice_bot")); assert!(!ch.is_user_allowed("alic")); assert!(!ch.is_user_allowed("malice")); @@ -1824,13 +1920,13 @@ mod tests { #[test] fn telegram_user_empty_string_denied() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); assert!(!ch.is_user_allowed("")); } #[test] fn telegram_user_case_sensitive() { - let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], false); assert!(ch.is_user_allowed("Alice")); assert!(!ch.is_user_allowed("alice")); assert!(!ch.is_user_allowed("ALICE")); @@ -1838,7 +1934,7 @@ mod tests { #[test] fn telegram_wildcard_with_specific_users() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], false); assert!(ch.is_user_allowed("alice")); assert!(ch.is_user_allowed("bob")); assert!(ch.is_user_allowed("anyone")); @@ -1846,25 +1942,25 @@ mod tests { #[test] fn telegram_user_allowed_by_numeric_id_identity() { - let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]); + let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], false); assert!(ch.is_any_user_allowed(["unknown", "123456789"])); } #[test] fn telegram_user_denied_when_none_of_identities_match() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], false); assert!(!ch.is_any_user_allowed(["unknown", "123456789"])); } #[test] fn telegram_pairing_enabled_with_empty_allowlist() { - let ch = TelegramChannel::new("t".into(), vec![]); + let ch = TelegramChannel::new("t".into(), vec![], false); assert!(ch.pairing_code_active()); } #[test] fn telegram_pairing_disabled_with_nonempty_allowlist() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false); assert!(!ch.pairing_code_active()); } @@ -1940,7 +2036,7 @@ mod tests { #[test] fn parse_update_message_uses_chat_id_as_reply_target() { - let ch = TelegramChannel::new("token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); let update = serde_json::json!({ "update_id": 1, "message": { @@ -1968,7 +2064,7 @@ mod tests { #[test] fn parse_update_message_allows_numeric_id_without_username() { - let ch = TelegramChannel::new("token".into(), vec!["555".into()]); + let ch = TelegramChannel::new("token".into(), vec!["555".into()], false); let update = serde_json::json!({ "update_id": 2, "message": { @@ -1993,7 +2089,7 @@ mod tests { #[test] fn parse_update_message_extracts_thread_id_for_forum_topic() { - let ch = TelegramChannel::new("token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); let update = serde_json::json!({ "update_id": 3, "message": { @@ -2024,7 +2120,7 @@ mod tests { #[test] fn telegram_api_url_send_document() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); assert_eq!( ch.api_url("sendDocument"), "https://api.telegram.org/bot123:ABC/sendDocument" @@ -2033,7 +2129,7 @@ mod tests { #[test] fn telegram_api_url_send_photo() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); assert_eq!( ch.api_url("sendPhoto"), "https://api.telegram.org/bot123:ABC/sendPhoto" @@ -2042,7 +2138,7 @@ mod tests { #[test] fn telegram_api_url_send_video() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); assert_eq!( ch.api_url("sendVideo"), "https://api.telegram.org/bot123:ABC/sendVideo" @@ -2051,7 +2147,7 @@ mod tests { #[test] fn telegram_api_url_send_audio() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); assert_eq!( ch.api_url("sendAudio"), "https://api.telegram.org/bot123:ABC/sendAudio" @@ -2060,7 +2156,7 @@ mod tests { #[test] fn telegram_api_url_send_voice() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], false); assert_eq!( ch.api_url("sendVoice"), "https://api.telegram.org/bot123:ABC/sendVoice" @@ -2072,7 +2168,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_builds_correct_form() { // This test verifies the method doesn't panic and handles bytes correctly - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let file_bytes = b"Hello, this is a test file content".to_vec(); // The actual API call will fail (no real server), but we verify the method exists @@ -2093,7 +2189,7 @@ mod tests { #[tokio::test] async fn telegram_send_photo_bytes_builds_correct_form() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); // Minimal valid PNG header bytes let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; @@ -2106,7 +2202,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_by_url_builds_correct_json() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let result = ch .send_document_by_url( @@ -2122,7 +2218,7 @@ mod tests { #[tokio::test] async fn telegram_send_photo_by_url_builds_correct_json() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let result = ch .send_photo_by_url("123456", None, "https://example.com/image.jpg", None) @@ -2135,7 +2231,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let path = Path::new("/nonexistent/path/to/file.txt"); let result = ch.send_document("123456", None, path, None).await; @@ -2151,7 +2247,7 @@ mod tests { #[tokio::test] async fn telegram_send_photo_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let path = Path::new("/nonexistent/path/to/photo.jpg"); let result = ch.send_photo("123456", None, path, None).await; @@ -2161,7 +2257,7 @@ mod tests { #[tokio::test] async fn telegram_send_video_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let path = Path::new("/nonexistent/path/to/video.mp4"); let result = ch.send_video("123456", None, path, None).await; @@ -2171,7 +2267,7 @@ mod tests { #[tokio::test] async fn telegram_send_audio_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let path = Path::new("/nonexistent/path/to/audio.mp3"); let result = ch.send_audio("123456", None, path, None).await; @@ -2181,7 +2277,7 @@ mod tests { #[tokio::test] async fn telegram_send_voice_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let path = Path::new("/nonexistent/path/to/voice.ogg"); let result = ch.send_voice("123456", None, path, None).await; @@ -2269,7 +2365,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_with_caption() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let file_bytes = b"test content".to_vec(); // With caption @@ -2293,7 +2389,7 @@ mod tests { #[tokio::test] async fn telegram_send_photo_bytes_with_caption() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let file_bytes = vec![0x89, 0x50, 0x4E, 0x47]; // With caption @@ -2319,7 +2415,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_empty_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let file_bytes: Vec = vec![]; let result = ch @@ -2332,7 +2428,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_empty_filename() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let file_bytes = b"content".to_vec(); let result = ch @@ -2345,7 +2441,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_empty_chat_id() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false); let file_bytes = b"content".to_vec(); let result = ch @@ -2516,4 +2612,78 @@ mod tests { let result = strip_tool_call_tags(input); assert_eq!(result, ""); } + + #[test] + fn telegram_contains_bot_mention_finds_mention() { + assert!(TelegramChannel::contains_bot_mention( + "Hello @mybot", + "mybot" + )); + assert!(TelegramChannel::contains_bot_mention( + "@mybot help", + "mybot" + )); + assert!(TelegramChannel::contains_bot_mention( + "Hey @mybot how are you?", + "mybot" + )); + } + + #[test] + fn telegram_contains_bot_mention_no_false_positives() { + assert!(!TelegramChannel::contains_bot_mention( + "Hello @otherbot", + "mybot" + )); + assert!(!TelegramChannel::contains_bot_mention( + "Hello mybot", + "mybot" + )); + assert!(!TelegramChannel::contains_bot_mention("", "mybot")); + } + + #[test] + fn telegram_normalize_incoming_content_strips_mention() { + let result = TelegramChannel::normalize_incoming_content("@mybot hello", "mybot"); + assert_eq!(result, Some("hello".to_string())); + } + + #[test] + fn telegram_normalize_incoming_content_handles_multiple_mentions() { + let result = TelegramChannel::normalize_incoming_content("@mybot @mybot test", "mybot"); + assert_eq!(result, Some("test".to_string())); + } + + #[test] + fn telegram_normalize_incoming_content_returns_none_for_empty() { + let result = TelegramChannel::normalize_incoming_content("@mybot", "mybot"); + assert_eq!(result, None); + } + + #[test] + fn telegram_is_group_message_detects_groups() { + let group_msg = serde_json::json!({ + "chat": { "type": "group" } + }); + assert!(TelegramChannel::is_group_message(&group_msg)); + + let supergroup_msg = serde_json::json!({ + "chat": { "type": "supergroup" } + }); + assert!(TelegramChannel::is_group_message(&supergroup_msg)); + + let private_msg = serde_json::json!({ + "chat": { "type": "private" } + }); + assert!(!TelegramChannel::is_group_message(&private_msg)); + } + + #[test] + fn telegram_mention_only_enabled_by_config() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], true); + assert!(ch.mention_only); + + let ch_disabled = TelegramChannel::new("token".into(), vec!["*".into()], false); + assert!(!ch_disabled.mention_only); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index 227789f..430e603 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -33,6 +33,7 @@ mod tests { allowed_users: vec!["alice".into()], stream_mode: StreamMode::default(), draft_update_interval_ms: 1000, + mention_only: false, }; let discord = DiscordConfig { diff --git a/src/config/schema.rs b/src/config/schema.rs index 1551a0c..2767201 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -1465,6 +1465,10 @@ pub struct TelegramConfig { /// Minimum interval (ms) between draft message edits to avoid rate limits. #[serde(default = "default_draft_update_interval_ms")] pub draft_update_interval_ms: u64, + /// When true, only respond to messages that @-mention the bot in groups. + /// Direct messages are always processed. + #[serde(default)] + pub mention_only: bool, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -2535,6 +2539,7 @@ default_temperature = 0.7 allowed_users: vec!["user1".into()], stream_mode: StreamMode::default(), draft_update_interval_ms: default_draft_update_interval_ms(), + mention_only: false, }), discord: None, slack: None, @@ -2808,6 +2813,7 @@ tool_dispatcher = "xml" allowed_users: vec!["alice".into(), "bob".into()], stream_mode: StreamMode::Partial, draft_update_interval_ms: 500, + mention_only: false, }; let json = serde_json::to_string(&tc).unwrap(); let parsed: TelegramConfig = serde_json::from_str(&json).unwrap(); diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index d09388e..783bdf7 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -259,7 +259,11 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) -> .telegram .as_ref() .ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?; - let channel = TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone()); + let channel = TelegramChannel::new( + tg.bot_token.clone(), + tg.allowed_users.clone(), + tg.mention_only, + ); channel.send(&SendMessage::new(output, target)).await?; } "discord" => { diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index ecb905d..ca0834b 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -298,6 +298,7 @@ mod tests { allowed_users: vec![], stream_mode: crate::config::StreamMode::default(), draft_update_interval_ms: 1000, + mention_only: false, }); assert!(has_supervised_channels(&config)); } diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index d2d161b..615ace0 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -790,6 +790,7 @@ mod tests { allowed_users: vec!["user".into()], stream_mode: StreamMode::default(), draft_update_interval_ms: 1000, + mention_only: false, }); let entries = all_integrations(); let tg = entries.iter().find(|e| e.name == "Telegram").unwrap(); diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index b0cbe24..ac30b18 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -2667,6 +2667,7 @@ fn setup_channels() -> Result { allowed_users, stream_mode: StreamMode::default(), draft_update_interval_ms: 1000, + mention_only: false, }); } 1 => {