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)
This commit is contained in:
ZeroClaw Contributor 2026-02-18 12:57:38 +03:00 committed by Chummy
parent 3b75c6cc42
commit c0a80ad656
10 changed files with 264 additions and 54 deletions

View file

@ -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 value if the input used the legacy `enc:` format
- `SecretStore::needs_migration()` — Check if a value uses 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 - `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 ### Deprecated
- `enc:` prefix for encrypted secrets — Use `enc2:` (ChaCha20-Poly1305) instead. - `enc:` prefix for encrypted secrets — Use `enc2:` (ChaCha20-Poly1305) instead.

View file

@ -101,7 +101,22 @@ After running automated tests, perform these manual checks:
- Verify: No "Too Many Requests" errors - Verify: No "Too Many Requests" errors
- Verify: Responses have delays - 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 ```bash
RUST_LOG=debug zeroclaw channel start RUST_LOG=debug zeroclaw channel start
@ -225,7 +240,7 @@ Expected values after all fixes:
| Message split overhead | <50ms | Check logs for timing | | Message split overhead | <50ms | Check logs for timing |
| Memory usage | <10MB | `ps aux \| grep zeroclaw` | | Memory usage | <10MB | `ps aux \| grep zeroclaw` |
| Binary size | ~3-4MB | `ls -lh target/release/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 ## 🐛 Debugging Failed Tests

View file

@ -942,7 +942,11 @@ pub async fn doctor_channels(config: Config) -> Result<()> {
channels.push(( channels.push((
"Telegram", "Telegram",
Arc::new( Arc::new(
TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone()) TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms), .with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
), ),
)); ));
@ -1262,7 +1266,11 @@ pub async fn start_channels(config: Config) -> Result<()> {
if let Some(ref tg) = config.channels_config.telegram { if let Some(ref tg) = config.channels_config.telegram {
channels.push(Arc::new( channels.push(Arc::new(
TelegramChannel::new(tg.bot_token.clone(), tg.allowed_users.clone()) TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms), .with_streaming(tg.stream_mode, tg.draft_update_interval_ms),
)); ));
} }

View file

@ -305,10 +305,12 @@ pub struct TelegramChannel {
stream_mode: StreamMode, stream_mode: StreamMode,
draft_update_interval_ms: u64, draft_update_interval_ms: u64,
last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>, last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
mention_only: bool,
bot_username: Mutex<Option<String>>,
} }
impl TelegramChannel { impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>) -> Self { pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
let normalized_allowed = Self::normalize_allowed_users(allowed_users); let normalized_allowed = Self::normalize_allowed_users(allowed_users);
let pairing = if normalized_allowed.is_empty() { let pairing = if normalized_allowed.is_empty() {
let guard = PairingGuard::new(true, &[]); let guard = PairingGuard::new(true, &[]);
@ -330,6 +332,8 @@ impl TelegramChannel {
draft_update_interval_ms: 1000, draft_update_interval_ms: 1000,
last_draft_edit: Mutex::new(std::collections::HashMap::new()), last_draft_edit: Mutex::new(std::collections::HashMap::new()),
typing_handle: Mutex::new(None), 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) format!("https://api.telegram.org/bot{}/{method}", self.bot_token)
} }
async fn fetch_bot_username(&self) -> anyhow::Result<String> {
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<String> {
{
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<String> {
let mention = format!("@{}", bot_username);
let normalized = text.replace(&mention, " ");
let normalized = normalized.split_whitespace().collect::<Vec<_>>().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 { fn is_user_allowed(&self, username: &str) -> bool {
let identity = Self::normalize_identity(username); let identity = Self::normalize_identity(username);
self.allowed_users self.allowed_users
@ -645,6 +713,18 @@ Allowlist Telegram username (without '@') or numeric user ID.",
return None; 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 let chat_id = message
.get("chat") .get("chat")
.and_then(|chat| chat.get("id")) .and_then(|chat| chat.get("id"))
@ -669,11 +749,23 @@ Allowlist Telegram username (without '@') or numeric user ID.",
chat_id.clone() 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 { Some(ChannelMessage {
id: format!("telegram_{chat_id}_{message_id}"), id: format!("telegram_{chat_id}_{message_id}"),
sender: sender_identity, sender: sender_identity,
reply_target, reply_target,
content: text.to_string(), content,
channel: "telegram".to_string(), channel: "telegram".to_string(),
timestamp: std::time::SystemTime::now() timestamp: std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH) .duration_since(std::time::UNIX_EPOCH)
@ -1522,6 +1614,10 @@ impl Channel for TelegramChannel {
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> { async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
let mut offset: i64 = 0; let mut offset: i64 = 0;
if self.mention_only {
let _ = self.get_bot_username().await;
}
tracing::info!("Telegram channel listening for messages..."); tracing::info!("Telegram channel listening for messages...");
loop { loop {
@ -1672,20 +1768,20 @@ mod tests {
#[test] #[test]
fn telegram_channel_name() { 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"); assert_eq!(ch.name(), "telegram");
} }
#[test] #[test]
fn typing_handle_starts_as_none() { 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(); let guard = ch.typing_handle.lock();
assert!(guard.is_none()); assert!(guard.is_none());
} }
#[tokio::test] #[tokio::test]
async fn stop_typing_clears_handle() { 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 // Manually insert a dummy handle
{ {
@ -1704,7 +1800,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn start_typing_replaces_previous_handle() { 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 // Insert a dummy handle first
{ {
@ -1723,10 +1819,10 @@ mod tests {
#[test] #[test]
fn supports_draft_updates_respects_stream_mode() { 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()); 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); .with_streaming(StreamMode::Partial, 750);
assert!(partial.supports_draft_updates()); assert!(partial.supports_draft_updates());
assert_eq!(partial.draft_update_interval_ms, 750); assert_eq!(partial.draft_update_interval_ms, 750);
@ -1734,7 +1830,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn send_draft_returns_none_when_stream_mode_off() { 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 let id = ch
.send_draft(&SendMessage::new("draft", "123")) .send_draft(&SendMessage::new("draft", "123"))
.await .await
@ -1744,7 +1840,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn update_draft_rate_limit_short_circuits_network() { 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); .with_streaming(StreamMode::Partial, 60_000);
ch.last_draft_edit ch.last_draft_edit
.lock() .lock()
@ -1756,7 +1852,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() { 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); .with_streaming(StreamMode::Partial, 0);
let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20); let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20);
@ -1770,7 +1866,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() { 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); .with_streaming(StreamMode::Partial, 0);
let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64); let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64);
@ -1782,7 +1878,7 @@ mod tests {
#[test] #[test]
fn telegram_api_url() { fn telegram_api_url() {
let ch = TelegramChannel::new("123:ABC".into(), vec![]); let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
assert_eq!( assert_eq!(
ch.api_url("getMe"), ch.api_url("getMe"),
"https://api.telegram.org/bot123:ABC/getMe" "https://api.telegram.org/bot123:ABC/getMe"
@ -1791,32 +1887,32 @@ mod tests {
#[test] #[test]
fn telegram_user_allowed_wildcard() { 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")); assert!(ch.is_user_allowed("anyone"));
} }
#[test] #[test]
fn telegram_user_allowed_specific() { 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("alice"));
assert!(!ch.is_user_allowed("eve")); assert!(!ch.is_user_allowed("eve"));
} }
#[test] #[test]
fn telegram_user_allowed_with_at_prefix_in_config() { 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")); assert!(ch.is_user_allowed("alice"));
} }
#[test] #[test]
fn telegram_user_denied_empty() { 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")); assert!(!ch.is_user_allowed("anyone"));
} }
#[test] #[test]
fn telegram_user_exact_match_not_substring() { 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("alice_bot"));
assert!(!ch.is_user_allowed("alic")); assert!(!ch.is_user_allowed("alic"));
assert!(!ch.is_user_allowed("malice")); assert!(!ch.is_user_allowed("malice"));
@ -1824,13 +1920,13 @@ mod tests {
#[test] #[test]
fn telegram_user_empty_string_denied() { 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("")); assert!(!ch.is_user_allowed(""));
} }
#[test] #[test]
fn telegram_user_case_sensitive() { 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")); 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] #[test]
fn telegram_wildcard_with_specific_users() { 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("alice"));
assert!(ch.is_user_allowed("bob")); assert!(ch.is_user_allowed("bob"));
assert!(ch.is_user_allowed("anyone")); assert!(ch.is_user_allowed("anyone"));
@ -1846,25 +1942,25 @@ mod tests {
#[test] #[test]
fn telegram_user_allowed_by_numeric_id_identity() { 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"])); assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
} }
#[test] #[test]
fn telegram_user_denied_when_none_of_identities_match() { 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"])); assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
} }
#[test] #[test]
fn telegram_pairing_enabled_with_empty_allowlist() { 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()); assert!(ch.pairing_code_active());
} }
#[test] #[test]
fn telegram_pairing_disabled_with_nonempty_allowlist() { 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()); assert!(!ch.pairing_code_active());
} }
@ -1940,7 +2036,7 @@ mod tests {
#[test] #[test]
fn parse_update_message_uses_chat_id_as_reply_target() { 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!({ let update = serde_json::json!({
"update_id": 1, "update_id": 1,
"message": { "message": {
@ -1968,7 +2064,7 @@ mod tests {
#[test] #[test]
fn parse_update_message_allows_numeric_id_without_username() { 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!({ let update = serde_json::json!({
"update_id": 2, "update_id": 2,
"message": { "message": {
@ -1993,7 +2089,7 @@ mod tests {
#[test] #[test]
fn parse_update_message_extracts_thread_id_for_forum_topic() { 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!({ let update = serde_json::json!({
"update_id": 3, "update_id": 3,
"message": { "message": {
@ -2024,7 +2120,7 @@ mod tests {
#[test] #[test]
fn telegram_api_url_send_document() { 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!( assert_eq!(
ch.api_url("sendDocument"), ch.api_url("sendDocument"),
"https://api.telegram.org/bot123:ABC/sendDocument" "https://api.telegram.org/bot123:ABC/sendDocument"
@ -2033,7 +2129,7 @@ mod tests {
#[test] #[test]
fn telegram_api_url_send_photo() { 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!( assert_eq!(
ch.api_url("sendPhoto"), ch.api_url("sendPhoto"),
"https://api.telegram.org/bot123:ABC/sendPhoto" "https://api.telegram.org/bot123:ABC/sendPhoto"
@ -2042,7 +2138,7 @@ mod tests {
#[test] #[test]
fn telegram_api_url_send_video() { 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!( assert_eq!(
ch.api_url("sendVideo"), ch.api_url("sendVideo"),
"https://api.telegram.org/bot123:ABC/sendVideo" "https://api.telegram.org/bot123:ABC/sendVideo"
@ -2051,7 +2147,7 @@ mod tests {
#[test] #[test]
fn telegram_api_url_send_audio() { 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!( assert_eq!(
ch.api_url("sendAudio"), ch.api_url("sendAudio"),
"https://api.telegram.org/bot123:ABC/sendAudio" "https://api.telegram.org/bot123:ABC/sendAudio"
@ -2060,7 +2156,7 @@ mod tests {
#[test] #[test]
fn telegram_api_url_send_voice() { 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!( assert_eq!(
ch.api_url("sendVoice"), ch.api_url("sendVoice"),
"https://api.telegram.org/bot123:ABC/sendVoice" "https://api.telegram.org/bot123:ABC/sendVoice"
@ -2072,7 +2168,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_bytes_builds_correct_form() { async fn telegram_send_document_bytes_builds_correct_form() {
// This test verifies the method doesn't panic and handles bytes correctly // 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(); 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 // The actual API call will fail (no real server), but we verify the method exists
@ -2093,7 +2189,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_photo_bytes_builds_correct_form() { 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 // Minimal valid PNG header bytes
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
@ -2106,7 +2202,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_by_url_builds_correct_json() { 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 let result = ch
.send_document_by_url( .send_document_by_url(
@ -2122,7 +2218,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_photo_by_url_builds_correct_json() { 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 let result = ch
.send_photo_by_url("123456", None, "https://example.com/image.jpg", None) .send_photo_by_url("123456", None, "https://example.com/image.jpg", None)
@ -2135,7 +2231,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_nonexistent_file() { 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 path = Path::new("/nonexistent/path/to/file.txt");
let result = ch.send_document("123456", None, path, None).await; let result = ch.send_document("123456", None, path, None).await;
@ -2151,7 +2247,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_photo_nonexistent_file() { 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 path = Path::new("/nonexistent/path/to/photo.jpg");
let result = ch.send_photo("123456", None, path, None).await; let result = ch.send_photo("123456", None, path, None).await;
@ -2161,7 +2257,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_video_nonexistent_file() { 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 path = Path::new("/nonexistent/path/to/video.mp4");
let result = ch.send_video("123456", None, path, None).await; let result = ch.send_video("123456", None, path, None).await;
@ -2171,7 +2267,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_audio_nonexistent_file() { 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 path = Path::new("/nonexistent/path/to/audio.mp3");
let result = ch.send_audio("123456", None, path, None).await; let result = ch.send_audio("123456", None, path, None).await;
@ -2181,7 +2277,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_voice_nonexistent_file() { 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 path = Path::new("/nonexistent/path/to/voice.ogg");
let result = ch.send_voice("123456", None, path, None).await; let result = ch.send_voice("123456", None, path, None).await;
@ -2269,7 +2365,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_bytes_with_caption() { 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(); let file_bytes = b"test content".to_vec();
// With caption // With caption
@ -2293,7 +2389,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_photo_bytes_with_caption() { 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]; let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
// With caption // With caption
@ -2319,7 +2415,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_bytes_empty_file() { 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<u8> = vec![]; let file_bytes: Vec<u8> = vec![];
let result = ch let result = ch
@ -2332,7 +2428,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_bytes_empty_filename() { 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 file_bytes = b"content".to_vec();
let result = ch let result = ch
@ -2345,7 +2441,7 @@ mod tests {
#[tokio::test] #[tokio::test]
async fn telegram_send_document_bytes_empty_chat_id() { 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 file_bytes = b"content".to_vec();
let result = ch let result = ch
@ -2516,4 +2612,78 @@ mod tests {
let result = strip_tool_call_tags(input); let result = strip_tool_call_tags(input);
assert_eq!(result, ""); 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);
}
} }

View file

@ -33,6 +33,7 @@ mod tests {
allowed_users: vec!["alice".into()], allowed_users: vec!["alice".into()],
stream_mode: StreamMode::default(), stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000, draft_update_interval_ms: 1000,
mention_only: false,
}; };
let discord = DiscordConfig { let discord = DiscordConfig {

View file

@ -1465,6 +1465,10 @@ pub struct TelegramConfig {
/// Minimum interval (ms) between draft message edits to avoid rate limits. /// Minimum interval (ms) between draft message edits to avoid rate limits.
#[serde(default = "default_draft_update_interval_ms")] #[serde(default = "default_draft_update_interval_ms")]
pub draft_update_interval_ms: u64, 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)] #[derive(Debug, Clone, Serialize, Deserialize)]
@ -2535,6 +2539,7 @@ default_temperature = 0.7
allowed_users: vec!["user1".into()], allowed_users: vec!["user1".into()],
stream_mode: StreamMode::default(), stream_mode: StreamMode::default(),
draft_update_interval_ms: default_draft_update_interval_ms(), draft_update_interval_ms: default_draft_update_interval_ms(),
mention_only: false,
}), }),
discord: None, discord: None,
slack: None, slack: None,
@ -2808,6 +2813,7 @@ tool_dispatcher = "xml"
allowed_users: vec!["alice".into(), "bob".into()], allowed_users: vec!["alice".into(), "bob".into()],
stream_mode: StreamMode::Partial, stream_mode: StreamMode::Partial,
draft_update_interval_ms: 500, draft_update_interval_ms: 500,
mention_only: false,
}; };
let json = serde_json::to_string(&tc).unwrap(); let json = serde_json::to_string(&tc).unwrap();
let parsed: TelegramConfig = serde_json::from_str(&json).unwrap(); let parsed: TelegramConfig = serde_json::from_str(&json).unwrap();

View file

@ -259,7 +259,11 @@ async fn deliver_if_configured(config: &Config, job: &CronJob, output: &str) ->
.telegram .telegram
.as_ref() .as_ref()
.ok_or_else(|| anyhow::anyhow!("telegram channel not configured"))?; .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?; channel.send(&SendMessage::new(output, target)).await?;
} }
"discord" => { "discord" => {

View file

@ -298,6 +298,7 @@ mod tests {
allowed_users: vec![], allowed_users: vec![],
stream_mode: crate::config::StreamMode::default(), stream_mode: crate::config::StreamMode::default(),
draft_update_interval_ms: 1000, draft_update_interval_ms: 1000,
mention_only: false,
}); });
assert!(has_supervised_channels(&config)); assert!(has_supervised_channels(&config));
} }

View file

@ -790,6 +790,7 @@ mod tests {
allowed_users: vec!["user".into()], allowed_users: vec!["user".into()],
stream_mode: StreamMode::default(), stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000, draft_update_interval_ms: 1000,
mention_only: false,
}); });
let entries = all_integrations(); let entries = all_integrations();
let tg = entries.iter().find(|e| e.name == "Telegram").unwrap(); let tg = entries.iter().find(|e| e.name == "Telegram").unwrap();

View file

@ -2667,6 +2667,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
allowed_users, allowed_users,
stream_mode: StreamMode::default(), stream_mode: StreamMode::default(),
draft_update_interval_ms: 1000, draft_update_interval_ms: 1000,
mention_only: false,
}); });
} }
1 => { 1 => {