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:
parent
3b75c6cc42
commit
c0a80ad656
10 changed files with 264 additions and 54 deletions
|
|
@ -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),
|
||||
));
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -305,10 +305,12 @@ pub struct TelegramChannel {
|
|||
stream_mode: StreamMode,
|
||||
draft_update_interval_ms: u64,
|
||||
last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
|
||||
mention_only: bool,
|
||||
bot_username: Mutex<Option<String>>,
|
||||
}
|
||||
|
||||
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 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<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 {
|
||||
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<ChannelMessage>) -> 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<u8> = 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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" => {
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -2667,6 +2667,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
|
|||
allowed_users,
|
||||
stream_mode: StreamMode::default(),
|
||||
draft_update_interval_ms: 1000,
|
||||
mention_only: false,
|
||||
});
|
||||
}
|
||||
1 => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue