diff --git a/src/channels/mod.rs b/src/channels/mod.rs index 0ec690cde..95b59bc92 100644 --- a/src/channels/mod.rs +++ b/src/channels/mod.rs @@ -3229,12 +3229,16 @@ fn build_channel_by_id(config: &Config, channel_id: &str) -> Result assert_eq!(channel.name(), "telegram"), diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index 972a090e3..e05c44fb5 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -332,6 +332,7 @@ pub struct TelegramChannel { transcription: Option, voice_transcriptions: Mutex>, workspace_dir: Option, + ack_reactions: bool, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -370,9 +371,16 @@ impl TelegramChannel { transcription: None, voice_transcriptions: Mutex::new(std::collections::HashMap::new()), workspace_dir: None, + ack_reactions: true, } } + /// Configure whether Telegram-native acknowledgement reactions are sent. + pub fn with_ack_reactions(mut self, enabled: bool) -> Self { + self.ack_reactions = enabled; + self + } + /// Configure workspace directory for saving downloaded attachments. pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self { self.workspace_dir = Some(dir); @@ -2689,13 +2697,15 @@ Ensure only one `zeroclaw` process is using this bot token." continue; }; - if let Some((reaction_chat_id, reaction_message_id)) = - Self::extract_update_message_target(update) - { - self.try_add_ack_reaction_nonblocking( - reaction_chat_id, - reaction_message_id, - ); + if self.ack_reactions { + if let Some((reaction_chat_id, reaction_message_id)) = + Self::extract_update_message_target(update) + { + self.try_add_ack_reaction_nonblocking( + reaction_chat_id, + reaction_message_id, + ); + } } // Send "typing" indicator immediately when we receive a message @@ -4681,4 +4691,24 @@ mod tests { // the agent loop will return ProviderCapabilityError before calling // the provider, and the channel will send "⚠️ Error: ..." to the user. } + + #[test] + fn ack_reactions_defaults_to_true() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], false); + assert!(ch.ack_reactions); + } + + #[test] + fn with_ack_reactions_false_disables_reactions() { + let ch = + TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(false); + assert!(!ch.ack_reactions); + } + + #[test] + fn with_ack_reactions_true_keeps_reactions() { + let ch = + TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(true); + assert!(ch.ack_reactions); + } } diff --git a/src/config/mod.rs b/src/config/mod.rs index c999783b5..285c697e2 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -55,6 +55,7 @@ mod tests { draft_update_interval_ms: 1000, interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }; let discord = DiscordConfig { diff --git a/src/config/schema.rs b/src/config/schema.rs index dc9b2ae0e..15e30de14 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -4511,6 +4511,11 @@ pub struct TelegramConfig { /// Direct messages are always processed. #[serde(default)] pub mention_only: bool, + /// Override for the top-level `ack_reactions` setting. When `None`, the + /// channel falls back to `[channels_config].ack_reactions`. When set + /// explicitly, it takes precedence. + #[serde(default)] + pub ack_reactions: Option, } impl ChannelConfig for TelegramConfig { @@ -8360,6 +8365,7 @@ default_temperature = 0.7 draft_update_interval_ms: default_draft_update_interval_ms(), interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }), discord: None, slack: None, @@ -8942,6 +8948,7 @@ tool_dispatcher = "xml" draft_update_interval_ms: 500, interrupt_on_new_message: true, mention_only: false, + ack_reactions: None, }; let json = serde_json::to_string(&tc).unwrap(); let parsed: TelegramConfig = serde_json::from_str(&json).unwrap(); @@ -11256,6 +11263,7 @@ require_otp_to_resume = true draft_update_interval_ms: default_draft_update_interval_ms(), interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }); // Save (triggers encryption) @@ -11811,4 +11819,67 @@ require_otp_to_resume = true "Debug output must show [REDACTED] for client_secret" ); } + + #[test] + async fn telegram_config_ack_reactions_false_deserializes() { + let toml_str = r#" + bot_token = "123:ABC" + allowed_users = ["alice"] + ack_reactions = false + "#; + let cfg: TelegramConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.ack_reactions, Some(false)); + } + + #[test] + async fn telegram_config_ack_reactions_true_deserializes() { + let toml_str = r#" + bot_token = "123:ABC" + allowed_users = ["alice"] + ack_reactions = true + "#; + let cfg: TelegramConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.ack_reactions, Some(true)); + } + + #[test] + async fn telegram_config_ack_reactions_missing_defaults_to_none() { + let toml_str = r#" + bot_token = "123:ABC" + allowed_users = ["alice"] + "#; + let cfg: TelegramConfig = toml::from_str(toml_str).unwrap(); + assert_eq!(cfg.ack_reactions, None); + } + + #[test] + async fn telegram_config_ack_reactions_channel_overrides_top_level() { + let tg_toml = r#" + bot_token = "123:ABC" + allowed_users = ["alice"] + ack_reactions = false + "#; + let tg: TelegramConfig = toml::from_str(tg_toml).unwrap(); + let top_level_ack = true; + let effective = tg.ack_reactions.unwrap_or(top_level_ack); + assert!( + !effective, + "channel-level false must override top-level true" + ); + } + + #[test] + async fn telegram_config_ack_reactions_falls_back_to_top_level() { + let tg_toml = r#" + bot_token = "123:ABC" + allowed_users = ["alice"] + "#; + let tg: TelegramConfig = toml::from_str(tg_toml).unwrap(); + let top_level_ack = false; + let effective = tg.ack_reactions.unwrap_or(top_level_ack); + assert!( + !effective, + "must fall back to top-level false when channel omits field" + ); + } } diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 9bd4e34b6..4a2e2b8c6 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -642,6 +642,7 @@ mod tests { draft_update_interval_ms: 1000, interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }); assert!(has_supervised_channels(&config)); } @@ -755,6 +756,7 @@ mod tests { draft_update_interval_ms: 1000, interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }); let target = resolve_heartbeat_delivery(&config).unwrap(); @@ -771,6 +773,7 @@ mod tests { draft_update_interval_ms: 1000, interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }); let target = resolve_heartbeat_delivery(&config).unwrap(); diff --git a/src/integrations/registry.rs b/src/integrations/registry.rs index c4e0cfd81..be88f9b1e 100644 --- a/src/integrations/registry.rs +++ b/src/integrations/registry.rs @@ -840,6 +840,7 @@ mod tests { draft_update_interval_ms: 1000, interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }); 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 10e931412..e1bc5921a 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -3683,6 +3683,7 @@ fn setup_channels() -> Result { draft_update_interval_ms: 1000, interrupt_on_new_message: false, mention_only: false, + ack_reactions: None, }); } ChannelMenuChoice::Discord => {