feat(telegram): add ack_enabled option to control emoji reactions

Add configuration option to enable/disable Telegram emoji reaction
acknowledgments (️, 👌, 👀, 🔥, 👍) sent to incoming messages.

Changes:
- Add ack_enabled field to TelegramConfig (default: true)
- Add ack_enabled field to TelegramChannel struct
- Add with_ack_enabled() builder method
- Conditionally send reactions in try_add_ack_reaction_nonblocking()
- Update all call sites and tests
- Update documentation with usage example

Usage:
  [channels_config.telegram]
  ack_enabled = false  # Disable emoji reactions

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
ake117 2026-02-28 12:58:12 +07:00 committed by Argenis
parent f89e99b7f9
commit 87fa327e0d
7 changed files with 58 additions and 26 deletions

View File

@ -201,6 +201,7 @@ stream_mode = "off" # optional: off | partial
draft_update_interval_ms = 1000 # optional: edit throttle for partial streaming
mention_only = false # legacy fallback; used when group_reply.mode is not set
interrupt_on_new_message = false # optional: cancel in-flight same-sender same-chat request
ack_enabled = true # optional: send emoji reaction acknowledgments (default: true)
[channels_config.telegram.group_reply]
mode = "all_messages" # optional: all_messages | mention_only
@ -211,6 +212,7 @@ Telegram notes:
- `interrupt_on_new_message = true` preserves interrupted user turns in conversation history, then restarts generation on the newest message.
- Interruption scope is strict: same sender in the same chat. Messages from different chats are processed independently.
- `ack_enabled = false` disables the emoji reaction (⚡️, 👌, 👀, 🔥, 👍) sent to incoming messages as acknowledgment.
### 4.2 Discord

View File

@ -4370,6 +4370,7 @@ fn collect_configured_channels(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.effective_group_reply_mode().requires_mention(),
tg.ack_enabled,
)
.with_group_reply_allowed_senders(tg.group_reply_allowed_sender_ids())
.with_streaming(tg.stream_mode, tg.draft_update_interval_ms)

View File

@ -465,10 +465,17 @@ pub struct TelegramChannel {
transcription: Option<crate::config::TranscriptionConfig>,
voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
workspace_dir: Option<std::path::PathBuf>,
/// Whether to send emoji reaction acknowledgments to incoming messages.
ack_enabled: bool,
}
impl TelegramChannel {
pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
pub fn new(
bot_token: String,
allowed_users: Vec<String>,
mention_only: bool,
ack_enabled: bool,
) -> Self {
let normalized_allowed = Self::normalize_allowed_users(allowed_users);
let pairing = if normalized_allowed.is_empty() {
let guard = PairingGuard::new(true, &[]);
@ -497,6 +504,7 @@ impl TelegramChannel {
transcription: None,
voice_transcriptions: Mutex::new(std::collections::HashMap::new()),
workspace_dir: None,
ack_enabled,
}
}
@ -539,6 +547,12 @@ impl TelegramChannel {
self
}
/// Enable or disable emoji reaction acknowledgments to incoming messages.
pub fn with_ack_enabled(mut self, enabled: bool) -> Self {
self.ack_enabled = enabled;
self
}
/// Parse reply_target into (chat_id, optional thread_id).
fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
@ -673,6 +687,10 @@ impl TelegramChannel {
}
fn try_add_ack_reaction_nonblocking(&self, chat_id: String, message_id: i64) {
if !self.ack_enabled {
return;
}
let client = self.http_client();
let url = self.api_url("setMessageReaction");
let emoji = random_telegram_ack_reaction().to_string();
@ -3425,7 +3443,7 @@ mod tests {
#[test]
fn telegram_channel_name() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
assert_eq!(ch.name(), "telegram");
}
@ -3462,14 +3480,14 @@ mod tests {
#[test]
fn typing_handle_starts_as_none() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
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()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
// Manually insert a dummy handle
{
@ -3488,7 +3506,7 @@ mod tests {
#[tokio::test]
async fn start_typing_replaces_previous_handle() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
// Insert a dummy handle first
{
@ -3507,10 +3525,10 @@ mod tests {
#[test]
fn supports_draft_updates_respects_stream_mode() {
let off = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let off = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
assert!(!off.supports_draft_updates());
let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true)
.with_streaming(StreamMode::Partial, 750);
assert!(partial.supports_draft_updates());
assert_eq!(partial.draft_update_interval_ms, 750);
@ -3518,7 +3536,7 @@ mod tests {
#[tokio::test]
async fn send_draft_returns_none_when_stream_mode_off() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let id = ch
.send_draft(&SendMessage::new("draft", "123"))
.await
@ -3528,7 +3546,7 @@ mod tests {
#[tokio::test]
async fn update_draft_rate_limit_short_circuits_network() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true)
.with_streaming(StreamMode::Partial, 60_000);
ch.last_draft_edit
.lock()
@ -3540,7 +3558,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()], false)
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true)
.with_streaming(StreamMode::Partial, 0);
let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20);
@ -3554,7 +3572,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()], false)
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true)
.with_streaming(StreamMode::Partial, 0);
let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64);
@ -4090,7 +4108,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()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
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
@ -4111,7 +4129,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_bytes_builds_correct_form() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
// Minimal valid PNG header bytes
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
@ -4124,7 +4142,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let result = ch
.send_document_by_url(
@ -4140,7 +4158,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_by_url_builds_correct_json() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let result = ch
.send_photo_by_url("123456", None, "https://example.com/image.jpg", None)
@ -4153,7 +4171,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let path = Path::new("/nonexistent/path/to/file.txt");
let result = ch.send_document("123456", None, path, None).await;
@ -4169,7 +4187,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let path = Path::new("/nonexistent/path/to/photo.jpg");
let result = ch.send_photo("123456", None, path, None).await;
@ -4179,7 +4197,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_video_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let path = Path::new("/nonexistent/path/to/video.mp4");
let result = ch.send_video("123456", None, path, None).await;
@ -4189,7 +4207,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_audio_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let path = Path::new("/nonexistent/path/to/audio.mp3");
let result = ch.send_audio("123456", None, path, None).await;
@ -4199,7 +4217,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_voice_nonexistent_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let path = Path::new("/nonexistent/path/to/voice.ogg");
let result = ch.send_voice("123456", None, path, None).await;
@ -4287,7 +4305,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let file_bytes = b"test content".to_vec();
// With caption
@ -4311,7 +4329,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_photo_bytes_with_caption() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
// With caption
@ -4337,7 +4355,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_file() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let file_bytes: Vec<u8> = vec![];
let result = ch
@ -4350,7 +4368,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_filename() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let file_bytes = b"content".to_vec();
let result = ch
@ -4363,7 +4381,7 @@ mod tests {
#[tokio::test]
async fn telegram_send_document_bytes_empty_chat_id() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true);
let file_bytes = b"content".to_vec();
let result = ch
@ -5475,7 +5493,7 @@ mod tests {
#[test]
fn with_workspace_dir_sets_field() {
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false, true)
.with_workspace_dir(std::path::PathBuf::from("/tmp/test_workspace"));
assert_eq!(
ch.workspace_dir.as_deref(),

View File

@ -53,6 +53,7 @@ mod tests {
mention_only: false,
group_reply: None,
base_url: None,
ack_enabled: true,
};
let discord = DiscordConfig {

View File

@ -3997,6 +3997,10 @@ fn default_draft_update_interval_ms() -> u64 {
1000
}
fn default_ack_enabled() -> bool {
true
}
/// Group-chat reply trigger mode for channels that support mention gating.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema)]
#[serde(rename_all = "snake_case")]
@ -4083,6 +4087,10 @@ pub struct TelegramConfig {
/// Example for Bale messenger: "https://tapi.bale.ai"
#[serde(default)]
pub base_url: Option<String>,
/// When true, send emoji reaction acknowledgments (⚡️, 👌, 👀, 🔥, 👍) to incoming messages.
/// When false, no reaction is sent. Default is true.
#[serde(default = "default_ack_enabled")]
pub ack_enabled: bool,
}
impl ChannelConfig for TelegramConfig {

View File

@ -331,6 +331,7 @@ pub(crate) async fn deliver_announcement(
tg.bot_token.clone(),
tg.allowed_users.clone(),
tg.mention_only,
tg.ack_enabled,
)
.with_workspace_dir(config.workspace_dir.clone());
channel.send(&SendMessage::new(output, target)).await?;

View File

@ -4251,6 +4251,7 @@ fn setup_channels() -> Result<ChannelsConfig> {
mention_only: false,
group_reply: None,
base_url: None,
ack_enabled: true,
});
}
ChannelMenuChoice::Discord => {