fix(channels): respect ack_reactions config for Telegram channel (#3834) (#3913)

The Telegram channel was ignoring the ack_reactions setting because it
sent setMessageReaction API calls directly in its polling loop, bypassing
the top-level channels_config.ack_reactions check.

- Add optional ack_reactions field to TelegramConfig so it can be set
  under [channels_config.telegram] without "unknown key" warnings
- Add ack_reactions field and with_ack_reactions() builder to
  TelegramChannel, defaulting to true
- Guard try_add_ack_reaction_nonblocking() behind self.ack_reactions
- Wire channel-level override with fallback to top-level default
- Add config deserialization and channel behavior tests
This commit is contained in:
Argenis 2026-03-18 15:40:31 -04:00 committed by Roman Tataurov
parent 733acca191
commit c6f94fda4f
No known key found for this signature in database
GPG Key ID: 70A51EF3185C334B
7 changed files with 123 additions and 7 deletions

View File

@ -3229,12 +3229,16 @@ fn build_channel_by_id(config: &Config, channel_id: &str) -> Result<Arc<dyn Chan
.telegram
.as_ref()
.context("Telegram channel is not configured")?;
let ack = tg
.ack_reactions
.unwrap_or(config.channels_config.ack_reactions);
Ok(Arc::new(
TelegramChannel::new(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
)
.with_ack_reactions(ack)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
.with_transcription(config.transcription.clone())
.with_workspace_dir(config.workspace_dir.clone()),
@ -3322,6 +3326,9 @@ fn collect_configured_channels(
let mut channels = Vec::new();
if let Some(ref tg) = config.channels_config.telegram {
let ack = tg
.ack_reactions
.unwrap_or(config.channels_config.ack_reactions);
channels.push(ConfiguredChannel {
display_name: "Telegram",
channel: Arc::new(
@ -3330,6 +3337,7 @@ fn collect_configured_channels(
tg.allowed_users.clone(),
tg.mention_only,
)
.with_ack_reactions(ack)
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)
.with_transcription(config.transcription.clone())
.with_workspace_dir(config.workspace_dir.clone()),
@ -8668,6 +8676,7 @@ This is an example JSON object for profile settings."#;
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
match build_channel_by_id(&config, "telegram") {
Ok(channel) => assert_eq!(channel.name(), "telegram"),

View File

@ -332,6 +332,7 @@ pub struct TelegramChannel {
transcription: Option<crate::config::TranscriptionConfig>,
voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
workspace_dir: Option<std::path::PathBuf>,
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);
}
}

View File

@ -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 {

View File

@ -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<bool>,
}
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"
);
}
}

View File

@ -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();

View File

@ -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();

View File

@ -3683,6 +3683,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
draft_update_interval_ms: 1000,
interrupt_on_new_message: false,
mention_only: false,
ack_reactions: None,
});
}
ChannelMenuChoice::Discord => {