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