Skip to main content

zeroclaw_channels/
telegram.rs

1use anyhow::Context;
2use async_trait::async_trait;
3use parking_lot::{Mutex, RwLock};
4use reqwest::multipart::{Form, Part};
5use std::fmt::Write as _;
6use std::path::Path;
7use std::sync::Arc;
8use std::time::Duration;
9use zeroclaw_api::channel::{Channel, ChannelMessage, SendMessage};
10use zeroclaw_config::schema::{Config, StreamMode};
11use zeroclaw_runtime::security::pairing::PairingGuard;
12
13/// Telegram's maximum message length for text messages
14const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
15const TELEGRAM_CONTINUED_PREFIX: &str = "(continued)\n\n";
16const TELEGRAM_CONTINUES_SUFFIX: &str = "\n\n(continues...)";
17const TELEGRAM_FENCE_REOPEN: &str = "```\n";
18const TELEGRAM_FENCE_CLOSE: &str = "```";
19const TELEGRAM_ACK_REACTIONS: &[&str] = &["⚡️", "👌", "👀", "🔥", "👍"];
20
21/// Metadata for an incoming document or photo attachment.
22#[derive(Debug, Clone, PartialEq, Eq)]
23struct IncomingAttachment {
24    file_id: String,
25    file_name: Option<String>,
26    file_size: Option<u64>,
27    caption: Option<String>,
28    kind: IncomingAttachmentKind,
29}
30
31/// The kind of incoming attachment (document vs photo).
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33enum IncomingAttachmentKind {
34    Document,
35    Photo,
36}
37const TELEGRAM_BIND_COMMAND: &str = "/bind";
38/// Telegram Bot API allows at most 100 commands via setMyCommands.
39const TELEGRAM_MAX_BOT_COMMANDS: usize = 100;
40/// Telegram command names: 1-32 lowercase a-z, 0-9, and underscore.
41const TELEGRAM_COMMAND_NAME_MAX_LEN: usize = 32;
42/// Telegram command descriptions nominally allow up to 256 characters per the API docs,
43/// but empirical testing shows the API returns errors for descriptions substantially
44/// longer than 100 characters. This conservative cap avoids that in practice.
45const TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN: usize = 100;
46
47/// Sanitize a skill name into a valid Telegram command name.
48/// Telegram commands must be 1-32 characters, lowercase a-z, 0-9, underscore only.
49fn sanitize_telegram_command_name(raw: &str) -> String {
50    let mut result = String::with_capacity(raw.len());
51    for ch in raw.chars() {
52        let lower = ch.to_ascii_lowercase();
53        if lower.is_ascii_lowercase() || lower.is_ascii_digit() {
54            result.push(lower);
55        } else if !result.ends_with('_') {
56            // Replace non-alphanumeric with underscore, collapsing consecutive runs.
57            result.push('_');
58        }
59    }
60
61    let trimmed = result.trim_matches('_');
62    if trimmed.len() <= TELEGRAM_COMMAND_NAME_MAX_LEN {
63        trimmed.to_string()
64    } else {
65        trimmed[..TELEGRAM_COMMAND_NAME_MAX_LEN]
66            .trim_end_matches('_')
67            .to_string()
68    }
69}
70
71/// Truncate a description to the conservative `TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN` cap.
72/// The API nominally supports 256 characters, but empirical testing shows errors occur
73/// for descriptions substantially longer than 100 characters.
74fn truncate_telegram_command_description(raw: &str) -> String {
75    let trimmed = raw.trim();
76    if trimmed.chars().count() <= TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN {
77        return trimmed.to_string();
78    }
79    let mut truncated: String = trimmed
80        .chars()
81        .take(TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN - 1)
82        .collect();
83    truncated.push('…');
84    truncated
85}
86
87/// Split a message into chunks that respect Telegram's 4096 character limit.
88/// Tries to split at word boundaries when possible, and handles continuation.
89/// The split budget includes continuation markers and synthetic code fences
90/// exactly as `send_text_chunks` will send them.
91fn split_message_for_telegram(message: &str) -> Vec<String> {
92    if message.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH {
93        return vec![message.to_string()];
94    }
95
96    let mut chunks = Vec::new();
97    let mut remaining = message;
98    let mut in_code_block = false;
99
100    while !remaining.is_empty() {
101        let has_previous = !chunks.is_empty();
102
103        if telegram_chunk_send_len(remaining, in_code_block, has_previous, false)
104            <= TELEGRAM_MAX_MESSAGE_LENGTH
105        {
106            let chunk = build_telegram_chunk(remaining, in_code_block, false);
107            chunks.push(chunk);
108            break;
109        }
110
111        let max_take = max_nonfinal_telegram_raw_chars(remaining, in_code_block, has_previous);
112        let hard_split = byte_index_after_chars(remaining, max_take);
113        let chunk_end = preferred_telegram_split_end(
114            remaining,
115            hard_split,
116            max_take,
117            in_code_block,
118            has_previous,
119        );
120
121        let raw_chunk = &remaining[..chunk_end];
122        let starts_in_code_block = in_code_block;
123        in_code_block = code_block_state_after(raw_chunk, in_code_block);
124        chunks.push(build_telegram_chunk(raw_chunk, starts_in_code_block, true));
125        remaining = &remaining[chunk_end..];
126    }
127
128    chunks
129}
130
131fn build_telegram_chunk(raw_chunk: &str, starts_in_code_block: bool, has_next: bool) -> String {
132    let reopen_prefix = if starts_in_code_block {
133        TELEGRAM_FENCE_REOPEN
134    } else {
135        ""
136    };
137    let ends_in_code_block = code_block_state_after(raw_chunk, starts_in_code_block);
138    let needs_synthetic_close = has_next && ends_in_code_block;
139    let mut chunk = String::with_capacity(
140        reopen_prefix.len()
141            + raw_chunk.len()
142            + if needs_synthetic_close {
143                "\n```".len()
144            } else {
145                0
146            },
147    );
148    chunk.push_str(reopen_prefix);
149    chunk.push_str(raw_chunk);
150    if needs_synthetic_close {
151        if !chunk.ends_with('\n') {
152            chunk.push('\n');
153        }
154        chunk.push_str(TELEGRAM_FENCE_CLOSE);
155    }
156    chunk
157}
158
159fn format_telegram_text_chunk(chunk: &str, index: usize, total: usize) -> String {
160    if total <= 1 {
161        return chunk.to_string();
162    }
163
164    if index == 0 {
165        format!("{chunk}{TELEGRAM_CONTINUES_SUFFIX}")
166    } else if index == total - 1 {
167        format!("{TELEGRAM_CONTINUED_PREFIX}{chunk}")
168    } else {
169        format!("{TELEGRAM_CONTINUED_PREFIX}{chunk}{TELEGRAM_CONTINUES_SUFFIX}")
170    }
171}
172
173fn telegram_chunk_marker_len(has_previous: bool, has_next: bool) -> usize {
174    let prefix_len = if has_previous {
175        TELEGRAM_CONTINUED_PREFIX.chars().count()
176    } else {
177        0
178    };
179    let suffix_len = if has_next {
180        TELEGRAM_CONTINUES_SUFFIX.chars().count()
181    } else {
182        0
183    };
184    prefix_len + suffix_len
185}
186
187fn telegram_chunk_body_len(raw_chunk: &str, starts_in_code_block: bool, has_next: bool) -> usize {
188    let reopen_len = if starts_in_code_block {
189        TELEGRAM_FENCE_REOPEN.chars().count()
190    } else {
191        0
192    };
193    let raw_len = raw_chunk.chars().count();
194    let ends_in_code_block = code_block_state_after(raw_chunk, starts_in_code_block);
195    let synthetic_close_len = if has_next && ends_in_code_block {
196        TELEGRAM_FENCE_CLOSE.chars().count() + usize::from(!raw_chunk.ends_with('\n'))
197    } else {
198        0
199    };
200
201    reopen_len + raw_len + synthetic_close_len
202}
203
204fn telegram_chunk_send_len(
205    raw_chunk: &str,
206    starts_in_code_block: bool,
207    has_previous: bool,
208    has_next: bool,
209) -> usize {
210    telegram_chunk_marker_len(has_previous, has_next)
211        + telegram_chunk_body_len(raw_chunk, starts_in_code_block, has_next)
212}
213
214fn max_nonfinal_telegram_raw_chars(
215    remaining: &str,
216    starts_in_code_block: bool,
217    has_previous: bool,
218) -> usize {
219    let remaining_chars = remaining.chars().count();
220    let marker_len = telegram_chunk_marker_len(has_previous, true);
221    let reopen_len = if starts_in_code_block {
222        TELEGRAM_FENCE_REOPEN.chars().count()
223    } else {
224        0
225    };
226    let upper = remaining_chars
227        .saturating_sub(1)
228        .min(TELEGRAM_MAX_MESSAGE_LENGTH - marker_len - reopen_len);
229
230    for take in (1..=upper).rev() {
231        let end = byte_index_after_chars(remaining, take);
232        if telegram_chunk_send_len(&remaining[..end], starts_in_code_block, has_previous, true)
233            <= TELEGRAM_MAX_MESSAGE_LENGTH
234        {
235            return take;
236        }
237    }
238
239    1
240}
241
242fn byte_index_after_chars(s: &str, char_count: usize) -> usize {
243    if char_count == 0 {
244        return 0;
245    }
246    s.char_indices()
247        .nth(char_count)
248        .map_or(s.len(), |(idx, _)| idx)
249}
250
251fn preferred_telegram_split_end(
252    remaining: &str,
253    hard_split: usize,
254    max_take: usize,
255    starts_in_code_block: bool,
256    has_previous: bool,
257) -> usize {
258    let search_area = &remaining[..hard_split];
259    let candidate_fits = |end: usize| {
260        end > 0
261            && end < remaining.len()
262            && telegram_chunk_send_len(&remaining[..end], starts_in_code_block, has_previous, true)
263                <= TELEGRAM_MAX_MESSAGE_LENGTH
264    };
265
266    if let Some(pos) = search_area.rfind('\n') {
267        let end = pos + '\n'.len_utf8();
268        if search_area[..pos].chars().count() >= max_take / 2 && candidate_fits(end) {
269            return end;
270        }
271    }
272
273    if let Some(pos) = search_area.rfind(' ') {
274        let end = pos + ' '.len_utf8();
275        if candidate_fits(end) {
276            return end;
277        }
278    }
279
280    hard_split
281}
282
283fn code_block_state_after(text: &str, mut in_code_block: bool) -> bool {
284    for line in text.split('\n') {
285        if line.trim_start().starts_with("```") {
286            in_code_block = !in_code_block;
287        }
288    }
289    in_code_block
290}
291
292fn pick_uniform_index(len: usize) -> usize {
293    debug_assert!(len > 0);
294    let upper = len as u64;
295    let reject_threshold = (u64::MAX / upper) * upper;
296
297    loop {
298        let value = rand::random::<u64>();
299        if value < reject_threshold {
300            #[allow(clippy::cast_possible_truncation)]
301            return (value % upper) as usize;
302        }
303    }
304}
305
306fn random_telegram_ack_reaction() -> &'static str {
307    TELEGRAM_ACK_REACTIONS[pick_uniform_index(TELEGRAM_ACK_REACTIONS.len())]
308}
309
310fn build_telegram_ack_reaction_request(
311    chat_id: &str,
312    message_id: i64,
313    emoji: &str,
314) -> serde_json::Value {
315    serde_json::json!({
316        "chat_id": chat_id,
317        "message_id": message_id,
318        "reaction": [{
319            "type": "emoji",
320            "emoji": emoji
321        }]
322    })
323}
324
325#[derive(Debug, Clone, Copy, PartialEq, Eq)]
326enum TelegramAttachmentKind {
327    Image,
328    Document,
329    Video,
330    Audio,
331    Voice,
332}
333
334#[derive(Debug, Clone, PartialEq, Eq)]
335struct TelegramAttachment {
336    kind: TelegramAttachmentKind,
337    target: String,
338}
339
340impl TelegramAttachmentKind {
341    fn from_marker(marker: &str) -> Option<Self> {
342        match marker.trim().to_ascii_uppercase().as_str() {
343            "IMAGE" | "PHOTO" => Some(Self::Image),
344            "DOCUMENT" | "FILE" => Some(Self::Document),
345            "VIDEO" => Some(Self::Video),
346            "AUDIO" => Some(Self::Audio),
347            "VOICE" => Some(Self::Voice),
348            _ => None,
349        }
350    }
351}
352
353/// Check whether a file path has a recognized image extension.
354fn is_image_extension(path: &Path) -> bool {
355    path.extension()
356        .and_then(|ext| ext.to_str())
357        .map(|ext| {
358            matches!(
359                ext.to_ascii_lowercase().as_str(),
360                "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
361            )
362        })
363        .unwrap_or(false)
364}
365
366/// Build the user-facing content string for an incoming attachment.
367///
368/// Photos with a recognized image extension use `[IMAGE:/path]` so the
369/// multimodal pipeline can validate vision capability. Non-image files
370/// always use `[Document: name] /path` regardless of how Telegram
371/// classified them.
372fn format_attachment_content(
373    kind: IncomingAttachmentKind,
374    local_filename: &str,
375    local_path: &Path,
376) -> String {
377    match kind {
378        IncomingAttachmentKind::Photo | IncomingAttachmentKind::Document
379            if is_image_extension(local_path) =>
380        {
381            format!("[IMAGE:{}]", local_path.display())
382        }
383        _ => {
384            format!("[Document: {}] {}", local_filename, local_path.display())
385        }
386    }
387}
388
389fn is_http_url(target: &str) -> bool {
390    target.starts_with("http://") || target.starts_with("https://")
391}
392
393fn infer_attachment_kind_from_target(target: &str) -> Option<TelegramAttachmentKind> {
394    let normalized = target
395        .split('?')
396        .next()
397        .unwrap_or(target)
398        .split('#')
399        .next()
400        .unwrap_or(target);
401
402    let extension = Path::new(normalized)
403        .extension()
404        .and_then(|ext| ext.to_str())?
405        .to_ascii_lowercase();
406
407    match extension.as_str() {
408        "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Some(TelegramAttachmentKind::Image),
409        "mp4" | "mov" | "mkv" | "avi" | "webm" => Some(TelegramAttachmentKind::Video),
410        "mp3" | "m4a" | "wav" | "flac" => Some(TelegramAttachmentKind::Audio),
411        "ogg" | "oga" | "opus" => Some(TelegramAttachmentKind::Voice),
412        "pdf" | "txt" | "md" | "csv" | "json" | "zip" | "tar" | "gz" | "doc" | "docx" | "xls"
413        | "xlsx" | "ppt" | "pptx" => Some(TelegramAttachmentKind::Document),
414        _ => None,
415    }
416}
417
418fn parse_path_only_attachment(message: &str) -> Option<TelegramAttachment> {
419    let trimmed = message.trim();
420    if trimmed.is_empty() || trimmed.contains('\n') {
421        return None;
422    }
423
424    let candidate = trimmed.trim_matches(|c| matches!(c, '`' | '"' | '\''));
425    if candidate.chars().any(char::is_whitespace) {
426        return None;
427    }
428
429    let candidate = candidate.strip_prefix("file://").unwrap_or(candidate);
430    let kind = infer_attachment_kind_from_target(candidate)?;
431
432    if !is_http_url(candidate) && !Path::new(candidate).exists() {
433        return None;
434    }
435
436    Some(TelegramAttachment {
437        kind,
438        target: candidate.to_string(),
439    })
440}
441
442/// Delegate to the shared `strip_tool_call_tags` in the orchestrator module.
443fn strip_tool_call_tags(message: &str) -> String {
444    crate::orchestrator::strip_tool_call_tags(message)
445}
446
447fn find_matching_close(s: &str) -> Option<usize> {
448    let mut depth = 1usize;
449    for (i, ch) in s.char_indices() {
450        match ch {
451            '[' => depth += 1,
452            ']' => {
453                depth -= 1;
454                if depth == 0 {
455                    return Some(i);
456                }
457            }
458            _ => {}
459        }
460    }
461    None
462}
463
464fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>) {
465    let mut cleaned = String::with_capacity(message.len());
466    let mut attachments = Vec::new();
467    let mut cursor = 0;
468
469    while cursor < message.len() {
470        let Some(open_rel) = message[cursor..].find('[') else {
471            cleaned.push_str(&message[cursor..]);
472            break;
473        };
474
475        let open = cursor + open_rel;
476        cleaned.push_str(&message[cursor..open]);
477
478        let Some(close_rel) = find_matching_close(&message[open + 1..]) else {
479            cleaned.push_str(&message[open..]);
480            break;
481        };
482
483        let close = open + 1 + close_rel;
484        let marker = &message[open + 1..close];
485
486        let parsed = marker.split_once(':').and_then(|(kind, target)| {
487            let kind = TelegramAttachmentKind::from_marker(kind)?;
488            let target = target.trim();
489            if target.is_empty() {
490                return None;
491            }
492            Some(TelegramAttachment {
493                kind,
494                target: target.to_string(),
495            })
496        });
497
498        if let Some(attachment) = parsed {
499            attachments.push(attachment);
500        } else {
501            cleaned.push_str(&message[open..=close]);
502        }
503
504        cursor = close + 1;
505    }
506
507    (cleaned.trim().to_string(), attachments)
508}
509
510/// Telegram Bot API maximum file download size (20 MB).
511const TELEGRAM_MAX_FILE_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
512
513/// Default minimum interval between Telegram draft edits.
514const TELEGRAM_DRAFT_UPDATE_INTERVAL_MS: u64 = 1000;
515
516/// Telegram channel — long-polls the Bot API for updates
517pub struct TelegramChannel {
518    bot_token: String,
519    /// The alias key under `[channels.telegram.<alias>]` this handle is
520    /// bound to. Used to scope peer-group writes and resolver lookups.
521    alias: String,
522    /// Resolves inbound external peers from canonical state at message-time.
523    /// No cache (see AGENTS.md "ABSOLUTE RULE — SINGLE SOURCE OF TRUTH").
524    peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
525    /// Optional pairing-persist handle. `None` in tests and one-shot
526    /// builds (pairing then doesn't survive restart). `Some` in the
527    /// long-running daemon, wired via `.with_persistence(config)`.
528    /// RwLock so concurrent peer reads from sibling channels don't
529    /// serialize; only the rare pairing-write path takes the exclusive lock.
530    persist: Option<Arc<RwLock<Config>>>,
531    pairing: Option<PairingGuard>,
532    client: reqwest::Client,
533    typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
534    stream_mode: StreamMode,
535    draft_update_interval_ms: u64,
536    last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
537    mention_only: bool,
538    bot_username: Mutex<Option<String>>,
539    /// Base URL for the Telegram Bot API. Defaults to `https://api.telegram.org`.
540    /// Override for local Bot API servers or testing.
541    api_base: String,
542    transcription: Option<zeroclaw_config::schema::TranscriptionConfig>,
543    transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
544    voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
545    workspace_dir: Option<std::path::PathBuf>,
546    ack_reactions: bool,
547    tts_manager: Option<Arc<super::tts::TtsManager>>,
548    voice_chats: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
549    /// Peers that always receive voice replies, sourced from peer-group
550    /// `output_modality = "voice"` config. Populated once at startup by
551    /// `with_voice_peer_prefs`; never mutated by session events.
552    static_voice_peers: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
553    pending_voice:
554        Arc<std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>>,
555    /// Per-channel proxy URL override.
556    proxy_url: Option<String>,
557    /// Pre-computed tool command specs (name, description) for bot command registration.
558    tool_command_specs: Vec<(String, String)>,
559    /// Pending approval requests: callback_data key → oneshot sender.
560    /// `listen()` resolves these when a matching `callback_query` arrives.
561    pending_approvals: Arc<
562        tokio::sync::Mutex<
563            std::collections::HashMap<
564                String,
565                tokio::sync::oneshot::Sender<zeroclaw_api::channel::ChannelApprovalResponse>,
566            >,
567        >,
568    >,
569    /// Seconds to wait for the operator to tap an inline-keyboard button on a
570    /// tool approval prompt before auto-denying. Configurable via
571    /// `channels.telegram.approval_timeout_secs`. Default: 120.
572    approval_timeout_secs: u64,
573}
574
575#[derive(Debug, Clone, Copy, PartialEq, Eq)]
576enum EditMessageResult {
577    Success,
578    NotModified,
579    Failed(reqwest::StatusCode),
580}
581
582impl TelegramChannel {
583    pub fn new(
584        bot_token: String,
585        alias: impl Into<String>,
586        peer_resolver: Arc<dyn Fn() -> Vec<String> + Send + Sync>,
587        mention_only: bool,
588    ) -> Self {
589        let has_peers = !peer_resolver().is_empty();
590        let pairing = if has_peers {
591            None
592        } else {
593            let guard = PairingGuard::new(true, &[]);
594            if let Some(code) = guard.pairing_code() {
595                println!("  🔐 Telegram pairing required. One-time bind code: {code}");
596                println!("     Send `{TELEGRAM_BIND_COMMAND} <code>` from your Telegram account.");
597            }
598            Some(guard)
599        };
600
601        Self {
602            bot_token,
603            alias: alias.into(),
604            peer_resolver,
605            persist: None,
606            pairing,
607            client: reqwest::Client::new(),
608            stream_mode: StreamMode::Off,
609            draft_update_interval_ms: TELEGRAM_DRAFT_UPDATE_INTERVAL_MS,
610            last_draft_edit: Mutex::new(std::collections::HashMap::new()),
611            typing_handle: Mutex::new(None),
612            mention_only,
613            bot_username: Mutex::new(None),
614            api_base: "https://api.telegram.org".to_string(),
615            transcription: None,
616            transcription_manager: None,
617            voice_transcriptions: Mutex::new(std::collections::HashMap::new()),
618            workspace_dir: None,
619            ack_reactions: true,
620            tts_manager: None,
621            voice_chats: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
622            static_voice_peers: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
623            pending_voice: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
624            proxy_url: None,
625            tool_command_specs: Vec::new(),
626            pending_approvals: Arc::new(tokio::sync::Mutex::new(std::collections::HashMap::new())),
627            approval_timeout_secs: 120,
628        }
629    }
630
631    /// Override the approval prompt timeout (default 120s).
632    pub fn with_approval_timeout_secs(mut self, secs: u64) -> Self {
633        self.approval_timeout_secs = secs;
634        self
635    }
636
637    /// Configure whether Telegram-native acknowledgement reactions are sent.
638    pub fn with_ack_reactions(mut self, enabled: bool) -> Self {
639        self.ack_reactions = enabled;
640        self
641    }
642
643    /// Set a per-channel proxy URL that overrides the global proxy config.
644    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
645        self.proxy_url = proxy_url;
646        self
647    }
648
649    /// Store pre-computed tool command specs for bot command registration.
650    pub fn with_tool_command_specs(mut self, specs: Vec<(String, String)>) -> Self {
651        self.tool_command_specs = specs;
652        self
653    }
654
655    /// Configure workspace directory for saving downloaded attachments.
656    pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
657        self.workspace_dir = Some(dir);
658        self
659    }
660
661    /// Configure streaming mode for progressive draft updates.
662    pub fn with_streaming(
663        mut self,
664        stream_mode: StreamMode,
665        draft_update_interval_ms: u64,
666    ) -> Self {
667        self.stream_mode = stream_mode;
668        self.draft_update_interval_ms = if draft_update_interval_ms == 0 {
669            TELEGRAM_DRAFT_UPDATE_INTERVAL_MS
670        } else {
671            draft_update_interval_ms
672        };
673        self
674    }
675
676    /// Override the Telegram Bot API base URL.
677    /// Useful for local Bot API servers or testing.
678    pub fn with_api_base(mut self, api_base: String) -> Self {
679        self.api_base = api_base;
680        self
681    }
682
683    /// Configure voice transcription.
684    pub fn with_transcription(
685        mut self,
686        config: zeroclaw_config::schema::TranscriptionConfig,
687    ) -> Self {
688        if !config.enabled {
689            return self;
690        }
691        match super::transcription::TranscriptionManager::new(&config) {
692            Ok(m) => {
693                // Wire the resolved STT backend alias here so the channel-internal
694                // voice path (`try_parse_voice_message` -> `manager.transcribe`)
695                // dispatches to a configured provider. The orchestrator only wires
696                // the alias for the MediaPipeline/attachment path, which inbound
697                // Telegram voice notes never traverse. Bind to the sole registered
698                // provider when exactly one is configured so the single-provider
699                // case dispatches without an agent context; multi-provider setups
700                // keep the alias empty and still require explicit
701                // `agent.<alias>.transcription_provider` routing through the
702                // orchestrator (mirrors `wati.rs` / `lark.rs` / `mattermost.rs`).
703                let names = m.available_providers();
704                let m = if names.len() == 1 {
705                    let only = names[0].to_string();
706                    m.with_agent_transcription_provider(only)
707                } else {
708                    m
709                };
710                self.transcription_manager = Some(std::sync::Arc::new(m));
711                self.transcription = Some(config);
712            }
713            Err(e) => {
714                ::zeroclaw_log::record!(
715                    WARN,
716                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
717                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
718                        .with_attrs(::serde_json::json!({"e": e.to_string()})),
719                    "transcription manager init failed, voice transcription disabled"
720                );
721            }
722        }
723        self
724    }
725
726    /// Configure text-to-speech for outgoing voice replies.
727    ///
728    /// Builds a [`super::tts::TtsManager`] from the
729    /// `[tts_providers.<type>.<alias>]` map. Disabled when `[tts].enabled = false`
730    /// or when the manager fails to construct (logged at warn).
731    pub fn with_tts(mut self, config: &zeroclaw_config::schema::Config) -> Self {
732        if config.tts.enabled {
733            // Bind the TTS manager to the agent that owns THIS channel so the
734            // voice reply uses that agent's `tts_provider`. Without this the
735            // shared manager resolves the lexicographically-smallest enabled
736            // agent, which silently breaks TTS when that agent has no
737            // `tts_provider` set (e.g. a background/delegate agent).
738            let owner = config.agent_for_channel(&format!("telegram.{}", self.alias));
739            match super::tts::TtsManager::from_config_for_agent(config, owner) {
740                Ok(m) => self.tts_manager = Some(Arc::new(m)),
741                Err(e) => ::zeroclaw_log::record!(
742                    WARN,
743                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
744                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
745                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
746                    "TTS disabled"
747                ),
748            }
749        }
750        self
751    }
752
753    /// Parse reply_target into (chat_id, optional thread_id).
754    fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
755        if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
756            (chat_id.to_string(), Some(thread_id.to_string()))
757        } else {
758            (reply_target.to_string(), None)
759        }
760    }
761
762    fn extract_update_message_target(update: &serde_json::Value) -> Option<(String, i64)> {
763        let message = update.get("message")?;
764        let chat_id = message
765            .get("chat")
766            .and_then(|chat| chat.get("id"))
767            .and_then(serde_json::Value::as_i64)?
768            .to_string();
769        let message_id = message
770            .get("message_id")
771            .and_then(serde_json::Value::as_i64)?;
772        Some((chat_id, message_id))
773    }
774
775    fn try_add_ack_reaction_nonblocking(&self, chat_id: String, message_id: i64) {
776        let client = self.http_client();
777        let url = self.api_url("setMessageReaction");
778        let emoji = random_telegram_ack_reaction().to_string();
779        let body = build_telegram_ack_reaction_request(&chat_id, message_id, &emoji);
780
781        zeroclaw_spawn::spawn!(async move {
782            let response = match client.post(&url).json(&body).send().await {
783                Ok(resp) => resp,
784                Err(err) => {
785                    ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"chat_id": chat_id, "message_id": message_id, "err": err.to_string()})), "failed to add ACK reaction to chat_id=, message_id=");
786                    return;
787                }
788            };
789
790            if !response.status().is_success() {
791                let status = response.status();
792                let err_body = response.text().await.unwrap_or_default();
793                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"chat_id": chat_id, "message_id": message_id, "status": status.to_string(), "err_body": err_body})), "add ACK reaction failed for chat_id=, message_id=: status=, body=");
794            }
795        });
796    }
797
798    fn http_client(&self) -> reqwest::Client {
799        zeroclaw_config::schema::build_channel_proxy_client(
800            "channel.telegram",
801            self.proxy_url.as_deref(),
802        )
803    }
804
805    fn normalize_identity(value: &str) -> String {
806        value.trim().trim_start_matches('@').to_string()
807    }
808
809    /// Pre-seed static voice preferences from peer-group config.
810    ///
811    /// Iterates `[peer_groups.*]` entries that reference this channel and
812    /// carry `output_modality = "voice"`, then records every `external_peers`
813    /// entry in `static_voice_peers`. These peers always receive TTS replies —
814    /// including cron/proactive messages with no inbound voice note to mirror.
815    ///
816    /// Unlike the session `voice_chats` set, `static_voice_peers` is never
817    /// cleared by voice-send or text-message events.
818    pub fn with_voice_peer_prefs(
819        self,
820        config: &zeroclaw_config::schema::Config,
821        channel_type: &str,
822        alias: impl AsRef<str>,
823    ) -> Self {
824        use zeroclaw_config::multi_agent::OutputModality;
825        let alias = alias.as_ref();
826        let dotted = format!("{channel_type}.{alias}");
827        if let Ok(mut sp) = self.static_voice_peers.lock() {
828            for group in config.peer_groups.values() {
829                let matches = group.channel == channel_type || group.channel == dotted;
830                if matches && group.output_modality == OutputModality::Voice {
831                    for peer in &group.external_peers {
832                        sp.insert(peer.to_string());
833                    }
834                }
835            }
836        }
837        self
838    }
839
840    /// write a paired user into `peer_groups` and save. The long-running
841    /// daemon sets this from the orchestrator; tests and one-shot
842    /// callers leave it unset (pairing works at runtime, doesn't persist).
843    pub fn with_persistence(mut self, config: Arc<RwLock<Config>>) -> Self {
844        self.persist = Some(config);
845        self
846    }
847
848    async fn persist_allowed_identity(&self, identity: &str) -> anyhow::Result<()> {
849        use zeroclaw_config::multi_agent::{PeerGroupConfig, PeerUsername};
850
851        let Some(config) = &self.persist else {
852            ::zeroclaw_log::record!(
853                WARN,
854                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
855                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
856                    .with_attrs(::serde_json::json!({"identity": identity})),
857                "paired identity not persisted (no persistence handle wired)"
858            );
859            return Ok(());
860        };
861        let normalized = Self::normalize_identity(identity);
862        if normalized.is_empty() {
863            anyhow::bail!("Cannot persist empty Telegram identity");
864        }
865        let group_name = format!("telegram_{}", self.alias);
866        let channel_ref = format!("telegram.{}", self.alias);
867        let snapshot = {
868            let mut cfg = config.write();
869            if !cfg.channels.telegram.contains_key(&self.alias) {
870                anyhow::bail!(
871                    "Missing [channels.telegram.{}] section. Run `zeroclaw config set channels.telegram.<alias>.bot-token=<token>` to configure.",
872                    self.alias
873                );
874            }
875            let group = cfg
876                .peer_groups
877                .entry(group_name)
878                .or_insert_with(|| PeerGroupConfig {
879                    channel: channel_ref,
880                    ..PeerGroupConfig::default()
881                });
882            if group
883                .external_peers
884                .iter()
885                .any(|p| Self::normalize_identity(p.as_str()) == normalized)
886            {
887                return Ok(());
888            }
889            group.external_peers.push(PeerUsername::new(normalized));
890            cfg.clone()
891        };
892        snapshot
893            .save()
894            .await
895            .context("Failed to persist Telegram peer to config.toml")?;
896        Ok(())
897    }
898
899    fn extract_bind_code(text: &str) -> Option<&str> {
900        let mut parts = text.split_whitespace();
901        let command = parts.next()?;
902        let base_command = command.split('@').next().unwrap_or(command);
903        if base_command != TELEGRAM_BIND_COMMAND {
904            return None;
905        }
906        parts.next().map(str::trim).filter(|code| !code.is_empty())
907    }
908
909    fn pairing_code_active(&self) -> bool {
910        self.pairing
911            .as_ref()
912            .and_then(PairingGuard::pairing_code)
913            .is_some()
914    }
915
916    fn api_url(&self, method: &str) -> String {
917        format!("{}/bot{}/{method}", self.api_base, self.bot_token)
918    }
919
920    /// Register the bot's slash commands with Telegram via `setMyCommands`.
921    /// Called once at startup so that users see a command menu when pressing `/`.
922    /// Includes built-in runtime commands, user-installed skill commands, and
923    /// enabled tool commands from the configuration.
924    async fn register_bot_commands(&self) {
925        let mut commands: Vec<serde_json::Value> = vec![
926            serde_json::json!({ "command": "new",    "description": "Start a new conversation session" }),
927            serde_json::json!({ "command": "stop",   "description": "Cancel the current in-flight task" }),
928            serde_json::json!({ "command": "model",  "description": "Show or switch the current model" }),
929            serde_json::json!({ "command": "models", "description": "List available model_providers or switch model_provider" }),
930            serde_json::json!({ "command": "config", "description": "Show current configuration" }),
931        ];
932
933        // Track registered names to deduplicate across skills and tools.
934        let mut used_names: std::collections::HashSet<String> = commands
935            .iter()
936            .filter_map(|c| c.get("command").and_then(|v| v.as_str()).map(String::from))
937            .collect();
938
939        // Collect commands from installed skills.
940        if let Some(ref workspace_dir) = self.workspace_dir {
941            let skills = zeroclaw_runtime::skills::load_skills(workspace_dir);
942
943            for skill in &skills {
944                let sanitized = sanitize_telegram_command_name(&skill.name);
945                if sanitized.is_empty() {
946                    ::zeroclaw_log::record!(
947                        DEBUG,
948                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
949                        &format!(
950                            "Skipping skill '{}': name produces empty Telegram command",
951                            skill.name
952                        )
953                    );
954                    continue;
955                }
956                if used_names.contains(&sanitized) {
957                    ::zeroclaw_log::record!(
958                        DEBUG,
959                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
960                        &format!(
961                            "Skipping skill '{}': command /{sanitized} conflicts with an existing command",
962                            skill.name
963                        )
964                    );
965                    continue;
966                }
967                let description = if skill.description.is_empty() {
968                    format!("Run the {name} skill", name = skill.name)
969                } else {
970                    truncate_telegram_command_description(&skill.description)
971                };
972                used_names.insert(sanitized.clone());
973                commands.push(serde_json::json!({
974                    "command": sanitized,
975                    "description": description,
976                }));
977            }
978        }
979
980        // Collect commands from enabled tools.
981        for (name, description) in &self.tool_command_specs {
982            let sanitized = sanitize_telegram_command_name(name);
983            if sanitized.is_empty() || used_names.contains(&sanitized) {
984                continue;
985            }
986            used_names.insert(sanitized.clone());
987            commands.push(serde_json::json!({
988                "command": sanitized,
989                "description": truncate_telegram_command_description(description),
990            }));
991        }
992
993        // Telegram allows at most 100 commands.
994        let total_before_cap = commands.len();
995        commands.truncate(TELEGRAM_MAX_BOT_COMMANDS);
996        if total_before_cap > TELEGRAM_MAX_BOT_COMMANDS {
997            ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"TELEGRAM_MAX_BOT_COMMANDS": TELEGRAM_MAX_BOT_COMMANDS, "total_before_cap": total_before_cap})), "Telegram limits bots to commands; configured, registering first . Reduce installed skills to expose more commands.");
998        }
999
1000        let url = self.api_url("setMyCommands");
1001        let body = serde_json::json!({ "commands": commands });
1002
1003        match self.http_client().post(&url).json(&body).send().await {
1004            Ok(resp) if resp.status().is_success() => {
1005                ::zeroclaw_log::record!(
1006                    INFO,
1007                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1008                    &format!(
1009                        "Telegram bot commands registered successfully ({} commands)",
1010                        commands.len()
1011                    )
1012                );
1013            }
1014            Ok(resp) => {
1015                let status = resp.status();
1016                let text = resp.text().await.unwrap_or_default();
1017                ::zeroclaw_log::record!(
1018                    WARN,
1019                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1020                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1021                        .with_attrs(
1022                            ::serde_json::json!({"status": status.to_string(), "text": text})
1023                        ),
1024                    "Failed to register Telegram bot commands:"
1025                );
1026            }
1027            Err(e) => {
1028                ::zeroclaw_log::record!(
1029                    WARN,
1030                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1031                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1032                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1033                    "Failed to register Telegram bot commands"
1034                );
1035            }
1036        }
1037    }
1038
1039    /// Check whether a voice reply should be queued for the given recipient and
1040    /// content. Shared between `send()` and `finalize_draft()` so the TTS
1041    /// voice-reply path works regardless of `stream_mode`.
1042    ///
1043    /// When `immediate` is `true` (called from `finalize_draft`), the 10-second
1044    /// debounce is skipped and `synthesize_and_send_voice` is called directly,
1045    /// since the text is already the final response.
1046    /// Returns true if this recipient should receive a TTS voice reply —
1047    /// either because they triggered a voice-note session (`voice_chats`) or
1048    /// because their peer group has `output_modality = "voice"` in config
1049    /// (`static_voice_peers`).
1050    fn is_voice_chat(&self, recipient: &str) -> bool {
1051        self.voice_chats
1052            .lock()
1053            .map(|vs| vs.contains(recipient))
1054            .unwrap_or(false)
1055            || self
1056                .static_voice_peers
1057                .lock()
1058                .map(|sp| sp.contains(recipient))
1059                .unwrap_or(false)
1060    }
1061
1062    fn try_queue_voice_reply(&self, recipient: &str, content: &str, immediate: bool) {
1063        if !self.is_voice_chat(recipient) || self.tts_manager.is_none() {
1064            return;
1065        }
1066
1067        // Only queue substantive natural-language replies for voice.
1068        // Skip tool outputs: URLs, JSON, code blocks, errors, short status.
1069        let is_substantive = content.len() > 40
1070            && !content.starts_with("http")
1071            && !content.starts_with('{')
1072            && !content.starts_with('[')
1073            && !content.starts_with("Error")
1074            && !content.contains("```")
1075            && !content.contains("tool_call")
1076            && !content.contains("wttr.in");
1077
1078        if !is_substantive {
1079            return;
1080        }
1081
1082        let (chat_id, thread_id) = Self::parse_reply_target(recipient);
1083        let voice_chats = self.voice_chats.clone();
1084        let api_base = self.api_base.clone();
1085        let bot_token = self.bot_token.clone();
1086        let tts_manager = self.tts_manager.clone().unwrap();
1087
1088        if immediate {
1089            // Finalize path: text is already the final answer — no debounce.
1090            let text = content.to_string();
1091            let recipient = recipient.to_string();
1092            zeroclaw_spawn::spawn!(async move {
1093                if let Ok(mut vc) = voice_chats.lock() {
1094                    vc.remove(&recipient);
1095                }
1096                match Self::synthesize_and_send_voice(
1097                    &api_base,
1098                    &bot_token,
1099                    &chat_id,
1100                    thread_id.as_deref(),
1101                    &text,
1102                    &tts_manager,
1103                )
1104                .await
1105                {
1106                    Ok(()) => {
1107                        ::zeroclaw_log::record!(
1108                            INFO,
1109                            ::zeroclaw_log::Event::new(
1110                                module_path!(),
1111                                ::zeroclaw_log::Action::Note
1112                            ),
1113                            &format!("voice reply sent ({} chars)", text.len())
1114                        );
1115                    }
1116                    Err(e) => {
1117                        ::zeroclaw_log::record!(
1118                            WARN,
1119                            ::zeroclaw_log::Event::new(
1120                                module_path!(),
1121                                ::zeroclaw_log::Action::Note
1122                            )
1123                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1124                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1125                            "TTS voice reply failed"
1126                        );
1127                    }
1128                }
1129            });
1130            return;
1131        }
1132
1133        // Send path: debounce to coalesce multi-part tool-chain responses.
1134        if let Ok(mut pv) = self.pending_voice.lock() {
1135            pv.insert(
1136                recipient.to_string(),
1137                (content.to_string(), std::time::Instant::now()),
1138            );
1139        }
1140
1141        let pending = self.pending_voice.clone();
1142        let recipient = recipient.to_string();
1143        zeroclaw_spawn::spawn!(async move {
1144            // Wait 10 seconds — long enough for the agent to finish its
1145            // full tool chain and send the final answer.
1146            tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
1147
1148            // Atomic check-and-remove: only one task gets the value
1149            let to_voice = pending.lock().ok().and_then(|mut pv| {
1150                if let Some((_, ts)) = pv.get(&recipient)
1151                    && ts.elapsed().as_secs() >= 8
1152                {
1153                    return pv.remove(&recipient).map(|(text, _)| text);
1154                }
1155                None
1156            });
1157
1158            if let Some(text) = to_voice {
1159                if let Ok(mut vc) = voice_chats.lock() {
1160                    vc.remove(&recipient);
1161                }
1162                match Self::synthesize_and_send_voice(
1163                    &api_base,
1164                    &bot_token,
1165                    &chat_id,
1166                    thread_id.as_deref(),
1167                    &text,
1168                    &tts_manager,
1169                )
1170                .await
1171                {
1172                    Ok(()) => {
1173                        ::zeroclaw_log::record!(
1174                            INFO,
1175                            ::zeroclaw_log::Event::new(
1176                                module_path!(),
1177                                ::zeroclaw_log::Action::Note
1178                            ),
1179                            &format!("voice reply sent ({} chars)", text.len())
1180                        );
1181                    }
1182                    Err(e) => {
1183                        ::zeroclaw_log::record!(
1184                            WARN,
1185                            ::zeroclaw_log::Event::new(
1186                                module_path!(),
1187                                ::zeroclaw_log::Action::Note
1188                            )
1189                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1190                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1191                            "TTS voice reply failed"
1192                        );
1193                    }
1194                }
1195            }
1196        });
1197    }
1198
1199    /// Synthesize text to speech and send as a Telegram voice note (static version for spawned tasks).
1200    async fn synthesize_and_send_voice(
1201        api_base: &str,
1202        bot_token: &str,
1203        chat_id: &str,
1204        thread_id: Option<&str>,
1205        text: &str,
1206        tts_manager: &crate::tts::TtsManager,
1207    ) -> anyhow::Result<()> {
1208        let audio_bytes = tts_manager.synthesize_opus(text).await?;
1209        let audio_len = audio_bytes.len();
1210        ::zeroclaw_log::record!(
1211            INFO,
1212            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1213                .with_attrs(::serde_json::json!({"audio_len": audio_len})),
1214            "synthesized bytes of audio"
1215        );
1216
1217        if audio_bytes.is_empty() {
1218            anyhow::bail!("TTS returned empty audio");
1219        }
1220
1221        let url = format!("{api_base}/bot{bot_token}/sendVoice");
1222        let client = zeroclaw_config::schema::build_runtime_proxy_client("channel.telegram");
1223
1224        let mut form = reqwest::multipart::Form::new()
1225            .text("chat_id", chat_id.to_string())
1226            .part(
1227                "voice",
1228                reqwest::multipart::Part::bytes(audio_bytes)
1229                    .file_name("voice.ogg")
1230                    .mime_str("audio/ogg; codecs=opus")?,
1231            );
1232
1233        if let Some(tid) = thread_id {
1234            form = form.text("message_thread_id", tid.to_string());
1235        }
1236
1237        let resp = client.post(&url).multipart(form).send().await?;
1238        if !resp.status().is_success() {
1239            let status = resp.status();
1240            let body = resp.text().await.unwrap_or_default();
1241            anyhow::bail!("sendVoice failed: status={status}, body={body}");
1242        }
1243
1244        ::zeroclaw_log::record!(
1245            INFO,
1246            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1247                .with_attrs(::serde_json::json!({"audio_len": audio_len})),
1248            "sent voice note ( bytes)"
1249        );
1250        Ok(())
1251    }
1252
1253    async fn classify_edit_message_response(resp: reqwest::Response) -> EditMessageResult {
1254        if resp.status().is_success() {
1255            return EditMessageResult::Success;
1256        }
1257
1258        let status = resp.status();
1259        let body = resp.text().await.unwrap_or_default();
1260        if body.contains("message is not modified") {
1261            return EditMessageResult::NotModified;
1262        }
1263
1264        EditMessageResult::Failed(status)
1265    }
1266
1267    async fn fetch_bot_username(&self) -> anyhow::Result<String> {
1268        let resp = self.http_client().get(self.api_url("getMe")).send().await?;
1269
1270        if !resp.status().is_success() {
1271            anyhow::bail!("Failed to fetch bot info: {}", resp.status());
1272        }
1273
1274        let data: serde_json::Value = resp.json().await?;
1275        let username = data
1276            .get("result")
1277            .and_then(|r| r.get("username"))
1278            .and_then(|u| u.as_str())
1279            .context("Bot username not found in response")?;
1280
1281        Ok(username.to_string())
1282    }
1283
1284    async fn get_bot_username(&self) -> Option<String> {
1285        {
1286            let cache = self.bot_username.lock();
1287            if let Some(ref username) = *cache {
1288                return Some(username.clone());
1289            }
1290        }
1291
1292        match self.fetch_bot_username().await {
1293            Ok(username) => {
1294                let mut cache = self.bot_username.lock();
1295                *cache = Some(username.clone());
1296                Some(username)
1297            }
1298            Err(e) => {
1299                ::zeroclaw_log::record!(
1300                    WARN,
1301                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1302                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1303                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1304                    "Failed to fetch bot username"
1305                );
1306                None
1307            }
1308        }
1309    }
1310
1311    fn is_telegram_username_char(ch: char) -> bool {
1312        ch.is_ascii_alphanumeric() || ch == '_'
1313    }
1314
1315    fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
1316        let bot_username = bot_username.trim_start_matches('@');
1317        if bot_username.is_empty() {
1318            return Vec::new();
1319        }
1320
1321        let mut spans = Vec::new();
1322
1323        for (at_idx, ch) in text.char_indices() {
1324            if ch != '@' {
1325                continue;
1326            }
1327
1328            if at_idx > 0 {
1329                let prev = text[..at_idx].chars().next_back().unwrap_or(' ');
1330                if Self::is_telegram_username_char(prev) {
1331                    continue;
1332                }
1333            }
1334
1335            let username_start = at_idx + 1;
1336            let mut username_end = username_start;
1337
1338            for (rel_idx, candidate_ch) in text[username_start..].char_indices() {
1339                if Self::is_telegram_username_char(candidate_ch) {
1340                    username_end = username_start + rel_idx + candidate_ch.len_utf8();
1341                } else {
1342                    break;
1343                }
1344            }
1345
1346            if username_end == username_start {
1347                continue;
1348            }
1349
1350            let mention_username = &text[username_start..username_end];
1351            if mention_username.eq_ignore_ascii_case(bot_username) {
1352                spans.push((at_idx, username_end));
1353            }
1354        }
1355
1356        spans
1357    }
1358
1359    fn contains_bot_mention(text: &str, bot_username: &str) -> bool {
1360        !Self::find_bot_mention_spans(text, bot_username).is_empty()
1361    }
1362
1363    fn normalize_incoming_content(text: &str, _bot_username: &str) -> Option<String> {
1364        let trimmed = text.trim();
1365        (!trimmed.is_empty()).then(|| trimmed.to_string())
1366    }
1367
1368    fn is_group_message(message: &serde_json::Value) -> bool {
1369        message
1370            .get("chat")
1371            .and_then(|c| c.get("type"))
1372            .and_then(|t| t.as_str())
1373            .map(|t| t == "group" || t == "supergroup")
1374            .unwrap_or(false)
1375    }
1376
1377    /// Apply the `mention_only` gate to a non-text update (photo / document /
1378    /// voice) using its caption as the channel for the mention.
1379    ///
1380    /// Returns:
1381    /// - `Some(None)` — gate does not apply (DM, or `mention_only = false`,
1382    ///   or the message is not in a group). The caller should use the raw
1383    ///   caption / transcript as-is.
1384    /// - `Some(Some(trimmed))` — caption mentions the bot; the trimmed
1385    ///   caption (mention preserved) is suitable for use as message content.
1386    /// - `None` — gated and rejected; the caller must drop the update
1387    ///   without performing any expensive work (no download, no
1388    ///   transcription).
1389    ///
1390    /// Voice notes typically arrive without a caption, so under
1391    /// `mention_only = true` they are rejected here before transcription
1392    /// runs. If a future change wants to honor a verbal mention inside the
1393    /// transcript, this gate would need to be split into a pre-download and
1394    /// a post-transcription stage. See #6229.
1395    fn check_media_mention_gate(
1396        &self,
1397        message: &serde_json::Value,
1398        caption: Option<&str>,
1399    ) -> Option<Option<String>> {
1400        let is_group = Self::is_group_message(message);
1401        if !self.mention_only || !is_group {
1402            return Some(caption.map(String::from));
1403        }
1404        let bot_username_guard = self.bot_username.lock();
1405        let bot_username = bot_username_guard.as_ref()?;
1406        let caption = caption?;
1407        if !Self::contains_bot_mention(caption, bot_username) {
1408            return None;
1409        }
1410        Some(Self::normalize_incoming_content(caption, bot_username))
1411    }
1412
1413    fn is_user_allowed(&self, username: &str) -> bool {
1414        let identity = Self::normalize_identity(username);
1415        let peers: Vec<String> = (self.peer_resolver)()
1416            .into_iter()
1417            .map(|p| Self::normalize_identity(&p))
1418            .filter(|p| !p.is_empty())
1419            .collect();
1420        crate::allowlist::is_user_allowed(&peers, &identity, crate::allowlist::Match::Sensitive)
1421    }
1422
1423    fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool
1424    where
1425        I: IntoIterator<Item = &'a str>,
1426    {
1427        identities.into_iter().any(|id| self.is_user_allowed(id))
1428    }
1429
1430    async fn handle_unauthorized_message(&self, update: &serde_json::Value) {
1431        let Some(message) = update.get("message") else {
1432            return;
1433        };
1434
1435        let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
1436            return;
1437        };
1438
1439        let username_opt = message
1440            .get("from")
1441            .and_then(|from| from.get("username"))
1442            .and_then(serde_json::Value::as_str);
1443        let username = username_opt.unwrap_or("unknown");
1444        let normalized_username = Self::normalize_identity(username);
1445
1446        let sender_id = message
1447            .get("from")
1448            .and_then(|from| from.get("id"))
1449            .and_then(serde_json::Value::as_i64);
1450        let sender_id_str = sender_id.map(|id| id.to_string());
1451        let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity);
1452
1453        let chat_id = message
1454            .get("chat")
1455            .and_then(|chat| chat.get("id"))
1456            .and_then(serde_json::Value::as_i64)
1457            .map(|id| id.to_string());
1458
1459        let Some(chat_id) = chat_id else {
1460            ::zeroclaw_log::record!(
1461                WARN,
1462                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1463                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1464                "missing chat_id in message, skipping"
1465            );
1466            return;
1467        };
1468
1469        let mut identities = vec![normalized_username.as_str()];
1470        if let Some(ref id) = normalized_sender_id {
1471            identities.push(id.as_str());
1472        }
1473
1474        if self.is_any_user_allowed(identities.iter().copied()) {
1475            return;
1476        }
1477
1478        if let Some(code) = Self::extract_bind_code(text) {
1479            if let Some(pairing) = self.pairing.as_ref() {
1480                match pairing.try_pair(code, &chat_id).await {
1481                    Ok(Some(_token)) => {
1482                        let bind_identity = normalized_sender_id.clone().or_else(|| {
1483                            if normalized_username.is_empty() || normalized_username == "unknown" {
1484                                None
1485                            } else {
1486                                Some(normalized_username.clone())
1487                            }
1488                        });
1489
1490                        if let Some(identity) = bind_identity {
1491                            match Box::pin(self.persist_allowed_identity(&identity)).await {
1492                                Ok(()) => {
1493                                    let _ = self
1494                                        .send(&SendMessage::new(
1495                                            "✅ Telegram account bound successfully. You can talk to ZeroClaw now.",
1496                                            &chat_id,
1497                                        ))
1498                                        .await;
1499                                    ::zeroclaw_log::record!(
1500                                        INFO,
1501                                        ::zeroclaw_log::Event::new(
1502                                            module_path!(),
1503                                            ::zeroclaw_log::Action::Note
1504                                        )
1505                                        .with_attrs(::serde_json::json!({"identity": identity})),
1506                                        "paired and allowlisted identity="
1507                                    );
1508                                }
1509                                Err(e) => {
1510                                    ::zeroclaw_log::record!(
1511                                        ERROR,
1512                                        ::zeroclaw_log::Event::new(
1513                                            module_path!(),
1514                                            ::zeroclaw_log::Action::Fail
1515                                        )
1516                                        .with_outcome(::zeroclaw_log::EventOutcome::Failure)
1517                                        .with_attrs(::serde_json::json!({"e": e.to_string()})),
1518                                        "failed to persist allowlist after bind"
1519                                    );
1520                                    let _ = self
1521                                        .send(&SendMessage::new(
1522                                            "⚠️ Bound for this runtime, but failed to persist config. Access may be lost after restart; check config file permissions.",
1523                                            &chat_id,
1524                                        ))
1525                                        .await;
1526                                }
1527                            }
1528                        } else {
1529                            let _ = self
1530                                .send(&SendMessage::new(
1531                                    "❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.",
1532                                    &chat_id,
1533                                ))
1534                                .await;
1535                        }
1536                    }
1537                    Ok(None) => {
1538                        let _ = self
1539                            .send(&SendMessage::new(
1540                                "❌ Invalid binding code. Ask operator for the latest code and retry.",
1541                                &chat_id,
1542                            ))
1543                            .await;
1544                    }
1545                    Err(lockout_secs) => {
1546                        let _ = self
1547                            .send(&SendMessage::new(
1548                                format!("⏳ Too many invalid attempts. Retry in {lockout_secs}s."),
1549                                &chat_id,
1550                            ))
1551                            .await;
1552                    }
1553                }
1554            } else {
1555                let _ = self
1556                    .send(&SendMessage::new(
1557                        "ℹ️ Telegram pairing is not active. Ask operator to add your user ID to the matching peer_groups.telegram_<alias>.external_peers entry in config.toml.",
1558                        &chat_id,
1559                    ))
1560                    .await;
1561            }
1562            return;
1563        }
1564
1565        ::zeroclaw_log::record!(
1566            WARN,
1567            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1568                .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1569            &format!(
1570                "ignoring message from unauthorized user: username={username}, sender_id={}. \
1571Allowlist Telegram username (without '@') or numeric user ID.",
1572                sender_id_str.as_deref().unwrap_or("unknown")
1573            )
1574        );
1575
1576        let suggested_identity = normalized_sender_id
1577            .clone()
1578            .or_else(|| {
1579                if normalized_username.is_empty() || normalized_username == "unknown" {
1580                    None
1581                } else {
1582                    Some(normalized_username.clone())
1583                }
1584            })
1585            .unwrap_or_else(|| "YOUR_TELEGRAM_ID".to_string());
1586
1587        let _ = self
1588            .send(&SendMessage::new(
1589                format!(
1590                    "🔐 This bot requires operator approval.\n\nCopy this command to operator terminal:\n`zeroclaw channel bind-telegram {suggested_identity}`\n\nAfter operator runs it, send your message again."
1591                ),
1592                &chat_id,
1593            ))
1594            .await;
1595
1596        if self.pairing_code_active() {
1597            let _ = self
1598                .send(&SendMessage::new(
1599                    "ℹ️ If operator provides a one-time pairing code, you can also run `/bind <code>`.",
1600                    &chat_id,
1601                ))
1602                .await;
1603        }
1604    }
1605
1606    /// Get the file path for a Telegram file ID via the Bot API.
1607    async fn get_file_path(&self, file_id: &str) -> anyhow::Result<String> {
1608        let url = self.api_url("getFile");
1609        let resp = self
1610            .http_client()
1611            .get(&url)
1612            .query(&[("file_id", file_id)])
1613            .send()
1614            .await
1615            .context("Failed to call Telegram getFile")?;
1616
1617        let data: serde_json::Value = resp.json().await?;
1618        data.get("result")
1619            .and_then(|r| r.get("file_path"))
1620            .and_then(serde_json::Value::as_str)
1621            .map(String::from)
1622            .context("Telegram getFile: missing file_path in response")
1623    }
1624
1625    /// Download a file from the Telegram CDN.
1626    async fn download_file(&self, file_path: &str) -> anyhow::Result<Vec<u8>> {
1627        let url = format!(
1628            "https://api.telegram.org/file/bot{}/{file_path}",
1629            self.bot_token
1630        );
1631        let resp = self
1632            .http_client()
1633            .get(&url)
1634            .send()
1635            .await
1636            .context("Failed to download Telegram file")?;
1637
1638        if !resp.status().is_success() {
1639            anyhow::bail!("Telegram file download failed: {}", resp.status());
1640        }
1641
1642        Ok(resp.bytes().await?.to_vec())
1643    }
1644
1645    /// Extract (file_id, duration) from a voice or audio message.
1646    fn parse_voice_metadata(message: &serde_json::Value) -> Option<(String, u64)> {
1647        let voice = message.get("voice").or_else(|| message.get("audio"))?;
1648        let file_id = voice.get("file_id")?.as_str()?.to_string();
1649        let duration = voice
1650            .get("duration")
1651            .and_then(serde_json::Value::as_u64)
1652            .unwrap_or(0);
1653        Some((file_id, duration))
1654    }
1655
1656    /// Extract attachment metadata from an incoming Telegram message (document or photo).
1657    ///
1658    /// Returns `None` for text-only, voice, and other unsupported message types.
1659    fn parse_attachment_metadata(message: &serde_json::Value) -> Option<IncomingAttachment> {
1660        // Try document first
1661        if let Some(doc) = message.get("document") {
1662            let file_id = doc.get("file_id")?.as_str()?.to_string();
1663            let file_name = doc
1664                .get("file_name")
1665                .and_then(serde_json::Value::as_str)
1666                .map(String::from);
1667            let file_size = doc.get("file_size").and_then(serde_json::Value::as_u64);
1668            let caption = message
1669                .get("caption")
1670                .and_then(serde_json::Value::as_str)
1671                .map(String::from);
1672            return Some(IncomingAttachment {
1673                file_id,
1674                file_name,
1675                file_size,
1676                caption,
1677                kind: IncomingAttachmentKind::Document,
1678            });
1679        }
1680
1681        // Try photo (array of PhotoSize, take last = highest resolution)
1682        if let Some(photos) = message.get("photo").and_then(serde_json::Value::as_array) {
1683            let best = photos.last()?;
1684            let file_id = best.get("file_id")?.as_str()?.to_string();
1685            let file_size = best.get("file_size").and_then(serde_json::Value::as_u64);
1686            let caption = message
1687                .get("caption")
1688                .and_then(serde_json::Value::as_str)
1689                .map(String::from);
1690            return Some(IncomingAttachment {
1691                file_id,
1692                file_name: None,
1693                file_size,
1694                caption,
1695                kind: IncomingAttachmentKind::Photo,
1696            });
1697        }
1698
1699        None
1700    }
1701
1702    /// Attempt to parse a Telegram update as a document/photo attachment.
1703    ///
1704    /// Downloads the file to `{workspace_dir}/telegram_files/` and returns a
1705    /// `ChannelMessage` with the local file path. Returns `None` if the message
1706    /// is not an attachment, workspace_dir is not configured, or the file exceeds
1707    /// size limits.
1708    async fn try_parse_attachment_message(
1709        &self,
1710        update: &serde_json::Value,
1711    ) -> Option<ChannelMessage> {
1712        let message = update.get("message")?;
1713        let attachment = Self::parse_attachment_metadata(message)?;
1714
1715        // Check file size limit
1716        if let Some(size) = attachment.file_size
1717            && size > TELEGRAM_MAX_FILE_DOWNLOAD_BYTES
1718        {
1719            ::zeroclaw_log::record!(
1720                INFO,
1721                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1722                &format!(
1723                    "Skipping attachment: file size {size} bytes exceeds {} MB limit",
1724                    TELEGRAM_MAX_FILE_DOWNLOAD_BYTES / (1024 * 1024)
1725                )
1726            );
1727            return None;
1728        }
1729
1730        let (username, sender_id, sender_identity) = Self::extract_sender_info(message);
1731
1732        let mut identities = vec![username.as_str()];
1733        if let Some(id) = sender_id.as_deref() {
1734            identities.push(id);
1735        }
1736
1737        if !self.is_any_user_allowed(identities.iter().copied()) {
1738            return None;
1739        }
1740
1741        // Apply mention_only gate before downloading. Photo / document
1742        // updates carry no `text` field, so the text-only gate in
1743        // `parse_update_message` can never see them and they used to slip
1744        // through unconditionally. See #6229.
1745        let gated_caption =
1746            self.check_media_mention_gate(message, attachment.caption.as_deref())?;
1747
1748        let chat_id = message
1749            .get("chat")
1750            .and_then(|chat| chat.get("id"))
1751            .and_then(serde_json::Value::as_i64)
1752            .map(|id| id.to_string())?;
1753
1754        let message_id = message
1755            .get("message_id")
1756            .and_then(serde_json::Value::as_i64)
1757            .unwrap_or(0);
1758
1759        let thread_id = message
1760            .get("message_thread_id")
1761            .and_then(serde_json::Value::as_i64)
1762            .map(|id| id.to_string());
1763
1764        let reply_target = if let Some(ref tid) = thread_id {
1765            format!("{}:{}", chat_id, tid)
1766        } else {
1767            chat_id.clone()
1768        };
1769
1770        // Ensure workspace directory is configured
1771        let workspace = self.workspace_dir.as_ref().or_else(|| {
1772            ::zeroclaw_log::record!(
1773                WARN,
1774                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1775                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
1776                "Cannot save attachment: workspace_dir not configured"
1777            );
1778            None
1779        })?;
1780
1781        let save_dir = workspace.join("telegram_files");
1782        if let Err(e) = tokio::fs::create_dir_all(&save_dir).await {
1783            ::zeroclaw_log::record!(
1784                WARN,
1785                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1786                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1787                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1788                "Failed to create telegram_files directory"
1789            );
1790            return None;
1791        }
1792
1793        // Download file from Telegram
1794        let tg_file_path = match self.get_file_path(&attachment.file_id).await {
1795            Ok(p) => p,
1796            Err(e) => {
1797                ::zeroclaw_log::record!(
1798                    WARN,
1799                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1800                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1801                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1802                    "Failed to get attachment file path"
1803                );
1804                return None;
1805            }
1806        };
1807
1808        let file_data = match self.download_file(&tg_file_path).await {
1809            Ok(d) => d,
1810            Err(e) => {
1811                ::zeroclaw_log::record!(
1812                    WARN,
1813                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1814                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1815                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1816                    "Failed to download attachment"
1817                );
1818                return None;
1819            }
1820        };
1821
1822        // Determine local filename
1823        let local_filename = match &attachment.file_name {
1824            Some(name) => name.clone(),
1825            None => {
1826                // For photos, derive extension from Telegram file path
1827                let ext = tg_file_path.rsplit('.').next().unwrap_or("jpg");
1828                format!("photo_{chat_id}_{message_id}.{ext}")
1829            }
1830        };
1831
1832        let local_path = save_dir.join(&local_filename);
1833        if let Err(e) = tokio::fs::write(&local_path, &file_data).await {
1834            ::zeroclaw_log::record!(
1835                WARN,
1836                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1837                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1838                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1839                &format!("Failed to save attachment to {}", local_path.display())
1840            );
1841            return None;
1842        }
1843
1844        // Build message content.
1845        // Photos with image extensions use [IMAGE:] marker so the multimodal
1846        // pipeline validates vision capability. Non-image files always get
1847        // [Document:] format regardless of Telegram's classification.
1848        let mut content = format_attachment_content(attachment.kind, &local_filename, &local_path);
1849        // `gated_caption` is the trimmed caption when the `mention_only`
1850        // gate admits it; otherwise the raw caption (or None).
1851        if let Some(caption) = gated_caption.as_deref()
1852            && !caption.is_empty()
1853        {
1854            use std::fmt::Write;
1855            let _ = write!(content, "\n\n{caption}");
1856        }
1857
1858        // Prepend reply context if replying to another message
1859        if let Some(quote) = self.extract_reply_context(message) {
1860            content = format!("{quote}\n\n{content}");
1861        }
1862
1863        // Prepend forwarding attribution when the message was forwarded
1864        if let Some(attr) = Self::format_forward_attribution(message) {
1865            content = Self::prepend_forward_attribution(&attr, content);
1866        }
1867
1868        Some(ChannelMessage {
1869            id: format!("telegram_{chat_id}_{message_id}"),
1870            sender: sender_identity,
1871            reply_target,
1872            content,
1873            channel: "telegram".to_string(),
1874            channel_alias: Some(self.alias.clone()),
1875            timestamp: std::time::SystemTime::now()
1876                .duration_since(std::time::UNIX_EPOCH)
1877                .unwrap_or_default()
1878                .as_secs(),
1879            thread_ts: thread_id,
1880            interruption_scope_id: None,
1881            attachments: vec![],
1882            subject: None,
1883        })
1884    }
1885
1886    /// Attempt to parse a Telegram update as a voice message and transcribe it.
1887    ///
1888    /// Returns `None` if the message is not a voice message, transcription is disabled,
1889    /// or the message exceeds duration limits.
1890    async fn try_parse_voice_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
1891        let config = self.transcription.as_ref()?;
1892        let manager = self.transcription_manager.as_deref()?;
1893        let message = update.get("message")?;
1894
1895        let (file_id, duration) = Self::parse_voice_metadata(message)?;
1896
1897        if duration > config.max_duration_secs {
1898            ::zeroclaw_log::record!(
1899                INFO,
1900                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
1901                &format!(
1902                    "Skipping voice message: duration {duration}s exceeds limit {}s",
1903                    config.max_duration_secs
1904                )
1905            );
1906            return None;
1907        }
1908
1909        let (username, sender_id, sender_identity) = Self::extract_sender_info(message);
1910
1911        let mut identities = vec![username.as_str()];
1912        if let Some(id) = sender_id.as_deref() {
1913            identities.push(id);
1914        }
1915
1916        if !self.is_any_user_allowed(identities.iter().copied()) {
1917            return None;
1918        }
1919
1920        // Apply mention_only gate before downloading + transcribing. Voice
1921        // notes typically have no caption, so under `mention_only = true`
1922        // they are rejected here — the bot has no reliable way to know it
1923        // was mentioned without first transcribing, and we don't want to
1924        // pay that cost for messages that will likely be dropped. See #6229.
1925        // The transcription itself is discarded; we only care whether the
1926        // gate returns Some (allowed) vs None (rejected).
1927        let voice_caption = message.get("caption").and_then(serde_json::Value::as_str);
1928        self.check_media_mention_gate(message, voice_caption)?;
1929
1930        let chat_id = message
1931            .get("chat")
1932            .and_then(|chat| chat.get("id"))
1933            .and_then(serde_json::Value::as_i64)
1934            .map(|id| id.to_string())?;
1935
1936        let message_id = message
1937            .get("message_id")
1938            .and_then(serde_json::Value::as_i64)
1939            .unwrap_or(0);
1940
1941        let thread_id = message
1942            .get("message_thread_id")
1943            .and_then(serde_json::Value::as_i64)
1944            .map(|id| id.to_string());
1945
1946        let reply_target = if let Some(ref tid) = thread_id {
1947            format!("{}:{}", chat_id, tid)
1948        } else {
1949            chat_id.clone()
1950        };
1951
1952        // Download and transcribe
1953        let file_path = match self.get_file_path(&file_id).await {
1954            Ok(p) => p,
1955            Err(e) => {
1956                ::zeroclaw_log::record!(
1957                    WARN,
1958                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1959                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1960                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1961                    "Failed to get voice file path"
1962                );
1963                return None;
1964            }
1965        };
1966
1967        let file_name = file_path
1968            .rsplit('/')
1969            .next()
1970            .unwrap_or("voice.ogg")
1971            .to_string();
1972
1973        let audio_data = match self.download_file(&file_path).await {
1974            Ok(d) => d,
1975            Err(e) => {
1976                ::zeroclaw_log::record!(
1977                    WARN,
1978                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1979                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1980                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1981                    "Failed to download voice file"
1982                );
1983                return None;
1984            }
1985        };
1986
1987        let text = match manager.transcribe(&audio_data, &file_name).await {
1988            Ok(t) => t,
1989            Err(e) => {
1990                ::zeroclaw_log::record!(
1991                    WARN,
1992                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
1993                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
1994                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
1995                    "Voice transcription failed"
1996                );
1997                return None;
1998            }
1999        };
2000
2001        if text.trim().is_empty() {
2002            ::zeroclaw_log::record!(
2003                INFO,
2004                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
2005                "Voice transcription returned empty text, skipping"
2006            );
2007            return None;
2008        }
2009
2010        // Enter voice-chat mode so outgoing replies get a TTS voice note
2011        if let Ok(mut vc) = self.voice_chats.lock() {
2012            vc.insert(reply_target.clone());
2013        }
2014
2015        // Cache transcription for reply-context lookups
2016        {
2017            let mut cache = self.voice_transcriptions.lock();
2018            if cache.len() >= 100 {
2019                cache.clear();
2020            }
2021            cache.insert(format!("{chat_id}:{message_id}"), text.clone());
2022        }
2023
2024        let content = if let Some(quote) = self.extract_reply_context(message) {
2025            format!("{quote}\n\n[Voice] {text}")
2026        } else {
2027            format!("[Voice] {text}")
2028        };
2029
2030        // Prepend forwarding attribution when the message was forwarded
2031        let content = if let Some(attr) = Self::format_forward_attribution(message) {
2032            Self::prepend_forward_attribution(&attr, content)
2033        } else {
2034            content
2035        };
2036
2037        Some(ChannelMessage {
2038            id: format!("telegram_{chat_id}_{message_id}"),
2039            sender: sender_identity,
2040            reply_target,
2041            content,
2042            channel: "telegram".to_string(),
2043            channel_alias: Some(self.alias.clone()),
2044            timestamp: std::time::SystemTime::now()
2045                .duration_since(std::time::UNIX_EPOCH)
2046                .unwrap_or_default()
2047                .as_secs(),
2048            thread_ts: thread_id,
2049            interruption_scope_id: None,
2050            attachments: vec![],
2051            subject: None,
2052        })
2053    }
2054
2055    /// Extract sender username and display identity from a Telegram message object.
2056    fn extract_sender_info(message: &serde_json::Value) -> (String, Option<String>, String) {
2057        let username = message
2058            .get("from")
2059            .and_then(|from| from.get("username"))
2060            .and_then(serde_json::Value::as_str)
2061            .unwrap_or("unknown")
2062            .to_string();
2063        let sender_id = message
2064            .get("from")
2065            .and_then(|from| from.get("id"))
2066            .and_then(serde_json::Value::as_i64)
2067            .map(|id| id.to_string());
2068        let sender_identity = if username == "unknown" {
2069            sender_id.clone().unwrap_or_else(|| "unknown".to_string())
2070        } else {
2071            username.clone()
2072        };
2073        (username, sender_id, sender_identity)
2074    }
2075
2076    /// Build a forwarding attribution prefix from Telegram forward fields.
2077    ///
2078    /// Returns `Some("[Forwarded from ...] ")` when the message is forwarded,
2079    /// `None` otherwise.
2080    fn format_forward_attribution(message: &serde_json::Value) -> Option<String> {
2081        if let Some(origin) = message.get("forward_origin") {
2082            let origin_type = origin.get("type").and_then(serde_json::Value::as_str)?;
2083            let label = match origin_type {
2084                "user" => {
2085                    let sender = origin.get("sender_user")?;
2086                    Self::format_forwarded_user_label(sender, "unknown")
2087                }
2088                "hidden_user" => origin
2089                    .get("sender_user_name")
2090                    .and_then(serde_json::Value::as_str)
2091                    .unwrap_or("unknown hidden user")
2092                    .to_string(),
2093                "chat" => {
2094                    let title = origin
2095                        .get("sender_chat")
2096                        .and_then(|chat| chat.get("title"))
2097                        .and_then(serde_json::Value::as_str)
2098                        .unwrap_or("unknown chat");
2099                    format!("chat: {title}")
2100                }
2101                "channel" => {
2102                    let title = origin
2103                        .get("chat")
2104                        .and_then(|chat| chat.get("title"))
2105                        .and_then(serde_json::Value::as_str)
2106                        .unwrap_or("unknown channel");
2107                    format!("channel: {title}")
2108                }
2109                _ => "unknown source".to_string(),
2110            };
2111            Some(format!("[Forwarded from {label}] "))
2112        } else if let Some(from_chat) = message.get("forward_from_chat") {
2113            // Forwarded from a channel or group
2114            let title = from_chat
2115                .get("title")
2116                .and_then(serde_json::Value::as_str)
2117                .unwrap_or("unknown channel");
2118            Some(format!("[Forwarded from channel: {title}] "))
2119        } else if let Some(from_user) = message.get("forward_from") {
2120            // Forwarded from a user (privacy allows identity)
2121            let label = Self::format_forwarded_user_label(from_user, "unknown");
2122            Some(format!("[Forwarded from {label}] "))
2123        } else {
2124            // Forwarded from a user who hides their identity
2125            message
2126                .get("forward_sender_name")
2127                .and_then(serde_json::Value::as_str)
2128                .map(|name| format!("[Forwarded from {name}] "))
2129        }
2130    }
2131
2132    fn prepend_forward_attribution(attr: &str, content: String) -> String {
2133        let attr = attr.trim_end();
2134        if content.starts_with("> ") {
2135            format!("{attr}\n\n{content}")
2136        } else {
2137            format!("{attr} {content}")
2138        }
2139    }
2140
2141    fn format_forwarded_user_label(user: &serde_json::Value, fallback: &str) -> String {
2142        if let Some(username) = user.get("username").and_then(serde_json::Value::as_str) {
2143            return format!("@{username}");
2144        }
2145
2146        let Some(first_name) = user.get("first_name").and_then(serde_json::Value::as_str) else {
2147            return fallback.to_string();
2148        };
2149
2150        let mut label = first_name.to_string();
2151        if let Some(last_name) = user.get("last_name").and_then(serde_json::Value::as_str) {
2152            label.push(' ');
2153            label.push_str(last_name);
2154        }
2155        label
2156    }
2157
2158    /// Extract reply context from a Telegram `reply_to_message`, if present.
2159    fn extract_reply_context(&self, message: &serde_json::Value) -> Option<String> {
2160        let reply = message.get("reply_to_message")?;
2161
2162        // Skip the auto-injected topic-root reference Telegram adds to every
2163        // message in a non-General forum topic. Its message_id equals the
2164        // parent message's message_thread_id. Treating it as a real reply
2165        // produces a spurious `> @user:\n> [Message]` blockquote prefix that
2166        // downstream reply-intent classification reads as "user is replying
2167        // to someone else" and rejects.
2168        let reply_mid = reply.get("message_id").and_then(serde_json::Value::as_i64);
2169        let thread_id = message
2170            .get("message_thread_id")
2171            .and_then(serde_json::Value::as_i64);
2172        if let (Some(rmid), Some(tid)) = (reply_mid, thread_id)
2173            && rmid == tid
2174        {
2175            return None;
2176        }
2177
2178        let reply_sender = reply
2179            .get("from")
2180            .and_then(|from| from.get("username"))
2181            .and_then(serde_json::Value::as_str)
2182            .or_else(|| {
2183                reply
2184                    .get("from")
2185                    .and_then(|from| from.get("first_name"))
2186                    .and_then(serde_json::Value::as_str)
2187            })
2188            .unwrap_or("unknown");
2189
2190        let reply_text = if let Some(text) = reply.get("text").and_then(serde_json::Value::as_str) {
2191            text.to_string()
2192        } else if reply.get("voice").is_some() || reply.get("audio").is_some() {
2193            let reply_mid = reply.get("message_id").and_then(serde_json::Value::as_i64);
2194            let chat_id = message
2195                .get("chat")
2196                .and_then(|c| c.get("id"))
2197                .and_then(serde_json::Value::as_i64);
2198            if let (Some(mid), Some(cid)) = (reply_mid, chat_id) {
2199                self.voice_transcriptions
2200                    .lock()
2201                    .get(&format!("{cid}:{mid}"))
2202                    .map(|t| format!("[Voice] {t}"))
2203                    .unwrap_or_else(|| "[Voice message]".to_string())
2204            } else {
2205                "[Voice message]".to_string()
2206            }
2207        } else if reply.get("photo").is_some() {
2208            "[Photo]".to_string()
2209        } else if reply.get("document").is_some() {
2210            "[Document]".to_string()
2211        } else if reply.get("video").is_some() {
2212            "[Video]".to_string()
2213        } else if reply.get("sticker").is_some() {
2214            "[Sticker]".to_string()
2215        } else {
2216            "[Message]".to_string()
2217        };
2218
2219        // Format as blockquote with sender attribution
2220        let quoted_lines: String = reply_text
2221            .lines()
2222            .map(|line| format!("> {line}"))
2223            .collect::<Vec<_>>()
2224            .join("\n");
2225
2226        Some(format!("> @{reply_sender}:\n{quoted_lines}"))
2227    }
2228
2229    fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
2230        let message = update.get("message")?;
2231
2232        let text = message.get("text").and_then(serde_json::Value::as_str)?;
2233
2234        let (username, sender_id, sender_identity) = Self::extract_sender_info(message);
2235
2236        let mut identities = vec![username.as_str()];
2237        if let Some(id) = sender_id.as_deref() {
2238            identities.push(id);
2239        }
2240
2241        if !self.is_any_user_allowed(identities.iter().copied()) {
2242            return None;
2243        }
2244
2245        let is_group = Self::is_group_message(message);
2246        if self.mention_only && is_group {
2247            let bot_username = self.bot_username.lock();
2248            if let Some(ref bot_username) = *bot_username {
2249                if !Self::contains_bot_mention(text, bot_username) {
2250                    return None;
2251                }
2252            } else {
2253                return None;
2254            }
2255        }
2256
2257        let chat_id = message
2258            .get("chat")
2259            .and_then(|chat| chat.get("id"))
2260            .and_then(serde_json::Value::as_i64)
2261            .map(|id| id.to_string())?;
2262
2263        let message_id = message
2264            .get("message_id")
2265            .and_then(serde_json::Value::as_i64)
2266            .unwrap_or(0);
2267
2268        // Extract thread/topic ID for forum support
2269        let thread_id = message
2270            .get("message_thread_id")
2271            .and_then(serde_json::Value::as_i64)
2272            .map(|id| id.to_string());
2273
2274        // reply_target: chat_id or chat_id:thread_id format
2275        let reply_target = if let Some(ref tid) = thread_id {
2276            format!("{}:{}", chat_id, tid)
2277        } else {
2278            chat_id.clone()
2279        };
2280
2281        let content = if self.mention_only && is_group {
2282            let bot_username = self.bot_username.lock();
2283            let bot_username = bot_username.as_ref()?;
2284            Self::normalize_incoming_content(text, bot_username)?
2285        } else {
2286            text.to_string()
2287        };
2288
2289        let content = if let Some(quote) = self.extract_reply_context(message) {
2290            format!("{quote}\n\n{content}")
2291        } else {
2292            content
2293        };
2294
2295        // Prepend forwarding attribution when the message was forwarded
2296        let content = if let Some(attr) = Self::format_forward_attribution(message) {
2297            Self::prepend_forward_attribution(&attr, content)
2298        } else {
2299            content
2300        };
2301
2302        // Exit voice-chat mode when user switches back to typing
2303        if let Ok(mut vc) = self.voice_chats.lock() {
2304            vc.remove(&reply_target);
2305        }
2306
2307        Some(ChannelMessage {
2308            id: format!("telegram_{chat_id}_{message_id}"),
2309            sender: sender_identity,
2310            reply_target,
2311            content,
2312            channel: "telegram".to_string(),
2313            channel_alias: Some(self.alias.clone()),
2314            timestamp: std::time::SystemTime::now()
2315                .duration_since(std::time::UNIX_EPOCH)
2316                .unwrap_or_default()
2317                .as_secs(),
2318            thread_ts: thread_id,
2319            interruption_scope_id: None,
2320            attachments: vec![],
2321            subject: None,
2322        })
2323    }
2324
2325    /// Convert Markdown to Telegram HTML format.
2326    /// Telegram HTML supports: <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">
2327    /// This mirrors OpenClaw's markdownToTelegramHtml approach.
2328    fn markdown_to_telegram_html(text: &str) -> String {
2329        let lines: Vec<&str> = text.split('\n').collect();
2330        let mut result_lines: Vec<String> = Vec::new();
2331
2332        for line in &lines {
2333            let trimmed_line = line.trim_start();
2334            if trimmed_line.starts_with("```") {
2335                // Preserve fence lines so the second-pass block parser can consume them
2336                // without interference from inline backtick handling.
2337                result_lines.push(trimmed_line.to_string());
2338                continue;
2339            }
2340
2341            let mut line_out = String::new();
2342
2343            // Handle code blocks (``` ... ```) - handled at text level below
2344            // Handle headers: ## Title → <b>Title</b>
2345            let stripped = line.trim_start_matches('#');
2346            let header_level = line.len() - stripped.len();
2347            if header_level > 0 && line.starts_with('#') && stripped.starts_with(' ') {
2348                let title = Self::escape_html(stripped.trim());
2349                result_lines.push(format!("<b>{title}</b>"));
2350                continue;
2351            }
2352
2353            // Inline formatting
2354            let mut i = 0;
2355            let bytes = line.as_bytes();
2356            let len = bytes.len();
2357            while i < len {
2358                // Bold: **text** or __text__
2359                if i + 1 < len
2360                    && bytes[i] == b'*'
2361                    && bytes[i + 1] == b'*'
2362                    && let Some(end) = line[i + 2..].find("**")
2363                {
2364                    let inner = Self::escape_html(&line[i + 2..i + 2 + end]);
2365                    let _ = write!(line_out, "<b>{inner}</b>");
2366                    i += 4 + end;
2367                    continue;
2368                }
2369                if i + 1 < len
2370                    && bytes[i] == b'_'
2371                    && bytes[i + 1] == b'_'
2372                    && let Some(end) = line[i + 2..].find("__")
2373                {
2374                    let inner = Self::escape_html(&line[i + 2..i + 2 + end]);
2375                    let _ = write!(line_out, "<b>{inner}</b>");
2376                    i += 4 + end;
2377                    continue;
2378                }
2379                // Italic: *text* or _text_ (single)
2380                if bytes[i] == b'*'
2381                    && (i == 0 || bytes[i - 1] != b'*')
2382                    && let Some(end) = line[i + 1..].find('*')
2383                    && end > 0
2384                {
2385                    let inner = Self::escape_html(&line[i + 1..i + 1 + end]);
2386                    let _ = write!(line_out, "<i>{inner}</i>");
2387                    i += 2 + end;
2388                    continue;
2389                }
2390                // Inline code: `code`
2391                if bytes[i] == b'`'
2392                    && (i == 0 || bytes[i - 1] != b'`')
2393                    && let Some(end) = line[i + 1..].find('`')
2394                {
2395                    let inner = Self::escape_html(&line[i + 1..i + 1 + end]);
2396                    let _ = write!(line_out, "<code>{inner}</code>");
2397                    i += 2 + end;
2398                    continue;
2399                }
2400                // Markdown link: [text](url)
2401                if bytes[i] == b'['
2402                    && let Some(bracket_end) = line[i + 1..].find(']')
2403                {
2404                    let text_part = &line[i + 1..i + 1 + bracket_end];
2405                    let after_bracket = i + 1 + bracket_end + 1; // position after ']'
2406                    if after_bracket < len
2407                        && bytes[after_bracket] == b'('
2408                        && let Some(paren_end) = line[after_bracket + 1..].find(')')
2409                    {
2410                        let url = &line[after_bracket + 1..after_bracket + 1 + paren_end];
2411                        if url.starts_with("http://") || url.starts_with("https://") {
2412                            let text_html = Self::escape_html(text_part);
2413                            let url_html = Self::escape_html(url);
2414                            let _ = write!(line_out, "<a href=\"{url_html}\">{text_html}</a>");
2415                            i = after_bracket + 1 + paren_end + 1;
2416                            continue;
2417                        }
2418                    }
2419                }
2420                // Strikethrough: ~~text~~
2421                if i + 1 < len
2422                    && bytes[i] == b'~'
2423                    && bytes[i + 1] == b'~'
2424                    && let Some(end) = line[i + 2..].find("~~")
2425                {
2426                    let inner = Self::escape_html(&line[i + 2..i + 2 + end]);
2427                    let _ = write!(line_out, "<s>{inner}</s>");
2428                    i += 4 + end;
2429                    continue;
2430                }
2431                // Default: escape HTML entities
2432                let ch = line[i..].chars().next().unwrap();
2433                match ch {
2434                    '<' => line_out.push_str("&lt;"),
2435                    '>' => line_out.push_str("&gt;"),
2436                    '&' => line_out.push_str("&amp;"),
2437                    '"' => line_out.push_str("&quot;"),
2438                    '\'' => line_out.push_str("&#39;"),
2439                    _ => line_out.push(ch),
2440                }
2441                i += ch.len_utf8();
2442            }
2443            result_lines.push(line_out);
2444        }
2445
2446        // Second pass: handle ``` code blocks across lines
2447        let joined = result_lines.join("\n");
2448        let mut final_out = String::with_capacity(joined.len());
2449        let mut in_code_block = false;
2450        let mut code_buf = String::new();
2451
2452        for line in joined.split('\n') {
2453            let trimmed = line.trim();
2454            if trimmed.starts_with("```") {
2455                if in_code_block {
2456                    in_code_block = false;
2457                    let escaped = code_buf.trim_end_matches('\n');
2458                    // Telegram HTML parse mode supports <pre> and <code>, but not class attributes.
2459                    let _ = writeln!(final_out, "<pre><code>{escaped}</code></pre>");
2460                    code_buf.clear();
2461                } else {
2462                    in_code_block = true;
2463                    code_buf.clear();
2464                }
2465            } else if in_code_block {
2466                code_buf.push_str(line);
2467                code_buf.push('\n');
2468            } else {
2469                final_out.push_str(line);
2470                final_out.push('\n');
2471            }
2472        }
2473        if in_code_block && !code_buf.is_empty() {
2474            let _ = writeln!(final_out, "<pre><code>{}</code></pre>", code_buf.trim_end());
2475        }
2476
2477        final_out.trim_end_matches('\n').to_string()
2478    }
2479
2480    fn escape_html(s: &str) -> String {
2481        s.replace('&', "&amp;")
2482            .replace('<', "&lt;")
2483            .replace('>', "&gt;")
2484            .replace('"', "&quot;")
2485            .replace('\'', "&#39;")
2486    }
2487
2488    async fn send_text_chunks(
2489        &self,
2490        message: &str,
2491        chat_id: &str,
2492        thread_id: Option<&str>,
2493    ) -> anyhow::Result<()> {
2494        let chunks = split_message_for_telegram(message);
2495
2496        for (index, chunk) in chunks.iter().enumerate() {
2497            let text = format_telegram_text_chunk(chunk, index, chunks.len());
2498
2499            let mut markdown_body = serde_json::json!({
2500                "chat_id": chat_id,
2501                "text": Self::markdown_to_telegram_html(&text),
2502                "parse_mode": "HTML"
2503            });
2504
2505            // Add message_thread_id for forum topic support
2506            if let Some(tid) = thread_id {
2507                markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
2508            }
2509
2510            let markdown_resp = self
2511                .http_client()
2512                .post(self.api_url("sendMessage"))
2513                .json(&markdown_body)
2514                .send()
2515                .await?;
2516
2517            if markdown_resp.status().is_success() {
2518                if index < chunks.len() - 1 {
2519                    tokio::time::sleep(Duration::from_millis(100)).await;
2520                }
2521                continue;
2522            }
2523
2524            let markdown_status = markdown_resp.status();
2525            let markdown_err = markdown_resp.text().await.unwrap_or_default();
2526            ::zeroclaw_log::record!(
2527                WARN,
2528                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2529                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2530                    .with_attrs(::serde_json::json!({"status": markdown_status.to_string()})),
2531                "Telegram sendMessage with Markdown failed; retrying without parse_mode"
2532            );
2533
2534            let mut plain_body = serde_json::json!({
2535                "chat_id": chat_id,
2536                "text": text,
2537            });
2538
2539            // Add message_thread_id for forum topic support
2540            if let Some(tid) = thread_id {
2541                plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
2542            }
2543            let plain_resp = self
2544                .http_client()
2545                .post(self.api_url("sendMessage"))
2546                .json(&plain_body)
2547                .send()
2548                .await?;
2549
2550            if !plain_resp.status().is_success() {
2551                let plain_status = plain_resp.status();
2552                let plain_err = plain_resp.text().await.unwrap_or_default();
2553                anyhow::bail!(
2554                    "Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
2555                    markdown_status,
2556                    markdown_err,
2557                    plain_status,
2558                    plain_err
2559                );
2560            }
2561
2562            if index < chunks.len() - 1 {
2563                tokio::time::sleep(Duration::from_millis(100)).await;
2564            }
2565        }
2566
2567        Ok(())
2568    }
2569
2570    async fn send_media_by_url(
2571        &self,
2572        method: &str,
2573        media_field: &str,
2574        chat_id: &str,
2575        thread_id: Option<&str>,
2576        url: &str,
2577        caption: Option<&str>,
2578    ) -> anyhow::Result<()> {
2579        let mut body = serde_json::json!({
2580            "chat_id": chat_id,
2581        });
2582        body[media_field] = serde_json::Value::String(url.to_string());
2583
2584        if let Some(tid) = thread_id {
2585            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
2586        }
2587
2588        if let Some(cap) = caption {
2589            body["caption"] = serde_json::Value::String(cap.to_string());
2590        }
2591
2592        let resp = self
2593            .http_client()
2594            .post(self.api_url(method))
2595            .json(&body)
2596            .send()
2597            .await?;
2598
2599        if !resp.status().is_success() {
2600            let err = resp.text().await?;
2601            anyhow::bail!("{method} by URL failed: {err}");
2602        }
2603
2604        ::zeroclaw_log::record!(
2605            INFO,
2606            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(
2607                ::serde_json::json!({"method": method, "chat_id": chat_id, "url": url})
2608            ),
2609            "sent to"
2610        );
2611        Ok(())
2612    }
2613
2614    async fn send_attachment(
2615        &self,
2616        chat_id: &str,
2617        thread_id: Option<&str>,
2618        attachment: &TelegramAttachment,
2619    ) -> anyhow::Result<()> {
2620        let target = attachment.target.trim();
2621
2622        if is_http_url(target) {
2623            let result = match attachment.kind {
2624                TelegramAttachmentKind::Image => {
2625                    self.send_photo_by_url(chat_id, thread_id, target, None)
2626                        .await
2627                }
2628                TelegramAttachmentKind::Document => {
2629                    self.send_document_by_url(chat_id, thread_id, target, None)
2630                        .await
2631                }
2632                TelegramAttachmentKind::Video => {
2633                    self.send_video_by_url(chat_id, thread_id, target, None)
2634                        .await
2635                }
2636                TelegramAttachmentKind::Audio => {
2637                    self.send_audio_by_url(chat_id, thread_id, target, None)
2638                        .await
2639                }
2640                TelegramAttachmentKind::Voice => {
2641                    self.send_voice_by_url(chat_id, thread_id, target, None)
2642                        .await
2643                }
2644            };
2645
2646            // If sending media by URL failed (e.g. Telegram can't fetch the URL,
2647            // wrong content type, etc.), fall back to sending the URL as a text link
2648            // instead of losing the reply entirely.
2649            if let Err(e) = result {
2650                ::zeroclaw_log::record!(
2651                    WARN,
2652                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2653                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
2654                        .with_attrs(
2655                            ::serde_json::json!({"url": target, "error": format!("{}", e)})
2656                        ),
2657                    "Telegram send media by URL failed; falling back to text link"
2658                );
2659                let kind_label = match attachment.kind {
2660                    TelegramAttachmentKind::Image => "Image",
2661                    TelegramAttachmentKind::Document => "Document",
2662                    TelegramAttachmentKind::Video => "Video",
2663                    TelegramAttachmentKind::Audio => "Audio",
2664                    TelegramAttachmentKind::Voice => "Voice",
2665                };
2666                let fallback_text = format!("{kind_label}: {target}");
2667                self.send_text_chunks(&fallback_text, chat_id, thread_id)
2668                    .await?;
2669            }
2670
2671            return Ok(());
2672        }
2673
2674        // Remap Docker container workspace path (/workspace/...) to the host
2675        // workspace directory so files written by the containerised runtime
2676        // can be found and sent by the host-side Telegram sender.
2677        let remapped;
2678        let target = if let Some(rel) = target.strip_prefix("/workspace/") {
2679            if let Some(ws) = &self.workspace_dir {
2680                remapped = ws.join(rel);
2681                remapped.to_str().unwrap_or(target)
2682            } else {
2683                target
2684            }
2685        } else {
2686            target
2687        };
2688
2689        let path = Path::new(target);
2690        if !path.exists() {
2691            anyhow::bail!("Telegram attachment path not found: {target}");
2692        }
2693
2694        match attachment.kind {
2695            TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await,
2696            TelegramAttachmentKind::Document => {
2697                self.send_document(chat_id, thread_id, path, None).await
2698            }
2699            TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await,
2700            TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await,
2701            TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await,
2702        }
2703    }
2704
2705    /// Send a document/file to a Telegram chat
2706    pub async fn send_document(
2707        &self,
2708        chat_id: &str,
2709        thread_id: Option<&str>,
2710        file_path: &Path,
2711        caption: Option<&str>,
2712    ) -> anyhow::Result<()> {
2713        let file_name = file_path
2714            .file_name()
2715            .and_then(|n| n.to_str())
2716            .unwrap_or("file");
2717
2718        let file_bytes = tokio::fs::read(file_path).await?;
2719        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2720
2721        let mut form = Form::new()
2722            .text("chat_id", chat_id.to_string())
2723            .part("document", part);
2724
2725        if let Some(tid) = thread_id {
2726            form = form.text("message_thread_id", tid.to_string());
2727        }
2728
2729        if let Some(cap) = caption {
2730            form = form.text("caption", cap.to_string());
2731        }
2732
2733        let resp = self
2734            .http_client()
2735            .post(self.api_url("sendDocument"))
2736            .multipart(form)
2737            .send()
2738            .await?;
2739
2740        if !resp.status().is_success() {
2741            let err = resp.text().await?;
2742            anyhow::bail!("Telegram sendDocument failed: {err}");
2743        }
2744
2745        ::zeroclaw_log::record!(
2746            INFO,
2747            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2748                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
2749            "document sent to"
2750        );
2751        Ok(())
2752    }
2753
2754    /// Send a document from bytes (in-memory) to a Telegram chat
2755    pub async fn send_document_bytes(
2756        &self,
2757        chat_id: &str,
2758        thread_id: Option<&str>,
2759        file_bytes: Vec<u8>,
2760        file_name: &str,
2761        caption: Option<&str>,
2762    ) -> anyhow::Result<()> {
2763        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2764
2765        let mut form = Form::new()
2766            .text("chat_id", chat_id.to_string())
2767            .part("document", part);
2768
2769        if let Some(tid) = thread_id {
2770            form = form.text("message_thread_id", tid.to_string());
2771        }
2772
2773        if let Some(cap) = caption {
2774            form = form.text("caption", cap.to_string());
2775        }
2776
2777        let resp = self
2778            .http_client()
2779            .post(self.api_url("sendDocument"))
2780            .multipart(form)
2781            .send()
2782            .await?;
2783
2784        if !resp.status().is_success() {
2785            let err = resp.text().await?;
2786            anyhow::bail!("Telegram sendDocument failed: {err}");
2787        }
2788
2789        ::zeroclaw_log::record!(
2790            INFO,
2791            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2792                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
2793            "document sent to"
2794        );
2795        Ok(())
2796    }
2797
2798    /// Send a photo to a Telegram chat
2799    pub async fn send_photo(
2800        &self,
2801        chat_id: &str,
2802        thread_id: Option<&str>,
2803        file_path: &Path,
2804        caption: Option<&str>,
2805    ) -> anyhow::Result<()> {
2806        let file_name = file_path
2807            .file_name()
2808            .and_then(|n| n.to_str())
2809            .unwrap_or("photo.jpg");
2810
2811        let file_bytes = tokio::fs::read(file_path).await?;
2812        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2813
2814        let mut form = Form::new()
2815            .text("chat_id", chat_id.to_string())
2816            .part("photo", part);
2817
2818        if let Some(tid) = thread_id {
2819            form = form.text("message_thread_id", tid.to_string());
2820        }
2821
2822        if let Some(cap) = caption {
2823            form = form.text("caption", cap.to_string());
2824        }
2825
2826        let resp = self
2827            .http_client()
2828            .post(self.api_url("sendPhoto"))
2829            .multipart(form)
2830            .send()
2831            .await?;
2832
2833        if !resp.status().is_success() {
2834            let err = resp.text().await?;
2835            anyhow::bail!("Telegram sendPhoto failed: {err}");
2836        }
2837
2838        ::zeroclaw_log::record!(
2839            INFO,
2840            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2841                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
2842            "photo sent to"
2843        );
2844        Ok(())
2845    }
2846
2847    /// Send a photo from bytes (in-memory) to a Telegram chat
2848    pub async fn send_photo_bytes(
2849        &self,
2850        chat_id: &str,
2851        thread_id: Option<&str>,
2852        file_bytes: Vec<u8>,
2853        file_name: &str,
2854        caption: Option<&str>,
2855    ) -> anyhow::Result<()> {
2856        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2857
2858        let mut form = Form::new()
2859            .text("chat_id", chat_id.to_string())
2860            .part("photo", part);
2861
2862        if let Some(tid) = thread_id {
2863            form = form.text("message_thread_id", tid.to_string());
2864        }
2865
2866        if let Some(cap) = caption {
2867            form = form.text("caption", cap.to_string());
2868        }
2869
2870        let resp = self
2871            .http_client()
2872            .post(self.api_url("sendPhoto"))
2873            .multipart(form)
2874            .send()
2875            .await?;
2876
2877        if !resp.status().is_success() {
2878            let err = resp.text().await?;
2879            anyhow::bail!("Telegram sendPhoto failed: {err}");
2880        }
2881
2882        ::zeroclaw_log::record!(
2883            INFO,
2884            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2885                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
2886            "photo sent to"
2887        );
2888        Ok(())
2889    }
2890
2891    /// Send a video to a Telegram chat
2892    pub async fn send_video(
2893        &self,
2894        chat_id: &str,
2895        thread_id: Option<&str>,
2896        file_path: &Path,
2897        caption: Option<&str>,
2898    ) -> anyhow::Result<()> {
2899        let file_name = file_path
2900            .file_name()
2901            .and_then(|n| n.to_str())
2902            .unwrap_or("video.mp4");
2903
2904        let file_bytes = tokio::fs::read(file_path).await?;
2905        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2906
2907        let mut form = Form::new()
2908            .text("chat_id", chat_id.to_string())
2909            .part("video", part);
2910
2911        if let Some(tid) = thread_id {
2912            form = form.text("message_thread_id", tid.to_string());
2913        }
2914
2915        if let Some(cap) = caption {
2916            form = form.text("caption", cap.to_string());
2917        }
2918
2919        let resp = self
2920            .http_client()
2921            .post(self.api_url("sendVideo"))
2922            .multipart(form)
2923            .send()
2924            .await?;
2925
2926        if !resp.status().is_success() {
2927            let err = resp.text().await?;
2928            anyhow::bail!("Telegram sendVideo failed: {err}");
2929        }
2930
2931        ::zeroclaw_log::record!(
2932            INFO,
2933            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2934                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
2935            "video sent to"
2936        );
2937        Ok(())
2938    }
2939
2940    /// Send an audio file to a Telegram chat
2941    pub async fn send_audio(
2942        &self,
2943        chat_id: &str,
2944        thread_id: Option<&str>,
2945        file_path: &Path,
2946        caption: Option<&str>,
2947    ) -> anyhow::Result<()> {
2948        let file_name = file_path
2949            .file_name()
2950            .and_then(|n| n.to_str())
2951            .unwrap_or("audio.mp3");
2952
2953        let file_bytes = tokio::fs::read(file_path).await?;
2954        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2955
2956        let mut form = Form::new()
2957            .text("chat_id", chat_id.to_string())
2958            .part("audio", part);
2959
2960        if let Some(tid) = thread_id {
2961            form = form.text("message_thread_id", tid.to_string());
2962        }
2963
2964        if let Some(cap) = caption {
2965            form = form.text("caption", cap.to_string());
2966        }
2967
2968        let resp = self
2969            .http_client()
2970            .post(self.api_url("sendAudio"))
2971            .multipart(form)
2972            .send()
2973            .await?;
2974
2975        if !resp.status().is_success() {
2976            let err = resp.text().await?;
2977            anyhow::bail!("Telegram sendAudio failed: {err}");
2978        }
2979
2980        ::zeroclaw_log::record!(
2981            INFO,
2982            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
2983                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
2984            "audio sent to"
2985        );
2986        Ok(())
2987    }
2988
2989    /// Send a voice message to a Telegram chat
2990    pub async fn send_voice(
2991        &self,
2992        chat_id: &str,
2993        thread_id: Option<&str>,
2994        file_path: &Path,
2995        caption: Option<&str>,
2996    ) -> anyhow::Result<()> {
2997        let file_name = file_path
2998            .file_name()
2999            .and_then(|n| n.to_str())
3000            .unwrap_or("voice.ogg");
3001
3002        let file_bytes = tokio::fs::read(file_path).await?;
3003        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
3004
3005        let mut form = Form::new()
3006            .text("chat_id", chat_id.to_string())
3007            .part("voice", part);
3008
3009        if let Some(tid) = thread_id {
3010            form = form.text("message_thread_id", tid.to_string());
3011        }
3012
3013        if let Some(cap) = caption {
3014            form = form.text("caption", cap.to_string());
3015        }
3016
3017        let resp = self
3018            .http_client()
3019            .post(self.api_url("sendVoice"))
3020            .multipart(form)
3021            .send()
3022            .await?;
3023
3024        if !resp.status().is_success() {
3025            let err = resp.text().await?;
3026            anyhow::bail!("Telegram sendVoice failed: {err}");
3027        }
3028
3029        ::zeroclaw_log::record!(
3030            INFO,
3031            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3032                .with_attrs(::serde_json::json!({"chat_id": chat_id, "file_name": file_name})),
3033            "voice sent to"
3034        );
3035        Ok(())
3036    }
3037
3038    /// Send a file by URL (Telegram will download it)
3039    pub async fn send_document_by_url(
3040        &self,
3041        chat_id: &str,
3042        thread_id: Option<&str>,
3043        url: &str,
3044        caption: Option<&str>,
3045    ) -> anyhow::Result<()> {
3046        let mut body = serde_json::json!({
3047            "chat_id": chat_id,
3048            "document": url
3049        });
3050
3051        if let Some(tid) = thread_id {
3052            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
3053        }
3054
3055        if let Some(cap) = caption {
3056            body["caption"] = serde_json::Value::String(cap.to_string());
3057        }
3058
3059        let resp = self
3060            .http_client()
3061            .post(self.api_url("sendDocument"))
3062            .json(&body)
3063            .send()
3064            .await?;
3065
3066        if !resp.status().is_success() {
3067            let err = resp.text().await?;
3068            anyhow::bail!("Telegram sendDocument by URL failed: {err}");
3069        }
3070
3071        ::zeroclaw_log::record!(
3072            INFO,
3073            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3074                .with_attrs(::serde_json::json!({"chat_id": chat_id, "url": url})),
3075            "document (URL) sent to"
3076        );
3077        Ok(())
3078    }
3079
3080    /// Send a photo by URL (Telegram will download it)
3081    pub async fn send_photo_by_url(
3082        &self,
3083        chat_id: &str,
3084        thread_id: Option<&str>,
3085        url: &str,
3086        caption: Option<&str>,
3087    ) -> anyhow::Result<()> {
3088        let mut body = serde_json::json!({
3089            "chat_id": chat_id,
3090            "photo": url
3091        });
3092
3093        if let Some(tid) = thread_id {
3094            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
3095        }
3096
3097        if let Some(cap) = caption {
3098            body["caption"] = serde_json::Value::String(cap.to_string());
3099        }
3100
3101        let resp = self
3102            .http_client()
3103            .post(self.api_url("sendPhoto"))
3104            .json(&body)
3105            .send()
3106            .await?;
3107
3108        if !resp.status().is_success() {
3109            let err = resp.text().await?;
3110            anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
3111        }
3112
3113        ::zeroclaw_log::record!(
3114            INFO,
3115            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3116                .with_attrs(::serde_json::json!({"chat_id": chat_id, "url": url})),
3117            "photo (URL) sent to"
3118        );
3119        Ok(())
3120    }
3121
3122    /// Send a video by URL (Telegram will download it)
3123    pub async fn send_video_by_url(
3124        &self,
3125        chat_id: &str,
3126        thread_id: Option<&str>,
3127        url: &str,
3128        caption: Option<&str>,
3129    ) -> anyhow::Result<()> {
3130        self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption)
3131            .await
3132    }
3133
3134    /// Send an audio file by URL (Telegram will download it)
3135    pub async fn send_audio_by_url(
3136        &self,
3137        chat_id: &str,
3138        thread_id: Option<&str>,
3139        url: &str,
3140        caption: Option<&str>,
3141    ) -> anyhow::Result<()> {
3142        self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption)
3143            .await
3144    }
3145
3146    /// Send a voice message by URL (Telegram will download it)
3147    pub async fn send_voice_by_url(
3148        &self,
3149        chat_id: &str,
3150        thread_id: Option<&str>,
3151        url: &str,
3152        caption: Option<&str>,
3153    ) -> anyhow::Result<()> {
3154        self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption)
3155            .await
3156    }
3157}
3158
3159impl ::zeroclaw_api::attribution::Attributable for TelegramChannel {
3160    fn role(&self) -> ::zeroclaw_api::attribution::Role {
3161        ::zeroclaw_api::attribution::Role::Channel(
3162            ::zeroclaw_api::attribution::ChannelKind::Telegram,
3163        )
3164    }
3165    fn alias(&self) -> &str {
3166        &self.alias
3167    }
3168}
3169
3170#[async_trait]
3171impl Channel for TelegramChannel {
3172    fn name(&self) -> &str {
3173        "telegram"
3174    }
3175
3176    /// Telegram's `getMe` username, populated lazily by
3177    /// `fetch_bot_username` and cached in `bot_username`. Returning
3178    /// the cache here lets the SDK self-loop guard
3179    /// (`Channel::drop_self_messages`) drop the bot's own messages
3180    /// once the cache is hot. Before the first `getMe` resolves, the
3181    /// cache is `None` and the guard falls through to the agent-loop
3182    /// fallback in the orchestrator.
3183    fn self_handle(&self) -> Option<String> {
3184        self.bot_username.lock().clone()
3185    }
3186
3187    /// Telegram users mention the bot as `@bot_username` in chat. The
3188    /// cached `bot_username` from `getMe` is already the bare form;
3189    /// prepend `@` to match what arrives in inbound message text.
3190    fn self_addressed_mention(&self) -> Option<String> {
3191        self.self_handle().map(|name| {
3192            let trimmed = name.trim_start_matches('@');
3193            format!("@{trimmed}")
3194        })
3195    }
3196
3197    fn supports_draft_updates(&self) -> bool {
3198        self.stream_mode != StreamMode::Off
3199    }
3200
3201    async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
3202        if self.stream_mode == StreamMode::Off {
3203            return Ok(None);
3204        }
3205
3206        let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient);
3207        let initial_text = if message.content.is_empty() {
3208            "...".to_string()
3209        } else {
3210            message.content.clone()
3211        };
3212
3213        let mut body = serde_json::json!({
3214            "chat_id": chat_id,
3215            "text": initial_text,
3216        });
3217        if let Some(tid) = thread_id {
3218            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
3219        }
3220
3221        let resp = self
3222            .client
3223            .post(self.api_url("sendMessage"))
3224            .json(&body)
3225            .send()
3226            .await?;
3227
3228        if !resp.status().is_success() {
3229            let err = resp.text().await.unwrap_or_default();
3230            anyhow::bail!("Telegram sendMessage (draft) failed: {err}");
3231        }
3232
3233        let resp_json: serde_json::Value = resp.json().await?;
3234        let message_id = resp_json
3235            .get("result")
3236            .and_then(|r| r.get("message_id"))
3237            .and_then(|id| id.as_i64())
3238            .map(|id| id.to_string());
3239
3240        self.last_draft_edit
3241            .lock()
3242            .insert(chat_id.to_string(), std::time::Instant::now());
3243
3244        Ok(message_id)
3245    }
3246
3247    async fn update_draft(
3248        &self,
3249        recipient: &str,
3250        message_id: &str,
3251        text: &str,
3252    ) -> anyhow::Result<()> {
3253        let (chat_id, _) = Self::parse_reply_target(recipient);
3254
3255        // Rate-limit edits per chat
3256        {
3257            let last_edits = self.last_draft_edit.lock();
3258            if let Some(last_time) = last_edits.get(&chat_id) {
3259                let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
3260                if elapsed < self.draft_update_interval_ms {
3261                    return Ok(());
3262                }
3263            }
3264        }
3265
3266        // Truncate to Telegram limit for mid-stream edits (UTF-8 safe)
3267        let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
3268            let mut end = 0;
3269            for (idx, ch) in text.char_indices() {
3270                let next = idx + ch.len_utf8();
3271                if next > TELEGRAM_MAX_MESSAGE_LENGTH {
3272                    break;
3273                }
3274                end = next;
3275            }
3276            &text[..end]
3277        } else {
3278            text
3279        };
3280
3281        let message_id_parsed = match message_id.parse::<i64>() {
3282            Ok(id) => id,
3283            Err(e) => {
3284                ::zeroclaw_log::record!(
3285                    WARN,
3286                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3287                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3288                        .with_attrs(
3289                            ::serde_json::json!({"error": format!("{}", e), "message_id": message_id})
3290                        ),
3291                    "Invalid Telegram message_id ''"
3292                );
3293                return Ok(());
3294            }
3295        };
3296
3297        let body = serde_json::json!({
3298            "chat_id": chat_id,
3299            "message_id": message_id_parsed,
3300            "text": display_text,
3301        });
3302
3303        let resp = self
3304            .client
3305            .post(self.api_url("editMessageText"))
3306            .json(&body)
3307            .send()
3308            .await?;
3309
3310        if resp.status().is_success() {
3311            self.last_draft_edit
3312                .lock()
3313                .insert(chat_id.clone(), std::time::Instant::now());
3314        } else {
3315            let status = resp.status();
3316            let err = resp.text().await.unwrap_or_default();
3317            ::zeroclaw_log::record!(DEBUG, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_attrs(::serde_json::json!({"error": format!("{}", err), "status": status.to_string()})), "editMessageText failed");
3318        }
3319
3320        Ok(())
3321    }
3322
3323    async fn finalize_draft(
3324        &self,
3325        recipient: &str,
3326        message_id: &str,
3327        text: &str,
3328    ) -> anyhow::Result<()> {
3329        let text = &strip_tool_call_tags(text);
3330        let (chat_id, thread_id) = Self::parse_reply_target(recipient);
3331
3332        // Queue TTS voice reply — immediate mode since text is already final
3333        self.try_queue_voice_reply(recipient, text, true);
3334
3335        // Clean up rate-limit tracking for this chat
3336        self.last_draft_edit.lock().remove(&chat_id);
3337
3338        // Parse attachments before processing
3339        let (text_without_markers, attachments) = parse_attachment_markers(text);
3340
3341        // Parse message ID once for reuse
3342        let msg_id = match message_id.parse::<i64>() {
3343            Ok(id) => Some(id),
3344            Err(e) => {
3345                ::zeroclaw_log::record!(
3346                    WARN,
3347                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3348                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3349                        .with_attrs(
3350                            ::serde_json::json!({"error": format!("{}", e), "message_id": message_id})
3351                        ),
3352                    "Invalid Telegram message_id ''"
3353                );
3354                None
3355            }
3356        };
3357
3358        // If we have attachments, delete the draft and send fresh messages
3359        // (Telegram editMessageText can't add attachments)
3360        if !attachments.is_empty() {
3361            // Delete the draft message
3362            if let Some(id) = msg_id {
3363                let _ = self
3364                    .client
3365                    .post(self.api_url("deleteMessage"))
3366                    .json(&serde_json::json!({
3367                        "chat_id": chat_id,
3368                        "message_id": id,
3369                    }))
3370                    .send()
3371                    .await;
3372            }
3373
3374            // Send text without markers
3375            if !text_without_markers.is_empty() {
3376                self.send_text_chunks(&text_without_markers, &chat_id, thread_id.as_deref())
3377                    .await?;
3378            }
3379
3380            // Send attachments
3381            for attachment in &attachments {
3382                self.send_attachment(&chat_id, thread_id.as_deref(), attachment)
3383                    .await?;
3384            }
3385
3386            return Ok(());
3387        }
3388
3389        // If text exceeds limit, delete draft and send as chunked messages
3390        if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
3391            if let Some(id) = msg_id {
3392                let _ = self
3393                    .client
3394                    .post(self.api_url("deleteMessage"))
3395                    .json(&serde_json::json!({
3396                        "chat_id": chat_id,
3397                        "message_id": id,
3398                    }))
3399                    .send()
3400                    .await;
3401            }
3402
3403            // Fall back to chunked send
3404            return self
3405                .send_text_chunks(text, &chat_id, thread_id.as_deref())
3406                .await;
3407        }
3408
3409        let Some(id) = msg_id else {
3410            return self
3411                .send_text_chunks(text, &chat_id, thread_id.as_deref())
3412                .await;
3413        };
3414
3415        // Try editing with HTML formatting
3416        let body = serde_json::json!({
3417            "chat_id": chat_id,
3418            "message_id": id,
3419            "text": Self::markdown_to_telegram_html(text),
3420            "parse_mode": "HTML",
3421        });
3422
3423        let resp = self
3424            .client
3425            .post(self.api_url("editMessageText"))
3426            .json(&body)
3427            .send()
3428            .await?;
3429
3430        match Self::classify_edit_message_response(resp).await {
3431            EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
3432            EditMessageResult::Failed(status) => {
3433                ::zeroclaw_log::record!(
3434                    DEBUG,
3435                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3436                        .with_attrs(::serde_json::json!({"status": status.to_string()})),
3437                    "Telegram finalize_draft HTML edit failed; retrying without parse_mode"
3438                );
3439            }
3440        }
3441
3442        // HTML failed — retry without parse_mode
3443        let plain_body = serde_json::json!({
3444            "chat_id": chat_id,
3445            "message_id": id,
3446            "text": text,
3447        });
3448
3449        let resp = self
3450            .client
3451            .post(self.api_url("editMessageText"))
3452            .json(&plain_body)
3453            .send()
3454            .await?;
3455
3456        match Self::classify_edit_message_response(resp).await {
3457            EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
3458            EditMessageResult::Failed(status) => {
3459                ::zeroclaw_log::record!(
3460                    WARN,
3461                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3462                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3463                        .with_attrs(::serde_json::json!({"status": status.to_string()})),
3464                    "Telegram finalize_draft plain edit failed; attempting delete+send fallback"
3465                );
3466            }
3467        }
3468
3469        let delete_resp = self
3470            .client
3471            .post(self.api_url("deleteMessage"))
3472            .json(&serde_json::json!({
3473                "chat_id": chat_id,
3474                "message_id": id,
3475            }))
3476            .send()
3477            .await;
3478
3479        match delete_resp {
3480            Ok(resp) if resp.status().is_success() => {
3481                self.send_text_chunks(text, &chat_id, thread_id.as_deref())
3482                    .await
3483            }
3484            Ok(resp) => {
3485                ::zeroclaw_log::record!(
3486                    WARN,
3487                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3488                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3489                        .with_attrs(::serde_json::json!({"status": resp.status().to_string()})),
3490                    "Telegram finalize_draft delete failed; skipping sendMessage to avoid duplicate"
3491                );
3492                Ok(())
3493            }
3494            Err(err) => {
3495                ::zeroclaw_log::record!(
3496                    WARN,
3497                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3498                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3499                        .with_attrs(::serde_json::json!({"err": err.to_string()})),
3500                    "Telegram finalize_draft delete request failed: ; skipping sendMessage to avoid duplicate"
3501                );
3502                Ok(())
3503            }
3504        }
3505    }
3506
3507    async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
3508        let (chat_id, _) = Self::parse_reply_target(recipient);
3509        self.last_draft_edit.lock().remove(&chat_id);
3510
3511        let message_id = match message_id.parse::<i64>() {
3512            Ok(id) => id,
3513            Err(e) => {
3514                ::zeroclaw_log::record!(
3515                    DEBUG,
3516                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3517                        .with_attrs(
3518                            ::serde_json::json!({"error": format!("{}", e), "message_id": message_id})
3519                        ),
3520                    "Invalid Telegram draft message_id ''"
3521                );
3522                return Ok(());
3523            }
3524        };
3525
3526        let response = self
3527            .client
3528            .post(self.api_url("deleteMessage"))
3529            .json(&serde_json::json!({
3530                "chat_id": chat_id,
3531                "message_id": message_id,
3532            }))
3533            .send()
3534            .await?;
3535
3536        if !response.status().is_success() {
3537            let status = response.status();
3538            let body = response.text().await.unwrap_or_default();
3539            ::zeroclaw_log::record!(
3540                DEBUG,
3541                ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3542                    .with_attrs(::serde_json::json!({"status": status.to_string(), "body": body})),
3543                "deleteMessage failed"
3544            );
3545        }
3546
3547        Ok(())
3548    }
3549
3550    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
3551        // Strip tool_call tags before processing to prevent Markdown parsing failures
3552        let content = strip_tool_call_tags(&message.content);
3553
3554        // Parse recipient: "chat_id" or "chat_id:thread_id" format
3555        let (chat_id, thread_id) = match message.recipient.split_once(':') {
3556            Some((chat, thread)) => (chat, Some(thread)),
3557            None => (message.recipient.as_str(), None),
3558        };
3559
3560        // Voice chat mode: send text normally AND queue a voice note of the
3561        // final answer. Text in → text out. Voice in → text + voice out.
3562        self.try_queue_voice_reply(&message.recipient, &content, false);
3563
3564        // Always send text reply (voice chat gets both text and voice)
3565        let (text_without_markers, attachments) = parse_attachment_markers(&content);
3566
3567        if !attachments.is_empty() {
3568            if !text_without_markers.is_empty() {
3569                self.send_text_chunks(&text_without_markers, chat_id, thread_id)
3570                    .await?;
3571            }
3572
3573            for attachment in &attachments {
3574                self.send_attachment(chat_id, thread_id, attachment).await?;
3575            }
3576
3577            return Ok(());
3578        }
3579
3580        if let Some(attachment) = parse_path_only_attachment(&content) {
3581            self.send_attachment(chat_id, thread_id, &attachment)
3582                .await?;
3583            return Ok(());
3584        }
3585
3586        self.send_text_chunks(&content, chat_id, thread_id).await
3587    }
3588
3589    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
3590        let mut offset: i64 = 0;
3591
3592        if self.mention_only {
3593            let _ = self.get_bot_username().await;
3594        }
3595
3596        ::zeroclaw_log::record!(
3597            INFO,
3598            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
3599            "channel listening for messages..."
3600        );
3601
3602        // Startup probe: claim the getUpdates slot before entering the long-poll loop.
3603        // A previous daemon's 30-second poll may still be active on Telegram's server.
3604        // We retry with timeout=0 until we receive a successful (non-409) response,
3605        // confirming the slot is ours. This prevents the long-poll loop from entering
3606        // a self-sustaining 409 cycle where each rejected request is immediately retried.
3607        loop {
3608            let url = self.api_url("getUpdates");
3609            let probe = serde_json::json!({
3610                "offset": offset,
3611                "timeout": 0,
3612                "allowed_updates": ["message", "callback_query"]
3613            });
3614            match self.http_client().post(&url).json(&probe).send().await {
3615                Err(e) => {
3616                    ::zeroclaw_log::record!(
3617                        WARN,
3618                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3619                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3620                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3621                        "startup probe error; retrying in 5s"
3622                    );
3623                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3624                }
3625                Ok(resp) => {
3626                    match resp.json::<serde_json::Value>().await {
3627                        Err(e) => {
3628                            ::zeroclaw_log::record!(
3629                                WARN,
3630                                ::zeroclaw_log::Event::new(
3631                                    module_path!(),
3632                                    ::zeroclaw_log::Action::Note
3633                                )
3634                                .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3635                                .with_attrs(::serde_json::json!({"e": e.to_string()})),
3636                                "startup probe parse error: ; retrying in 5s"
3637                            );
3638                            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3639                        }
3640                        Ok(data) => {
3641                            let ok = data
3642                                .get("ok")
3643                                .and_then(serde_json::Value::as_bool)
3644                                .unwrap_or(false);
3645                            if ok {
3646                                // Slot claimed — advance offset past any queued updates.
3647                                if let Some(results) =
3648                                    data.get("result").and_then(serde_json::Value::as_array)
3649                                {
3650                                    for update in results {
3651                                        if let Some(uid) = update
3652                                            .get("update_id")
3653                                            .and_then(serde_json::Value::as_i64)
3654                                        {
3655                                            offset = uid + 1;
3656                                        }
3657                                    }
3658                                }
3659                                break; // Probe succeeded; enter the long-poll loop.
3660                            }
3661
3662                            let error_code = data
3663                                .get("error_code")
3664                                .and_then(serde_json::Value::as_i64)
3665                                .unwrap_or_default();
3666                            if error_code == 409 {
3667                                ::zeroclaw_log::record!(
3668                                    DEBUG,
3669                                    ::zeroclaw_log::Event::new(
3670                                        module_path!(),
3671                                        ::zeroclaw_log::Action::Note
3672                                    ),
3673                                    "Startup probe: slot busy (409), retrying in 5s"
3674                                );
3675                            } else {
3676                                let desc = data
3677                                    .get("description")
3678                                    .and_then(serde_json::Value::as_str)
3679                                    .unwrap_or("unknown");
3680                                ::zeroclaw_log::record!(WARN, ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note).with_outcome(::zeroclaw_log::EventOutcome::Unknown).with_attrs(::serde_json::json!({"error_code": error_code, "desc": desc})), "Startup probe: API error : ; retrying in 5s");
3681                            }
3682                            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3683                        }
3684                    }
3685                }
3686            }
3687        }
3688
3689        ::zeroclaw_log::record!(
3690            DEBUG,
3691            ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
3692            "Startup probe succeeded; entering main long-poll loop."
3693        );
3694
3695        self.register_bot_commands().await;
3696
3697        loop {
3698            if self.mention_only {
3699                let missing_username = self.bot_username.lock().is_none();
3700                if missing_username {
3701                    let _ = self.get_bot_username().await;
3702                }
3703            }
3704
3705            let url = self.api_url("getUpdates");
3706            let body = serde_json::json!({
3707                "offset": offset,
3708                "timeout": 30,
3709                "allowed_updates": ["message", "callback_query"]
3710            });
3711
3712            let resp = match self.http_client().post(&url).json(&body).send().await {
3713                Ok(r) => r,
3714                Err(e) => {
3715                    ::zeroclaw_log::record!(
3716                        WARN,
3717                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3718                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3719                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3720                        "poll error"
3721                    );
3722                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3723                    continue;
3724                }
3725            };
3726
3727            let data: serde_json::Value = match resp.json().await {
3728                Ok(d) => d,
3729                Err(e) => {
3730                    ::zeroclaw_log::record!(
3731                        WARN,
3732                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3733                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3734                            .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3735                        "parse error"
3736                    );
3737                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3738                    continue;
3739                }
3740            };
3741
3742            let ok = data
3743                .get("ok")
3744                .and_then(serde_json::Value::as_bool)
3745                .unwrap_or(true);
3746            if !ok {
3747                let error_code = data
3748                    .get("error_code")
3749                    .and_then(serde_json::Value::as_i64)
3750                    .unwrap_or_default();
3751                let description = data
3752                    .get("description")
3753                    .and_then(serde_json::Value::as_str)
3754                    .unwrap_or("unknown Telegram API error");
3755
3756                if error_code == 409 {
3757                    ::zeroclaw_log::record!(
3758                        WARN,
3759                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3760                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3761                            .with_attrs(::serde_json::json!({"description": description})),
3762                        "Telegram polling conflict (409): . \
3763Ensure only one `zeroclaw` process is using this bot token."
3764                    );
3765                    // Back off for 35 seconds — longer than Telegram's 30-second poll
3766                    // timeout — so any competing session (e.g. a stale connection from
3767                    // a previous daemon) has time to expire before we retry.
3768                    tokio::time::sleep(std::time::Duration::from_secs(35)).await;
3769                } else {
3770                    ::zeroclaw_log::record!(
3771                        WARN,
3772                        ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3773                            .with_outcome(::zeroclaw_log::EventOutcome::Unknown),
3774                        &format!(
3775                            "Telegram getUpdates API error (code={}): {description}",
3776                            error_code
3777                        )
3778                    );
3779                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3780                }
3781                continue;
3782            }
3783
3784            if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) {
3785                for update in results {
3786                    // Advance offset past this update
3787                    if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) {
3788                        offset = uid + 1;
3789                    }
3790
3791                    // ── Handle callback_query (inline keyboard taps) ──
3792                    if let Some(cb) = update.get("callback_query") {
3793                        let cb_id = cb
3794                            .get("id")
3795                            .and_then(serde_json::Value::as_str)
3796                            .unwrap_or_default();
3797                        let cb_data = cb
3798                            .get("data")
3799                            .and_then(serde_json::Value::as_str)
3800                            .unwrap_or_default();
3801
3802                        if let Some(rest) = cb_data.strip_prefix("approval:")
3803                            && let Some((approval_id, action)) = rest.rsplit_once(':')
3804                        {
3805                            let response = match action {
3806                                "approve" => {
3807                                    Some(zeroclaw_api::channel::ChannelApprovalResponse::Approve)
3808                                }
3809                                "always" => Some(
3810                                    zeroclaw_api::channel::ChannelApprovalResponse::AlwaysApprove,
3811                                ),
3812                                "deny" => {
3813                                    Some(zeroclaw_api::channel::ChannelApprovalResponse::Deny)
3814                                }
3815                                other => {
3816                                    ::zeroclaw_log::record!(
3817                                        WARN,
3818                                        ::zeroclaw_log::Event::new(
3819                                            module_path!(),
3820                                            ::zeroclaw_log::Action::Note
3821                                        )
3822                                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3823                                        .with_attrs(::serde_json::json!({"other": other})),
3824                                        "Unknown approval callback action"
3825                                    );
3826                                    None
3827                                }
3828                            };
3829
3830                            if let Some(resp) = response
3831                                && let Some(sender) =
3832                                    self.pending_approvals.lock().await.remove(approval_id)
3833                            {
3834                                let _ = sender.send(resp);
3835                            }
3836
3837                            // Answer the callback query to dismiss the spinner.
3838                            let answer_text = match action {
3839                                "approve" => "✅ Approved",
3840                                "always" => "✅✅ Always approved",
3841                                "deny" => "❌ Denied",
3842                                _ => "⚠️ Unknown action",
3843                            };
3844                            let answer_body = serde_json::json!({
3845                                "callback_query_id": cb_id,
3846                                "text": answer_text,
3847                            });
3848                            if let Err(e) = self
3849                                .http_client()
3850                                .post(self.api_url("answerCallbackQuery"))
3851                                .json(&answer_body)
3852                                .send()
3853                                .await
3854                            {
3855                                ::zeroclaw_log::record!(
3856                                    WARN,
3857                                    ::zeroclaw_log::Event::new(
3858                                        module_path!(),
3859                                        ::zeroclaw_log::Action::Note
3860                                    )
3861                                    .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
3862                                    .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3863                                    "answerCallbackQuery failed"
3864                                );
3865                            }
3866                        }
3867
3868                        continue; // callback_query is not a regular message
3869                    }
3870
3871                    let msg = if let Some(m) = self.parse_update_message(update) {
3872                        m
3873                    } else if let Some(m) = self.try_parse_voice_message(update).await {
3874                        m
3875                    } else if let Some(m) = self.try_parse_attachment_message(update).await {
3876                        m
3877                    } else {
3878                        Box::pin(self.handle_unauthorized_message(update)).await;
3879                        continue;
3880                    };
3881
3882                    if self.ack_reactions
3883                        && let Some((reaction_chat_id, reaction_message_id)) =
3884                            Self::extract_update_message_target(update)
3885                    {
3886                        self.try_add_ack_reaction_nonblocking(
3887                            reaction_chat_id,
3888                            reaction_message_id,
3889                        );
3890                    }
3891
3892                    // Send "typing" indicator immediately when we receive a message
3893                    let typing_body = serde_json::json!({
3894                        "chat_id": &msg.reply_target,
3895                        "action": "typing"
3896                    });
3897                    let _ = self
3898                        .http_client()
3899                        .post(self.api_url("sendChatAction"))
3900                        .json(&typing_body)
3901                        .send()
3902                        .await; // Ignore errors for typing indicator
3903
3904                    if tx.send(msg).await.is_err() {
3905                        return Ok(());
3906                    }
3907                }
3908            }
3909        }
3910    }
3911
3912    async fn health_check(&self) -> bool {
3913        let timeout_duration = Duration::from_secs(5);
3914
3915        match tokio::time::timeout(
3916            timeout_duration,
3917            self.http_client().get(self.api_url("getMe")).send(),
3918        )
3919        .await
3920        {
3921            Ok(Ok(resp)) => resp.status().is_success(),
3922            Ok(Err(e)) => {
3923                ::zeroclaw_log::record!(
3924                    DEBUG,
3925                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
3926                        .with_attrs(::serde_json::json!({"error": format!("{}", e)})),
3927                    "health check failed"
3928                );
3929                false
3930            }
3931            Err(_) => {
3932                ::zeroclaw_log::record!(
3933                    DEBUG,
3934                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note),
3935                    "health check timed out after 5s"
3936                );
3937                false
3938            }
3939        }
3940    }
3941
3942    async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
3943        self.stop_typing(recipient).await?;
3944
3945        let client = self.http_client();
3946        let url = self.api_url("sendChatAction");
3947        let chat_id = recipient.to_string();
3948
3949        let handle = zeroclaw_spawn::spawn!(async move {
3950            loop {
3951                let body = serde_json::json!({
3952                    "chat_id": &chat_id,
3953                    "action": "typing"
3954                });
3955                let _ = client.post(&url).json(&body).send().await;
3956                // Telegram typing indicator expires after 5s; refresh at 4s
3957                tokio::time::sleep(Duration::from_secs(4)).await;
3958            }
3959        });
3960
3961        let mut guard = self.typing_handle.lock();
3962        *guard = Some(handle);
3963
3964        Ok(())
3965    }
3966
3967    async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
3968        let mut guard = self.typing_handle.lock();
3969        if let Some(handle) = guard.take() {
3970            handle.abort();
3971        }
3972        Ok(())
3973    }
3974
3975    async fn request_approval(
3976        &self,
3977        recipient: &str,
3978        request: &zeroclaw_api::channel::ChannelApprovalRequest,
3979    ) -> anyhow::Result<Option<zeroclaw_api::channel::ChannelApprovalResponse>> {
3980        use zeroclaw_api::channel::ChannelApprovalResponse;
3981
3982        // Parse recipient for chat_id + optional thread_id ("chat_id:thread_id" format).
3983        let (chat_id, thread_id) = recipient
3984            .split_once(':')
3985            .map_or((recipient, None), |(c, t)| (c, Some(t)));
3986
3987        // Unique key embedded in callback_data so listen() can route the tap.
3988        let approval_id = uuid::Uuid::new_v4().to_string();
3989
3990        let tool = Self::escape_html(&request.tool_name);
3991        let args = Self::escape_html(&request.arguments_summary);
3992        let text = format!(
3993            "\u{1f527} <b>Tool approval required</b>\n\n\
3994             Tool: <code>{tool}</code>\n\
3995             {args}\n\n\
3996             Tap a button below:",
3997        );
3998
3999        let reply_markup = serde_json::json!({
4000            "inline_keyboard": [[
4001                { "text": "✅ Approve",  "callback_data": format!("approval:{}:approve", approval_id) },
4002                { "text": "❌ Deny",     "callback_data": format!("approval:{}:deny", approval_id) },
4003                { "text": "✅✅ Always", "callback_data": format!("approval:{}:always", approval_id) },
4004            ]]
4005        });
4006
4007        let mut body = serde_json::json!({
4008            "chat_id": chat_id,
4009            "text": text,
4010            "parse_mode": "HTML",
4011            "reply_markup": reply_markup,
4012        });
4013        if let Some(tid) = thread_id {
4014            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
4015        }
4016
4017        // Register the oneshot BEFORE sending the message to avoid a race
4018        // where the user taps the button before the sender is in the map.
4019        let (tx, rx) = tokio::sync::oneshot::channel();
4020        self.pending_approvals
4021            .lock()
4022            .await
4023            .insert(approval_id.clone(), tx);
4024
4025        let resp = self
4026            .http_client()
4027            .post(self.api_url("sendMessage"))
4028            .json(&body)
4029            .send()
4030            .await;
4031
4032        let send_ok = match resp {
4033            Ok(r) if r.status().is_success() => true,
4034            Ok(r) => {
4035                let status = r.status();
4036                let err = r.text().await.unwrap_or_default();
4037                ::zeroclaw_log::record!(
4038                    WARN,
4039                    ::zeroclaw_log::Event::new(module_path!(), ::zeroclaw_log::Action::Note)
4040                        .with_outcome(::zeroclaw_log::EventOutcome::Unknown)
4041                        .with_attrs(
4042                            ::serde_json::json!({"status": status.to_string(), "err": err})
4043                        ),
4044                    "Telegram sendMessage (approval) with HTML failed; retrying without parse_mode"
4045                );
4046
4047                // Fallback: plain text, no parse_mode, keep the buttons
4048                let plain_text = format!(
4049                    "🔧 Tool approval required\n\nTool: {}\n{}\n\nTap a button below:",
4050                    request.tool_name, request.arguments_summary
4051                );
4052                let mut plain_body = serde_json::json!({
4053                    "chat_id": chat_id,
4054                    "text": plain_text,
4055                    "reply_markup": reply_markup,
4056                });
4057                if let Some(tid) = thread_id {
4058                    plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
4059                }
4060
4061                let plain_resp = self
4062                    .http_client()
4063                    .post(self.api_url("sendMessage"))
4064                    .json(&plain_body)
4065                    .send()
4066                    .await;
4067
4068                match plain_resp {
4069                    Ok(r) if r.status().is_success() => true,
4070                    Ok(r) => {
4071                        let status = r.status();
4072                        let err = r.text().await.unwrap_or_default();
4073                        self.pending_approvals.lock().await.remove(&approval_id);
4074                        anyhow::bail!("Telegram sendMessage (approval) failed ({status}): {err}");
4075                    }
4076                    Err(e) => {
4077                        self.pending_approvals.lock().await.remove(&approval_id);
4078                        return Err(e.into());
4079                    }
4080                }
4081            }
4082            Err(e) => {
4083                self.pending_approvals.lock().await.remove(&approval_id);
4084                return Err(e.into());
4085            }
4086        };
4087
4088        if !send_ok {
4089            self.pending_approvals.lock().await.remove(&approval_id);
4090            anyhow::bail!("Telegram sendMessage (approval) failed after fallback");
4091        }
4092
4093        // Wait for the user to tap a button. Timeout is configurable via
4094        // `channels.telegram.approval_timeout_secs` (default 120s).
4095        let result =
4096            match tokio::time::timeout(Duration::from_secs(self.approval_timeout_secs), rx).await {
4097                Ok(Ok(response)) => Some(response),
4098                _ => {
4099                    // Timeout or sender dropped — clean up and deny.
4100                    self.pending_approvals.lock().await.remove(&approval_id);
4101                    Some(ChannelApprovalResponse::Deny)
4102                }
4103            };
4104
4105        Ok(result)
4106    }
4107}
4108
4109#[cfg(test)]
4110mod tests {
4111    use super::*;
4112
4113    #[test]
4114    fn with_voice_peer_prefs_seeds_static_voice_peers_for_matching_groups_only() {
4115        use zeroclaw_config::multi_agent::{OutputModality, PeerGroupConfig, PeerUsername};
4116
4117        let mut config = zeroclaw_config::schema::Config::default();
4118        // Voice group on this channel type — should be seeded.
4119        config.peer_groups.insert(
4120            "voicers".to_string(),
4121            PeerGroupConfig {
4122                channel: "telegram".to_string(),
4123                external_peers: vec![PeerUsername::new("@alice"), PeerUsername::new("@bob")],
4124                output_modality: OutputModality::Voice,
4125                ..Default::default()
4126            },
4127        );
4128        // Voice group on a different channel — must NOT leak into telegram.
4129        config.peer_groups.insert(
4130            "other".to_string(),
4131            PeerGroupConfig {
4132                channel: "signal".to_string(),
4133                external_peers: vec![PeerUsername::new("@carol")],
4134                output_modality: OutputModality::Voice,
4135                ..Default::default()
4136            },
4137        );
4138        // Mirror group on this channel — not a voice preference, skip.
4139        config.peer_groups.insert(
4140            "mirrorers".to_string(),
4141            PeerGroupConfig {
4142                channel: "telegram".to_string(),
4143                external_peers: vec![PeerUsername::new("@dave")],
4144                output_modality: OutputModality::Mirror,
4145                ..Default::default()
4146            },
4147        );
4148
4149        let ch = TelegramChannel::new(
4150            "fake-token".into(),
4151            "default",
4152            Arc::new(|| vec!["*".into()]),
4153            false,
4154        )
4155        .with_voice_peer_prefs(&config, "telegram", "default");
4156
4157        // Peers go into static_voice_peers, not into the session voice_chats set.
4158        let sp = ch.static_voice_peers.lock().unwrap();
4159        assert!(sp.contains("@alice"), "voice peer should be in static set");
4160        assert!(sp.contains("@bob"), "voice peer should be in static set");
4161        assert!(
4162            !sp.contains("@carol"),
4163            "peers on another channel must not be seeded"
4164        );
4165        assert!(
4166            !sp.contains("@dave"),
4167            "mirror-modality peers must not be seeded"
4168        );
4169        drop(sp);
4170
4171        let vc = ch.voice_chats.lock().unwrap();
4172        assert!(
4173            !vc.contains("@alice"),
4174            "static peers must not pollute the session voice_chats set"
4175        );
4176    }
4177
4178    #[test]
4179    fn static_voice_peers_survive_session_voice_chats_removal() {
4180        use zeroclaw_config::multi_agent::{OutputModality, PeerGroupConfig, PeerUsername};
4181
4182        let mut config = zeroclaw_config::schema::Config::default();
4183        config.peer_groups.insert(
4184            "voicers".to_string(),
4185            PeerGroupConfig {
4186                channel: "telegram".to_string(),
4187                external_peers: vec![PeerUsername::new("@alice")],
4188                output_modality: OutputModality::Voice,
4189                ..Default::default()
4190            },
4191        );
4192
4193        let ch = TelegramChannel::new(
4194            "fake-token".into(),
4195            "default",
4196            Arc::new(|| vec!["*".into()]),
4197            false,
4198        )
4199        .with_voice_peer_prefs(&config, "telegram", "default");
4200
4201        // Simulate a voice-send removing @alice from voice_chats (even though
4202        // she was never in it — this proves static peers are checked separately).
4203        ch.voice_chats.lock().unwrap().remove("@alice");
4204
4205        // is_voice_chat must still return true via static_voice_peers.
4206        assert!(
4207            ch.is_voice_chat("@alice"),
4208            "static voice peer must remain active after voice_chats removal"
4209        );
4210    }
4211
4212    #[test]
4213    fn telegram_channel_name() {
4214        let mention_only = false;
4215        let ch = TelegramChannel::new(
4216            "fake-token".into(),
4217            "telegram_test_alias",
4218            Arc::new(|| vec!["*".into()]),
4219            mention_only,
4220        );
4221        assert_eq!(ch.name(), "telegram");
4222    }
4223
4224    /// Regression for #6999 / #7000: the channel-internal voice path
4225    /// (`try_parse_voice_message` -> `manager.transcribe`) must dispatch to a
4226    /// configured STT backend. When exactly one provider is registered,
4227    /// `with_transcription` binds it as the resolved alias so `transcribe()`
4228    /// no longer fails with "Agent has no transcription_provider configured".
4229    #[tokio::test]
4230    async fn telegram_with_transcription_binds_sole_provider_alias() {
4231        // SAFETY: test-only, single-threaded test runner.
4232        unsafe { std::env::remove_var("GROQ_API_KEY") };
4233
4234        // Only the Groq key is set -> exactly one provider registers.
4235        let config = zeroclaw_config::schema::TranscriptionConfig {
4236            enabled: true,
4237            api_key: Some("test-groq-key".to_string()),
4238            ..zeroclaw_config::schema::TranscriptionConfig::default()
4239        };
4240
4241        let ch = TelegramChannel::new(
4242            "fake-token".into(),
4243            "telegram_test_alias",
4244            Arc::new(|| vec!["*".into()]),
4245            false,
4246        )
4247        .with_transcription(config);
4248
4249        let manager = ch
4250            .transcription_manager
4251            .as_ref()
4252            .expect("single configured provider must build a transcription manager");
4253
4254        // Alias is bound for the single-provider case. Stop before any network
4255        // call by using an unsupported audio format, which `validate_audio`
4256        // rejects first inside the provider's `transcribe`.
4257        let err = manager
4258            .transcribe(&[0u8; 16], "voice.aac")
4259            .await
4260            .expect_err("unsupported format must error before any network call");
4261        let msg = err.to_string();
4262        assert!(
4263            !msg.contains("no transcription_provider configured"),
4264            "alias must be bound for the single-provider case; got: {msg}"
4265        );
4266        assert!(
4267            msg.contains("Unsupported audio format"),
4268            "expected the bound provider to reach audio validation; got: {msg}"
4269        );
4270    }
4271
4272    #[test]
4273    fn random_telegram_ack_reaction_is_from_pool() {
4274        for _ in 0..128 {
4275            let emoji = random_telegram_ack_reaction();
4276            assert!(TELEGRAM_ACK_REACTIONS.contains(&emoji));
4277        }
4278    }
4279
4280    #[test]
4281    fn telegram_ack_reaction_request_shape() {
4282        let body = build_telegram_ack_reaction_request("-100200300", 42, "⚡️");
4283        assert_eq!(body["chat_id"], "-100200300");
4284        assert_eq!(body["message_id"], 42);
4285        assert_eq!(body["reaction"][0]["type"], "emoji");
4286        assert_eq!(body["reaction"][0]["emoji"], "⚡️");
4287    }
4288
4289    #[test]
4290    fn telegram_extract_update_message_target_parses_ids() {
4291        let update = serde_json::json!({
4292            "update_id": 1,
4293            "message": {
4294                "message_id": 99,
4295                "chat": { "id": -100_123_456 }
4296            }
4297        });
4298
4299        let target = TelegramChannel::extract_update_message_target(&update);
4300        assert_eq!(target, Some(("-100123456".to_string(), 99)));
4301    }
4302
4303    #[test]
4304    fn typing_handle_starts_as_none() {
4305        let mention_only = false;
4306        let ch = TelegramChannel::new(
4307            "fake-token".into(),
4308            "telegram_test_alias",
4309            Arc::new(|| vec!["*".into()]),
4310            mention_only,
4311        );
4312        let guard = ch.typing_handle.lock();
4313        assert!(guard.is_none());
4314    }
4315
4316    #[tokio::test]
4317    async fn stop_typing_clears_handle() {
4318        let mention_only = false;
4319        let ch = TelegramChannel::new(
4320            "fake-token".into(),
4321            "telegram_test_alias",
4322            Arc::new(|| vec!["*".into()]),
4323            mention_only,
4324        );
4325
4326        // Manually insert a dummy handle
4327        {
4328            let mut guard = ch.typing_handle.lock();
4329            *guard = Some(zeroclaw_spawn::spawn!(async {
4330                tokio::time::sleep(Duration::from_secs(60)).await;
4331            }));
4332        }
4333
4334        // stop_typing should abort and clear
4335        ch.stop_typing("123").await.unwrap();
4336
4337        let guard = ch.typing_handle.lock();
4338        assert!(guard.is_none());
4339    }
4340
4341    #[tokio::test]
4342    async fn start_typing_replaces_previous_handle() {
4343        let mention_only = false;
4344        let ch = TelegramChannel::new(
4345            "fake-token".into(),
4346            "telegram_test_alias",
4347            Arc::new(|| vec!["*".into()]),
4348            mention_only,
4349        );
4350
4351        // Insert a dummy handle first
4352        {
4353            let mut guard = ch.typing_handle.lock();
4354            *guard = Some(zeroclaw_spawn::spawn!(async {
4355                tokio::time::sleep(Duration::from_secs(60)).await;
4356            }));
4357        }
4358
4359        // start_typing should abort the old handle and set a new one
4360        let _ = ch.start_typing("123").await;
4361
4362        let guard = ch.typing_handle.lock();
4363        assert!(guard.is_some());
4364    }
4365
4366    #[test]
4367    fn supports_draft_updates_respects_stream_mode() {
4368        let mention_only = false;
4369        let off = TelegramChannel::new(
4370            "fake-token".into(),
4371            "telegram_test_alias",
4372            Arc::new(|| vec!["*".into()]),
4373            mention_only,
4374        );
4375        assert!(!off.supports_draft_updates());
4376
4377        let partial = TelegramChannel::new(
4378            "fake-token".into(),
4379            "telegram_test_alias",
4380            Arc::new(|| vec!["*".into()]),
4381            mention_only,
4382        )
4383        .with_streaming(StreamMode::Partial, 750);
4384        assert!(partial.supports_draft_updates());
4385        assert_eq!(partial.draft_update_interval_ms, 750);
4386    }
4387
4388    #[test]
4389    fn with_streaming_uses_default_for_zero_draft_update_interval() {
4390        let ch = TelegramChannel::new(
4391            "fake-token".into(),
4392            "telegram_test_alias",
4393            Arc::new(|| vec!["*".into()]),
4394            false,
4395        )
4396        .with_streaming(StreamMode::Partial, 0);
4397
4398        assert_eq!(
4399            ch.draft_update_interval_ms,
4400            TELEGRAM_DRAFT_UPDATE_INTERVAL_MS
4401        );
4402    }
4403
4404    #[tokio::test]
4405    async fn send_draft_returns_none_when_stream_mode_off() {
4406        let mention_only = false;
4407        let ch = TelegramChannel::new(
4408            "fake-token".into(),
4409            "telegram_test_alias",
4410            Arc::new(|| vec!["*".into()]),
4411            mention_only,
4412        );
4413        let id = ch
4414            .send_draft(&SendMessage::new("draft", "123"))
4415            .await
4416            .unwrap();
4417        assert!(id.is_none());
4418    }
4419
4420    #[tokio::test]
4421    async fn update_draft_rate_limit_short_circuits_network() {
4422        let mention_only = false;
4423        let ch = TelegramChannel::new(
4424            "fake-token".into(),
4425            "telegram_test_alias",
4426            Arc::new(|| vec!["*".into()]),
4427            mention_only,
4428        )
4429        .with_streaming(StreamMode::Partial, 60_000);
4430        ch.last_draft_edit
4431            .lock()
4432            .insert("123".to_string(), std::time::Instant::now());
4433
4434        let result = ch.update_draft("123", "42", "delta text").await;
4435        assert!(result.is_ok());
4436    }
4437
4438    #[tokio::test]
4439    async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() {
4440        let mention_only = false;
4441        let ch = TelegramChannel::new(
4442            "fake-token".into(),
4443            "telegram_test_alias",
4444            Arc::new(|| vec!["*".into()]),
4445            mention_only,
4446        )
4447        .with_streaming(StreamMode::Partial, 0);
4448        let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20);
4449
4450        // Invalid message_id returns early after building display_text.
4451        // This asserts truncation never panics on UTF-8 boundaries.
4452        let result = ch
4453            .update_draft("123", "not-a-number", &long_emoji_text)
4454            .await;
4455        assert!(result.is_ok());
4456    }
4457
4458    #[tokio::test]
4459    async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() {
4460        let mention_only = false;
4461        let ch = TelegramChannel::new(
4462            "fake-token".into(),
4463            "telegram_test_alias",
4464            Arc::new(|| vec!["*".into()]),
4465            mention_only,
4466        )
4467        .with_streaming(StreamMode::Partial, 0);
4468        let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64);
4469
4470        // For oversized text + invalid draft message_id, finalize_draft should
4471        // fall back to chunked send instead of returning early.
4472        let result = ch.finalize_draft("123", "not-a-number", &long_text).await;
4473        assert!(result.is_err());
4474    }
4475
4476    #[test]
4477    fn telegram_api_url() {
4478        let mention_only = false;
4479        let ch = TelegramChannel::new(
4480            "123:ABC".into(),
4481            "telegram_test_alias",
4482            Arc::new(Vec::new),
4483            mention_only,
4484        );
4485        assert_eq!(
4486            ch.api_url("getMe"),
4487            "https://api.telegram.org/bot123:ABC/getMe"
4488        );
4489    }
4490
4491    #[test]
4492    fn telegram_markdown_to_html_escapes_quotes_in_link_href() {
4493        let rendered = TelegramChannel::markdown_to_telegram_html(
4494            "[click](https://example.com?q=\"x\"&a='b')",
4495        );
4496        assert_eq!(
4497            rendered,
4498            "<a href=\"https://example.com?q=&quot;x&quot;&amp;a=&#39;b&#39;\">click</a>"
4499        );
4500    }
4501
4502    #[test]
4503    fn telegram_markdown_to_html_escapes_quotes_in_plain_text() {
4504        let rendered = TelegramChannel::markdown_to_telegram_html("say \"hi\" & <tag> 'ok'");
4505        assert_eq!(
4506            rendered,
4507            "say &quot;hi&quot; &amp; &lt;tag&gt; &#39;ok&#39;"
4508        );
4509    }
4510
4511    #[test]
4512    fn telegram_markdown_to_html_code_block_drops_language_attribute() {
4513        let rendered = TelegramChannel::markdown_to_telegram_html(
4514            "```rust\" onclick=\"alert(1)\nlet x = 1;\n```",
4515        );
4516        assert_eq!(rendered, "<pre><code>let x = 1;</code></pre>");
4517        assert!(!rendered.contains("language-"));
4518        assert!(!rendered.contains("onclick"));
4519    }
4520
4521    #[test]
4522    fn telegram_user_allowed_wildcard() {
4523        let mention_only = false;
4524        let ch = TelegramChannel::new(
4525            "t".into(),
4526            "telegram_test_alias",
4527            Arc::new(|| vec!["*".into()]),
4528            mention_only,
4529        );
4530        assert!(ch.is_user_allowed("anyone"));
4531    }
4532
4533    #[test]
4534    fn telegram_user_allowed_specific() {
4535        let mention_only = false;
4536        let ch = TelegramChannel::new(
4537            "t".into(),
4538            "telegram_test_alias",
4539            Arc::new(|| vec!["alice".into(), "bob".into()]),
4540            mention_only,
4541        );
4542        assert!(ch.is_user_allowed("alice"));
4543        assert!(!ch.is_user_allowed("eve"));
4544    }
4545
4546    #[test]
4547    fn telegram_user_allowed_with_at_prefix_in_config() {
4548        let mention_only = false;
4549        let ch = TelegramChannel::new(
4550            "t".into(),
4551            "telegram_test_alias",
4552            Arc::new(|| vec!["@alice".into()]),
4553            mention_only,
4554        );
4555        assert!(ch.is_user_allowed("alice"));
4556    }
4557
4558    #[test]
4559    fn telegram_user_denied_empty() {
4560        let mention_only = false;
4561        let ch = TelegramChannel::new(
4562            "t".into(),
4563            "telegram_test_alias",
4564            Arc::new(Vec::new),
4565            mention_only,
4566        );
4567        assert!(!ch.is_user_allowed("anyone"));
4568    }
4569
4570    #[test]
4571    fn telegram_user_exact_match_not_substring() {
4572        let mention_only = false;
4573        let ch = TelegramChannel::new(
4574            "t".into(),
4575            "telegram_test_alias",
4576            Arc::new(|| vec!["alice".into()]),
4577            mention_only,
4578        );
4579        assert!(!ch.is_user_allowed("alice_bot"));
4580        assert!(!ch.is_user_allowed("alic"));
4581        assert!(!ch.is_user_allowed("malice"));
4582    }
4583
4584    #[test]
4585    fn telegram_user_empty_string_denied() {
4586        let mention_only = false;
4587        let ch = TelegramChannel::new(
4588            "t".into(),
4589            "telegram_test_alias",
4590            Arc::new(|| vec!["alice".into()]),
4591            mention_only,
4592        );
4593        assert!(!ch.is_user_allowed(""));
4594    }
4595
4596    #[test]
4597    fn telegram_user_case_sensitive() {
4598        let mention_only = false;
4599        let ch = TelegramChannel::new(
4600            "t".into(),
4601            "telegram_test_alias",
4602            Arc::new(|| vec!["Alice".into()]),
4603            mention_only,
4604        );
4605        assert!(ch.is_user_allowed("Alice"));
4606        assert!(!ch.is_user_allowed("alice"));
4607        assert!(!ch.is_user_allowed("ALICE"));
4608    }
4609
4610    #[test]
4611    fn telegram_wildcard_with_specific_users() {
4612        let mention_only = false;
4613        let ch = TelegramChannel::new(
4614            "t".into(),
4615            "telegram_test_alias",
4616            Arc::new(|| vec!["alice".into(), "*".into()]),
4617            mention_only,
4618        );
4619        assert!(ch.is_user_allowed("alice"));
4620        assert!(ch.is_user_allowed("bob"));
4621        assert!(ch.is_user_allowed("anyone"));
4622    }
4623
4624    #[test]
4625    fn telegram_user_allowed_by_numeric_id_identity() {
4626        let mention_only = false;
4627        let ch = TelegramChannel::new(
4628            "t".into(),
4629            "telegram_test_alias",
4630            Arc::new(|| vec!["123456789".into()]),
4631            mention_only,
4632        );
4633        assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
4634    }
4635
4636    #[test]
4637    fn telegram_user_denied_when_none_of_identities_match() {
4638        let mention_only = false;
4639        let ch = TelegramChannel::new(
4640            "t".into(),
4641            "telegram_test_alias",
4642            Arc::new(|| vec!["alice".into(), "987654321".into()]),
4643            mention_only,
4644        );
4645        assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
4646    }
4647
4648    #[test]
4649    fn telegram_pairing_enabled_with_empty_allowlist() {
4650        let mention_only = false;
4651        let ch = TelegramChannel::new(
4652            "t".into(),
4653            "telegram_test_alias",
4654            Arc::new(Vec::new),
4655            mention_only,
4656        );
4657        assert!(ch.pairing_code_active());
4658    }
4659
4660    #[test]
4661    fn telegram_pairing_disabled_with_nonempty_allowlist() {
4662        let mention_only = false;
4663        let ch = TelegramChannel::new(
4664            "t".into(),
4665            "telegram_test_alias",
4666            Arc::new(|| vec!["alice".into()]),
4667            mention_only,
4668        );
4669        assert!(!ch.pairing_code_active());
4670    }
4671
4672    #[test]
4673    fn telegram_extract_bind_code_plain_command() {
4674        assert_eq!(
4675            TelegramChannel::extract_bind_code("/bind 123456"),
4676            Some("123456")
4677        );
4678    }
4679
4680    #[test]
4681    fn telegram_extract_bind_code_supports_bot_mention() {
4682        assert_eq!(
4683            TelegramChannel::extract_bind_code("/bind@zeroclaw_bot 654321"),
4684            Some("654321")
4685        );
4686    }
4687
4688    #[test]
4689    fn telegram_extract_bind_code_rejects_invalid_forms() {
4690        assert_eq!(TelegramChannel::extract_bind_code("/bind"), None);
4691        assert_eq!(TelegramChannel::extract_bind_code("/start"), None);
4692    }
4693
4694    #[test]
4695    fn parse_attachment_markers_extracts_multiple_types() {
4696        let message = "Here are files [IMAGE:/tmp/a.png] and [DOCUMENT:https://example.com/a.pdf]";
4697        let (cleaned, attachments) = parse_attachment_markers(message);
4698
4699        assert_eq!(cleaned, "Here are files  and");
4700        assert_eq!(attachments.len(), 2);
4701        assert_eq!(attachments[0].kind, TelegramAttachmentKind::Image);
4702        assert_eq!(attachments[0].target, "/tmp/a.png");
4703        assert_eq!(attachments[1].kind, TelegramAttachmentKind::Document);
4704        assert_eq!(attachments[1].target, "https://example.com/a.pdf");
4705    }
4706
4707    #[test]
4708    fn parse_attachment_markers_keeps_invalid_markers_in_text() {
4709        let message = "Report [UNKNOWN:/tmp/a.bin]";
4710        let (cleaned, attachments) = parse_attachment_markers(message);
4711
4712        assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
4713        assert!(attachments.is_empty());
4714    }
4715
4716    #[test]
4717    fn parse_path_only_attachment_detects_existing_file() {
4718        let dir = tempfile::tempdir().unwrap();
4719        let image_path = dir.path().join("snap.png");
4720        std::fs::write(&image_path, b"fake-png").unwrap();
4721
4722        let parsed = parse_path_only_attachment(image_path.to_string_lossy().as_ref())
4723            .expect("expected attachment");
4724
4725        assert_eq!(parsed.kind, TelegramAttachmentKind::Image);
4726        assert_eq!(parsed.target, image_path.to_string_lossy());
4727    }
4728
4729    #[test]
4730    fn parse_path_only_attachment_rejects_sentence_text() {
4731        assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none());
4732    }
4733
4734    #[test]
4735    fn infer_attachment_kind_from_target_detects_document_extension() {
4736        assert_eq!(
4737            infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"),
4738            Some(TelegramAttachmentKind::Document)
4739        );
4740    }
4741
4742    #[test]
4743    fn parse_update_message_uses_chat_id_as_reply_target() {
4744        let mention_only = false;
4745        let ch = TelegramChannel::new(
4746            "token".into(),
4747            "telegram_test_alias",
4748            Arc::new(|| vec!["*".into()]),
4749            mention_only,
4750        );
4751        let update = serde_json::json!({
4752            "update_id": 1,
4753            "message": {
4754                "message_id": 33,
4755                "text": "hello",
4756                "from": {
4757                    "id": 555,
4758                    "username": "alice"
4759                },
4760                "chat": {
4761                    "id": -100_200_300
4762                }
4763            }
4764        });
4765
4766        let msg = ch
4767            .parse_update_message(&update)
4768            .expect("message should parse");
4769
4770        assert_eq!(msg.sender, "alice");
4771        assert_eq!(msg.reply_target, "-100200300");
4772        assert_eq!(msg.content, "hello");
4773        assert_eq!(msg.id, "telegram_-100200300_33");
4774    }
4775
4776    #[test]
4777    fn parse_update_message_allows_numeric_id_without_username() {
4778        let mention_only = false;
4779        let ch = TelegramChannel::new(
4780            "token".into(),
4781            "telegram_test_alias",
4782            Arc::new(|| vec!["555".into()]),
4783            mention_only,
4784        );
4785        let update = serde_json::json!({
4786            "update_id": 2,
4787            "message": {
4788                "message_id": 9,
4789                "text": "ping",
4790                "from": {
4791                    "id": 555
4792                },
4793                "chat": {
4794                    "id": 12345
4795                }
4796            }
4797        });
4798
4799        let msg = ch
4800            .parse_update_message(&update)
4801            .expect("numeric allowlist should pass");
4802
4803        assert_eq!(msg.sender, "555");
4804        assert_eq!(msg.reply_target, "12345");
4805    }
4806
4807    #[test]
4808    fn parse_update_message_extracts_thread_id_for_forum_topic() {
4809        let mention_only = false;
4810        let ch = TelegramChannel::new(
4811            "token".into(),
4812            "telegram_test_alias",
4813            Arc::new(|| vec!["*".into()]),
4814            mention_only,
4815        );
4816        let update = serde_json::json!({
4817            "update_id": 3,
4818            "message": {
4819                "message_id": 42,
4820                "text": "hello from topic",
4821                "from": {
4822                    "id": 555,
4823                    "username": "alice"
4824                },
4825                "chat": {
4826                    "id": -100_200_300
4827                },
4828                "message_thread_id": 789
4829            }
4830        });
4831
4832        let msg = ch
4833            .parse_update_message(&update)
4834            .expect("message with thread_id should parse");
4835
4836        assert_eq!(msg.sender, "alice");
4837        assert_eq!(msg.reply_target, "-100200300:789");
4838        assert_eq!(msg.content, "hello from topic");
4839        assert_eq!(msg.id, "telegram_-100200300_42");
4840    }
4841
4842    // ── File sending API URL tests ──────────────────────────────────
4843
4844    #[test]
4845    fn telegram_api_url_send_document() {
4846        let mention_only = false;
4847        let ch = TelegramChannel::new(
4848            "123:ABC".into(),
4849            "telegram_test_alias",
4850            Arc::new(Vec::new),
4851            mention_only,
4852        );
4853        assert_eq!(
4854            ch.api_url("sendDocument"),
4855            "https://api.telegram.org/bot123:ABC/sendDocument"
4856        );
4857    }
4858
4859    #[test]
4860    fn telegram_api_url_send_photo() {
4861        let mention_only = false;
4862        let ch = TelegramChannel::new(
4863            "123:ABC".into(),
4864            "telegram_test_alias",
4865            Arc::new(Vec::new),
4866            mention_only,
4867        );
4868        assert_eq!(
4869            ch.api_url("sendPhoto"),
4870            "https://api.telegram.org/bot123:ABC/sendPhoto"
4871        );
4872    }
4873
4874    #[test]
4875    fn telegram_api_url_send_video() {
4876        let mention_only = false;
4877        let ch = TelegramChannel::new(
4878            "123:ABC".into(),
4879            "telegram_test_alias",
4880            Arc::new(Vec::new),
4881            mention_only,
4882        );
4883        assert_eq!(
4884            ch.api_url("sendVideo"),
4885            "https://api.telegram.org/bot123:ABC/sendVideo"
4886        );
4887    }
4888
4889    #[test]
4890    fn telegram_api_url_send_audio() {
4891        let mention_only = false;
4892        let ch = TelegramChannel::new(
4893            "123:ABC".into(),
4894            "telegram_test_alias",
4895            Arc::new(Vec::new),
4896            mention_only,
4897        );
4898        assert_eq!(
4899            ch.api_url("sendAudio"),
4900            "https://api.telegram.org/bot123:ABC/sendAudio"
4901        );
4902    }
4903
4904    #[test]
4905    fn telegram_api_url_send_voice() {
4906        let mention_only = false;
4907        let ch = TelegramChannel::new(
4908            "123:ABC".into(),
4909            "telegram_test_alias",
4910            Arc::new(Vec::new),
4911            mention_only,
4912        );
4913        assert_eq!(
4914            ch.api_url("sendVoice"),
4915            "https://api.telegram.org/bot123:ABC/sendVoice"
4916        );
4917    }
4918
4919    // ── File sending integration tests (with mock server) ──────────
4920
4921    #[tokio::test]
4922    async fn telegram_send_document_bytes_builds_correct_form() {
4923        // This test verifies the method doesn't panic and handles bytes correctly
4924        let mention_only = false;
4925        let ch = TelegramChannel::new(
4926            "fake-token".into(),
4927            "telegram_test_alias",
4928            Arc::new(|| vec!["*".into()]),
4929            mention_only,
4930        );
4931        let file_bytes = b"Hello, this is a test file content".to_vec();
4932
4933        // The actual API call will fail (no real server), but we verify the method exists
4934        // and handles the input correctly up to the network call
4935        let result = ch
4936            .send_document_bytes("123456", None, file_bytes, "test.txt", Some("Test caption"))
4937            .await;
4938
4939        // Should fail with network error, not a panic or type error
4940        assert!(result.is_err());
4941        let err = result.unwrap_err().to_string();
4942        // Error should be network-related, not a code bug
4943        assert!(
4944            err.contains("error") || err.contains("failed") || err.contains("connect"),
4945            "Expected network error, got: {err}"
4946        );
4947    }
4948
4949    #[tokio::test]
4950    async fn telegram_send_photo_bytes_builds_correct_form() {
4951        let mention_only = false;
4952        let ch = TelegramChannel::new(
4953            "fake-token".into(),
4954            "telegram_test_alias",
4955            Arc::new(|| vec!["*".into()]),
4956            mention_only,
4957        );
4958        // Minimal valid PNG header bytes
4959        let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
4960
4961        let result = ch
4962            .send_photo_bytes("123456", None, file_bytes, "test.png", None)
4963            .await;
4964
4965        assert!(result.is_err());
4966    }
4967
4968    #[tokio::test]
4969    async fn telegram_send_document_by_url_builds_correct_json() {
4970        let mention_only = false;
4971        let ch = TelegramChannel::new(
4972            "fake-token".into(),
4973            "telegram_test_alias",
4974            Arc::new(|| vec!["*".into()]),
4975            mention_only,
4976        );
4977
4978        let result = ch
4979            .send_document_by_url(
4980                "123456",
4981                None,
4982                "https://example.com/file.pdf",
4983                Some("PDF doc"),
4984            )
4985            .await;
4986
4987        assert!(result.is_err());
4988    }
4989
4990    #[tokio::test]
4991    async fn telegram_send_photo_by_url_builds_correct_json() {
4992        let mention_only = false;
4993        let ch = TelegramChannel::new(
4994            "fake-token".into(),
4995            "telegram_test_alias",
4996            Arc::new(|| vec!["*".into()]),
4997            mention_only,
4998        );
4999
5000        let result = ch
5001            .send_photo_by_url("123456", None, "https://example.com/image.jpg", None)
5002            .await;
5003
5004        assert!(result.is_err());
5005    }
5006
5007    // ── File path handling tests ────────────────────────────────────
5008
5009    #[tokio::test]
5010    async fn telegram_send_document_nonexistent_file() {
5011        let mention_only = false;
5012        let ch = TelegramChannel::new(
5013            "fake-token".into(),
5014            "telegram_test_alias",
5015            Arc::new(|| vec!["*".into()]),
5016            mention_only,
5017        );
5018        let path = Path::new("/nonexistent/path/to/file.txt");
5019
5020        let result = ch.send_document("123456", None, path, None).await;
5021
5022        assert!(result.is_err());
5023        let err = result.unwrap_err().to_string();
5024        // Should fail with file not found error
5025        assert!(
5026            err.contains("No such file") || err.contains("not found") || err.contains("os error"),
5027            "Expected file not found error, got: {err}"
5028        );
5029    }
5030
5031    #[tokio::test]
5032    async fn telegram_send_photo_nonexistent_file() {
5033        let mention_only = false;
5034        let ch = TelegramChannel::new(
5035            "fake-token".into(),
5036            "telegram_test_alias",
5037            Arc::new(|| vec!["*".into()]),
5038            mention_only,
5039        );
5040        let path = Path::new("/nonexistent/path/to/photo.jpg");
5041
5042        let result = ch.send_photo("123456", None, path, None).await;
5043
5044        assert!(result.is_err());
5045    }
5046
5047    #[tokio::test]
5048    async fn telegram_send_video_nonexistent_file() {
5049        let mention_only = false;
5050        let ch = TelegramChannel::new(
5051            "fake-token".into(),
5052            "telegram_test_alias",
5053            Arc::new(|| vec!["*".into()]),
5054            mention_only,
5055        );
5056        let path = Path::new("/nonexistent/path/to/video.mp4");
5057
5058        let result = ch.send_video("123456", None, path, None).await;
5059
5060        assert!(result.is_err());
5061    }
5062
5063    #[tokio::test]
5064    async fn telegram_send_audio_nonexistent_file() {
5065        let mention_only = false;
5066        let ch = TelegramChannel::new(
5067            "fake-token".into(),
5068            "telegram_test_alias",
5069            Arc::new(|| vec!["*".into()]),
5070            mention_only,
5071        );
5072        let path = Path::new("/nonexistent/path/to/audio.mp3");
5073
5074        let result = ch.send_audio("123456", None, path, None).await;
5075
5076        assert!(result.is_err());
5077    }
5078
5079    #[tokio::test]
5080    async fn telegram_send_voice_nonexistent_file() {
5081        let mention_only = false;
5082        let ch = TelegramChannel::new(
5083            "fake-token".into(),
5084            "telegram_test_alias",
5085            Arc::new(|| vec!["*".into()]),
5086            mention_only,
5087        );
5088        let path = Path::new("/nonexistent/path/to/voice.ogg");
5089
5090        let result = ch.send_voice("123456", None, path, None).await;
5091
5092        assert!(result.is_err());
5093    }
5094
5095    // ── Message splitting tests ─────────────────────────────────────
5096
5097    #[test]
5098    fn telegram_split_short_message() {
5099        let msg = "Hello, world!";
5100        let chunks = split_message_for_telegram(msg);
5101        assert_eq!(chunks.len(), 1);
5102        assert_eq!(chunks[0], msg);
5103    }
5104
5105    #[test]
5106    fn telegram_split_exact_limit() {
5107        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH);
5108        let chunks = split_message_for_telegram(&msg);
5109        assert_eq!(chunks.len(), 1);
5110        assert_eq!(chunks[0].len(), TELEGRAM_MAX_MESSAGE_LENGTH);
5111    }
5112
5113    #[test]
5114    fn telegram_split_over_limit() {
5115        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 100);
5116        let chunks = split_message_for_telegram(&msg);
5117        assert_eq!(chunks.len(), 2);
5118        assert!(chunks[0].len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
5119        assert!(chunks[1].len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
5120    }
5121
5122    #[test]
5123    fn telegram_split_counts_final_continued_marker_in_send_length() {
5124        let msg = "a".repeat(8142);
5125        let chunks = split_message_for_telegram(&msg);
5126        assert!(chunks.len() >= 2);
5127
5128        for (index, chunk) in chunks.iter().enumerate() {
5129            let text = format_telegram_text_chunk(chunk, index, chunks.len());
5130            assert!(
5131                text.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5132                "final sent chunk {index} must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
5133                text.chars().count()
5134            );
5135        }
5136
5137        let final_text =
5138            format_telegram_text_chunk(chunks.last().unwrap(), chunks.len() - 1, chunks.len());
5139        assert!(final_text.starts_with(TELEGRAM_CONTINUED_PREFIX));
5140    }
5141
5142    #[test]
5143    fn telegram_split_counts_middle_continuation_markers_in_send_length() {
5144        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH * 3);
5145        let chunks = split_message_for_telegram(&msg);
5146        assert!(chunks.len() >= 3);
5147
5148        for (index, chunk) in chunks.iter().enumerate() {
5149            let text = format_telegram_text_chunk(chunk, index, chunks.len());
5150            assert!(
5151                text.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5152                "sent chunk {index} must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
5153                text.chars().count()
5154            );
5155        }
5156
5157        let middle = format_telegram_text_chunk(&chunks[1], 1, chunks.len());
5158        assert!(middle.starts_with(TELEGRAM_CONTINUED_PREFIX));
5159        assert!(middle.ends_with(TELEGRAM_CONTINUES_SUFFIX));
5160    }
5161
5162    #[test]
5163    fn telegram_split_at_word_boundary() {
5164        let msg = format!(
5165            "{} more text here",
5166            "word ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5)
5167        );
5168        let chunks = split_message_for_telegram(&msg);
5169        assert!(chunks.len() >= 2);
5170        // First chunk should end with a complete word (space at the end)
5171        for chunk in &chunks[..chunks.len() - 1] {
5172            assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
5173        }
5174    }
5175
5176    #[test]
5177    fn telegram_split_at_newline() {
5178        let text_block = "Line of text\n".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 13 + 1);
5179        let chunks = split_message_for_telegram(&text_block);
5180        assert!(chunks.len() >= 2);
5181        for chunk in chunks {
5182            assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
5183        }
5184    }
5185
5186    #[test]
5187    fn telegram_split_preserves_content() {
5188        let msg = "test ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5 + 100);
5189        let chunks = split_message_for_telegram(&msg);
5190        let rejoined = chunks.join("");
5191        assert_eq!(rejoined, msg);
5192    }
5193
5194    #[test]
5195    fn telegram_split_empty_message() {
5196        let chunks = split_message_for_telegram("");
5197        assert_eq!(chunks.len(), 1);
5198        assert_eq!(chunks[0], "");
5199    }
5200
5201    #[test]
5202    fn telegram_split_very_long_message() {
5203        let msg = "x".repeat(TELEGRAM_MAX_MESSAGE_LENGTH * 3);
5204        let chunks = split_message_for_telegram(&msg);
5205        assert!(chunks.len() >= 3);
5206        for chunk in chunks {
5207            assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
5208        }
5209    }
5210
5211    // ── Caption handling tests ──────────────────────────────────────
5212
5213    #[tokio::test]
5214    async fn telegram_send_document_bytes_with_caption() {
5215        let mention_only = false;
5216        let ch = TelegramChannel::new(
5217            "fake-token".into(),
5218            "telegram_test_alias",
5219            Arc::new(|| vec!["*".into()]),
5220            mention_only,
5221        );
5222        let file_bytes = b"test content".to_vec();
5223
5224        // With caption
5225        let result = ch
5226            .send_document_bytes(
5227                "123456",
5228                None,
5229                file_bytes.clone(),
5230                "test.txt",
5231                Some("My caption"),
5232            )
5233            .await;
5234        assert!(result.is_err()); // Network error expected
5235
5236        // Without caption
5237        let result = ch
5238            .send_document_bytes("123456", None, file_bytes, "test.txt", None)
5239            .await;
5240        assert!(result.is_err()); // Network error expected
5241    }
5242
5243    #[tokio::test]
5244    async fn telegram_send_photo_bytes_with_caption() {
5245        let mention_only = false;
5246        let ch = TelegramChannel::new(
5247            "fake-token".into(),
5248            "telegram_test_alias",
5249            Arc::new(|| vec!["*".into()]),
5250            mention_only,
5251        );
5252        let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
5253
5254        // With caption
5255        let result = ch
5256            .send_photo_bytes(
5257                "123456",
5258                None,
5259                file_bytes.clone(),
5260                "test.png",
5261                Some("Photo caption"),
5262            )
5263            .await;
5264        assert!(result.is_err());
5265
5266        // Without caption
5267        let result = ch
5268            .send_photo_bytes("123456", None, file_bytes, "test.png", None)
5269            .await;
5270        assert!(result.is_err());
5271    }
5272
5273    // ── Empty/edge case tests ───────────────────────────────────────
5274
5275    #[tokio::test]
5276    async fn telegram_send_document_bytes_empty_file() {
5277        use wiremock::matchers::{method, path_regex};
5278        use wiremock::{Mock, MockServer, ResponseTemplate};
5279
5280        let mock_server = MockServer::start().await;
5281
5282        Mock::given(method("POST"))
5283            .and(path_regex(r"/bot[^/]+/sendDocument$"))
5284            .respond_with(ResponseTemplate::new(400).set_body_json(
5285                serde_json::json!({ "ok": false, "description": "empty document rejected" }),
5286            ))
5287            .expect(1)
5288            .mount(&mock_server)
5289            .await;
5290
5291        let mention_only = false;
5292        let ch = TelegramChannel::new(
5293            "fake-token".into(),
5294            "telegram_test_alias",
5295            Arc::new(|| vec!["*".into()]),
5296            mention_only,
5297        )
5298        .with_api_base(mock_server.uri());
5299        let file_bytes: Vec<u8> = vec![];
5300
5301        let result = ch
5302            .send_document_bytes("123456", None, file_bytes, "empty.txt", None)
5303            .await;
5304
5305        let err = result.expect_err("empty document send should fail");
5306        assert!(
5307            err.to_string().contains("empty document rejected"),
5308            "expected mocked Telegram error, got: {err}"
5309        );
5310    }
5311
5312    #[tokio::test]
5313    async fn telegram_send_document_bytes_empty_filename() {
5314        let mention_only = false;
5315        let ch = TelegramChannel::new(
5316            "fake-token".into(),
5317            "telegram_test_alias",
5318            Arc::new(|| vec!["*".into()]),
5319            mention_only,
5320        );
5321        let file_bytes = b"content".to_vec();
5322
5323        let result = ch
5324            .send_document_bytes("123456", None, file_bytes, "", None)
5325            .await;
5326
5327        // Should not panic
5328        assert!(result.is_err());
5329    }
5330
5331    #[tokio::test]
5332    async fn telegram_send_document_bytes_empty_chat_id() {
5333        let mention_only = false;
5334        let ch = TelegramChannel::new(
5335            "fake-token".into(),
5336            "telegram_test_alias",
5337            Arc::new(|| vec!["*".into()]),
5338            mention_only,
5339        );
5340        let file_bytes = b"content".to_vec();
5341
5342        let result = ch
5343            .send_document_bytes("", None, file_bytes, "test.txt", None)
5344            .await;
5345
5346        // Should not panic
5347        assert!(result.is_err());
5348    }
5349
5350    // ── Message ID edge cases ─────────────────────────────────────
5351
5352    #[test]
5353    fn telegram_message_id_format_includes_chat_and_message_id() {
5354        // Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
5355        let chat_id = "123456";
5356        let message_id = 789;
5357        let expected_id = format!("telegram_{chat_id}_{message_id}");
5358        assert_eq!(expected_id, "telegram_123456_789");
5359    }
5360
5361    #[test]
5362    fn telegram_message_id_is_deterministic() {
5363        // Same chat_id + same message_id = same ID (prevents duplicates after restart)
5364        let chat_id = "123456";
5365        let message_id = 789;
5366        let id1 = format!("telegram_{chat_id}_{message_id}");
5367        let id2 = format!("telegram_{chat_id}_{message_id}");
5368        assert_eq!(id1, id2);
5369    }
5370
5371    #[test]
5372    fn telegram_message_id_different_message_different_id() {
5373        // Different message IDs produce different IDs
5374        let chat_id = "123456";
5375        let id1 = format!("telegram_{chat_id}_789");
5376        let id2 = format!("telegram_{chat_id}_790");
5377        assert_ne!(id1, id2);
5378    }
5379
5380    #[test]
5381    fn telegram_message_id_different_chat_different_id() {
5382        // Different chats produce different IDs even with same message_id
5383        let message_id = 789;
5384        let id1 = format!("telegram_123456_{message_id}");
5385        let id2 = format!("telegram_789012_{message_id}");
5386        assert_ne!(id1, id2);
5387    }
5388
5389    #[test]
5390    fn telegram_message_id_no_uuid_randomness() {
5391        // Verify format doesn't contain random UUID components
5392        let chat_id = "123456";
5393        let message_id = 789;
5394        let id = format!("telegram_{chat_id}_{message_id}");
5395        assert!(!id.contains('-')); // No UUID dashes
5396        assert!(id.starts_with("telegram_"));
5397    }
5398
5399    #[test]
5400    fn telegram_message_id_handles_zero_message_id() {
5401        // Edge case: message_id can be 0 (fallback/missing case)
5402        let chat_id = "123456";
5403        let message_id = 0;
5404        let id = format!("telegram_{chat_id}_{message_id}");
5405        assert_eq!(id, "telegram_123456_0");
5406    }
5407
5408    // ── Tool call tag stripping tests ───────────────────────────────────
5409
5410    #[test]
5411    fn strip_tool_call_tags_removes_standard_tags() {
5412        let input =
5413            "Hello <tool>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool> world";
5414        let result = strip_tool_call_tags(input);
5415        assert_eq!(result, "Hello  world");
5416    }
5417
5418    #[test]
5419    fn strip_tool_call_tags_removes_alias_tags() {
5420        let input = "Hello <toolcall>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</toolcall> world";
5421        let result = strip_tool_call_tags(input);
5422        assert_eq!(result, "Hello  world");
5423    }
5424
5425    #[test]
5426    fn strip_tool_call_tags_removes_dash_tags() {
5427        let input = "Hello <tool-call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool-call> world";
5428        let result = strip_tool_call_tags(input);
5429        assert_eq!(result, "Hello  world");
5430    }
5431
5432    #[test]
5433    fn strip_tool_call_tags_removes_tool_call_tags() {
5434        let input = "Hello <tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call> world";
5435        let result = strip_tool_call_tags(input);
5436        assert_eq!(result, "Hello  world");
5437    }
5438
5439    #[test]
5440    fn strip_tool_call_tags_removes_invoke_tags() {
5441        let input = "Hello <invoke>{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}</invoke> world";
5442        let result = strip_tool_call_tags(input);
5443        assert_eq!(result, "Hello  world");
5444    }
5445
5446    #[test]
5447    fn strip_tool_call_tags_handles_multiple_tags() {
5448        let input = "Start <tool>a</tool> middle <tool>b</tool> end";
5449        let result = strip_tool_call_tags(input);
5450        assert_eq!(result, "Start  middle  end");
5451    }
5452
5453    #[test]
5454    fn strip_tool_call_tags_handles_mixed_tags() {
5455        let input = "A <tool>a</tool> B <toolcall>b</toolcall> C <tool-call>c</tool-call> D";
5456        let result = strip_tool_call_tags(input);
5457        assert_eq!(result, "A  B  C  D");
5458    }
5459
5460    #[test]
5461    fn strip_tool_call_tags_preserves_normal_text() {
5462        let input = "Hello world! This is a test.";
5463        let result = strip_tool_call_tags(input);
5464        assert_eq!(result, "Hello world! This is a test.");
5465    }
5466
5467    #[test]
5468    fn strip_tool_call_tags_handles_unclosed_tags() {
5469        let input = "Hello <tool>world";
5470        let result = strip_tool_call_tags(input);
5471        assert_eq!(result, "Hello <tool>world");
5472    }
5473
5474    #[test]
5475    fn strip_tool_call_tags_handles_unclosed_tool_call_with_json() {
5476        let input =
5477            "Status:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}";
5478        let result = strip_tool_call_tags(input);
5479        assert_eq!(result, "Status:");
5480    }
5481
5482    #[test]
5483    fn strip_tool_call_tags_handles_mismatched_close_tag() {
5484        let input =
5485            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}</arg_value>";
5486        let result = strip_tool_call_tags(input);
5487        assert_eq!(result, "");
5488    }
5489
5490    #[test]
5491    fn strip_tool_call_tags_cleans_extra_newlines() {
5492        let input = "Hello\n\n<tool>\ntest\n</tool>\n\n\nworld";
5493        let result = strip_tool_call_tags(input);
5494        assert_eq!(result, "Hello\n\nworld");
5495    }
5496
5497    #[test]
5498    fn strip_tool_call_tags_handles_empty_input() {
5499        let input = "";
5500        let result = strip_tool_call_tags(input);
5501        assert_eq!(result, "");
5502    }
5503
5504    #[test]
5505    fn strip_tool_call_tags_handles_only_tags() {
5506        let input = "<tool>{\"name\":\"test\"}</tool>";
5507        let result = strip_tool_call_tags(input);
5508        assert_eq!(result, "");
5509    }
5510
5511    #[test]
5512    fn telegram_contains_bot_mention_finds_mention() {
5513        assert!(TelegramChannel::contains_bot_mention(
5514            "Hello @mybot",
5515            "mybot"
5516        ));
5517        assert!(TelegramChannel::contains_bot_mention(
5518            "@mybot help",
5519            "mybot"
5520        ));
5521        assert!(TelegramChannel::contains_bot_mention(
5522            "Hey @mybot how are you?",
5523            "mybot"
5524        ));
5525        assert!(TelegramChannel::contains_bot_mention(
5526            "Hello @MyBot, can you help?",
5527            "mybot"
5528        ));
5529    }
5530
5531    #[test]
5532    fn telegram_contains_bot_mention_no_false_positives() {
5533        assert!(!TelegramChannel::contains_bot_mention(
5534            "Hello @otherbot",
5535            "mybot"
5536        ));
5537        assert!(!TelegramChannel::contains_bot_mention(
5538            "Hello mybot",
5539            "mybot"
5540        ));
5541        assert!(!TelegramChannel::contains_bot_mention(
5542            "Hello @mybot2",
5543            "mybot"
5544        ));
5545        assert!(!TelegramChannel::contains_bot_mention("", "mybot"));
5546    }
5547
5548    #[test]
5549    fn telegram_normalize_incoming_content_preserves_mention() {
5550        let result = TelegramChannel::normalize_incoming_content("@mybot hello", "mybot");
5551        assert_eq!(result, Some("@mybot hello".to_string()));
5552    }
5553
5554    #[test]
5555    fn telegram_normalize_incoming_content_returns_none_for_empty() {
5556        let result = TelegramChannel::normalize_incoming_content("   ", "mybot");
5557        assert_eq!(result, None);
5558    }
5559
5560    #[test]
5561    fn parse_update_message_mention_only_group_requires_exact_mention() {
5562        let mention_only = true;
5563        let ch = TelegramChannel::new(
5564            "token".into(),
5565            "telegram_test_alias",
5566            Arc::new(|| vec!["*".into()]),
5567            mention_only,
5568        );
5569        {
5570            let mut cache = ch.bot_username.lock();
5571            *cache = Some("mybot".to_string());
5572        }
5573
5574        let update = serde_json::json!({
5575            "update_id": 10,
5576            "message": {
5577                "message_id": 44,
5578                "text": "hello @mybot2",
5579                "from": {
5580                    "id": 555,
5581                    "username": "alice"
5582                },
5583                "chat": {
5584                    "id": -100_200_300,
5585                    "type": "group"
5586                }
5587            }
5588        });
5589
5590        assert!(ch.parse_update_message(&update).is_none());
5591    }
5592
5593    #[test]
5594    fn parse_update_message_mention_only_group_preserves_mention_in_body() {
5595        let mention_only = true;
5596        let ch = TelegramChannel::new(
5597            "token".into(),
5598            "telegram_test_alias",
5599            Arc::new(|| vec!["*".into()]),
5600            mention_only,
5601        );
5602        {
5603            let mut cache = ch.bot_username.lock();
5604            *cache = Some("mybot".to_string());
5605        }
5606
5607        let update = serde_json::json!({
5608            "update_id": 11,
5609            "message": {
5610                "message_id": 45,
5611                "text": "Hi @MyBot status please",
5612                "from": {
5613                    "id": 555,
5614                    "username": "alice"
5615                },
5616                "chat": {
5617                    "id": -100_200_300,
5618                    "type": "group"
5619                }
5620            }
5621        });
5622
5623        let parsed = ch
5624            .parse_update_message(&update)
5625            .expect("mention should parse");
5626        assert_eq!(parsed.content, "Hi @MyBot status please");
5627
5628        let mention_only_update = serde_json::json!({
5629            "update_id": 12,
5630            "message": {
5631                "message_id": 46,
5632                "text": "@mybot",
5633                "from": {
5634                    "id": 555,
5635                    "username": "alice"
5636                },
5637                "chat": {
5638                    "id": -100_200_300,
5639                    "type": "group"
5640                }
5641            }
5642        });
5643
5644        let parsed = ch
5645            .parse_update_message(&mention_only_update)
5646            .expect("mention-only body admits");
5647        assert_eq!(parsed.content, "@mybot");
5648    }
5649
5650    #[test]
5651    fn telegram_is_group_message_detects_groups() {
5652        let group_msg = serde_json::json!({
5653            "chat": { "type": "group" }
5654        });
5655        assert!(TelegramChannel::is_group_message(&group_msg));
5656
5657        let supergroup_msg = serde_json::json!({
5658            "chat": { "type": "supergroup" }
5659        });
5660        assert!(TelegramChannel::is_group_message(&supergroup_msg));
5661
5662        let private_msg = serde_json::json!({
5663            "chat": { "type": "private" }
5664        });
5665        assert!(!TelegramChannel::is_group_message(&private_msg));
5666    }
5667
5668    #[test]
5669    fn telegram_mention_only_enabled_by_config() {
5670        let mention_only = true;
5671        let ch = TelegramChannel::new(
5672            "token".into(),
5673            "telegram_test_alias",
5674            Arc::new(|| vec!["*".into()]),
5675            mention_only,
5676        );
5677        assert!(ch.mention_only);
5678
5679        let disabled_mention_only = false;
5680        let ch_disabled = TelegramChannel::new(
5681            "token".into(),
5682            "telegram_test_alias",
5683            Arc::new(|| vec!["*".into()]),
5684            disabled_mention_only,
5685        );
5686        assert!(!ch_disabled.mention_only);
5687    }
5688
5689    fn group_message_with_caption(caption: Option<&str>) -> serde_json::Value {
5690        let mut msg = serde_json::json!({
5691            "message_id": 1,
5692            "from": { "id": 1, "username": "alice" },
5693            "chat": { "id": -1, "type": "group" }
5694        });
5695        if let Some(c) = caption {
5696            msg["caption"] = serde_json::Value::String(c.to_string());
5697        }
5698        msg
5699    }
5700
5701    /// Regression test for #6229 — when `mention_only = true` and a group
5702    /// photo/document arrives without any caption mentioning the bot, the
5703    /// gate must reject it. Before the fix, photo/document updates skipped
5704    /// the gate entirely (the gate only inspected `message.text`) and the
5705    /// bot replied to every photo posted in a group.
5706    #[test]
5707    fn check_media_mention_gate_rejects_group_media_without_mention() {
5708        let ch = TelegramChannel::new(
5709            "token".into(),
5710            "default",
5711            std::sync::Arc::new(|| vec!["*".into()]),
5712            true,
5713        );
5714        {
5715            let mut cache = ch.bot_username.lock();
5716            *cache = Some("mybot".to_string());
5717        }
5718        let no_caption = group_message_with_caption(None);
5719        assert!(
5720            ch.check_media_mention_gate(&no_caption, None).is_none(),
5721            "no caption + mention_only group ⇒ reject"
5722        );
5723        let unrelated_caption = group_message_with_caption(Some("nice photo"));
5724        assert!(
5725            ch.check_media_mention_gate(&unrelated_caption, Some("nice photo"))
5726                .is_none(),
5727            "caption without bot mention + mention_only group ⇒ reject"
5728        );
5729        let other_bot_caption = group_message_with_caption(Some("hey @otherbot look"));
5730        assert!(
5731            ch.check_media_mention_gate(&other_bot_caption, Some("hey @otherbot look"))
5732                .is_none(),
5733            "caption mentioning a different bot ⇒ reject"
5734        );
5735    }
5736
5737    /// When the caption mentions the bot, the gate admits and returns the
5738    /// trimmed caption with the mention preserved verbatim, matching the
5739    /// text-message behavior of `normalize_incoming_content`.
5740    #[test]
5741    fn check_media_mention_gate_admits_and_preserves_caption_mention() {
5742        let ch = TelegramChannel::new(
5743            "token".into(),
5744            "default",
5745            std::sync::Arc::new(|| vec!["*".into()]),
5746            true,
5747        );
5748        {
5749            let mut cache = ch.bot_username.lock();
5750            *cache = Some("mybot".to_string());
5751        }
5752        let msg = group_message_with_caption(Some("@mybot describe this"));
5753        let result = ch.check_media_mention_gate(&msg, Some("@mybot describe this"));
5754        assert_eq!(
5755            result,
5756            Some(Some("@mybot describe this".to_string())),
5757            "mention text preserved verbatim once gate admits"
5758        );
5759    }
5760
5761    /// `mention_only = true` only applies to groups. DMs always pass with
5762    /// the caption preserved verbatim.
5763    #[test]
5764    fn check_media_mention_gate_passes_dm_unchanged() {
5765        let ch = TelegramChannel::new(
5766            "token".into(),
5767            "default",
5768            std::sync::Arc::new(|| vec!["*".into()]),
5769            true,
5770        );
5771        let dm = serde_json::json!({
5772            "message_id": 1,
5773            "from": { "id": 1, "username": "alice" },
5774            "chat": { "id": 1, "type": "private" },
5775            "caption": "hello"
5776        });
5777        assert_eq!(
5778            ch.check_media_mention_gate(&dm, Some("hello")),
5779            Some(Some("hello".to_string())),
5780            "DM media must always pass with caption verbatim"
5781        );
5782        let dm_no_caption = serde_json::json!({
5783            "message_id": 1,
5784            "from": { "id": 1, "username": "alice" },
5785            "chat": { "id": 1, "type": "private" }
5786        });
5787        assert_eq!(
5788            ch.check_media_mention_gate(&dm_no_caption, None),
5789            Some(None),
5790            "DM media with no caption must pass"
5791        );
5792    }
5793
5794    /// When `mention_only = false` the gate is a no-op even in groups.
5795    #[test]
5796    fn check_media_mention_gate_passes_when_mention_only_disabled() {
5797        let ch = TelegramChannel::new(
5798            "token".into(),
5799            "default",
5800            std::sync::Arc::new(|| vec!["*".into()]),
5801            false,
5802        );
5803        let group_no_caption = group_message_with_caption(None);
5804        assert_eq!(
5805            ch.check_media_mention_gate(&group_no_caption, None),
5806            Some(None),
5807            "mention_only off ⇒ all media pass"
5808        );
5809    }
5810
5811    /// Edge case: `mention_only = true` and the bot username has not yet
5812    /// been resolved (e.g., `/getMe` hasn't completed). The gate must
5813    /// reject in groups rather than fail-open, matching the existing text
5814    /// path's behavior at telegram.rs:1640.
5815    #[test]
5816    fn check_media_mention_gate_rejects_group_when_bot_username_unknown() {
5817        let ch = TelegramChannel::new(
5818            "token".into(),
5819            "default",
5820            std::sync::Arc::new(|| vec!["*".into()]),
5821            true,
5822        );
5823        // Do NOT set bot_username — leave it None.
5824        let group = group_message_with_caption(Some("@somebody hi"));
5825        assert!(
5826            ch.check_media_mention_gate(&group, Some("@somebody hi"))
5827                .is_none(),
5828            "missing bot_username in group must fail closed"
5829        );
5830    }
5831
5832    // ─────────────────────────────────────────────────────────────────────
5833    // TG6: Channel platform limit edge cases for Telegram (4096 char limit)
5834    // Prevents: Pattern 6 — issues #574, #499
5835    // ─────────────────────────────────────────────────────────────────────
5836
5837    #[test]
5838    fn telegram_split_code_block_at_boundary() {
5839        let mut msg = String::new();
5840        msg.push_str("```python\n");
5841        msg.push_str(&"x".repeat(4085));
5842        msg.push_str("\n```\nMore text after code block");
5843        let parts = split_message_for_telegram(&msg);
5844        assert!(
5845            parts.len() >= 2,
5846            "code block spanning boundary should split"
5847        );
5848        for part in &parts {
5849            assert!(
5850                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5851                "each part must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
5852                part.len()
5853            );
5854        }
5855    }
5856
5857    #[test]
5858    fn telegram_split_long_fenced_code_block_balances_each_chunk() {
5859        let mut msg = String::new();
5860        msg.push_str("Intro\n\n```rust\n");
5861        for i in 0..700 {
5862            let _ = writeln!(msg, "fn generated_{i}() {{ println!(\"line {i:03}\"); }}");
5863        }
5864        msg.push_str("```\n\nOutro");
5865
5866        let parts = split_message_for_telegram(&msg);
5867        assert!(parts.len() >= 2, "long fenced code block should split");
5868        for part in &parts {
5869            assert!(
5870                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5871                "balanced chunk must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
5872                part.len()
5873            );
5874            assert_eq!(
5875                part.matches("```").count() % 2,
5876                0,
5877                "each chunk should have balanced markdown fences"
5878            );
5879
5880            let html = TelegramChannel::markdown_to_telegram_html(part);
5881            assert_eq!(
5882                html.matches("<pre><code>").count(),
5883                html.matches("</code></pre>").count(),
5884                "rendered Telegram HTML should have balanced code blocks"
5885            );
5886        }
5887
5888        assert!(
5889            parts.iter().skip(1).any(|part| part.starts_with("```\n")),
5890            "continuation inside a code block should reopen a fence"
5891        );
5892        assert!(
5893            parts
5894                .iter()
5895                .take(parts.len() - 1)
5896                .any(|part| part.ends_with("\n```") || part.ends_with("```")),
5897            "split chunks inside a code block should close the fence"
5898        );
5899    }
5900
5901    #[test]
5902    fn telegram_split_fenced_code_send_text_stays_within_limit_and_balanced() {
5903        let mut msg = String::new();
5904        msg.push_str("```rust\n");
5905        msg.push_str(&"a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 120));
5906        msg.push_str("\n```\n");
5907
5908        let parts = split_message_for_telegram(&msg);
5909        assert!(parts.len() >= 2);
5910
5911        for (index, part) in parts.iter().enumerate() {
5912            let text = format_telegram_text_chunk(part, index, parts.len());
5913            assert!(
5914                text.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5915                "sent fenced chunk {index} must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
5916                text.chars().count()
5917            );
5918            assert_eq!(
5919                text.matches("```").count() % 2,
5920                0,
5921                "sent fenced chunk {index} should have balanced markdown fences"
5922            );
5923
5924            let html = TelegramChannel::markdown_to_telegram_html(&text);
5925            assert_eq!(
5926                html.matches("<pre><code>").count(),
5927                html.matches("</code></pre>").count(),
5928                "sent fenced chunk {index} should render balanced Telegram HTML"
5929            );
5930        }
5931    }
5932
5933    #[test]
5934    fn telegram_split_single_long_word() {
5935        let long_word = "a".repeat(5000);
5936        let parts = split_message_for_telegram(&long_word);
5937        assert!(parts.len() >= 2, "word exceeding limit must be split");
5938        for part in &parts {
5939            assert!(
5940                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5941                "hard-split part must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
5942                part.len()
5943            );
5944        }
5945        let reassembled: String = parts.join("");
5946        assert_eq!(reassembled, long_word);
5947    }
5948
5949    #[test]
5950    fn telegram_split_exactly_at_limit_no_split() {
5951        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH);
5952        let parts = split_message_for_telegram(&msg);
5953        assert_eq!(parts.len(), 1, "message exactly at limit should not split");
5954    }
5955
5956    #[test]
5957    fn telegram_split_one_over_limit() {
5958        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 1);
5959        let parts = split_message_for_telegram(&msg);
5960        assert!(parts.len() >= 2, "message 1 char over limit must split");
5961    }
5962
5963    #[test]
5964    fn telegram_split_many_short_lines() {
5965        let msg: String = (0..1000).fold(String::new(), |mut acc, i| {
5966            let _ = writeln!(acc, "line {i}");
5967            acc
5968        });
5969        let parts = split_message_for_telegram(&msg);
5970        for part in &parts {
5971            assert!(
5972                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5973                "short-line batch must be <= limit"
5974            );
5975        }
5976    }
5977
5978    #[test]
5979    fn telegram_split_only_whitespace() {
5980        let msg = "   \n\n\t  ";
5981        let parts = split_message_for_telegram(msg);
5982        assert!(parts.len() <= 1);
5983    }
5984
5985    #[test]
5986    fn telegram_split_emoji_at_boundary() {
5987        let mut msg = "a".repeat(4094);
5988        msg.push_str("🎉🎊"); // 4096 chars total
5989        let parts = split_message_for_telegram(&msg);
5990        for part in &parts {
5991            // The function splits on character count, not byte count
5992            assert!(
5993                part.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
5994                "emoji boundary split must respect limit"
5995            );
5996        }
5997    }
5998
5999    #[test]
6000    fn telegram_split_consecutive_newlines() {
6001        let mut msg = "a".repeat(4090);
6002        msg.push_str("\n\n\n\n\n\n");
6003        msg.push_str(&"b".repeat(100));
6004        let parts = split_message_for_telegram(&msg);
6005        for part in &parts {
6006            assert!(part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
6007        }
6008    }
6009
6010    #[test]
6011    fn parse_voice_metadata_extracts_voice() {
6012        let msg = serde_json::json!({
6013            "voice": {
6014                "file_id": "abc123",
6015                "duration": 5
6016            }
6017        });
6018        let (file_id, dur) = TelegramChannel::parse_voice_metadata(&msg).unwrap();
6019        assert_eq!(file_id, "abc123");
6020        assert_eq!(dur, 5);
6021    }
6022
6023    #[test]
6024    fn parse_voice_metadata_extracts_audio() {
6025        let msg = serde_json::json!({
6026            "audio": {
6027                "file_id": "audio456",
6028                "duration": 30
6029            }
6030        });
6031        let (file_id, dur) = TelegramChannel::parse_voice_metadata(&msg).unwrap();
6032        assert_eq!(file_id, "audio456");
6033        assert_eq!(dur, 30);
6034    }
6035
6036    #[test]
6037    fn parse_voice_metadata_returns_none_for_text() {
6038        let msg = serde_json::json!({
6039            "text": "hello"
6040        });
6041        assert!(TelegramChannel::parse_voice_metadata(&msg).is_none());
6042    }
6043
6044    #[test]
6045    fn parse_voice_metadata_defaults_duration_to_zero() {
6046        let msg = serde_json::json!({
6047            "voice": {
6048                "file_id": "no_dur"
6049            }
6050        });
6051        let (_, dur) = TelegramChannel::parse_voice_metadata(&msg).unwrap();
6052        assert_eq!(dur, 0);
6053    }
6054
6055    // ─────────────────────────────────────────────────────────────────────
6056    // extract_sender_info tests
6057    // ─────────────────────────────────────────────────────────────────────
6058
6059    #[test]
6060    fn extract_sender_info_with_username() {
6061        let msg = serde_json::json!({
6062            "from": { "id": 123, "username": "alice" }
6063        });
6064        let (username, sender_id, identity) = TelegramChannel::extract_sender_info(&msg);
6065        assert_eq!(username, "alice");
6066        assert_eq!(sender_id, Some("123".to_string()));
6067        assert_eq!(identity, "alice");
6068    }
6069
6070    #[test]
6071    fn extract_sender_info_without_username() {
6072        let msg = serde_json::json!({
6073            "from": { "id": 42 }
6074        });
6075        let (username, sender_id, identity) = TelegramChannel::extract_sender_info(&msg);
6076        assert_eq!(username, "unknown");
6077        assert_eq!(sender_id, Some("42".to_string()));
6078        assert_eq!(identity, "42");
6079    }
6080
6081    // ─────────────────────────────────────────────────────────────────────
6082    // extract_reply_context tests
6083    // ─────────────────────────────────────────────────────────────────────
6084
6085    #[test]
6086    fn extract_reply_context_text_message() {
6087        let mention_only = false;
6088        let ch = TelegramChannel::new(
6089            "t".into(),
6090            "telegram_test_alias",
6091            Arc::new(|| vec!["*".into()]),
6092            mention_only,
6093        );
6094        let msg = serde_json::json!({
6095            "reply_to_message": {
6096                "from": { "username": "alice" },
6097                "text": "Hello world"
6098            }
6099        });
6100        let ctx = ch.extract_reply_context(&msg).unwrap();
6101        assert_eq!(ctx, "> @alice:\n> Hello world");
6102    }
6103
6104    #[test]
6105    fn extract_reply_context_voice_message() {
6106        let mention_only = false;
6107        let ch = TelegramChannel::new(
6108            "t".into(),
6109            "telegram_test_alias",
6110            Arc::new(|| vec!["*".into()]),
6111            mention_only,
6112        );
6113        let msg = serde_json::json!({
6114            "reply_to_message": {
6115                "from": { "username": "bob" },
6116                "voice": { "file_id": "abc", "duration": 5 }
6117            }
6118        });
6119        let ctx = ch.extract_reply_context(&msg).unwrap();
6120        assert_eq!(ctx, "> @bob:\n> [Voice message]");
6121    }
6122
6123    #[test]
6124    fn extract_reply_context_no_reply() {
6125        let mention_only = false;
6126        let ch = TelegramChannel::new(
6127            "t".into(),
6128            "telegram_test_alias",
6129            Arc::new(|| vec!["*".into()]),
6130            mention_only,
6131        );
6132        let msg = serde_json::json!({
6133            "text": "just a regular message"
6134        });
6135        assert!(ch.extract_reply_context(&msg).is_none());
6136    }
6137
6138    #[test]
6139    fn extract_reply_context_skips_topic_root() {
6140        // Telegram auto-injects a reply_to_message pointing at the topic-root
6141        // message on every message in a non-General forum topic. The injected
6142        // reply's message_id equals the parent's message_thread_id. It is
6143        // not a real reply and must not produce a blockquote prefix.
6144        let mention_only = false;
6145        let ch = TelegramChannel::new(
6146            "t".into(),
6147            "telegram_test_alias",
6148            Arc::new(|| vec!["*".into()]),
6149            mention_only,
6150        );
6151        let msg = serde_json::json!({
6152            "message_thread_id": 42,
6153            "text": "hello in topic",
6154            "reply_to_message": {
6155                "message_id": 42,
6156                "from": { "username": "alice" },
6157                "forum_topic_created": { "name": "General Discussion", "icon_color": 0 }
6158            }
6159        });
6160        assert!(ch.extract_reply_context(&msg).is_none());
6161    }
6162
6163    #[test]
6164    fn extract_reply_context_real_reply_in_topic() {
6165        // A genuine reply inside a forum topic (reply.message_id differs from
6166        // the parent's message_thread_id) should still produce a blockquote.
6167        let mention_only = false;
6168        let ch = TelegramChannel::new(
6169            "t".into(),
6170            "telegram_test_alias",
6171            Arc::new(|| vec!["*".into()]),
6172            mention_only,
6173        );
6174        let msg = serde_json::json!({
6175            "message_thread_id": 42,
6176            "text": "I agree",
6177            "reply_to_message": {
6178                "message_id": 100,
6179                "from": { "username": "alice" },
6180                "text": "What do you think?"
6181            }
6182        });
6183        let ctx = ch.extract_reply_context(&msg).unwrap();
6184        assert_eq!(ctx, "> @alice:\n> What do you think?");
6185    }
6186
6187    #[test]
6188    fn extract_reply_context_no_username_uses_first_name() {
6189        let mention_only = false;
6190        let ch = TelegramChannel::new(
6191            "t".into(),
6192            "telegram_test_alias",
6193            Arc::new(|| vec!["*".into()]),
6194            mention_only,
6195        );
6196        let msg = serde_json::json!({
6197            "reply_to_message": {
6198                "from": { "id": 999, "first_name": "Charlie" },
6199                "text": "Hi there"
6200            }
6201        });
6202        let ctx = ch.extract_reply_context(&msg).unwrap();
6203        assert_eq!(ctx, "> @Charlie:\n> Hi there");
6204    }
6205
6206    #[test]
6207    fn extract_reply_context_voice_with_cached_transcription() {
6208        let mention_only = false;
6209        let ch = TelegramChannel::new(
6210            "t".into(),
6211            "telegram_test_alias",
6212            Arc::new(|| vec!["*".into()]),
6213            mention_only,
6214        );
6215        // Pre-populate transcription cache
6216        ch.voice_transcriptions
6217            .lock()
6218            .insert("100:42".to_string(), "Hello from voice".to_string());
6219        let msg = serde_json::json!({
6220            "chat": { "id": 100 },
6221            "reply_to_message": {
6222                "message_id": 42,
6223                "from": { "username": "bob" },
6224                "voice": { "file_id": "abc", "duration": 5 }
6225            }
6226        });
6227        let ctx = ch.extract_reply_context(&msg).unwrap();
6228        assert_eq!(ctx, "> @bob:\n> [Voice] Hello from voice");
6229    }
6230
6231    #[test]
6232    fn parse_update_message_includes_reply_context() {
6233        let mention_only = false;
6234        let ch = TelegramChannel::new(
6235            "t".into(),
6236            "telegram_test_alias",
6237            Arc::new(|| vec!["*".into()]),
6238            mention_only,
6239        );
6240        let update = serde_json::json!({
6241            "message": {
6242                "message_id": 10,
6243                "text": "translate this",
6244                "from": { "id": 1, "username": "alice" },
6245                "chat": { "id": 100, "type": "private" },
6246                "reply_to_message": {
6247                    "from": { "username": "bot" },
6248                    "text": "Bonjour le monde"
6249                }
6250            }
6251        });
6252        let parsed = ch.parse_update_message(&update).unwrap();
6253        assert!(
6254            parsed.content.starts_with("> @bot:"),
6255            "content should start with quote: {}",
6256            parsed.content
6257        );
6258        assert!(
6259            parsed.content.contains("translate this"),
6260            "content should contain user text"
6261        );
6262        assert!(
6263            parsed.content.contains("Bonjour le monde"),
6264            "content should contain quoted text"
6265        );
6266    }
6267
6268    #[test]
6269    fn with_transcription_sets_config_when_enabled() {
6270        let tc = zeroclaw_config::schema::TranscriptionConfig {
6271            enabled: true,
6272            api_key: Some("test_key".to_string()),
6273            ..zeroclaw_config::schema::TranscriptionConfig::default()
6274        };
6275
6276        let mention_only = false;
6277        let ch = TelegramChannel::new(
6278            "token".into(),
6279            "telegram_test_alias",
6280            Arc::new(|| vec!["*".into()]),
6281            mention_only,
6282        )
6283        .with_transcription(tc);
6284        assert!(ch.transcription.is_some());
6285        assert!(ch.transcription_manager.is_some());
6286    }
6287
6288    #[test]
6289    fn with_transcription_skips_when_disabled() {
6290        let tc = zeroclaw_config::schema::TranscriptionConfig::default(); // enabled = false
6291        let mention_only = false;
6292        let ch = TelegramChannel::new(
6293            "token".into(),
6294            "telegram_test_alias",
6295            Arc::new(|| vec!["*".into()]),
6296            mention_only,
6297        )
6298        .with_transcription(tc);
6299        assert!(ch.transcription.is_none());
6300        assert!(ch.transcription_manager.is_none());
6301    }
6302
6303    #[tokio::test]
6304    async fn try_parse_voice_message_returns_none_when_transcription_disabled() {
6305        let mention_only = false;
6306        let ch = TelegramChannel::new(
6307            "token".into(),
6308            "telegram_test_alias",
6309            Arc::new(|| vec!["*".into()]),
6310            mention_only,
6311        );
6312        let update = serde_json::json!({
6313            "message": {
6314                "message_id": 1,
6315                "voice": { "file_id": "voice_file", "duration": 4 },
6316                "from": { "id": 123, "username": "alice" },
6317                "chat": { "id": 456, "type": "private" }
6318            }
6319        });
6320
6321        let parsed = ch.try_parse_voice_message(&update).await;
6322        assert!(parsed.is_none());
6323    }
6324
6325    #[tokio::test]
6326    async fn try_parse_voice_message_skips_when_duration_exceeds_limit() {
6327        let tc = zeroclaw_config::schema::TranscriptionConfig {
6328            enabled: true,
6329            api_key: Some("test_key".to_string()),
6330            max_duration_secs: 5,
6331            ..Default::default()
6332        };
6333
6334        let mention_only = false;
6335        let ch = TelegramChannel::new(
6336            "token".into(),
6337            "telegram_test_alias",
6338            Arc::new(|| vec!["*".into()]),
6339            mention_only,
6340        )
6341        .with_transcription(tc);
6342        let update = serde_json::json!({
6343            "message": {
6344                "message_id": 2,
6345                "voice": { "file_id": "voice_file", "duration": 30 },
6346                "from": { "id": 123, "username": "alice" },
6347                "chat": { "id": 456, "type": "private" }
6348            }
6349        });
6350
6351        let parsed = ch.try_parse_voice_message(&update).await;
6352        assert!(parsed.is_none());
6353    }
6354
6355    #[tokio::test]
6356    async fn try_parse_voice_message_rejects_unauthorized_sender_before_download() {
6357        let tc = zeroclaw_config::schema::TranscriptionConfig {
6358            enabled: true,
6359            api_key: Some("test_key".to_string()),
6360            max_duration_secs: 120,
6361            ..Default::default()
6362        };
6363
6364        let mention_only = false;
6365        let ch = TelegramChannel::new(
6366            "token".into(),
6367            "telegram_test_alias",
6368            Arc::new(|| vec!["alice".into()]),
6369            mention_only,
6370        )
6371        .with_transcription(tc);
6372        let update = serde_json::json!({
6373            "message": {
6374                "message_id": 3,
6375                "voice": { "file_id": "voice_file", "duration": 4 },
6376                "from": { "id": 999, "username": "bob" },
6377                "chat": { "id": 456, "type": "private" }
6378            }
6379        });
6380
6381        let parsed = ch.try_parse_voice_message(&update).await;
6382        assert!(parsed.is_none());
6383        assert!(ch.voice_transcriptions.lock().is_empty());
6384    }
6385
6386    // ─────────────────────────────────────────────────────────────────────
6387    // Live e2e: voice transcription via Groq Whisper + reply cache lookup
6388    // ─────────────────────────────────────────────────────────────────────
6389
6390    /// Live test: voice transcription via Groq Whisper + reply cache lookup.
6391    ///
6392    /// Loads a pre-recorded MP3 fixture ("hello"), sends it to Groq Whisper
6393    /// API, verifies the transcription contains "hello", then caches it and
6394    /// checks that `extract_reply_context` returns the cached text instead
6395    /// of the `[Voice message]` fallback placeholder.
6396    ///
6397    /// Skipped automatically when `GROQ_API_KEY` is not set.
6398    /// Run: `GROQ_API_KEY=<key> cargo test --lib -- telegram::tests::e2e_live_voice_transcription_and_reply_cache --ignored`
6399    ///
6400    /// Production code no longer reads `GROQ_API_KEY` from env — this
6401    /// test still uses the env var as a test-runner setup hook (the
6402    /// canonical way to supply credentials to integration tests) and
6403    /// plumbs the value into `TranscriptionConfig.api_key` directly.
6404    #[tokio::test]
6405    #[ignore = "requires GROQ_API_KEY environment variable"]
6406    async fn e2e_live_voice_transcription_and_reply_cache() {
6407        let Ok(api_key) = std::env::var("GROQ_API_KEY") else {
6408            eprintln!("GROQ_API_KEY not set — skipping live voice transcription test");
6409            return;
6410        };
6411
6412        // 1. Load pre-recorded fixture (TTS-generated "hello", ~7 KB MP3)
6413        let fixture_path =
6414            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.mp3");
6415        let audio_data = std::fs::read(&fixture_path)
6416            .unwrap_or_else(|e| panic!("Failed to read fixture {}: {e}", fixture_path.display()));
6417        assert!(
6418            audio_data.len() > 1000,
6419            "fixture too small ({} bytes), likely corrupt",
6420            audio_data.len()
6421        );
6422
6423        // 2. Call TranscriptionManager.transcribe() — real Groq Whisper API
6424        let config = zeroclaw_config::schema::TranscriptionConfig {
6425            enabled: true,
6426            api_key: Some(api_key),
6427            ..Default::default()
6428        };
6429        let manager = crate::transcription::TranscriptionManager::new(&config)
6430            .expect("TranscriptionManager::new should succeed with valid GROQ_API_KEY");
6431        let transcript: String = manager
6432            .transcribe(&audio_data, "hello.mp3")
6433            .await
6434            .expect("transcribe should succeed with valid GROQ_API_KEY");
6435
6436        // 3. Verify Whisper actually recognized "hello"
6437        assert!(
6438            transcript.to_lowercase().contains("hello"),
6439            "expected transcription to contain 'hello', got: '{transcript}'"
6440        );
6441
6442        // 4. Create TelegramChannel, insert transcription into voice_transcriptions cache
6443        let mention_only = false;
6444        let ch = TelegramChannel::new(
6445            "test_token".into(),
6446            "telegram_test_alias",
6447            Arc::new(|| vec!["*".into()]),
6448            mention_only,
6449        );
6450        let chat_id: i64 = 12345;
6451        let message_id: i64 = 67;
6452        let cache_key = format!("{chat_id}:{message_id}");
6453        ch.voice_transcriptions
6454            .lock()
6455            .insert(cache_key, transcript.clone());
6456
6457        // 5. Build reply message with voice + message_id + chat.id
6458        let msg = serde_json::json!({
6459            "chat": { "id": chat_id },
6460            "reply_to_message": {
6461                "message_id": message_id,
6462                "from": { "username": "zeroclaw_user" },
6463                "voice": { "file_id": "test_file", "duration": 1 }
6464            }
6465        });
6466
6467        // 6. Verify extract_reply_context returns cached transcription
6468        let ctx = ch
6469            .extract_reply_context(&msg)
6470            .expect("extract_reply_context should return Some for voice reply");
6471
6472        assert!(
6473            ctx.contains(&format!("[Voice] {transcript}")),
6474            "expected cached transcription in reply context, got: {ctx}"
6475        );
6476
6477        // Must NOT contain the fallback placeholder
6478        assert!(
6479            !ctx.contains("[Voice message]"),
6480            "context should use cached transcription, not fallback placeholder, got: {ctx}"
6481        );
6482    }
6483
6484    // ── IncomingAttachment / parse_attachment_metadata tests ─────────
6485
6486    #[test]
6487    fn parse_attachment_metadata_detects_document() {
6488        let message = serde_json::json!({
6489            "document": {
6490                "file_id": "BQACAgIAAxk",
6491                "file_name": "report.pdf",
6492                "file_size": 12345
6493            }
6494        });
6495        let att = TelegramChannel::parse_attachment_metadata(&message).unwrap();
6496        assert_eq!(att.kind, IncomingAttachmentKind::Document);
6497        assert_eq!(att.file_id, "BQACAgIAAxk");
6498        assert_eq!(att.file_name.as_deref(), Some("report.pdf"));
6499        assert_eq!(att.file_size, Some(12345));
6500        assert!(att.caption.is_none());
6501    }
6502
6503    #[test]
6504    fn parse_attachment_metadata_detects_photo() {
6505        let message = serde_json::json!({
6506            "photo": [
6507                {"file_id": "small_id", "file_size": 100, "width": 90, "height": 90},
6508                {"file_id": "medium_id", "file_size": 500, "width": 320, "height": 320},
6509                {"file_id": "large_id", "file_size": 2000, "width": 800, "height": 800}
6510            ]
6511        });
6512        let att = TelegramChannel::parse_attachment_metadata(&message).unwrap();
6513        assert_eq!(att.kind, IncomingAttachmentKind::Photo);
6514        assert_eq!(att.file_id, "large_id");
6515        assert_eq!(att.file_size, Some(2000));
6516        assert!(att.file_name.is_none());
6517    }
6518
6519    #[test]
6520    fn parse_attachment_metadata_extracts_caption() {
6521        // Document with caption
6522        let doc_msg = serde_json::json!({
6523            "document": {
6524                "file_id": "doc_id",
6525                "file_name": "data.csv"
6526            },
6527            "caption": "Monthly report"
6528        });
6529        let att = TelegramChannel::parse_attachment_metadata(&doc_msg).unwrap();
6530        assert_eq!(att.caption.as_deref(), Some("Monthly report"));
6531
6532        // Photo with caption
6533        let photo_msg = serde_json::json!({
6534            "photo": [
6535                {"file_id": "photo_id", "file_size": 1000}
6536            ],
6537            "caption": "Look at this"
6538        });
6539        let att = TelegramChannel::parse_attachment_metadata(&photo_msg).unwrap();
6540        assert_eq!(att.caption.as_deref(), Some("Look at this"));
6541    }
6542
6543    #[test]
6544    fn parse_attachment_metadata_document_without_optional_fields() {
6545        let message = serde_json::json!({
6546            "document": {
6547                "file_id": "doc_no_name"
6548            }
6549        });
6550        let att = TelegramChannel::parse_attachment_metadata(&message).unwrap();
6551        assert_eq!(att.kind, IncomingAttachmentKind::Document);
6552        assert_eq!(att.file_id, "doc_no_name");
6553        assert!(att.file_name.is_none());
6554        assert!(att.file_size.is_none());
6555        assert!(att.caption.is_none());
6556    }
6557
6558    #[test]
6559    fn parse_attachment_metadata_returns_none_for_text() {
6560        let message = serde_json::json!({
6561            "text": "Hello world"
6562        });
6563        assert!(TelegramChannel::parse_attachment_metadata(&message).is_none());
6564    }
6565
6566    #[test]
6567    fn parse_attachment_metadata_returns_none_for_voice() {
6568        let message = serde_json::json!({
6569            "voice": {
6570                "file_id": "voice_id",
6571                "duration": 5
6572            }
6573        });
6574        assert!(TelegramChannel::parse_attachment_metadata(&message).is_none());
6575    }
6576
6577    #[test]
6578    fn parse_attachment_metadata_empty_photo_array() {
6579        let message = serde_json::json!({
6580            "photo": []
6581        });
6582        assert!(TelegramChannel::parse_attachment_metadata(&message).is_none());
6583    }
6584
6585    #[test]
6586    fn with_workspace_dir_sets_field() {
6587        let mention_only = false;
6588        let ch = TelegramChannel::new(
6589            "fake-token".into(),
6590            "telegram_test_alias",
6591            Arc::new(|| vec!["*".into()]),
6592            mention_only,
6593        )
6594        .with_workspace_dir(std::path::PathBuf::from("/tmp/test_workspace"));
6595        assert_eq!(
6596            ch.workspace_dir.as_deref(),
6597            Some(std::path::Path::new("/tmp/test_workspace"))
6598        );
6599    }
6600
6601    #[test]
6602    fn telegram_max_file_download_bytes_is_20mb() {
6603        assert_eq!(TELEGRAM_MAX_FILE_DOWNLOAD_BYTES, 20 * 1024 * 1024);
6604    }
6605
6606    // ── Attachment content format tests ──────────────────────────────
6607
6608    /// Photo attachments with image extension must use `[IMAGE:/path]` marker
6609    /// so the multimodal pipeline validates vision capability on the model_provider.
6610    #[test]
6611    fn attachment_photo_content_uses_image_marker() {
6612        let local_path = std::path::Path::new("/tmp/workspace/photo_123_45.jpg");
6613        let local_filename = "photo_123_45.jpg";
6614
6615        let content =
6616            format_attachment_content(IncomingAttachmentKind::Photo, local_filename, local_path);
6617
6618        assert_eq!(content, "[IMAGE:/tmp/workspace/photo_123_45.jpg]");
6619        assert!(content.starts_with("[IMAGE:"));
6620        assert!(content.ends_with(']'));
6621    }
6622
6623    /// Document attachments keep `[Document: name] /path` format.
6624    #[test]
6625    fn attachment_document_content_uses_document_label() {
6626        let local_path = std::path::Path::new("/tmp/workspace/report.pdf");
6627        let local_filename = "report.pdf";
6628
6629        let content =
6630            format_attachment_content(IncomingAttachmentKind::Document, local_filename, local_path);
6631
6632        assert_eq!(content, "[Document: report.pdf] /tmp/workspace/report.pdf");
6633        assert!(!content.contains("[IMAGE:"));
6634    }
6635
6636    /// Markdown files must never produce `[IMAGE:]` markers (issue #1274).
6637    #[test]
6638    fn markdown_file_never_produces_image_marker() {
6639        let local_path = std::path::Path::new("/tmp/workspace/telegram_files/notes.md");
6640        let local_filename = "notes.md";
6641
6642        // Even if Telegram misclassifies as Photo, extension guard prevents [IMAGE:].
6643        let content =
6644            format_attachment_content(IncomingAttachmentKind::Photo, local_filename, local_path);
6645        assert!(
6646            !content.contains("[IMAGE:"),
6647            "markdown must not get [IMAGE:] marker: {content}"
6648        );
6649        assert!(content.starts_with("[Document:"));
6650
6651        // As Document, it should also be correct.
6652        let content_doc =
6653            format_attachment_content(IncomingAttachmentKind::Document, local_filename, local_path);
6654        assert!(
6655            !content_doc.contains("[IMAGE:"),
6656            "markdown document must not get [IMAGE:] marker: {content_doc}"
6657        );
6658    }
6659
6660    /// Non-image files classified as Photo fall back to `[Document:]` format.
6661    #[test]
6662    fn non_image_photo_falls_back_to_document_format() {
6663        for (filename, ext_path) in [
6664            ("file.md", "/tmp/ws/file.md"),
6665            ("file.txt", "/tmp/ws/file.txt"),
6666            ("file.pdf", "/tmp/ws/file.pdf"),
6667            ("file.csv", "/tmp/ws/file.csv"),
6668            ("file.json", "/tmp/ws/file.json"),
6669            ("file.zip", "/tmp/ws/file.zip"),
6670            ("file", "/tmp/ws/file"),
6671        ] {
6672            let path = std::path::Path::new(ext_path);
6673            let content = format_attachment_content(IncomingAttachmentKind::Photo, filename, path);
6674            assert!(
6675                !content.contains("[IMAGE:"),
6676                "{filename}: non-image file should not get [IMAGE:] marker, got: {content}"
6677            );
6678            assert!(
6679                content.starts_with("[Document:"),
6680                "{filename}: should use [Document:] format, got: {content}"
6681            );
6682        }
6683    }
6684
6685    /// All recognized image extensions produce `[IMAGE:]` when classified as Photo.
6686    #[test]
6687    fn image_extensions_produce_image_marker() {
6688        for ext in ["png", "jpg", "jpeg", "gif", "webp", "bmp"] {
6689            let filename = format!("photo_1_2.{ext}");
6690            let path_str = format!("/tmp/ws/{filename}");
6691            let path = std::path::Path::new(&path_str);
6692            let content = format_attachment_content(IncomingAttachmentKind::Photo, &filename, path);
6693            assert!(
6694                content.starts_with("[IMAGE:"),
6695                "{ext}: image should get [IMAGE:] marker, got: {content}"
6696            );
6697        }
6698    }
6699
6700    /// Multimodal pipeline must return 0 image markers for document-formatted
6701    /// content — even for a file misclassified as Photo (issue #1274).
6702    #[test]
6703    fn markdown_attachment_not_detected_by_multimodal_image_markers() {
6704        let content = format_attachment_content(
6705            IncomingAttachmentKind::Photo,
6706            "notes.md",
6707            std::path::Path::new("/tmp/ws/notes.md"),
6708        );
6709        let messages = vec![zeroclaw_providers::ChatMessage::user(content)];
6710        assert_eq!(
6711            zeroclaw_providers::multimodal::count_image_markers(&messages),
6712            0,
6713            "markdown file must not trigger image marker detection"
6714        );
6715    }
6716
6717    /// `is_image_extension` helper recognizes image formats and rejects others.
6718    #[test]
6719    fn is_image_extension_recognizes_images() {
6720        assert!(is_image_extension(std::path::Path::new("photo.png")));
6721        assert!(is_image_extension(std::path::Path::new("photo.jpg")));
6722        assert!(is_image_extension(std::path::Path::new("photo.jpeg")));
6723        assert!(is_image_extension(std::path::Path::new("photo.gif")));
6724        assert!(is_image_extension(std::path::Path::new("photo.webp")));
6725        assert!(is_image_extension(std::path::Path::new("photo.bmp")));
6726        assert!(is_image_extension(std::path::Path::new("PHOTO.PNG")));
6727
6728        assert!(!is_image_extension(std::path::Path::new("file.md")));
6729        assert!(!is_image_extension(std::path::Path::new("file.txt")));
6730        assert!(!is_image_extension(std::path::Path::new("file.pdf")));
6731        assert!(!is_image_extension(std::path::Path::new("file.csv")));
6732        assert!(!is_image_extension(std::path::Path::new("file")));
6733    }
6734
6735    /// `count_image_markers` from the multimodal module must detect the
6736    /// `[IMAGE:]` marker produced by photo attachment formatting.
6737    #[test]
6738    fn photo_image_marker_detected_by_multimodal() {
6739        let photo_content = "[IMAGE:/tmp/workspace/photo_1_2.jpg]";
6740        let messages = vec![zeroclaw_providers::ChatMessage::user(
6741            photo_content.to_string(),
6742        )];
6743        let count = zeroclaw_providers::multimodal::count_image_markers(&messages);
6744        assert_eq!(
6745            count, 1,
6746            "multimodal should detect exactly one image marker"
6747        );
6748    }
6749
6750    /// Photo with caption: `[IMAGE:/path]\n\nCaption text`.
6751    #[test]
6752    fn photo_image_marker_with_caption() {
6753        let local_path = std::path::Path::new("/tmp/workspace/photo_1_2.jpg");
6754        let mut content = format!("[IMAGE:{}]", local_path.display());
6755        let caption = "Look at this screenshot";
6756        use std::fmt::Write;
6757        let _ = write!(content, "\n\n{caption}");
6758
6759        assert_eq!(
6760            content,
6761            "[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nLook at this screenshot"
6762        );
6763
6764        // Multimodal pipeline still detects the marker.
6765        let messages = vec![zeroclaw_providers::ChatMessage::user(content)];
6766        assert_eq!(
6767            zeroclaw_providers::multimodal::count_image_markers(&messages),
6768            1
6769        );
6770    }
6771
6772    // ── E2E: attachment saves file and formats content ───────────────
6773
6774    /// Full pipeline test: simulate file download → save to workspace →
6775    /// verify content format for both document and photo attachments.
6776    #[test]
6777    fn e2e_attachment_saves_file_and_formats_content() {
6778        let workspace = tempfile::tempdir().expect("create temp workspace");
6779
6780        // ── Document attachment ──────────────────────────────────────
6781        let doc_filename = "report.pdf";
6782        let doc_path = workspace.path().join(doc_filename);
6783        // Simulate downloaded file.
6784        std::fs::write(&doc_path, b"%PDF-1.4 fake").expect("write doc fixture");
6785        assert!(doc_path.exists(), "document file must exist on disk");
6786
6787        let doc_content =
6788            format_attachment_content(IncomingAttachmentKind::Document, doc_filename, &doc_path);
6789        assert!(
6790            doc_content.starts_with("[Document: report.pdf]"),
6791            "document label format mismatch: {doc_content}"
6792        );
6793        // Multimodal must NOT detect image markers in document content.
6794        let doc_msgs = vec![zeroclaw_providers::ChatMessage::user(doc_content)];
6795        assert_eq!(
6796            zeroclaw_providers::multimodal::count_image_markers(&doc_msgs),
6797            0,
6798            "document content must not contain image markers"
6799        );
6800
6801        // ── Photo attachment ─────────────────────────────────────────
6802        let photo_filename = "photo_99_1.jpg";
6803        let photo_path = workspace.path().join(photo_filename);
6804        // Copy the JPEG fixture.
6805        let fixture =
6806            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test_photo.jpg");
6807        std::fs::copy(&fixture, &photo_path).expect("copy photo fixture");
6808        assert!(photo_path.exists(), "photo file must exist on disk");
6809
6810        let photo_content =
6811            format_attachment_content(IncomingAttachmentKind::Photo, photo_filename, &photo_path);
6812        assert!(
6813            photo_content.starts_with("[IMAGE:"),
6814            "photo must use [IMAGE:] marker: {photo_content}"
6815        );
6816        assert!(
6817            photo_content.ends_with(']'),
6818            "photo marker must close with ]: {photo_content}"
6819        );
6820
6821        // Multimodal detects the marker.
6822        let photo_msgs = vec![zeroclaw_providers::ChatMessage::user(photo_content.clone())];
6823        assert_eq!(
6824            zeroclaw_providers::multimodal::count_image_markers(&photo_msgs),
6825            1,
6826            "multimodal must detect exactly one image marker in photo content"
6827        );
6828
6829        // ── Photo with caption ───────────────────────────────────────
6830        let mut captioned = photo_content;
6831        use std::fmt::Write;
6832        let _ = write!(captioned, "\n\nCheck this out");
6833        let cap_msgs = vec![zeroclaw_providers::ChatMessage::user(captioned.clone())];
6834        assert_eq!(
6835            zeroclaw_providers::multimodal::count_image_markers(&cap_msgs),
6836            1,
6837            "caption must not break image marker detection"
6838        );
6839        assert!(
6840            captioned.contains("Check this out"),
6841            "caption text must be present in content"
6842        );
6843
6844        // ── Markdown file sent as Photo (issue #1274) ────────────────
6845        let md_filename = "notes.md";
6846        let md_path = workspace.path().join(md_filename);
6847        std::fs::write(&md_path, b"# Hello\nSome markdown").expect("write md fixture");
6848        let md_content =
6849            format_attachment_content(IncomingAttachmentKind::Photo, md_filename, &md_path);
6850        assert!(
6851            !md_content.contains("[IMAGE:"),
6852            "markdown must not get [IMAGE:] marker: {md_content}"
6853        );
6854        let md_msgs = vec![zeroclaw_providers::ChatMessage::user(md_content)];
6855        assert_eq!(
6856            zeroclaw_providers::multimodal::count_image_markers(&md_msgs),
6857            0,
6858            "markdown file must not trigger image marker detection"
6859        );
6860    }
6861
6862    // ── Groq model_provider rejects photo with vision error ────────────────
6863
6864    /// Verify that the Groq model_provider (OpenAI-compatible) does not support
6865    /// vision, so the existing `count_image_markers > 0 && !supports_vision()`
6866    /// guard in `agent/loop_.rs` will reject photo messages.
6867    #[test]
6868    fn groq_provider_rejects_photo_with_vision_error() {
6869        use zeroclaw_providers::ModelProvider;
6870        use zeroclaw_providers::compatible::{AuthStyle, OpenAiCompatibleModelProvider};
6871
6872        let groq = OpenAiCompatibleModelProvider::new(
6873            "test",
6874            "Groq",
6875            "https://api.groq.com/openai",
6876            Some("fake_key"),
6877            AuthStyle::Bearer,
6878        );
6879
6880        // Groq must not support vision.
6881        assert!(
6882            !groq.supports_vision(),
6883            "Groq model_provider must not support vision"
6884        );
6885
6886        // Build a message with an [IMAGE:] marker (as photo attachment would).
6887        let messages = vec![zeroclaw_providers::ChatMessage::user(
6888            "[IMAGE:/tmp/photo.jpg]\n\nDescribe this image".to_string(),
6889        )];
6890        let marker_count = zeroclaw_providers::multimodal::count_image_markers(&messages);
6891        assert_eq!(marker_count, 1, "must detect image marker in photo content");
6892
6893        // The combination of marker_count > 0 && !supports_vision() means
6894        // the agent loop will return ProviderCapabilityError before calling
6895        // the model_provider, and the channel will send "⚠️ Error: ..." to the user.
6896    }
6897
6898    #[test]
6899    fn ack_reactions_defaults_to_true() {
6900        let mention_only = false;
6901        let ch = TelegramChannel::new(
6902            "token".into(),
6903            "telegram_test_alias",
6904            Arc::new(|| vec!["*".into()]),
6905            mention_only,
6906        );
6907        assert!(ch.ack_reactions);
6908    }
6909
6910    #[test]
6911    fn with_ack_reactions_false_disables_reactions() {
6912        let mention_only = false;
6913        let ack_enabled = false;
6914        let ch = TelegramChannel::new(
6915            "token".into(),
6916            "telegram_test_alias",
6917            Arc::new(|| vec!["*".into()]),
6918            mention_only,
6919        )
6920        .with_ack_reactions(ack_enabled);
6921        assert!(!ch.ack_reactions);
6922    }
6923
6924    #[test]
6925    fn with_ack_reactions_true_keeps_reactions() {
6926        let mention_only = false;
6927        let ack_enabled = true;
6928        let ch = TelegramChannel::new(
6929            "token".into(),
6930            "telegram_test_alias",
6931            Arc::new(|| vec!["*".into()]),
6932            mention_only,
6933        )
6934        .with_ack_reactions(ack_enabled);
6935        assert!(ch.ack_reactions);
6936    }
6937
6938    // ── Forwarded message tests ─────────────────────────────────────
6939
6940    #[test]
6941    fn format_forward_attribution_supports_forward_origin_variants() {
6942        let cases = vec![
6943            (
6944                "user with username",
6945                serde_json::json!({
6946                    "type": "user",
6947                    "sender_user": { "id": 123, "username": "alice" }
6948                }),
6949                "[Forwarded from @alice] ",
6950            ),
6951            (
6952                "user with display name",
6953                serde_json::json!({
6954                    "type": "user",
6955                    "sender_user": {
6956                        "id": 123,
6957                        "first_name": "Alice",
6958                        "last_name": "Smith"
6959                    }
6960                }),
6961                "[Forwarded from Alice Smith] ",
6962            ),
6963            (
6964                "hidden user",
6965                serde_json::json!({
6966                    "type": "hidden_user",
6967                    "sender_user_name": "Anonymous Sender"
6968                }),
6969                "[Forwarded from Anonymous Sender] ",
6970            ),
6971            (
6972                "chat",
6973                serde_json::json!({
6974                    "type": "chat",
6975                    "sender_chat": { "id": 123, "title": "Secret Group" }
6976                }),
6977                "[Forwarded from chat: Secret Group] ",
6978            ),
6979            (
6980                "channel",
6981                serde_json::json!({
6982                    "type": "channel",
6983                    "chat": { "id": 123, "title": "News Channel" }
6984                }),
6985                "[Forwarded from channel: News Channel] ",
6986            ),
6987        ];
6988
6989        for (name, origin, expected) in cases {
6990            let message = serde_json::json!({ "forward_origin": origin });
6991            assert_eq!(
6992                TelegramChannel::format_forward_attribution(&message),
6993                Some(expected.to_string()),
6994                "{name}"
6995            );
6996        }
6997    }
6998
6999    #[test]
7000    fn parse_update_message_forward_origin_variants_reach_channel_content() {
7001        let mention_only = false;
7002        let ch = TelegramChannel::new(
7003            "token".into(),
7004            "telegram_test_alias",
7005            Arc::new(|| vec!["*".into()]),
7006            mention_only,
7007        );
7008
7009        let cases = vec![
7010            (
7011                serde_json::json!({
7012                    "type": "user",
7013                    "sender_user": { "id": 123, "username": "bob" }
7014                }),
7015                "[Forwarded from @bob] forwarded item",
7016            ),
7017            (
7018                serde_json::json!({
7019                    "type": "hidden_user",
7020                    "sender_user_name": "Hidden User"
7021                }),
7022                "[Forwarded from Hidden User] forwarded item",
7023            ),
7024            (
7025                serde_json::json!({
7026                    "type": "chat",
7027                    "sender_chat": { "id": -123, "title": "Secret Group" }
7028                }),
7029                "[Forwarded from chat: Secret Group] forwarded item",
7030            ),
7031            (
7032                serde_json::json!({
7033                    "type": "channel",
7034                    "chat": { "id": 123, "title": "News Channel" }
7035                }),
7036                "[Forwarded from channel: News Channel] forwarded item",
7037            ),
7038        ];
7039
7040        for (index, (origin, expected)) in cases.into_iter().enumerate() {
7041            let update = serde_json::json!({
7042                "update_id": 99 + index,
7043                "message": {
7044                    "message_id": 49 + index,
7045                    "text": "forwarded item",
7046                    "from": { "id": 1, "username": "alice" },
7047                    "chat": { "id": 999 },
7048                    "forward_origin": origin
7049                }
7050            });
7051
7052            let msg = ch
7053                .parse_update_message(&update)
7054                .expect("forward_origin message should parse");
7055            assert_eq!(msg.content, expected);
7056        }
7057    }
7058
7059    #[test]
7060    fn parse_update_message_forwarded_reply_keeps_quote_block_separate() {
7061        let mention_only = false;
7062        let ch = TelegramChannel::new(
7063            "token".into(),
7064            "telegram_test_alias",
7065            Arc::new(|| vec!["*".into()]),
7066            mention_only,
7067        );
7068        let update = serde_json::json!({
7069            "update_id": 110,
7070            "message": {
7071                "message_id": 60,
7072                "text": "look at this news",
7073                "from": { "id": 1, "username": "alice" },
7074                "chat": { "id": 999 },
7075                "forward_origin": {
7076                    "type": "channel",
7077                    "chat": { "id": 123, "title": "News Channel" }
7078                },
7079                "reply_to_message": {
7080                    "message_id": 59,
7081                    "text": "What do you think?",
7082                    "from": { "id": 2, "username": "bot" }
7083                }
7084            }
7085        });
7086
7087        let msg = ch
7088            .parse_update_message(&update)
7089            .expect("forwarded reply should parse");
7090        assert_eq!(
7091            msg.content,
7092            "[Forwarded from channel: News Channel]\n\n> @bot:\n> What do you think?\n\nlook at this news"
7093        );
7094    }
7095
7096    #[test]
7097    fn parse_update_message_forwarded_from_user_with_username() {
7098        let mention_only = false;
7099        let ch = TelegramChannel::new(
7100            "token".into(),
7101            "telegram_test_alias",
7102            Arc::new(|| vec!["*".into()]),
7103            mention_only,
7104        );
7105        let update = serde_json::json!({
7106            "update_id": 100,
7107            "message": {
7108                "message_id": 50,
7109                "text": "Check this out",
7110                "from": { "id": 1, "username": "alice" },
7111                "chat": { "id": 999 },
7112                "forward_from": {
7113                    "id": 42,
7114                    "first_name": "Bob",
7115                    "username": "bob"
7116                },
7117                "forward_date": 1_700_000_000
7118            }
7119        });
7120
7121        let msg = ch
7122            .parse_update_message(&update)
7123            .expect("forwarded message should parse");
7124        assert_eq!(msg.content, "[Forwarded from @bob] Check this out");
7125    }
7126
7127    #[test]
7128    fn parse_update_message_forwarded_from_channel() {
7129        let mention_only = false;
7130        let ch = TelegramChannel::new(
7131            "token".into(),
7132            "telegram_test_alias",
7133            Arc::new(|| vec!["*".into()]),
7134            mention_only,
7135        );
7136        let update = serde_json::json!({
7137            "update_id": 101,
7138            "message": {
7139                "message_id": 51,
7140                "text": "Breaking news",
7141                "from": { "id": 1, "username": "alice" },
7142                "chat": { "id": 999 },
7143                "forward_from_chat": {
7144                    "id": -1_001_234_567_890_i64,
7145                    "title": "Daily News",
7146                    "username": "dailynews",
7147                    "type": "channel"
7148                },
7149                "forward_date": 1_700_000_000
7150            }
7151        });
7152
7153        let msg = ch
7154            .parse_update_message(&update)
7155            .expect("channel-forwarded message should parse");
7156        assert_eq!(
7157            msg.content,
7158            "[Forwarded from channel: Daily News] Breaking news"
7159        );
7160    }
7161
7162    #[test]
7163    fn parse_update_message_forwarded_hidden_sender() {
7164        let mention_only = false;
7165        let ch = TelegramChannel::new(
7166            "token".into(),
7167            "telegram_test_alias",
7168            Arc::new(|| vec!["*".into()]),
7169            mention_only,
7170        );
7171        let update = serde_json::json!({
7172            "update_id": 102,
7173            "message": {
7174                "message_id": 52,
7175                "text": "Secret tip",
7176                "from": { "id": 1, "username": "alice" },
7177                "chat": { "id": 999 },
7178                "forward_sender_name": "Hidden User",
7179                "forward_date": 1_700_000_000
7180            }
7181        });
7182
7183        let msg = ch
7184            .parse_update_message(&update)
7185            .expect("hidden-sender forwarded message should parse");
7186        assert_eq!(msg.content, "[Forwarded from Hidden User] Secret tip");
7187    }
7188
7189    #[test]
7190    fn parse_update_message_non_forwarded_unaffected() {
7191        let mention_only = false;
7192        let ch = TelegramChannel::new(
7193            "token".into(),
7194            "telegram_test_alias",
7195            Arc::new(|| vec!["*".into()]),
7196            mention_only,
7197        );
7198        let update = serde_json::json!({
7199            "update_id": 103,
7200            "message": {
7201                "message_id": 53,
7202                "text": "Normal message",
7203                "from": { "id": 1, "username": "alice" },
7204                "chat": { "id": 999 }
7205            }
7206        });
7207
7208        let msg = ch
7209            .parse_update_message(&update)
7210            .expect("non-forwarded message should parse");
7211        assert_eq!(msg.content, "Normal message");
7212    }
7213
7214    #[test]
7215    fn parse_update_message_forwarded_from_user_no_username() {
7216        let mention_only = false;
7217        let ch = TelegramChannel::new(
7218            "token".into(),
7219            "telegram_test_alias",
7220            Arc::new(|| vec!["*".into()]),
7221            mention_only,
7222        );
7223        let update = serde_json::json!({
7224            "update_id": 104,
7225            "message": {
7226                "message_id": 54,
7227                "text": "Hello there",
7228                "from": { "id": 1, "username": "alice" },
7229                "chat": { "id": 999 },
7230                "forward_from": {
7231                    "id": 77,
7232                    "first_name": "Charlie"
7233                },
7234                "forward_date": 1_700_000_000
7235            }
7236        });
7237
7238        let msg = ch
7239            .parse_update_message(&update)
7240            .expect("forwarded message without username should parse");
7241        assert_eq!(msg.content, "[Forwarded from Charlie] Hello there");
7242    }
7243
7244    #[test]
7245    fn forwarded_photo_attachment_has_attribution() {
7246        // Verify that format_forward_attribution produces correct prefix
7247        // for a photo message (the actual download is async, so we test the
7248        // helper directly with a photo-bearing message structure).
7249        let message = serde_json::json!({
7250            "message_id": 60,
7251            "from": { "id": 1, "username": "alice" },
7252            "chat": { "id": 999 },
7253            "photo": [
7254                { "file_id": "abc123", "file_unique_id": "u1", "width": 320, "height": 240 }
7255            ],
7256            "forward_origin": {
7257                "type": "user",
7258                "sender_user": {
7259                    "id": 42,
7260                    "username": "bob"
7261                }
7262            },
7263            "forward_date": 1_700_000_000
7264        });
7265
7266        let attr =
7267            TelegramChannel::format_forward_attribution(&message).expect("should detect forward");
7268        assert_eq!(attr, "[Forwarded from @bob] ");
7269
7270        // Simulate what try_parse_attachment_message does after building content
7271        let photo_content = "[IMAGE:/tmp/photo.jpg]".to_string();
7272        let content = TelegramChannel::prepend_forward_attribution(&attr, photo_content);
7273        assert_eq!(content, "[Forwarded from @bob] [IMAGE:/tmp/photo.jpg]");
7274    }
7275
7276    #[tokio::test]
7277    async fn register_bot_commands_sends_correct_payload() {
7278        use wiremock::matchers::{body_json, method, path_regex};
7279        use wiremock::{Mock, MockServer, ResponseTemplate};
7280
7281        let mock_server = MockServer::start().await;
7282
7283        let expected_body = serde_json::json!({
7284            "commands": [
7285                { "command": "new",    "description": "Start a new conversation session" },
7286                { "command": "stop",   "description": "Cancel the current in-flight task" },
7287                { "command": "model",  "description": "Show or switch the current model" },
7288                { "command": "models", "description": "List available model_providers or switch model_provider" },
7289                { "command": "config", "description": "Show current configuration" },
7290            ]
7291        });
7292
7293        Mock::given(method("POST"))
7294            .and(path_regex(r"/bot[^/]+/setMyCommands$"))
7295            .and(body_json(&expected_body))
7296            .respond_with(
7297                ResponseTemplate::new(200)
7298                    .set_body_json(serde_json::json!({ "ok": true, "result": true })),
7299            )
7300            .expect(1)
7301            .mount(&mock_server)
7302            .await;
7303
7304        let mention_only = false;
7305        let ch = TelegramChannel::new(
7306            "fake-token".into(),
7307            "telegram_test_alias",
7308            Arc::new(|| vec!["*".into()]),
7309            mention_only,
7310        )
7311        .with_api_base(mock_server.uri());
7312
7313        ch.register_bot_commands().await;
7314
7315        // Mock expectation assert happens on MockServer drop
7316    }
7317
7318    #[tokio::test]
7319    async fn register_bot_commands_handles_failure_gracefully() {
7320        use wiremock::matchers::{method, path_regex};
7321        use wiremock::{Mock, MockServer, ResponseTemplate};
7322
7323        let mock_server = MockServer::start().await;
7324
7325        Mock::given(method("POST"))
7326            .and(path_regex(r"/bot[^/]+/setMyCommands$"))
7327            .respond_with(ResponseTemplate::new(500).set_body_json(
7328                serde_json::json!({ "ok": false, "description": "Internal Server Error" }),
7329            ))
7330            .expect(1)
7331            .mount(&mock_server)
7332            .await;
7333
7334        let mention_only = false;
7335        let ch = TelegramChannel::new(
7336            "fake-token".into(),
7337            "telegram_test_alias",
7338            Arc::new(|| vec!["*".into()]),
7339            mention_only,
7340        )
7341        .with_api_base(mock_server.uri());
7342
7343        // Should not panic — errors are logged, not propagated.
7344        ch.register_bot_commands().await;
7345    }
7346
7347    #[test]
7348    fn sanitize_telegram_command_name_basic() {
7349        assert_eq!(sanitize_telegram_command_name("hello"), "hello");
7350        assert_eq!(sanitize_telegram_command_name("Hello"), "hello");
7351        assert_eq!(sanitize_telegram_command_name("my-skill"), "my_skill");
7352        assert_eq!(sanitize_telegram_command_name("my skill"), "my_skill");
7353        assert_eq!(
7354            sanitize_telegram_command_name("My Cool Skill!"),
7355            "my_cool_skill"
7356        );
7357    }
7358
7359    #[test]
7360    fn sanitize_telegram_command_name_trims_underscores() {
7361        assert_eq!(sanitize_telegram_command_name("_leading"), "leading");
7362        assert_eq!(sanitize_telegram_command_name("trailing_"), "trailing");
7363        assert_eq!(sanitize_telegram_command_name("__both__"), "both");
7364    }
7365
7366    #[test]
7367    fn sanitize_telegram_command_name_collapses_double_underscores() {
7368        assert_eq!(sanitize_telegram_command_name("a--b"), "a_b");
7369        assert_eq!(sanitize_telegram_command_name("a---b"), "a_b");
7370    }
7371
7372    #[test]
7373    fn sanitize_telegram_command_name_truncates_to_32_chars() {
7374        let long = "a".repeat(50);
7375        let result = sanitize_telegram_command_name(&long);
7376        assert!(result.len() <= TELEGRAM_COMMAND_NAME_MAX_LEN);
7377        assert_eq!(result.len(), 32);
7378    }
7379
7380    #[test]
7381    fn sanitize_telegram_command_name_empty_input() {
7382        assert_eq!(sanitize_telegram_command_name(""), "");
7383        assert_eq!(sanitize_telegram_command_name("---"), "");
7384    }
7385
7386    #[test]
7387    fn truncate_telegram_command_description_short() {
7388        assert_eq!(
7389            truncate_telegram_command_description("Short desc"),
7390            "Short desc"
7391        );
7392    }
7393
7394    #[test]
7395    fn truncate_telegram_command_description_at_limit() {
7396        let exact = "a".repeat(TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN);
7397        assert_eq!(truncate_telegram_command_description(&exact), exact);
7398    }
7399
7400    #[test]
7401    fn truncate_telegram_command_description_over_limit() {
7402        let long = "a".repeat(TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN + 10);
7403        let result = truncate_telegram_command_description(&long);
7404        assert!(result.chars().count() <= TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN);
7405        assert!(result.ends_with('…'));
7406    }
7407
7408    #[test]
7409    fn truncate_telegram_command_description_multibyte_within_char_limit() {
7410        // Multibyte string within Telegram's 100-character description limit
7411        // but well over 100 bytes in UTF-8 encoding. The function must use
7412        // character count (not byte count) to decide whether to truncate, so
7413        // a string like this should pass through unchanged with no trailing
7414        // ellipsis. Construction is deterministic via `repeat` so the byte
7415        // arithmetic is verifiable from the source: 31 ASCII bytes + 30 × 4
7416        // bytes (`🌧` is U+1F327, 4 bytes UTF-8) = 151 bytes, 61 chars.
7417        let desc = format!("Multibyte weather description: {}", "🌧".repeat(30));
7418        assert!(desc.chars().count() <= TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN);
7419        assert!(desc.len() > TELEGRAM_COMMAND_DESCRIPTION_MAX_LEN);
7420        let result = truncate_telegram_command_description(&desc);
7421        assert!(
7422            !result.ends_with('…'),
7423            "should not append ellipsis when within char limit"
7424        );
7425        assert_eq!(result, desc.trim());
7426    }
7427
7428    #[tokio::test]
7429    async fn register_bot_commands_includes_skills() {
7430        use wiremock::matchers::{body_json, method, path_regex};
7431        use wiremock::{Mock, MockServer, ResponseTemplate};
7432
7433        let workspace = tempfile::tempdir().unwrap();
7434        let skill_dir = workspace.path().join("skills").join("weather");
7435        std::fs::create_dir_all(&skill_dir).unwrap();
7436        std::fs::write(
7437            skill_dir.join("SKILL.md"),
7438            "---\nname: weather\ndescription: Check the weather forecast\n---\n# Weather\n",
7439        )
7440        .unwrap();
7441
7442        let mock_server = MockServer::start().await;
7443
7444        let expected_body = serde_json::json!({
7445            "commands": [
7446                { "command": "new",     "description": "Start a new conversation session" },
7447                { "command": "stop",    "description": "Cancel the current in-flight task" },
7448                { "command": "model",   "description": "Show or switch the current model" },
7449                { "command": "models",  "description": "List available model_providers or switch model_provider" },
7450                { "command": "config",  "description": "Show current configuration" },
7451                { "command": "weather", "description": "Check the weather forecast" },
7452            ]
7453        });
7454
7455        Mock::given(method("POST"))
7456            .and(path_regex(r"/bot[^/]+/setMyCommands$"))
7457            .and(body_json(&expected_body))
7458            .respond_with(
7459                ResponseTemplate::new(200)
7460                    .set_body_json(serde_json::json!({ "ok": true, "result": true })),
7461            )
7462            .expect(1)
7463            .mount(&mock_server)
7464            .await;
7465
7466        let mention_only = false;
7467        let ch = TelegramChannel::new(
7468            "fake-token".into(),
7469            "telegram_test_alias",
7470            Arc::new(|| vec!["*".into()]),
7471            mention_only,
7472        )
7473        .with_api_base(mock_server.uri())
7474        .with_workspace_dir(workspace.path().to_path_buf());
7475
7476        ch.register_bot_commands().await;
7477    }
7478
7479    #[tokio::test]
7480    async fn register_bot_commands_includes_tools_from_config() {
7481        use wiremock::matchers::{body_json, method, path_regex};
7482        use wiremock::{Mock, MockServer, ResponseTemplate};
7483
7484        let mock_server = MockServer::start().await;
7485
7486        let expected_body = serde_json::json!({
7487            "commands": [
7488                { "command": "new",       "description": "Start a new conversation session" },
7489                { "command": "stop",      "description": "Cancel the current in-flight task" },
7490                { "command": "model",     "description": "Show or switch the current model" },
7491                { "command": "models",    "description": "List available model_providers or switch model_provider" },
7492                { "command": "config",    "description": "Show current configuration" },
7493                { "command": "test_tool", "description": "A test tool" },
7494            ]
7495        });
7496
7497        Mock::given(method("POST"))
7498            .and(path_regex(r"/bot[^/]+/setMyCommands$"))
7499            .and(body_json(&expected_body))
7500            .respond_with(
7501                ResponseTemplate::new(200)
7502                    .set_body_json(serde_json::json!({ "ok": true, "result": true })),
7503            )
7504            .expect(1)
7505            .mount(&mock_server)
7506            .await;
7507
7508        let specs = vec![("test_tool".to_string(), "A test tool".to_string())];
7509        let mention_only = false;
7510        let ch = TelegramChannel::new(
7511            "fake-token".into(),
7512            "telegram_test_alias",
7513            Arc::new(|| vec!["*".into()]),
7514            mention_only,
7515        )
7516        .with_api_base(mock_server.uri())
7517        .with_tool_command_specs(specs);
7518
7519        ch.register_bot_commands().await;
7520    }
7521
7522    // ── Approval inline keyboard tests ────────────────────────
7523
7524    #[test]
7525    fn pending_approvals_map_is_initially_empty() {
7526        let mention_only = false;
7527        let ch = TelegramChannel::new(
7528            "token".into(),
7529            "telegram_test_alias",
7530            Arc::new(|| vec!["*".into()]),
7531            mention_only,
7532        );
7533        let rt = tokio::runtime::Builder::new_current_thread()
7534            .enable_all()
7535            .build()
7536            .unwrap();
7537        rt.block_on(async {
7538            let map = ch.pending_approvals.lock().await;
7539            assert!(map.is_empty());
7540        });
7541    }
7542
7543    #[test]
7544    fn approval_timeout_defaults_to_120_and_is_overridable() {
7545        let mention_only = false;
7546        let ch = TelegramChannel::new(
7547            "t".into(),
7548            "telegram_test_alias",
7549            Arc::new(|| vec!["*".into()]),
7550            mention_only,
7551        );
7552        assert_eq!(ch.approval_timeout_secs, 120);
7553        let ch = ch.with_approval_timeout_secs(30);
7554        assert_eq!(ch.approval_timeout_secs, 30);
7555    }
7556
7557    #[tokio::test]
7558    async fn pending_approval_oneshot_delivers_response() {
7559        use zeroclaw_api::channel::ChannelApprovalResponse;
7560
7561        let mention_only = false;
7562        let ch = TelegramChannel::new(
7563            "token".into(),
7564            "telegram_test_alias",
7565            Arc::new(|| vec!["*".into()]),
7566            mention_only,
7567        );
7568        let approval_id = "test-approval-123".to_string();
7569        let (tx, rx) = tokio::sync::oneshot::channel();
7570
7571        ch.pending_approvals
7572            .lock()
7573            .await
7574            .insert(approval_id.clone(), tx);
7575
7576        // Simulate what listen() does when a callback_query arrives
7577        if let Some(sender) = ch.pending_approvals.lock().await.remove(&approval_id) {
7578            sender.send(ChannelApprovalResponse::Approve).unwrap();
7579        }
7580
7581        let result = rx.await.unwrap();
7582        assert_eq!(result, ChannelApprovalResponse::Approve);
7583    }
7584
7585    #[test]
7586    fn callback_data_format_parses_correctly() {
7587        // Verify the callback_data format used by request_approval
7588        let cb_data = "approval:abc-123:approve";
7589        let rest = cb_data.strip_prefix("approval:").unwrap();
7590        let (id, action) = rest.rsplit_once(':').unwrap();
7591        assert_eq!(id, "abc-123");
7592        assert_eq!(action, "approve");
7593
7594        let cb_data = "approval:abc-123:deny";
7595        let rest = cb_data.strip_prefix("approval:").unwrap();
7596        let (id, action) = rest.rsplit_once(':').unwrap();
7597        assert_eq!(id, "abc-123");
7598        assert_eq!(action, "deny");
7599
7600        let cb_data = "approval:abc-123:always";
7601        let rest = cb_data.strip_prefix("approval:").unwrap();
7602        let (id, action) = rest.rsplit_once(':').unwrap();
7603        assert_eq!(id, "abc-123");
7604        assert_eq!(action, "always");
7605    }
7606
7607    #[test]
7608    fn callback_data_with_uuid_parses_correctly() {
7609        // UUIDs contain hyphens — rsplit_once(':') must split at the LAST colon
7610        let uuid = "550e8400-e29b-41d4-a716-446655440000";
7611        let cb_data = format!("approval:{uuid}:approve");
7612        let rest = cb_data.strip_prefix("approval:").unwrap();
7613        let (id, action) = rest.rsplit_once(':').unwrap();
7614        assert_eq!(id, uuid);
7615        assert_eq!(action, "approve");
7616    }
7617
7618    #[test]
7619    fn non_approval_callback_data_is_ignored() {
7620        let cb_data = "some_other_action:data";
7621        assert!(cb_data.strip_prefix("approval:").is_none());
7622    }
7623}