Skip to main content

construct/channels/
telegram.rs

1use super::traits::{Channel, ChannelMessage, SendMessage};
2use crate::config::{Config, StreamMode};
3use crate::security::pairing::PairingGuard;
4use anyhow::Context;
5use async_trait::async_trait;
6use directories::UserDirs;
7use parking_lot::Mutex;
8use reqwest::multipart::{Form, Part};
9use std::fmt::Write as _;
10use std::path::Path;
11use std::sync::{Arc, RwLock};
12use std::time::Duration;
13use tokio::fs;
14
15/// Telegram's maximum message length for text messages
16const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
17/// Reserve space for continuation markers added by send_text_chunks:
18/// worst case is "(continued)\n\n" + chunk + "\n\n(continues...)" = 30 extra chars
19const TELEGRAM_CONTINUATION_OVERHEAD: usize = 30;
20const TELEGRAM_ACK_REACTIONS: &[&str] = &["⚡️", "👌", "👀", "🔥", "👍"];
21
22/// Metadata for an incoming document or photo attachment.
23#[derive(Debug, Clone, PartialEq, Eq)]
24struct IncomingAttachment {
25    file_id: String,
26    file_name: Option<String>,
27    file_size: Option<u64>,
28    caption: Option<String>,
29    kind: IncomingAttachmentKind,
30}
31
32/// The kind of incoming attachment (document vs photo).
33#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum IncomingAttachmentKind {
35    Document,
36    Photo,
37}
38const TELEGRAM_BIND_COMMAND: &str = "/bind";
39
40/// Split a message into chunks that respect Telegram's 4096 character limit.
41/// Tries to split at word boundaries when possible, and handles continuation.
42/// The effective per-chunk limit is reduced to leave room for continuation markers.
43fn split_message_for_telegram(message: &str) -> Vec<String> {
44    if message.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH {
45        return vec![message.to_string()];
46    }
47
48    let mut chunks = Vec::new();
49    let mut remaining = message;
50    let chunk_limit = TELEGRAM_MAX_MESSAGE_LENGTH - TELEGRAM_CONTINUATION_OVERHEAD;
51
52    while !remaining.is_empty() {
53        // If the remainder fits within the full limit, take it all (last chunk
54        // or single chunk — continuation overhead is at most 14 chars).
55        if remaining.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH {
56            chunks.push(remaining.to_string());
57            break;
58        }
59
60        // Find the byte offset for the Nth character boundary.
61        let hard_split = remaining
62            .char_indices()
63            .nth(chunk_limit)
64            .map_or(remaining.len(), |(idx, _)| idx);
65
66        let chunk_end = if hard_split == remaining.len() {
67            hard_split
68        } else {
69            // Try to find a good break point (newline, then space)
70            let search_area = &remaining[..hard_split];
71
72            // Prefer splitting at newline
73            if let Some(pos) = search_area.rfind('\n') {
74                // Don't split if the newline is too close to the start
75                if search_area[..pos].chars().count() >= chunk_limit / 2 {
76                    pos + 1
77                } else {
78                    // Try space as fallback
79                    search_area.rfind(' ').unwrap_or(hard_split) + 1
80                }
81            } else if let Some(pos) = search_area.rfind(' ') {
82                pos + 1
83            } else {
84                // Hard split at character boundary
85                hard_split
86            }
87        };
88
89        chunks.push(remaining[..chunk_end].to_string());
90        remaining = &remaining[chunk_end..];
91    }
92
93    chunks
94}
95
96fn pick_uniform_index(len: usize) -> usize {
97    debug_assert!(len > 0);
98    let upper = len as u64;
99    let reject_threshold = (u64::MAX / upper) * upper;
100
101    loop {
102        let value = rand::random::<u64>();
103        if value < reject_threshold {
104            #[allow(clippy::cast_possible_truncation)]
105            return (value % upper) as usize;
106        }
107    }
108}
109
110fn random_telegram_ack_reaction() -> &'static str {
111    TELEGRAM_ACK_REACTIONS[pick_uniform_index(TELEGRAM_ACK_REACTIONS.len())]
112}
113
114fn build_telegram_ack_reaction_request(
115    chat_id: &str,
116    message_id: i64,
117    emoji: &str,
118) -> serde_json::Value {
119    serde_json::json!({
120        "chat_id": chat_id,
121        "message_id": message_id,
122        "reaction": [{
123            "type": "emoji",
124            "emoji": emoji
125        }]
126    })
127}
128
129#[derive(Debug, Clone, Copy, PartialEq, Eq)]
130enum TelegramAttachmentKind {
131    Image,
132    Document,
133    Video,
134    Audio,
135    Voice,
136}
137
138#[derive(Debug, Clone, PartialEq, Eq)]
139struct TelegramAttachment {
140    kind: TelegramAttachmentKind,
141    target: String,
142}
143
144impl TelegramAttachmentKind {
145    fn from_marker(marker: &str) -> Option<Self> {
146        match marker.trim().to_ascii_uppercase().as_str() {
147            "IMAGE" | "PHOTO" => Some(Self::Image),
148            "DOCUMENT" | "FILE" => Some(Self::Document),
149            "VIDEO" => Some(Self::Video),
150            "AUDIO" => Some(Self::Audio),
151            "VOICE" => Some(Self::Voice),
152            _ => None,
153        }
154    }
155}
156
157/// Check whether a file path has a recognized image extension.
158fn is_image_extension(path: &Path) -> bool {
159    path.extension()
160        .and_then(|ext| ext.to_str())
161        .map(|ext| {
162            matches!(
163                ext.to_ascii_lowercase().as_str(),
164                "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp"
165            )
166        })
167        .unwrap_or(false)
168}
169
170/// Build the user-facing content string for an incoming attachment.
171///
172/// Photos with a recognized image extension use `[IMAGE:/path]` so the
173/// multimodal pipeline can validate vision capability. Non-image files
174/// always use `[Document: name] /path` regardless of how Telegram
175/// classified them.
176fn format_attachment_content(
177    kind: IncomingAttachmentKind,
178    local_filename: &str,
179    local_path: &Path,
180) -> String {
181    match kind {
182        IncomingAttachmentKind::Photo | IncomingAttachmentKind::Document
183            if is_image_extension(local_path) =>
184        {
185            format!("[IMAGE:{}]", local_path.display())
186        }
187        _ => {
188            format!("[Document: {}] {}", local_filename, local_path.display())
189        }
190    }
191}
192
193fn is_http_url(target: &str) -> bool {
194    target.starts_with("http://") || target.starts_with("https://")
195}
196
197fn infer_attachment_kind_from_target(target: &str) -> Option<TelegramAttachmentKind> {
198    let normalized = target
199        .split('?')
200        .next()
201        .unwrap_or(target)
202        .split('#')
203        .next()
204        .unwrap_or(target);
205
206    let extension = Path::new(normalized)
207        .extension()
208        .and_then(|ext| ext.to_str())?
209        .to_ascii_lowercase();
210
211    match extension.as_str() {
212        "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Some(TelegramAttachmentKind::Image),
213        "mp4" | "mov" | "mkv" | "avi" | "webm" => Some(TelegramAttachmentKind::Video),
214        "mp3" | "m4a" | "wav" | "flac" => Some(TelegramAttachmentKind::Audio),
215        "ogg" | "oga" | "opus" => Some(TelegramAttachmentKind::Voice),
216        "pdf" | "txt" | "md" | "csv" | "json" | "zip" | "tar" | "gz" | "doc" | "docx" | "xls"
217        | "xlsx" | "ppt" | "pptx" => Some(TelegramAttachmentKind::Document),
218        _ => None,
219    }
220}
221
222fn parse_path_only_attachment(message: &str) -> Option<TelegramAttachment> {
223    let trimmed = message.trim();
224    if trimmed.is_empty() || trimmed.contains('\n') {
225        return None;
226    }
227
228    let candidate = trimmed.trim_matches(|c| matches!(c, '`' | '"' | '\''));
229    if candidate.chars().any(char::is_whitespace) {
230        return None;
231    }
232
233    let candidate = candidate.strip_prefix("file://").unwrap_or(candidate);
234    let kind = infer_attachment_kind_from_target(candidate)?;
235
236    if !is_http_url(candidate) && !Path::new(candidate).exists() {
237        return None;
238    }
239
240    Some(TelegramAttachment {
241        kind,
242        target: candidate.to_string(),
243    })
244}
245
246/// Delegate to the shared `strip_tool_call_tags` in the parent module.
247fn strip_tool_call_tags(message: &str) -> String {
248    super::strip_tool_call_tags(message)
249}
250
251fn find_matching_close(s: &str) -> Option<usize> {
252    let mut depth = 1usize;
253    for (i, ch) in s.char_indices() {
254        match ch {
255            '[' => depth += 1,
256            ']' => {
257                depth -= 1;
258                if depth == 0 {
259                    return Some(i);
260                }
261            }
262            _ => {}
263        }
264    }
265    None
266}
267
268fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>) {
269    let mut cleaned = String::with_capacity(message.len());
270    let mut attachments = Vec::new();
271    let mut cursor = 0;
272
273    while cursor < message.len() {
274        let Some(open_rel) = message[cursor..].find('[') else {
275            cleaned.push_str(&message[cursor..]);
276            break;
277        };
278
279        let open = cursor + open_rel;
280        cleaned.push_str(&message[cursor..open]);
281
282        let Some(close_rel) = find_matching_close(&message[open + 1..]) else {
283            cleaned.push_str(&message[open..]);
284            break;
285        };
286
287        let close = open + 1 + close_rel;
288        let marker = &message[open + 1..close];
289
290        let parsed = marker.split_once(':').and_then(|(kind, target)| {
291            let kind = TelegramAttachmentKind::from_marker(kind)?;
292            let target = target.trim();
293            if target.is_empty() {
294                return None;
295            }
296            Some(TelegramAttachment {
297                kind,
298                target: target.to_string(),
299            })
300        });
301
302        if let Some(attachment) = parsed {
303            attachments.push(attachment);
304        } else {
305            cleaned.push_str(&message[open..=close]);
306        }
307
308        cursor = close + 1;
309    }
310
311    (cleaned.trim().to_string(), attachments)
312}
313
314/// Telegram Bot API maximum file download size (20 MB).
315const TELEGRAM_MAX_FILE_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
316
317/// Telegram channel — long-polls the Bot API for updates
318pub struct TelegramChannel {
319    bot_token: String,
320    allowed_users: Arc<RwLock<Vec<String>>>,
321    pairing: Option<PairingGuard>,
322    client: reqwest::Client,
323    typing_handle: Mutex<Option<tokio::task::JoinHandle<()>>>,
324    stream_mode: StreamMode,
325    draft_update_interval_ms: u64,
326    last_draft_edit: Mutex<std::collections::HashMap<String, std::time::Instant>>,
327    mention_only: bool,
328    bot_username: Mutex<Option<String>>,
329    /// Base URL for the Telegram Bot API. Defaults to `https://api.telegram.org`.
330    /// Override for local Bot API servers or testing.
331    api_base: String,
332    transcription: Option<crate::config::TranscriptionConfig>,
333    transcription_manager: Option<std::sync::Arc<super::transcription::TranscriptionManager>>,
334    voice_transcriptions: Mutex<std::collections::HashMap<String, String>>,
335    workspace_dir: Option<std::path::PathBuf>,
336    ack_reactions: bool,
337    tts_config: Option<crate::config::TtsConfig>,
338    voice_chats: Arc<std::sync::Mutex<std::collections::HashSet<String>>>,
339    pending_voice:
340        Arc<std::sync::Mutex<std::collections::HashMap<String, (String, std::time::Instant)>>>,
341    /// Per-channel proxy URL override.
342    proxy_url: Option<String>,
343    /// Process-global registry for pending workflow approvals.
344    approval_registry: Option<std::sync::Arc<crate::gateway::approval_registry::ApprovalRegistry>>,
345    /// Gateway HTTP port for calling the workflow approval endpoint.
346    gateway_port: u16,
347}
348
349#[derive(Debug, Clone, Copy, PartialEq, Eq)]
350enum EditMessageResult {
351    Success,
352    NotModified,
353    Failed(reqwest::StatusCode),
354}
355
356impl TelegramChannel {
357    pub fn new(bot_token: String, allowed_users: Vec<String>, mention_only: bool) -> Self {
358        let normalized_allowed = Self::normalize_allowed_users(allowed_users);
359        let pairing = if normalized_allowed.is_empty() {
360            let guard = PairingGuard::new(true, &[]);
361            if let Some(code) = guard.pairing_code() {
362                println!("  🔐 Telegram pairing required. One-time bind code: {code}");
363                println!("     Send `{TELEGRAM_BIND_COMMAND} <code>` from your Telegram account.");
364            }
365            Some(guard)
366        } else {
367            None
368        };
369
370        Self {
371            bot_token,
372            allowed_users: Arc::new(RwLock::new(normalized_allowed)),
373            pairing,
374            client: reqwest::Client::new(),
375            stream_mode: StreamMode::Off,
376            draft_update_interval_ms: 1000,
377            last_draft_edit: Mutex::new(std::collections::HashMap::new()),
378            typing_handle: Mutex::new(None),
379            mention_only,
380            bot_username: Mutex::new(None),
381            api_base: "https://api.telegram.org".to_string(),
382            transcription: None,
383            transcription_manager: None,
384            voice_transcriptions: Mutex::new(std::collections::HashMap::new()),
385            workspace_dir: None,
386            ack_reactions: true,
387            tts_config: None,
388            voice_chats: Arc::new(std::sync::Mutex::new(std::collections::HashSet::new())),
389            pending_voice: Arc::new(std::sync::Mutex::new(std::collections::HashMap::new())),
390            proxy_url: None,
391            approval_registry: None,
392            gateway_port: 42617,
393        }
394    }
395
396    /// Attach the process-global approval registry so approval keyword replies
397    /// (Telegram messages that reply-to the approval prompt) can be routed to
398    /// the gateway's resume endpoint.
399    pub fn with_approval_registry(
400        mut self,
401        registry: std::sync::Arc<crate::gateway::approval_registry::ApprovalRegistry>,
402        gateway_port: u16,
403    ) -> Self {
404        self.approval_registry = Some(registry);
405        self.gateway_port = gateway_port;
406        self
407    }
408
409    /// Check whether an incoming Telegram message is a reply to an approval
410    /// prompt and, if so, atomically claim the pending approval, POST to the
411    /// gateway resume endpoint, and send a confirmation reply. Returns true
412    /// when the message was intercepted.
413    fn try_intercept_approval(
414        &self,
415        chat_id: &str,
416        reply_to_message_id: Option<i64>,
417        clean_text: &str,
418        author_display: &str,
419    ) -> bool {
420        let Some(ref registry) = self.approval_registry else {
421            return false;
422        };
423        let Some(reply_to) = reply_to_message_id else {
424            return false;
425        };
426        let Some((run_id, is_approve, feedback)) =
427            registry.match_telegram_keyword(chat_id, Some(reply_to), clean_text)
428        else {
429            return false;
430        };
431        let Some(pending) = registry.try_claim(&run_id) else {
432            return false;
433        };
434
435        let port = self.gateway_port;
436        let token = self.bot_token.clone();
437        let api_base = self.api_base.clone();
438        let chat = chat_id.to_string();
439        let workflow_name = pending.workflow_name.clone();
440        let author = author_display.to_string();
441        let reply_message_id = reply_to;
442
443        tokio::spawn(async move {
444            let url = format!("http://127.0.0.1:{port}/api/workflows/runs/{run_id}/approve");
445            let client = reqwest::Client::new();
446            match client
447                .post(&url)
448                .json(&serde_json::json!({
449                    "approved": is_approve,
450                    "feedback": feedback,
451                }))
452                .send()
453                .await
454            {
455                Ok(resp) if resp.status().is_success() => {
456                    tracing::info!(
457                        run_id = %run_id,
458                        approved = %is_approve,
459                        "Telegram: workflow approval processed"
460                    );
461                }
462                Ok(resp) => {
463                    tracing::warn!(
464                        run_id = %run_id,
465                        status = %resp.status(),
466                        "Telegram: workflow approval endpoint returned error"
467                    );
468                }
469                Err(e) => {
470                    tracing::warn!(
471                        run_id = %run_id,
472                        error = %e,
473                        "Telegram: failed to call workflow approval endpoint"
474                    );
475                }
476            }
477
478            let confirm = if is_approve {
479                format!("✅ Workflow `{workflow_name}` approved by {author}")
480            } else if feedback.is_empty() {
481                format!("❌ Workflow `{workflow_name}` rejected by {author}")
482            } else {
483                format!("❌ Workflow `{workflow_name}` rejected by {author}. Feedback: {feedback}")
484            };
485
486            let send_url = format!("{api_base}/bot{token}/sendMessage");
487            let _ = client
488                .post(&send_url)
489                .json(&serde_json::json!({
490                    "chat_id": chat,
491                    "text": confirm,
492                    "reply_to_message_id": reply_message_id,
493                }))
494                .send()
495                .await;
496        });
497
498        true
499    }
500
501    /// Configure whether Telegram-native acknowledgement reactions are sent.
502    pub fn with_ack_reactions(mut self, enabled: bool) -> Self {
503        self.ack_reactions = enabled;
504        self
505    }
506
507    /// Set a per-channel proxy URL that overrides the global proxy config.
508    pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
509        self.proxy_url = proxy_url;
510        self
511    }
512
513    /// Configure workspace directory for saving downloaded attachments.
514    pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
515        self.workspace_dir = Some(dir);
516        self
517    }
518
519    /// Configure streaming mode for progressive draft updates.
520    pub fn with_streaming(
521        mut self,
522        stream_mode: StreamMode,
523        draft_update_interval_ms: u64,
524    ) -> Self {
525        self.stream_mode = stream_mode;
526        self.draft_update_interval_ms = draft_update_interval_ms;
527        self
528    }
529
530    /// Override the Telegram Bot API base URL.
531    /// Useful for local Bot API servers or testing.
532    pub fn with_api_base(mut self, api_base: String) -> Self {
533        self.api_base = api_base;
534        self
535    }
536
537    /// Configure voice transcription.
538    pub fn with_transcription(mut self, config: crate::config::TranscriptionConfig) -> Self {
539        if !config.enabled {
540            return self;
541        }
542        match super::transcription::TranscriptionManager::new(&config) {
543            Ok(m) => {
544                self.transcription_manager = Some(std::sync::Arc::new(m));
545                self.transcription = Some(config);
546            }
547            Err(e) => {
548                tracing::warn!(
549                    "transcription manager init failed, voice transcription disabled: {e}"
550                );
551            }
552        }
553        self
554    }
555
556    /// Configure text-to-speech for outgoing voice replies.
557    pub fn with_tts(mut self, config: crate::config::TtsConfig) -> Self {
558        if config.enabled {
559            self.tts_config = Some(config);
560        }
561        self
562    }
563
564    /// Parse reply_target into (chat_id, optional thread_id).
565    fn parse_reply_target(reply_target: &str) -> (String, Option<String>) {
566        if let Some((chat_id, thread_id)) = reply_target.split_once(':') {
567            (chat_id.to_string(), Some(thread_id.to_string()))
568        } else {
569            (reply_target.to_string(), None)
570        }
571    }
572
573    fn extract_update_message_target(update: &serde_json::Value) -> Option<(String, i64)> {
574        let message = update.get("message")?;
575        let chat_id = message
576            .get("chat")
577            .and_then(|chat| chat.get("id"))
578            .and_then(serde_json::Value::as_i64)?
579            .to_string();
580        let message_id = message
581            .get("message_id")
582            .and_then(serde_json::Value::as_i64)?;
583        Some((chat_id, message_id))
584    }
585
586    fn try_add_ack_reaction_nonblocking(&self, chat_id: String, message_id: i64) {
587        let client = self.http_client();
588        let url = self.api_url("setMessageReaction");
589        let emoji = random_telegram_ack_reaction().to_string();
590        let body = build_telegram_ack_reaction_request(&chat_id, message_id, &emoji);
591
592        tokio::spawn(async move {
593            let response = match client.post(&url).json(&body).send().await {
594                Ok(resp) => resp,
595                Err(err) => {
596                    tracing::warn!(
597                        "Telegram: failed to add ACK reaction to chat_id={chat_id}, message_id={message_id}: {err}"
598                    );
599                    return;
600                }
601            };
602
603            if !response.status().is_success() {
604                let status = response.status();
605                let err_body = response.text().await.unwrap_or_default();
606                tracing::warn!(
607                    "Telegram: add ACK reaction failed for chat_id={chat_id}, message_id={message_id}: status={status}, body={err_body}"
608                );
609            }
610        });
611    }
612
613    fn http_client(&self) -> reqwest::Client {
614        crate::config::build_channel_proxy_client("channel.telegram", self.proxy_url.as_deref())
615    }
616
617    fn normalize_identity(value: &str) -> String {
618        value.trim().trim_start_matches('@').to_string()
619    }
620
621    fn normalize_allowed_users(allowed_users: Vec<String>) -> Vec<String> {
622        allowed_users
623            .into_iter()
624            .map(|entry| Self::normalize_identity(&entry))
625            .filter(|entry| !entry.is_empty())
626            .collect()
627    }
628
629    async fn load_config_without_env() -> anyhow::Result<Config> {
630        let home = UserDirs::new()
631            .map(|u| u.home_dir().to_path_buf())
632            .context("Could not find home directory")?;
633        let construct_dir = home.join(".construct");
634        let config_path = construct_dir.join("config.toml");
635
636        let contents = fs::read_to_string(&config_path)
637            .await
638            .with_context(|| format!("Failed to read config file: {}", config_path.display()))?;
639        let mut config: Config = toml::from_str(&contents).context(
640            "Failed to parse config.toml — check [channels.telegram] section for syntax errors",
641        )?;
642        config.config_path = config_path;
643        config.workspace_dir = construct_dir.join("workspace");
644        Ok(config)
645    }
646
647    async fn persist_allowed_identity(&self, identity: &str) -> anyhow::Result<()> {
648        let mut config = Self::load_config_without_env().await?;
649        let Some(telegram) = config.channels_config.telegram.as_mut() else {
650            anyhow::bail!(
651                "Missing [channels.telegram] section in config.toml. \
652                Add bot_token and allowed_users under [channels.telegram], \
653                or run `construct onboard --channels-only` to configure interactively"
654            );
655        };
656
657        let normalized = Self::normalize_identity(identity);
658        if normalized.is_empty() {
659            anyhow::bail!("Cannot persist empty Telegram identity");
660        }
661
662        if !telegram.allowed_users.iter().any(|u| u == &normalized) {
663            telegram.allowed_users.push(normalized);
664            config
665                .save()
666                .await
667                .context("Failed to persist Telegram allowlist to config.toml")?;
668        }
669
670        Ok(())
671    }
672
673    fn add_allowed_identity_runtime(&self, identity: &str) {
674        let normalized = Self::normalize_identity(identity);
675        if normalized.is_empty() {
676            return;
677        }
678        if let Ok(mut users) = self.allowed_users.write() {
679            if !users.iter().any(|u| u == &normalized) {
680                users.push(normalized);
681            }
682        }
683    }
684
685    fn extract_bind_code(text: &str) -> Option<&str> {
686        let mut parts = text.split_whitespace();
687        let command = parts.next()?;
688        let base_command = command.split('@').next().unwrap_or(command);
689        if base_command != TELEGRAM_BIND_COMMAND {
690            return None;
691        }
692        parts.next().map(str::trim).filter(|code| !code.is_empty())
693    }
694
695    fn pairing_code_active(&self) -> bool {
696        self.pairing
697            .as_ref()
698            .and_then(PairingGuard::pairing_code)
699            .is_some()
700    }
701
702    fn api_url(&self, method: &str) -> String {
703        format!("{}/bot{}/{method}", self.api_base, self.bot_token)
704    }
705
706    /// Synthesize text to speech and send as a Telegram voice note (static version for spawned tasks).
707    async fn synthesize_and_send_voice(
708        api_base: &str,
709        bot_token: &str,
710        chat_id: &str,
711        thread_id: Option<&str>,
712        text: &str,
713        tts_config: &crate::config::TtsConfig,
714    ) -> anyhow::Result<()> {
715        let tts_manager = super::tts::TtsManager::new(tts_config)?;
716        let audio_bytes = tts_manager.synthesize(text).await?;
717        let audio_len = audio_bytes.len();
718        tracing::info!("Telegram TTS: synthesized {audio_len} bytes of audio");
719
720        if audio_bytes.is_empty() {
721            anyhow::bail!("TTS returned empty audio");
722        }
723
724        let url = format!("{api_base}/bot{bot_token}/sendVoice");
725        let client = crate::config::build_runtime_proxy_client("channel.telegram");
726
727        let mut form = reqwest::multipart::Form::new()
728            .text("chat_id", chat_id.to_string())
729            .part(
730                "voice",
731                reqwest::multipart::Part::bytes(audio_bytes)
732                    .file_name("voice.ogg")
733                    .mime_str("audio/ogg")?,
734            );
735
736        if let Some(tid) = thread_id {
737            form = form.text("message_thread_id", tid.to_string());
738        }
739
740        let resp = client.post(&url).multipart(form).send().await?;
741        if !resp.status().is_success() {
742            let status = resp.status();
743            let body = resp.text().await.unwrap_or_default();
744            anyhow::bail!("sendVoice failed: status={status}, body={body}");
745        }
746
747        tracing::info!("Telegram TTS: sent voice note ({audio_len} bytes)");
748        Ok(())
749    }
750
751    async fn classify_edit_message_response(resp: reqwest::Response) -> EditMessageResult {
752        if resp.status().is_success() {
753            return EditMessageResult::Success;
754        }
755
756        let status = resp.status();
757        let body = resp.text().await.unwrap_or_default();
758        if body.contains("message is not modified") {
759            return EditMessageResult::NotModified;
760        }
761
762        EditMessageResult::Failed(status)
763    }
764
765    async fn fetch_bot_username(&self) -> anyhow::Result<String> {
766        let resp = self.http_client().get(self.api_url("getMe")).send().await?;
767
768        if !resp.status().is_success() {
769            anyhow::bail!("Failed to fetch bot info: {}", resp.status());
770        }
771
772        let data: serde_json::Value = resp.json().await?;
773        let username = data
774            .get("result")
775            .and_then(|r| r.get("username"))
776            .and_then(|u| u.as_str())
777            .context("Bot username not found in response")?;
778
779        Ok(username.to_string())
780    }
781
782    async fn get_bot_username(&self) -> Option<String> {
783        {
784            let cache = self.bot_username.lock();
785            if let Some(ref username) = *cache {
786                return Some(username.clone());
787            }
788        }
789
790        match self.fetch_bot_username().await {
791            Ok(username) => {
792                let mut cache = self.bot_username.lock();
793                *cache = Some(username.clone());
794                Some(username)
795            }
796            Err(e) => {
797                tracing::warn!("Failed to fetch bot username: {e}");
798                None
799            }
800        }
801    }
802
803    fn is_telegram_username_char(ch: char) -> bool {
804        ch.is_ascii_alphanumeric() || ch == '_'
805    }
806
807    fn find_bot_mention_spans(text: &str, bot_username: &str) -> Vec<(usize, usize)> {
808        let bot_username = bot_username.trim_start_matches('@');
809        if bot_username.is_empty() {
810            return Vec::new();
811        }
812
813        let mut spans = Vec::new();
814
815        for (at_idx, ch) in text.char_indices() {
816            if ch != '@' {
817                continue;
818            }
819
820            if at_idx > 0 {
821                let prev = text[..at_idx].chars().next_back().unwrap_or(' ');
822                if Self::is_telegram_username_char(prev) {
823                    continue;
824                }
825            }
826
827            let username_start = at_idx + 1;
828            let mut username_end = username_start;
829
830            for (rel_idx, candidate_ch) in text[username_start..].char_indices() {
831                if Self::is_telegram_username_char(candidate_ch) {
832                    username_end = username_start + rel_idx + candidate_ch.len_utf8();
833                } else {
834                    break;
835                }
836            }
837
838            if username_end == username_start {
839                continue;
840            }
841
842            let mention_username = &text[username_start..username_end];
843            if mention_username.eq_ignore_ascii_case(bot_username) {
844                spans.push((at_idx, username_end));
845            }
846        }
847
848        spans
849    }
850
851    fn contains_bot_mention(text: &str, bot_username: &str) -> bool {
852        !Self::find_bot_mention_spans(text, bot_username).is_empty()
853    }
854
855    fn normalize_incoming_content(text: &str, bot_username: &str) -> Option<String> {
856        let spans = Self::find_bot_mention_spans(text, bot_username);
857        if spans.is_empty() {
858            let normalized = text.split_whitespace().collect::<Vec<_>>().join(" ");
859            return (!normalized.is_empty()).then_some(normalized);
860        }
861
862        let mut normalized = String::with_capacity(text.len());
863        let mut cursor = 0;
864        for (start, end) in spans {
865            normalized.push_str(&text[cursor..start]);
866            cursor = end;
867        }
868        normalized.push_str(&text[cursor..]);
869
870        let normalized = normalized.split_whitespace().collect::<Vec<_>>().join(" ");
871        (!normalized.is_empty()).then_some(normalized)
872    }
873
874    fn is_group_message(message: &serde_json::Value) -> bool {
875        message
876            .get("chat")
877            .and_then(|c| c.get("type"))
878            .and_then(|t| t.as_str())
879            .map(|t| t == "group" || t == "supergroup")
880            .unwrap_or(false)
881    }
882
883    fn is_user_allowed(&self, username: &str) -> bool {
884        let identity = Self::normalize_identity(username);
885        self.allowed_users
886            .read()
887            .map(|users| users.iter().any(|u| u == "*" || u == &identity))
888            .unwrap_or(false)
889    }
890
891    fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool
892    where
893        I: IntoIterator<Item = &'a str>,
894    {
895        identities.into_iter().any(|id| self.is_user_allowed(id))
896    }
897
898    async fn handle_unauthorized_message(&self, update: &serde_json::Value) {
899        let Some(message) = update.get("message") else {
900            return;
901        };
902
903        let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else {
904            return;
905        };
906
907        let username_opt = message
908            .get("from")
909            .and_then(|from| from.get("username"))
910            .and_then(serde_json::Value::as_str);
911        let username = username_opt.unwrap_or("unknown");
912        let normalized_username = Self::normalize_identity(username);
913
914        let sender_id = message
915            .get("from")
916            .and_then(|from| from.get("id"))
917            .and_then(serde_json::Value::as_i64);
918        let sender_id_str = sender_id.map(|id| id.to_string());
919        let normalized_sender_id = sender_id_str.as_deref().map(Self::normalize_identity);
920
921        let chat_id = message
922            .get("chat")
923            .and_then(|chat| chat.get("id"))
924            .and_then(serde_json::Value::as_i64)
925            .map(|id| id.to_string());
926
927        let Some(chat_id) = chat_id else {
928            tracing::warn!("Telegram: missing chat_id in message, skipping");
929            return;
930        };
931
932        let mut identities = vec![normalized_username.as_str()];
933        if let Some(ref id) = normalized_sender_id {
934            identities.push(id.as_str());
935        }
936
937        if self.is_any_user_allowed(identities.iter().copied()) {
938            return;
939        }
940
941        if let Some(code) = Self::extract_bind_code(text) {
942            if let Some(pairing) = self.pairing.as_ref() {
943                match pairing.try_pair(code, &chat_id).await {
944                    Ok(Some(_token)) => {
945                        let bind_identity = normalized_sender_id.clone().or_else(|| {
946                            if normalized_username.is_empty() || normalized_username == "unknown" {
947                                None
948                            } else {
949                                Some(normalized_username.clone())
950                            }
951                        });
952
953                        if let Some(identity) = bind_identity {
954                            self.add_allowed_identity_runtime(&identity);
955                            match Box::pin(self.persist_allowed_identity(&identity)).await {
956                                Ok(()) => {
957                                    let _ = self
958                                        .send(&SendMessage::new(
959                                            "✅ Telegram account bound successfully. You can talk to Construct now.",
960                                            &chat_id,
961                                        ))
962                                        .await;
963                                    tracing::info!(
964                                        "Telegram: paired and allowlisted identity={identity}"
965                                    );
966                                }
967                                Err(e) => {
968                                    tracing::error!(
969                                        "Telegram: failed to persist allowlist after bind: {e}"
970                                    );
971                                    let _ = self
972                                        .send(&SendMessage::new(
973                                            "⚠️ Bound for this runtime, but failed to persist config. Access may be lost after restart; check config file permissions.",
974                                            &chat_id,
975                                        ))
976                                        .await;
977                                }
978                            }
979                        } else {
980                            let _ = self
981                                .send(&SendMessage::new(
982                                    "❌ Could not identify your Telegram account. Ensure your account has a username or stable user ID, then retry.",
983                                    &chat_id,
984                                ))
985                                .await;
986                        }
987                    }
988                    Ok(None) => {
989                        let _ = self
990                            .send(&SendMessage::new(
991                                "❌ Invalid binding code. Ask operator for the latest code and retry.",
992                                &chat_id,
993                            ))
994                            .await;
995                    }
996                    Err(lockout_secs) => {
997                        let _ = self
998                            .send(&SendMessage::new(
999                                format!("⏳ Too many invalid attempts. Retry in {lockout_secs}s."),
1000                                &chat_id,
1001                            ))
1002                            .await;
1003                    }
1004                }
1005            } else {
1006                let _ = self
1007                    .send(&SendMessage::new(
1008                        "ℹ️ Telegram pairing is not active. Ask operator to add your user ID to channels.telegram.allowed_users in config.toml.",
1009                        &chat_id,
1010                    ))
1011                    .await;
1012            }
1013            return;
1014        }
1015
1016        tracing::warn!(
1017            "Telegram: ignoring message from unauthorized user: username={username}, sender_id={}. \
1018Allowlist Telegram username (without '@') or numeric user ID.",
1019            sender_id_str.as_deref().unwrap_or("unknown")
1020        );
1021
1022        let suggested_identity = normalized_sender_id
1023            .clone()
1024            .or_else(|| {
1025                if normalized_username.is_empty() || normalized_username == "unknown" {
1026                    None
1027                } else {
1028                    Some(normalized_username.clone())
1029                }
1030            })
1031            .unwrap_or_else(|| "YOUR_TELEGRAM_ID".to_string());
1032
1033        let _ = self
1034            .send(&SendMessage::new(
1035                format!(
1036                    "🔐 This bot requires operator approval.\n\nCopy this command to operator terminal:\n`construct channel bind-telegram {suggested_identity}`\n\nAfter operator runs it, send your message again."
1037                ),
1038                &chat_id,
1039            ))
1040            .await;
1041
1042        if self.pairing_code_active() {
1043            let _ = self
1044                .send(&SendMessage::new(
1045                    "ℹ️ If operator provides a one-time pairing code, you can also run `/bind <code>`.",
1046                    &chat_id,
1047                ))
1048                .await;
1049        }
1050    }
1051
1052    /// Get the file path for a Telegram file ID via the Bot API.
1053    async fn get_file_path(&self, file_id: &str) -> anyhow::Result<String> {
1054        let url = self.api_url("getFile");
1055        let resp = self
1056            .http_client()
1057            .get(&url)
1058            .query(&[("file_id", file_id)])
1059            .send()
1060            .await
1061            .context("Failed to call Telegram getFile")?;
1062
1063        let data: serde_json::Value = resp.json().await?;
1064        data.get("result")
1065            .and_then(|r| r.get("file_path"))
1066            .and_then(serde_json::Value::as_str)
1067            .map(String::from)
1068            .context("Telegram getFile: missing file_path in response")
1069    }
1070
1071    /// Download a file from the Telegram CDN.
1072    async fn download_file(&self, file_path: &str) -> anyhow::Result<Vec<u8>> {
1073        let url = format!(
1074            "https://api.telegram.org/file/bot{}/{file_path}",
1075            self.bot_token
1076        );
1077        let resp = self
1078            .http_client()
1079            .get(&url)
1080            .send()
1081            .await
1082            .context("Failed to download Telegram file")?;
1083
1084        if !resp.status().is_success() {
1085            anyhow::bail!("Telegram file download failed: {}", resp.status());
1086        }
1087
1088        Ok(resp.bytes().await?.to_vec())
1089    }
1090
1091    /// Extract (file_id, duration) from a voice or audio message.
1092    fn parse_voice_metadata(message: &serde_json::Value) -> Option<(String, u64)> {
1093        let voice = message.get("voice").or_else(|| message.get("audio"))?;
1094        let file_id = voice.get("file_id")?.as_str()?.to_string();
1095        let duration = voice
1096            .get("duration")
1097            .and_then(serde_json::Value::as_u64)
1098            .unwrap_or(0);
1099        Some((file_id, duration))
1100    }
1101
1102    /// Extract attachment metadata from an incoming Telegram message (document or photo).
1103    ///
1104    /// Returns `None` for text-only, voice, and other unsupported message types.
1105    fn parse_attachment_metadata(message: &serde_json::Value) -> Option<IncomingAttachment> {
1106        // Try document first
1107        if let Some(doc) = message.get("document") {
1108            let file_id = doc.get("file_id")?.as_str()?.to_string();
1109            let file_name = doc
1110                .get("file_name")
1111                .and_then(serde_json::Value::as_str)
1112                .map(String::from);
1113            let file_size = doc.get("file_size").and_then(serde_json::Value::as_u64);
1114            let caption = message
1115                .get("caption")
1116                .and_then(serde_json::Value::as_str)
1117                .map(String::from);
1118            return Some(IncomingAttachment {
1119                file_id,
1120                file_name,
1121                file_size,
1122                caption,
1123                kind: IncomingAttachmentKind::Document,
1124            });
1125        }
1126
1127        // Try photo (array of PhotoSize, take last = highest resolution)
1128        if let Some(photos) = message.get("photo").and_then(serde_json::Value::as_array) {
1129            let best = photos.last()?;
1130            let file_id = best.get("file_id")?.as_str()?.to_string();
1131            let file_size = best.get("file_size").and_then(serde_json::Value::as_u64);
1132            let caption = message
1133                .get("caption")
1134                .and_then(serde_json::Value::as_str)
1135                .map(String::from);
1136            return Some(IncomingAttachment {
1137                file_id,
1138                file_name: None,
1139                file_size,
1140                caption,
1141                kind: IncomingAttachmentKind::Photo,
1142            });
1143        }
1144
1145        None
1146    }
1147
1148    /// Attempt to parse a Telegram update as a document/photo attachment.
1149    ///
1150    /// Downloads the file to `{workspace_dir}/telegram_files/` and returns a
1151    /// `ChannelMessage` with the local file path. Returns `None` if the message
1152    /// is not an attachment, workspace_dir is not configured, or the file exceeds
1153    /// size limits.
1154    async fn try_parse_attachment_message(
1155        &self,
1156        update: &serde_json::Value,
1157    ) -> Option<ChannelMessage> {
1158        let message = update.get("message")?;
1159        let attachment = Self::parse_attachment_metadata(message)?;
1160
1161        // Check file size limit
1162        if let Some(size) = attachment.file_size {
1163            if size > TELEGRAM_MAX_FILE_DOWNLOAD_BYTES {
1164                tracing::info!(
1165                    "Skipping attachment: file size {size} bytes exceeds {} MB limit",
1166                    TELEGRAM_MAX_FILE_DOWNLOAD_BYTES / (1024 * 1024)
1167                );
1168                return None;
1169            }
1170        }
1171
1172        let (username, sender_id, sender_identity) = Self::extract_sender_info(message);
1173
1174        let mut identities = vec![username.as_str()];
1175        if let Some(id) = sender_id.as_deref() {
1176            identities.push(id);
1177        }
1178
1179        if !self.is_any_user_allowed(identities.iter().copied()) {
1180            return None;
1181        }
1182
1183        let chat_id = message
1184            .get("chat")
1185            .and_then(|chat| chat.get("id"))
1186            .and_then(serde_json::Value::as_i64)
1187            .map(|id| id.to_string())?;
1188
1189        let message_id = message
1190            .get("message_id")
1191            .and_then(serde_json::Value::as_i64)
1192            .unwrap_or(0);
1193
1194        let thread_id = message
1195            .get("message_thread_id")
1196            .and_then(serde_json::Value::as_i64)
1197            .map(|id| id.to_string());
1198
1199        let reply_target = if let Some(ref tid) = thread_id {
1200            format!("{}:{}", chat_id, tid)
1201        } else {
1202            chat_id.clone()
1203        };
1204
1205        // Ensure workspace directory is configured
1206        let workspace = self.workspace_dir.as_ref().or_else(|| {
1207            tracing::warn!("Cannot save attachment: workspace_dir not configured");
1208            None
1209        })?;
1210
1211        let save_dir = workspace.join("telegram_files");
1212        if let Err(e) = tokio::fs::create_dir_all(&save_dir).await {
1213            tracing::warn!("Failed to create telegram_files directory: {e}");
1214            return None;
1215        }
1216
1217        // Download file from Telegram
1218        let tg_file_path = match self.get_file_path(&attachment.file_id).await {
1219            Ok(p) => p,
1220            Err(e) => {
1221                tracing::warn!("Failed to get attachment file path: {e}");
1222                return None;
1223            }
1224        };
1225
1226        let file_data = match self.download_file(&tg_file_path).await {
1227            Ok(d) => d,
1228            Err(e) => {
1229                tracing::warn!("Failed to download attachment: {e}");
1230                return None;
1231            }
1232        };
1233
1234        // Determine local filename
1235        let local_filename = match &attachment.file_name {
1236            Some(name) => name.clone(),
1237            None => {
1238                // For photos, derive extension from Telegram file path
1239                let ext = tg_file_path.rsplit('.').next().unwrap_or("jpg");
1240                format!("photo_{chat_id}_{message_id}.{ext}")
1241            }
1242        };
1243
1244        let local_path = save_dir.join(&local_filename);
1245        if let Err(e) = tokio::fs::write(&local_path, &file_data).await {
1246            tracing::warn!("Failed to save attachment to {}: {e}", local_path.display());
1247            return None;
1248        }
1249
1250        // Build message content.
1251        // Photos with image extensions use [IMAGE:] marker so the multimodal
1252        // pipeline validates vision capability. Non-image files always get
1253        // [Document:] format regardless of Telegram's classification.
1254        let mut content = format_attachment_content(attachment.kind, &local_filename, &local_path);
1255        if let Some(caption) = &attachment.caption {
1256            if !caption.is_empty() {
1257                use std::fmt::Write;
1258                let _ = write!(content, "\n\n{caption}");
1259            }
1260        }
1261
1262        // Prepend reply context if replying to another message
1263        if let Some(quote) = self.extract_reply_context(message) {
1264            content = format!("{quote}\n\n{content}");
1265        }
1266
1267        // Prepend forwarding attribution when the message was forwarded
1268        if let Some(attr) = Self::format_forward_attribution(message) {
1269            content = format!("{attr}{content}");
1270        }
1271
1272        Some(ChannelMessage {
1273            id: format!("telegram_{chat_id}_{message_id}"),
1274            sender: sender_identity,
1275            reply_target,
1276            content,
1277            channel: "telegram".to_string(),
1278            timestamp: std::time::SystemTime::now()
1279                .duration_since(std::time::UNIX_EPOCH)
1280                .unwrap_or_default()
1281                .as_secs(),
1282            thread_ts: thread_id,
1283            interruption_scope_id: None,
1284            attachments: vec![],
1285        })
1286    }
1287
1288    /// Attempt to parse a Telegram update as a voice message and transcribe it.
1289    ///
1290    /// Returns `None` if the message is not a voice message, transcription is disabled,
1291    /// or the message exceeds duration limits.
1292    async fn try_parse_voice_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
1293        let config = self.transcription.as_ref()?;
1294        let manager = self.transcription_manager.as_deref()?;
1295        let message = update.get("message")?;
1296
1297        let (file_id, duration) = Self::parse_voice_metadata(message)?;
1298
1299        if duration > config.max_duration_secs {
1300            tracing::info!(
1301                "Skipping voice message: duration {duration}s exceeds limit {}s",
1302                config.max_duration_secs
1303            );
1304            return None;
1305        }
1306
1307        let (username, sender_id, sender_identity) = Self::extract_sender_info(message);
1308
1309        let mut identities = vec![username.as_str()];
1310        if let Some(id) = sender_id.as_deref() {
1311            identities.push(id);
1312        }
1313
1314        if !self.is_any_user_allowed(identities.iter().copied()) {
1315            return None;
1316        }
1317
1318        let chat_id = message
1319            .get("chat")
1320            .and_then(|chat| chat.get("id"))
1321            .and_then(serde_json::Value::as_i64)
1322            .map(|id| id.to_string())?;
1323
1324        let message_id = message
1325            .get("message_id")
1326            .and_then(serde_json::Value::as_i64)
1327            .unwrap_or(0);
1328
1329        let thread_id = message
1330            .get("message_thread_id")
1331            .and_then(serde_json::Value::as_i64)
1332            .map(|id| id.to_string());
1333
1334        let reply_target = if let Some(ref tid) = thread_id {
1335            format!("{}:{}", chat_id, tid)
1336        } else {
1337            chat_id.clone()
1338        };
1339
1340        // Download and transcribe
1341        let file_path = match self.get_file_path(&file_id).await {
1342            Ok(p) => p,
1343            Err(e) => {
1344                tracing::warn!("Failed to get voice file path: {e}");
1345                return None;
1346            }
1347        };
1348
1349        let file_name = file_path
1350            .rsplit('/')
1351            .next()
1352            .unwrap_or("voice.ogg")
1353            .to_string();
1354
1355        let audio_data = match self.download_file(&file_path).await {
1356            Ok(d) => d,
1357            Err(e) => {
1358                tracing::warn!("Failed to download voice file: {e}");
1359                return None;
1360            }
1361        };
1362
1363        let text = match manager.transcribe(&audio_data, &file_name).await {
1364            Ok(t) => t,
1365            Err(e) => {
1366                tracing::warn!("Voice transcription failed: {e}");
1367                return None;
1368            }
1369        };
1370
1371        if text.trim().is_empty() {
1372            tracing::info!("Voice transcription returned empty text, skipping");
1373            return None;
1374        }
1375
1376        // Enter voice-chat mode so outgoing replies get a TTS voice note
1377        if let Ok(mut vc) = self.voice_chats.lock() {
1378            vc.insert(reply_target.clone());
1379        }
1380
1381        // Cache transcription for reply-context lookups
1382        {
1383            let mut cache = self.voice_transcriptions.lock();
1384            if cache.len() >= 100 {
1385                cache.clear();
1386            }
1387            cache.insert(format!("{chat_id}:{message_id}"), text.clone());
1388        }
1389
1390        let content = if let Some(quote) = self.extract_reply_context(message) {
1391            format!("{quote}\n\n[Voice] {text}")
1392        } else {
1393            format!("[Voice] {text}")
1394        };
1395
1396        // Prepend forwarding attribution when the message was forwarded
1397        let content = if let Some(attr) = Self::format_forward_attribution(message) {
1398            format!("{attr}{content}")
1399        } else {
1400            content
1401        };
1402
1403        Some(ChannelMessage {
1404            id: format!("telegram_{chat_id}_{message_id}"),
1405            sender: sender_identity,
1406            reply_target,
1407            content,
1408            channel: "telegram".to_string(),
1409            timestamp: std::time::SystemTime::now()
1410                .duration_since(std::time::UNIX_EPOCH)
1411                .unwrap_or_default()
1412                .as_secs(),
1413            thread_ts: thread_id,
1414            interruption_scope_id: None,
1415            attachments: vec![],
1416        })
1417    }
1418
1419    /// Extract sender username and display identity from a Telegram message object.
1420    fn extract_sender_info(message: &serde_json::Value) -> (String, Option<String>, String) {
1421        let username = message
1422            .get("from")
1423            .and_then(|from| from.get("username"))
1424            .and_then(serde_json::Value::as_str)
1425            .unwrap_or("unknown")
1426            .to_string();
1427        let sender_id = message
1428            .get("from")
1429            .and_then(|from| from.get("id"))
1430            .and_then(serde_json::Value::as_i64)
1431            .map(|id| id.to_string());
1432        let sender_identity = if username == "unknown" {
1433            sender_id.clone().unwrap_or_else(|| "unknown".to_string())
1434        } else {
1435            username.clone()
1436        };
1437        (username, sender_id, sender_identity)
1438    }
1439
1440    /// Build a forwarding attribution prefix from Telegram forward fields.
1441    ///
1442    /// Returns `Some("[Forwarded from ...] ")` when the message is forwarded,
1443    /// `None` otherwise.
1444    fn format_forward_attribution(message: &serde_json::Value) -> Option<String> {
1445        if let Some(from_chat) = message.get("forward_from_chat") {
1446            // Forwarded from a channel or group
1447            let title = from_chat
1448                .get("title")
1449                .and_then(serde_json::Value::as_str)
1450                .unwrap_or("unknown channel");
1451            Some(format!("[Forwarded from channel: {title}] "))
1452        } else if let Some(from_user) = message.get("forward_from") {
1453            // Forwarded from a user (privacy allows identity)
1454            let label = from_user
1455                .get("username")
1456                .and_then(serde_json::Value::as_str)
1457                .map(|u| format!("@{u}"))
1458                .or_else(|| {
1459                    from_user
1460                        .get("first_name")
1461                        .and_then(serde_json::Value::as_str)
1462                        .map(String::from)
1463                })
1464                .unwrap_or_else(|| "unknown".to_string());
1465            Some(format!("[Forwarded from {label}] "))
1466        } else {
1467            // Forwarded from a user who hides their identity
1468            message
1469                .get("forward_sender_name")
1470                .and_then(serde_json::Value::as_str)
1471                .map(|name| format!("[Forwarded from {name}] "))
1472        }
1473    }
1474
1475    /// Extract reply context from a Telegram `reply_to_message`, if present.
1476    fn extract_reply_context(&self, message: &serde_json::Value) -> Option<String> {
1477        let reply = message.get("reply_to_message")?;
1478
1479        let reply_sender = reply
1480            .get("from")
1481            .and_then(|from| from.get("username"))
1482            .and_then(serde_json::Value::as_str)
1483            .or_else(|| {
1484                reply
1485                    .get("from")
1486                    .and_then(|from| from.get("first_name"))
1487                    .and_then(serde_json::Value::as_str)
1488            })
1489            .unwrap_or("unknown");
1490
1491        let reply_text = if let Some(text) = reply.get("text").and_then(serde_json::Value::as_str) {
1492            text.to_string()
1493        } else if reply.get("voice").is_some() || reply.get("audio").is_some() {
1494            let reply_mid = reply.get("message_id").and_then(serde_json::Value::as_i64);
1495            let chat_id = message
1496                .get("chat")
1497                .and_then(|c| c.get("id"))
1498                .and_then(serde_json::Value::as_i64);
1499            if let (Some(mid), Some(cid)) = (reply_mid, chat_id) {
1500                self.voice_transcriptions
1501                    .lock()
1502                    .get(&format!("{cid}:{mid}"))
1503                    .map(|t| format!("[Voice] {t}"))
1504                    .unwrap_or_else(|| "[Voice message]".to_string())
1505            } else {
1506                "[Voice message]".to_string()
1507            }
1508        } else if reply.get("photo").is_some() {
1509            "[Photo]".to_string()
1510        } else if reply.get("document").is_some() {
1511            "[Document]".to_string()
1512        } else if reply.get("video").is_some() {
1513            "[Video]".to_string()
1514        } else if reply.get("sticker").is_some() {
1515            "[Sticker]".to_string()
1516        } else {
1517            "[Message]".to_string()
1518        };
1519
1520        // Format as blockquote with sender attribution
1521        let quoted_lines: String = reply_text
1522            .lines()
1523            .map(|line| format!("> {line}"))
1524            .collect::<Vec<_>>()
1525            .join("\n");
1526
1527        Some(format!("> @{reply_sender}:\n{quoted_lines}"))
1528    }
1529
1530    fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
1531        let message = update.get("message")?;
1532
1533        let text = message.get("text").and_then(serde_json::Value::as_str)?;
1534
1535        let (username, sender_id, sender_identity) = Self::extract_sender_info(message);
1536
1537        let mut identities = vec![username.as_str()];
1538        if let Some(id) = sender_id.as_deref() {
1539            identities.push(id);
1540        }
1541
1542        if !self.is_any_user_allowed(identities.iter().copied()) {
1543            return None;
1544        }
1545
1546        let is_group = Self::is_group_message(message);
1547        if self.mention_only && is_group {
1548            let bot_username = self.bot_username.lock();
1549            if let Some(ref bot_username) = *bot_username {
1550                if !Self::contains_bot_mention(text, bot_username) {
1551                    return None;
1552                }
1553            } else {
1554                return None;
1555            }
1556        }
1557
1558        let chat_id = message
1559            .get("chat")
1560            .and_then(|chat| chat.get("id"))
1561            .and_then(serde_json::Value::as_i64)
1562            .map(|id| id.to_string())?;
1563
1564        let message_id = message
1565            .get("message_id")
1566            .and_then(serde_json::Value::as_i64)
1567            .unwrap_or(0);
1568
1569        // Extract thread/topic ID for forum support
1570        let thread_id = message
1571            .get("message_thread_id")
1572            .and_then(serde_json::Value::as_i64)
1573            .map(|id| id.to_string());
1574
1575        // reply_target: chat_id or chat_id:thread_id format
1576        let reply_target = if let Some(ref tid) = thread_id {
1577            format!("{}:{}", chat_id, tid)
1578        } else {
1579            chat_id.clone()
1580        };
1581
1582        let content = if self.mention_only && is_group {
1583            let bot_username = self.bot_username.lock();
1584            let bot_username = bot_username.as_ref()?;
1585            Self::normalize_incoming_content(text, bot_username)?
1586        } else {
1587            text.to_string()
1588        };
1589
1590        let content = if let Some(quote) = self.extract_reply_context(message) {
1591            format!("{quote}\n\n{content}")
1592        } else {
1593            content
1594        };
1595
1596        // Prepend forwarding attribution when the message was forwarded
1597        let content = if let Some(attr) = Self::format_forward_attribution(message) {
1598            format!("{attr}{content}")
1599        } else {
1600            content
1601        };
1602
1603        // Exit voice-chat mode when user switches back to typing
1604        if let Ok(mut vc) = self.voice_chats.lock() {
1605            vc.remove(&reply_target);
1606        }
1607
1608        Some(ChannelMessage {
1609            id: format!("telegram_{chat_id}_{message_id}"),
1610            sender: sender_identity,
1611            reply_target,
1612            content,
1613            channel: "telegram".to_string(),
1614            timestamp: std::time::SystemTime::now()
1615                .duration_since(std::time::UNIX_EPOCH)
1616                .unwrap_or_default()
1617                .as_secs(),
1618            thread_ts: thread_id,
1619            interruption_scope_id: None,
1620            attachments: vec![],
1621        })
1622    }
1623
1624    /// Download a Telegram photo by file_id, resize to fit within 1024px, and return as base64 data URI.
1625    async fn resolve_photo_data_uri(&self, file_id: &str) -> anyhow::Result<String> {
1626        use base64::Engine as _;
1627
1628        // Step 1: call getFile to get file_path
1629        let get_file_url = self.api_url(&format!("getFile?file_id={}", file_id));
1630        let resp = self.http_client().get(&get_file_url).send().await?;
1631        let json: serde_json::Value = resp.json().await?;
1632        let file_path = json
1633            .get("result")
1634            .and_then(|r| r.get("file_path"))
1635            .and_then(|p| p.as_str())
1636            .ok_or_else(|| anyhow::anyhow!("getFile: no file_path in response"))?
1637            .to_string();
1638
1639        // Step 2: download the actual file
1640        let download_url = format!(
1641            "https://api.telegram.org/file/bot{}/{}",
1642            self.bot_token, file_path
1643        );
1644        let img_resp = self.http_client().get(&download_url).send().await?;
1645        let bytes = img_resp.bytes().await?;
1646
1647        // Step 3: resize to max 1024px on longest side to fit within model context
1648        let resized_bytes = tokio::task::spawn_blocking(move || -> anyhow::Result<Vec<u8>> {
1649            let img = image::load_from_memory(&bytes)?;
1650            let (w, h) = (img.width(), img.height());
1651            let max_dim = 512u32;
1652            let resized = if w > max_dim || h > max_dim {
1653                img.thumbnail(max_dim, max_dim)
1654            } else {
1655                img
1656            };
1657            let mut buf = Vec::new();
1658            resized.write_to(
1659                &mut std::io::Cursor::new(&mut buf),
1660                image::ImageFormat::Jpeg,
1661            )?;
1662            Ok(buf)
1663        })
1664        .await??;
1665
1666        let b64 = base64::engine::general_purpose::STANDARD.encode(&resized_bytes);
1667        Ok(format!("data:image/jpeg;base64,{}", b64))
1668    }
1669
1670    /// Convert Markdown to Telegram HTML format.
1671    /// Telegram HTML supports: <b>, <i>, <u>, <s>, <code>, <pre>, <a href="...">
1672    /// This mirrors OpenClaw's markdownToTelegramHtml approach.
1673    fn markdown_to_telegram_html(text: &str) -> String {
1674        let lines: Vec<&str> = text.split('\n').collect();
1675        let mut result_lines: Vec<String> = Vec::new();
1676
1677        for line in &lines {
1678            let trimmed_line = line.trim_start();
1679            if trimmed_line.starts_with("```") {
1680                // Preserve fence lines so the second-pass block parser can consume them
1681                // without interference from inline backtick handling.
1682                result_lines.push(trimmed_line.to_string());
1683                continue;
1684            }
1685
1686            let mut line_out = String::new();
1687
1688            // Handle code blocks (``` ... ```) - handled at text level below
1689            // Handle headers: ## Title → <b>Title</b>
1690            let stripped = line.trim_start_matches('#');
1691            let header_level = line.len() - stripped.len();
1692            if header_level > 0 && line.starts_with('#') && stripped.starts_with(' ') {
1693                let title = Self::escape_html(stripped.trim());
1694                result_lines.push(format!("<b>{title}</b>"));
1695                continue;
1696            }
1697
1698            // Inline formatting
1699            let mut i = 0;
1700            let bytes = line.as_bytes();
1701            let len = bytes.len();
1702            while i < len {
1703                // Bold: **text** or __text__
1704                if i + 1 < len && bytes[i] == b'*' && bytes[i + 1] == b'*' {
1705                    if let Some(end) = line[i + 2..].find("**") {
1706                        let inner = Self::escape_html(&line[i + 2..i + 2 + end]);
1707                        let _ = write!(line_out, "<b>{inner}</b>");
1708                        i += 4 + end;
1709                        continue;
1710                    }
1711                }
1712                if i + 1 < len && bytes[i] == b'_' && bytes[i + 1] == b'_' {
1713                    if let Some(end) = line[i + 2..].find("__") {
1714                        let inner = Self::escape_html(&line[i + 2..i + 2 + end]);
1715                        let _ = write!(line_out, "<b>{inner}</b>");
1716                        i += 4 + end;
1717                        continue;
1718                    }
1719                }
1720                // Italic: *text* or _text_ (single)
1721                if bytes[i] == b'*' && (i == 0 || bytes[i - 1] != b'*') {
1722                    if let Some(end) = line[i + 1..].find('*') {
1723                        if end > 0 {
1724                            let inner = Self::escape_html(&line[i + 1..i + 1 + end]);
1725                            let _ = write!(line_out, "<i>{inner}</i>");
1726                            i += 2 + end;
1727                            continue;
1728                        }
1729                    }
1730                }
1731                // Inline code: `code`
1732                if bytes[i] == b'`' && (i == 0 || bytes[i - 1] != b'`') {
1733                    if let Some(end) = line[i + 1..].find('`') {
1734                        let inner = Self::escape_html(&line[i + 1..i + 1 + end]);
1735                        let _ = write!(line_out, "<code>{inner}</code>");
1736                        i += 2 + end;
1737                        continue;
1738                    }
1739                }
1740                // Markdown link: [text](url)
1741                if bytes[i] == b'[' {
1742                    if let Some(bracket_end) = line[i + 1..].find(']') {
1743                        let text_part = &line[i + 1..i + 1 + bracket_end];
1744                        let after_bracket = i + 1 + bracket_end + 1; // position after ']'
1745                        if after_bracket < len && bytes[after_bracket] == b'(' {
1746                            if let Some(paren_end) = line[after_bracket + 1..].find(')') {
1747                                let url = &line[after_bracket + 1..after_bracket + 1 + paren_end];
1748                                if url.starts_with("http://") || url.starts_with("https://") {
1749                                    let text_html = Self::escape_html(text_part);
1750                                    let url_html = Self::escape_html(url);
1751                                    let _ =
1752                                        write!(line_out, "<a href=\"{url_html}\">{text_html}</a>");
1753                                    i = after_bracket + 1 + paren_end + 1;
1754                                    continue;
1755                                }
1756                            }
1757                        }
1758                    }
1759                }
1760                // Strikethrough: ~~text~~
1761                if i + 1 < len && bytes[i] == b'~' && bytes[i + 1] == b'~' {
1762                    if let Some(end) = line[i + 2..].find("~~") {
1763                        let inner = Self::escape_html(&line[i + 2..i + 2 + end]);
1764                        let _ = write!(line_out, "<s>{inner}</s>");
1765                        i += 4 + end;
1766                        continue;
1767                    }
1768                }
1769                // Default: escape HTML entities
1770                let ch = line[i..].chars().next().unwrap();
1771                match ch {
1772                    '<' => line_out.push_str("&lt;"),
1773                    '>' => line_out.push_str("&gt;"),
1774                    '&' => line_out.push_str("&amp;"),
1775                    '"' => line_out.push_str("&quot;"),
1776                    '\'' => line_out.push_str("&#39;"),
1777                    _ => line_out.push(ch),
1778                }
1779                i += ch.len_utf8();
1780            }
1781            result_lines.push(line_out);
1782        }
1783
1784        // Second pass: handle ``` code blocks across lines
1785        let joined = result_lines.join("\n");
1786        let mut final_out = String::with_capacity(joined.len());
1787        let mut in_code_block = false;
1788        let mut code_buf = String::new();
1789
1790        for line in joined.split('\n') {
1791            let trimmed = line.trim();
1792            if trimmed.starts_with("```") {
1793                if in_code_block {
1794                    in_code_block = false;
1795                    let escaped = code_buf.trim_end_matches('\n');
1796                    // Telegram HTML parse mode supports <pre> and <code>, but not class attributes.
1797                    let _ = writeln!(final_out, "<pre><code>{escaped}</code></pre>");
1798                    code_buf.clear();
1799                } else {
1800                    in_code_block = true;
1801                    code_buf.clear();
1802                }
1803            } else if in_code_block {
1804                code_buf.push_str(line);
1805                code_buf.push('\n');
1806            } else {
1807                final_out.push_str(line);
1808                final_out.push('\n');
1809            }
1810        }
1811        if in_code_block && !code_buf.is_empty() {
1812            let _ = writeln!(final_out, "<pre><code>{}</code></pre>", code_buf.trim_end());
1813        }
1814
1815        final_out.trim_end_matches('\n').to_string()
1816    }
1817
1818    fn escape_html(s: &str) -> String {
1819        s.replace('&', "&amp;")
1820            .replace('<', "&lt;")
1821            .replace('>', "&gt;")
1822            .replace('"', "&quot;")
1823            .replace('\'', "&#39;")
1824    }
1825
1826    async fn send_text_chunks(
1827        &self,
1828        message: &str,
1829        chat_id: &str,
1830        thread_id: Option<&str>,
1831    ) -> anyhow::Result<()> {
1832        let chunks = split_message_for_telegram(message);
1833
1834        for (index, chunk) in chunks.iter().enumerate() {
1835            let text = if chunks.len() > 1 {
1836                if index == 0 {
1837                    format!("{chunk}\n\n(continues...)")
1838                } else if index == chunks.len() - 1 {
1839                    format!("(continued)\n\n{chunk}")
1840                } else {
1841                    format!("(continued)\n\n{chunk}\n\n(continues...)")
1842                }
1843            } else {
1844                chunk.to_string()
1845            };
1846
1847            let mut markdown_body = serde_json::json!({
1848                "chat_id": chat_id,
1849                "text": Self::markdown_to_telegram_html(&text),
1850                "parse_mode": "HTML"
1851            });
1852
1853            // Add message_thread_id for forum topic support
1854            if let Some(tid) = thread_id {
1855                markdown_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
1856            }
1857
1858            let markdown_resp = self
1859                .http_client()
1860                .post(self.api_url("sendMessage"))
1861                .json(&markdown_body)
1862                .send()
1863                .await?;
1864
1865            if markdown_resp.status().is_success() {
1866                if index < chunks.len() - 1 {
1867                    tokio::time::sleep(Duration::from_millis(100)).await;
1868                }
1869                continue;
1870            }
1871
1872            let markdown_status = markdown_resp.status();
1873            let markdown_err = markdown_resp.text().await.unwrap_or_default();
1874            tracing::warn!(
1875                status = ?markdown_status,
1876                "Telegram sendMessage with Markdown failed; retrying without parse_mode"
1877            );
1878
1879            let mut plain_body = serde_json::json!({
1880                "chat_id": chat_id,
1881                "text": text,
1882            });
1883
1884            // Add message_thread_id for forum topic support
1885            if let Some(tid) = thread_id {
1886                plain_body["message_thread_id"] = serde_json::Value::String(tid.to_string());
1887            }
1888            let plain_resp = self
1889                .http_client()
1890                .post(self.api_url("sendMessage"))
1891                .json(&plain_body)
1892                .send()
1893                .await?;
1894
1895            if !plain_resp.status().is_success() {
1896                let plain_status = plain_resp.status();
1897                let plain_err = plain_resp.text().await.unwrap_or_default();
1898                anyhow::bail!(
1899                    "Telegram sendMessage failed (markdown {}: {}; plain {}: {})",
1900                    markdown_status,
1901                    markdown_err,
1902                    plain_status,
1903                    plain_err
1904                );
1905            }
1906
1907            if index < chunks.len() - 1 {
1908                tokio::time::sleep(Duration::from_millis(100)).await;
1909            }
1910        }
1911
1912        Ok(())
1913    }
1914
1915    async fn send_media_by_url(
1916        &self,
1917        method: &str,
1918        media_field: &str,
1919        chat_id: &str,
1920        thread_id: Option<&str>,
1921        url: &str,
1922        caption: Option<&str>,
1923    ) -> anyhow::Result<()> {
1924        let mut body = serde_json::json!({
1925            "chat_id": chat_id,
1926        });
1927        body[media_field] = serde_json::Value::String(url.to_string());
1928
1929        if let Some(tid) = thread_id {
1930            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
1931        }
1932
1933        if let Some(cap) = caption {
1934            body["caption"] = serde_json::Value::String(cap.to_string());
1935        }
1936
1937        let resp = self
1938            .http_client()
1939            .post(self.api_url(method))
1940            .json(&body)
1941            .send()
1942            .await?;
1943
1944        if !resp.status().is_success() {
1945            let err = resp.text().await?;
1946            anyhow::bail!("Telegram {method} by URL failed: {err}");
1947        }
1948
1949        tracing::info!("Telegram {method} sent to {chat_id}: {url}");
1950        Ok(())
1951    }
1952
1953    async fn send_attachment(
1954        &self,
1955        chat_id: &str,
1956        thread_id: Option<&str>,
1957        attachment: &TelegramAttachment,
1958    ) -> anyhow::Result<()> {
1959        let target = attachment.target.trim();
1960
1961        if is_http_url(target) {
1962            let result = match attachment.kind {
1963                TelegramAttachmentKind::Image => {
1964                    self.send_photo_by_url(chat_id, thread_id, target, None)
1965                        .await
1966                }
1967                TelegramAttachmentKind::Document => {
1968                    self.send_document_by_url(chat_id, thread_id, target, None)
1969                        .await
1970                }
1971                TelegramAttachmentKind::Video => {
1972                    self.send_video_by_url(chat_id, thread_id, target, None)
1973                        .await
1974                }
1975                TelegramAttachmentKind::Audio => {
1976                    self.send_audio_by_url(chat_id, thread_id, target, None)
1977                        .await
1978                }
1979                TelegramAttachmentKind::Voice => {
1980                    self.send_voice_by_url(chat_id, thread_id, target, None)
1981                        .await
1982                }
1983            };
1984
1985            // If sending media by URL failed (e.g. Telegram can't fetch the URL,
1986            // wrong content type, etc.), fall back to sending the URL as a text link
1987            // instead of losing the reply entirely.
1988            if let Err(e) = result {
1989                tracing::warn!(
1990                    url = target,
1991                    error = %e,
1992                    "Telegram send media by URL failed; falling back to text link"
1993                );
1994                let kind_label = match attachment.kind {
1995                    TelegramAttachmentKind::Image => "Image",
1996                    TelegramAttachmentKind::Document => "Document",
1997                    TelegramAttachmentKind::Video => "Video",
1998                    TelegramAttachmentKind::Audio => "Audio",
1999                    TelegramAttachmentKind::Voice => "Voice",
2000                };
2001                let fallback_text = format!("{kind_label}: {target}");
2002                self.send_text_chunks(&fallback_text, chat_id, thread_id)
2003                    .await?;
2004            }
2005
2006            return Ok(());
2007        }
2008
2009        // Remap Docker container workspace path (/workspace/...) to the host
2010        // workspace directory so files written by the containerised runtime
2011        // can be found and sent by the host-side Telegram sender.
2012        let remapped;
2013        let target = if let Some(rel) = target.strip_prefix("/workspace/") {
2014            if let Some(ws) = &self.workspace_dir {
2015                remapped = ws.join(rel);
2016                remapped.to_str().unwrap_or(target)
2017            } else {
2018                target
2019            }
2020        } else {
2021            target
2022        };
2023
2024        let path = Path::new(target);
2025        if !path.exists() {
2026            anyhow::bail!("Telegram attachment path not found: {target}");
2027        }
2028
2029        match attachment.kind {
2030            TelegramAttachmentKind::Image => self.send_photo(chat_id, thread_id, path, None).await,
2031            TelegramAttachmentKind::Document => {
2032                self.send_document(chat_id, thread_id, path, None).await
2033            }
2034            TelegramAttachmentKind::Video => self.send_video(chat_id, thread_id, path, None).await,
2035            TelegramAttachmentKind::Audio => self.send_audio(chat_id, thread_id, path, None).await,
2036            TelegramAttachmentKind::Voice => self.send_voice(chat_id, thread_id, path, None).await,
2037        }
2038    }
2039
2040    /// Send a document/file to a Telegram chat
2041    pub async fn send_document(
2042        &self,
2043        chat_id: &str,
2044        thread_id: Option<&str>,
2045        file_path: &Path,
2046        caption: Option<&str>,
2047    ) -> anyhow::Result<()> {
2048        let file_name = file_path
2049            .file_name()
2050            .and_then(|n| n.to_str())
2051            .unwrap_or("file");
2052
2053        let file_bytes = tokio::fs::read(file_path).await?;
2054        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2055
2056        let mut form = Form::new()
2057            .text("chat_id", chat_id.to_string())
2058            .part("document", part);
2059
2060        if let Some(tid) = thread_id {
2061            form = form.text("message_thread_id", tid.to_string());
2062        }
2063
2064        if let Some(cap) = caption {
2065            form = form.text("caption", cap.to_string());
2066        }
2067
2068        let resp = self
2069            .http_client()
2070            .post(self.api_url("sendDocument"))
2071            .multipart(form)
2072            .send()
2073            .await?;
2074
2075        if !resp.status().is_success() {
2076            let err = resp.text().await?;
2077            anyhow::bail!("Telegram sendDocument failed: {err}");
2078        }
2079
2080        tracing::info!("Telegram document sent to {chat_id}: {file_name}");
2081        Ok(())
2082    }
2083
2084    /// Send a document from bytes (in-memory) to a Telegram chat
2085    pub async fn send_document_bytes(
2086        &self,
2087        chat_id: &str,
2088        thread_id: Option<&str>,
2089        file_bytes: Vec<u8>,
2090        file_name: &str,
2091        caption: Option<&str>,
2092    ) -> anyhow::Result<()> {
2093        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2094
2095        let mut form = Form::new()
2096            .text("chat_id", chat_id.to_string())
2097            .part("document", part);
2098
2099        if let Some(tid) = thread_id {
2100            form = form.text("message_thread_id", tid.to_string());
2101        }
2102
2103        if let Some(cap) = caption {
2104            form = form.text("caption", cap.to_string());
2105        }
2106
2107        let resp = self
2108            .http_client()
2109            .post(self.api_url("sendDocument"))
2110            .multipart(form)
2111            .send()
2112            .await?;
2113
2114        if !resp.status().is_success() {
2115            let err = resp.text().await?;
2116            anyhow::bail!("Telegram sendDocument failed: {err}");
2117        }
2118
2119        tracing::info!("Telegram document sent to {chat_id}: {file_name}");
2120        Ok(())
2121    }
2122
2123    /// Send a photo to a Telegram chat
2124    pub async fn send_photo(
2125        &self,
2126        chat_id: &str,
2127        thread_id: Option<&str>,
2128        file_path: &Path,
2129        caption: Option<&str>,
2130    ) -> anyhow::Result<()> {
2131        let file_name = file_path
2132            .file_name()
2133            .and_then(|n| n.to_str())
2134            .unwrap_or("photo.jpg");
2135
2136        let file_bytes = tokio::fs::read(file_path).await?;
2137        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2138
2139        let mut form = Form::new()
2140            .text("chat_id", chat_id.to_string())
2141            .part("photo", part);
2142
2143        if let Some(tid) = thread_id {
2144            form = form.text("message_thread_id", tid.to_string());
2145        }
2146
2147        if let Some(cap) = caption {
2148            form = form.text("caption", cap.to_string());
2149        }
2150
2151        let resp = self
2152            .http_client()
2153            .post(self.api_url("sendPhoto"))
2154            .multipart(form)
2155            .send()
2156            .await?;
2157
2158        if !resp.status().is_success() {
2159            let err = resp.text().await?;
2160            anyhow::bail!("Telegram sendPhoto failed: {err}");
2161        }
2162
2163        tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
2164        Ok(())
2165    }
2166
2167    /// Send a photo from bytes (in-memory) to a Telegram chat
2168    pub async fn send_photo_bytes(
2169        &self,
2170        chat_id: &str,
2171        thread_id: Option<&str>,
2172        file_bytes: Vec<u8>,
2173        file_name: &str,
2174        caption: Option<&str>,
2175    ) -> anyhow::Result<()> {
2176        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2177
2178        let mut form = Form::new()
2179            .text("chat_id", chat_id.to_string())
2180            .part("photo", part);
2181
2182        if let Some(tid) = thread_id {
2183            form = form.text("message_thread_id", tid.to_string());
2184        }
2185
2186        if let Some(cap) = caption {
2187            form = form.text("caption", cap.to_string());
2188        }
2189
2190        let resp = self
2191            .http_client()
2192            .post(self.api_url("sendPhoto"))
2193            .multipart(form)
2194            .send()
2195            .await?;
2196
2197        if !resp.status().is_success() {
2198            let err = resp.text().await?;
2199            anyhow::bail!("Telegram sendPhoto failed: {err}");
2200        }
2201
2202        tracing::info!("Telegram photo sent to {chat_id}: {file_name}");
2203        Ok(())
2204    }
2205
2206    /// Send a video to a Telegram chat
2207    pub async fn send_video(
2208        &self,
2209        chat_id: &str,
2210        thread_id: Option<&str>,
2211        file_path: &Path,
2212        caption: Option<&str>,
2213    ) -> anyhow::Result<()> {
2214        let file_name = file_path
2215            .file_name()
2216            .and_then(|n| n.to_str())
2217            .unwrap_or("video.mp4");
2218
2219        let file_bytes = tokio::fs::read(file_path).await?;
2220        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2221
2222        let mut form = Form::new()
2223            .text("chat_id", chat_id.to_string())
2224            .part("video", part);
2225
2226        if let Some(tid) = thread_id {
2227            form = form.text("message_thread_id", tid.to_string());
2228        }
2229
2230        if let Some(cap) = caption {
2231            form = form.text("caption", cap.to_string());
2232        }
2233
2234        let resp = self
2235            .http_client()
2236            .post(self.api_url("sendVideo"))
2237            .multipart(form)
2238            .send()
2239            .await?;
2240
2241        if !resp.status().is_success() {
2242            let err = resp.text().await?;
2243            anyhow::bail!("Telegram sendVideo failed: {err}");
2244        }
2245
2246        tracing::info!("Telegram video sent to {chat_id}: {file_name}");
2247        Ok(())
2248    }
2249
2250    /// Send an audio file to a Telegram chat
2251    pub async fn send_audio(
2252        &self,
2253        chat_id: &str,
2254        thread_id: Option<&str>,
2255        file_path: &Path,
2256        caption: Option<&str>,
2257    ) -> anyhow::Result<()> {
2258        let file_name = file_path
2259            .file_name()
2260            .and_then(|n| n.to_str())
2261            .unwrap_or("audio.mp3");
2262
2263        let file_bytes = tokio::fs::read(file_path).await?;
2264        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2265
2266        let mut form = Form::new()
2267            .text("chat_id", chat_id.to_string())
2268            .part("audio", part);
2269
2270        if let Some(tid) = thread_id {
2271            form = form.text("message_thread_id", tid.to_string());
2272        }
2273
2274        if let Some(cap) = caption {
2275            form = form.text("caption", cap.to_string());
2276        }
2277
2278        let resp = self
2279            .http_client()
2280            .post(self.api_url("sendAudio"))
2281            .multipart(form)
2282            .send()
2283            .await?;
2284
2285        if !resp.status().is_success() {
2286            let err = resp.text().await?;
2287            anyhow::bail!("Telegram sendAudio failed: {err}");
2288        }
2289
2290        tracing::info!("Telegram audio sent to {chat_id}: {file_name}");
2291        Ok(())
2292    }
2293
2294    /// Send a voice message to a Telegram chat
2295    pub async fn send_voice(
2296        &self,
2297        chat_id: &str,
2298        thread_id: Option<&str>,
2299        file_path: &Path,
2300        caption: Option<&str>,
2301    ) -> anyhow::Result<()> {
2302        let file_name = file_path
2303            .file_name()
2304            .and_then(|n| n.to_str())
2305            .unwrap_or("voice.ogg");
2306
2307        let file_bytes = tokio::fs::read(file_path).await?;
2308        let part = Part::bytes(file_bytes).file_name(file_name.to_string());
2309
2310        let mut form = Form::new()
2311            .text("chat_id", chat_id.to_string())
2312            .part("voice", part);
2313
2314        if let Some(tid) = thread_id {
2315            form = form.text("message_thread_id", tid.to_string());
2316        }
2317
2318        if let Some(cap) = caption {
2319            form = form.text("caption", cap.to_string());
2320        }
2321
2322        let resp = self
2323            .http_client()
2324            .post(self.api_url("sendVoice"))
2325            .multipart(form)
2326            .send()
2327            .await?;
2328
2329        if !resp.status().is_success() {
2330            let err = resp.text().await?;
2331            anyhow::bail!("Telegram sendVoice failed: {err}");
2332        }
2333
2334        tracing::info!("Telegram voice sent to {chat_id}: {file_name}");
2335        Ok(())
2336    }
2337
2338    /// Send a file by URL (Telegram will download it)
2339    pub async fn send_document_by_url(
2340        &self,
2341        chat_id: &str,
2342        thread_id: Option<&str>,
2343        url: &str,
2344        caption: Option<&str>,
2345    ) -> anyhow::Result<()> {
2346        let mut body = serde_json::json!({
2347            "chat_id": chat_id,
2348            "document": url
2349        });
2350
2351        if let Some(tid) = thread_id {
2352            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
2353        }
2354
2355        if let Some(cap) = caption {
2356            body["caption"] = serde_json::Value::String(cap.to_string());
2357        }
2358
2359        let resp = self
2360            .http_client()
2361            .post(self.api_url("sendDocument"))
2362            .json(&body)
2363            .send()
2364            .await?;
2365
2366        if !resp.status().is_success() {
2367            let err = resp.text().await?;
2368            anyhow::bail!("Telegram sendDocument by URL failed: {err}");
2369        }
2370
2371        tracing::info!("Telegram document (URL) sent to {chat_id}: {url}");
2372        Ok(())
2373    }
2374
2375    /// Send a photo by URL (Telegram will download it)
2376    pub async fn send_photo_by_url(
2377        &self,
2378        chat_id: &str,
2379        thread_id: Option<&str>,
2380        url: &str,
2381        caption: Option<&str>,
2382    ) -> anyhow::Result<()> {
2383        let mut body = serde_json::json!({
2384            "chat_id": chat_id,
2385            "photo": url
2386        });
2387
2388        if let Some(tid) = thread_id {
2389            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
2390        }
2391
2392        if let Some(cap) = caption {
2393            body["caption"] = serde_json::Value::String(cap.to_string());
2394        }
2395
2396        let resp = self
2397            .http_client()
2398            .post(self.api_url("sendPhoto"))
2399            .json(&body)
2400            .send()
2401            .await?;
2402
2403        if !resp.status().is_success() {
2404            let err = resp.text().await?;
2405            anyhow::bail!("Telegram sendPhoto by URL failed: {err}");
2406        }
2407
2408        tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}");
2409        Ok(())
2410    }
2411
2412    /// Send a video by URL (Telegram will download it)
2413    pub async fn send_video_by_url(
2414        &self,
2415        chat_id: &str,
2416        thread_id: Option<&str>,
2417        url: &str,
2418        caption: Option<&str>,
2419    ) -> anyhow::Result<()> {
2420        self.send_media_by_url("sendVideo", "video", chat_id, thread_id, url, caption)
2421            .await
2422    }
2423
2424    /// Send an audio file by URL (Telegram will download it)
2425    pub async fn send_audio_by_url(
2426        &self,
2427        chat_id: &str,
2428        thread_id: Option<&str>,
2429        url: &str,
2430        caption: Option<&str>,
2431    ) -> anyhow::Result<()> {
2432        self.send_media_by_url("sendAudio", "audio", chat_id, thread_id, url, caption)
2433            .await
2434    }
2435
2436    /// Send a voice message by URL (Telegram will download it)
2437    pub async fn send_voice_by_url(
2438        &self,
2439        chat_id: &str,
2440        thread_id: Option<&str>,
2441        url: &str,
2442        caption: Option<&str>,
2443    ) -> anyhow::Result<()> {
2444        self.send_media_by_url("sendVoice", "voice", chat_id, thread_id, url, caption)
2445            .await
2446    }
2447}
2448
2449#[async_trait]
2450impl Channel for TelegramChannel {
2451    fn name(&self) -> &str {
2452        "telegram"
2453    }
2454
2455    fn supports_draft_updates(&self) -> bool {
2456        self.stream_mode != StreamMode::Off
2457    }
2458
2459    async fn send_draft(&self, message: &SendMessage) -> anyhow::Result<Option<String>> {
2460        if self.stream_mode == StreamMode::Off {
2461            return Ok(None);
2462        }
2463
2464        let (chat_id, thread_id) = Self::parse_reply_target(&message.recipient);
2465        let initial_text = if message.content.is_empty() {
2466            "...".to_string()
2467        } else {
2468            message.content.clone()
2469        };
2470
2471        let mut body = serde_json::json!({
2472            "chat_id": chat_id,
2473            "text": initial_text,
2474        });
2475        if let Some(tid) = thread_id {
2476            body["message_thread_id"] = serde_json::Value::String(tid.to_string());
2477        }
2478
2479        let resp = self
2480            .client
2481            .post(self.api_url("sendMessage"))
2482            .json(&body)
2483            .send()
2484            .await?;
2485
2486        if !resp.status().is_success() {
2487            let err = resp.text().await.unwrap_or_default();
2488            anyhow::bail!("Telegram sendMessage (draft) failed: {err}");
2489        }
2490
2491        let resp_json: serde_json::Value = resp.json().await?;
2492        let message_id = resp_json
2493            .get("result")
2494            .and_then(|r| r.get("message_id"))
2495            .and_then(|id| id.as_i64())
2496            .map(|id| id.to_string());
2497
2498        self.last_draft_edit
2499            .lock()
2500            .insert(chat_id.to_string(), std::time::Instant::now());
2501
2502        Ok(message_id)
2503    }
2504
2505    async fn update_draft(
2506        &self,
2507        recipient: &str,
2508        message_id: &str,
2509        text: &str,
2510    ) -> anyhow::Result<()> {
2511        let (chat_id, _) = Self::parse_reply_target(recipient);
2512
2513        // Rate-limit edits per chat
2514        {
2515            let last_edits = self.last_draft_edit.lock();
2516            if let Some(last_time) = last_edits.get(&chat_id) {
2517                let elapsed = u64::try_from(last_time.elapsed().as_millis()).unwrap_or(u64::MAX);
2518                if elapsed < self.draft_update_interval_ms {
2519                    return Ok(());
2520                }
2521            }
2522        }
2523
2524        // Truncate to Telegram limit for mid-stream edits (UTF-8 safe)
2525        let display_text = if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
2526            let mut end = 0;
2527            for (idx, ch) in text.char_indices() {
2528                let next = idx + ch.len_utf8();
2529                if next > TELEGRAM_MAX_MESSAGE_LENGTH {
2530                    break;
2531                }
2532                end = next;
2533            }
2534            &text[..end]
2535        } else {
2536            text
2537        };
2538
2539        let message_id_parsed = match message_id.parse::<i64>() {
2540            Ok(id) => id,
2541            Err(e) => {
2542                tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
2543                return Ok(());
2544            }
2545        };
2546
2547        let body = serde_json::json!({
2548            "chat_id": chat_id,
2549            "message_id": message_id_parsed,
2550            "text": display_text,
2551        });
2552
2553        let resp = self
2554            .client
2555            .post(self.api_url("editMessageText"))
2556            .json(&body)
2557            .send()
2558            .await?;
2559
2560        if resp.status().is_success() {
2561            self.last_draft_edit
2562                .lock()
2563                .insert(chat_id.clone(), std::time::Instant::now());
2564        } else {
2565            let status = resp.status();
2566            let err = resp.text().await.unwrap_or_default();
2567            tracing::debug!("Telegram editMessageText failed ({status}): {err}");
2568        }
2569
2570        Ok(())
2571    }
2572
2573    async fn finalize_draft(
2574        &self,
2575        recipient: &str,
2576        message_id: &str,
2577        text: &str,
2578    ) -> anyhow::Result<()> {
2579        let text = &strip_tool_call_tags(text);
2580        let (chat_id, thread_id) = Self::parse_reply_target(recipient);
2581
2582        // Clean up rate-limit tracking for this chat
2583        self.last_draft_edit.lock().remove(&chat_id);
2584
2585        // Parse attachments before processing
2586        let (text_without_markers, attachments) = parse_attachment_markers(text);
2587
2588        // Parse message ID once for reuse
2589        let msg_id = match message_id.parse::<i64>() {
2590            Ok(id) => Some(id),
2591            Err(e) => {
2592                tracing::warn!("Invalid Telegram message_id '{message_id}': {e}");
2593                None
2594            }
2595        };
2596
2597        // If we have attachments, delete the draft and send fresh messages
2598        // (Telegram editMessageText can't add attachments)
2599        if !attachments.is_empty() {
2600            // Delete the draft message
2601            if let Some(id) = msg_id {
2602                let _ = self
2603                    .client
2604                    .post(self.api_url("deleteMessage"))
2605                    .json(&serde_json::json!({
2606                        "chat_id": chat_id,
2607                        "message_id": id,
2608                    }))
2609                    .send()
2610                    .await;
2611            }
2612
2613            // Send text without markers
2614            if !text_without_markers.is_empty() {
2615                self.send_text_chunks(&text_without_markers, &chat_id, thread_id.as_deref())
2616                    .await?;
2617            }
2618
2619            // Send attachments
2620            for attachment in &attachments {
2621                self.send_attachment(&chat_id, thread_id.as_deref(), attachment)
2622                    .await?;
2623            }
2624
2625            return Ok(());
2626        }
2627
2628        // If text exceeds limit, delete draft and send as chunked messages
2629        if text.len() > TELEGRAM_MAX_MESSAGE_LENGTH {
2630            if let Some(id) = msg_id {
2631                let _ = self
2632                    .client
2633                    .post(self.api_url("deleteMessage"))
2634                    .json(&serde_json::json!({
2635                        "chat_id": chat_id,
2636                        "message_id": id,
2637                    }))
2638                    .send()
2639                    .await;
2640            }
2641
2642            // Fall back to chunked send
2643            return self
2644                .send_text_chunks(text, &chat_id, thread_id.as_deref())
2645                .await;
2646        }
2647
2648        let Some(id) = msg_id else {
2649            return self
2650                .send_text_chunks(text, &chat_id, thread_id.as_deref())
2651                .await;
2652        };
2653
2654        // Try editing with HTML formatting
2655        let body = serde_json::json!({
2656            "chat_id": chat_id,
2657            "message_id": id,
2658            "text": Self::markdown_to_telegram_html(text),
2659            "parse_mode": "HTML",
2660        });
2661
2662        let resp = self
2663            .client
2664            .post(self.api_url("editMessageText"))
2665            .json(&body)
2666            .send()
2667            .await?;
2668
2669        match Self::classify_edit_message_response(resp).await {
2670            EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
2671            EditMessageResult::Failed(status) => {
2672                tracing::debug!(
2673                    status = ?status,
2674                    "Telegram finalize_draft HTML edit failed; retrying without parse_mode"
2675                );
2676            }
2677        }
2678
2679        // HTML failed — retry without parse_mode
2680        let plain_body = serde_json::json!({
2681            "chat_id": chat_id,
2682            "message_id": id,
2683            "text": text,
2684        });
2685
2686        let resp = self
2687            .client
2688            .post(self.api_url("editMessageText"))
2689            .json(&plain_body)
2690            .send()
2691            .await?;
2692
2693        match Self::classify_edit_message_response(resp).await {
2694            EditMessageResult::Success | EditMessageResult::NotModified => return Ok(()),
2695            EditMessageResult::Failed(status) => {
2696                tracing::warn!(
2697                    status = ?status,
2698                    "Telegram finalize_draft plain edit failed; attempting delete+send fallback"
2699                );
2700            }
2701        }
2702
2703        let delete_resp = self
2704            .client
2705            .post(self.api_url("deleteMessage"))
2706            .json(&serde_json::json!({
2707                "chat_id": chat_id,
2708                "message_id": id,
2709            }))
2710            .send()
2711            .await;
2712
2713        match delete_resp {
2714            Ok(resp) if resp.status().is_success() => {
2715                self.send_text_chunks(text, &chat_id, thread_id.as_deref())
2716                    .await
2717            }
2718            Ok(resp) => {
2719                tracing::warn!(
2720                    status = ?resp.status(),
2721                    "Telegram finalize_draft delete failed; skipping sendMessage to avoid duplicate"
2722                );
2723                Ok(())
2724            }
2725            Err(err) => {
2726                tracing::warn!(
2727                    "Telegram finalize_draft delete request failed: {err}; skipping sendMessage to avoid duplicate"
2728                );
2729                Ok(())
2730            }
2731        }
2732    }
2733
2734    async fn cancel_draft(&self, recipient: &str, message_id: &str) -> anyhow::Result<()> {
2735        let (chat_id, _) = Self::parse_reply_target(recipient);
2736        self.last_draft_edit.lock().remove(&chat_id);
2737
2738        let message_id = match message_id.parse::<i64>() {
2739            Ok(id) => id,
2740            Err(e) => {
2741                tracing::debug!("Invalid Telegram draft message_id '{message_id}': {e}");
2742                return Ok(());
2743            }
2744        };
2745
2746        let response = self
2747            .client
2748            .post(self.api_url("deleteMessage"))
2749            .json(&serde_json::json!({
2750                "chat_id": chat_id,
2751                "message_id": message_id,
2752            }))
2753            .send()
2754            .await?;
2755
2756        if !response.status().is_success() {
2757            let status = response.status();
2758            let body = response.text().await.unwrap_or_default();
2759            tracing::debug!("Telegram deleteMessage failed ({status}): {body}");
2760        }
2761
2762        Ok(())
2763    }
2764
2765    async fn send(&self, message: &SendMessage) -> anyhow::Result<()> {
2766        // Strip tool_call tags before processing to prevent Markdown parsing failures
2767        let content = strip_tool_call_tags(&message.content);
2768
2769        // Parse recipient: "chat_id" or "chat_id:thread_id" format
2770        let (chat_id, thread_id) = match message.recipient.split_once(':') {
2771            Some((chat, thread)) => (chat, Some(thread)),
2772            None => (message.recipient.as_str(), None),
2773        };
2774
2775        // Voice chat mode: send text normally AND queue a voice note of the
2776        // final answer. Text in → text out. Voice in → text + voice out.
2777        let is_voice_chat = self
2778            .voice_chats
2779            .lock()
2780            .map(|vs| vs.contains(&message.recipient))
2781            .unwrap_or(false);
2782
2783        if is_voice_chat && self.tts_config.is_some() {
2784            // Only queue substantive natural-language replies for voice.
2785            // Skip tool outputs: URLs, JSON, code blocks, errors, short status.
2786            let is_substantive = content.len() > 40
2787                && !content.starts_with("http")
2788                && !content.starts_with('{')
2789                && !content.starts_with('[')
2790                && !content.starts_with("Error")
2791                && !content.contains("```")
2792                && !content.contains("tool_call")
2793                && !content.contains("wttr.in");
2794
2795            if is_substantive {
2796                if let Ok(mut pv) = self.pending_voice.lock() {
2797                    pv.insert(
2798                        message.recipient.clone(),
2799                        (content.clone(), std::time::Instant::now()),
2800                    );
2801                }
2802
2803                let pending = self.pending_voice.clone();
2804                let voice_chats = self.voice_chats.clone();
2805                let api_base = self.api_base.clone();
2806                let bot_token = self.bot_token.clone();
2807                let chat_id_owned = chat_id.to_string();
2808                let thread_id_owned = thread_id.map(str::to_string);
2809                let recipient = message.recipient.clone();
2810                let tts_config = self.tts_config.clone().unwrap();
2811                tokio::spawn(async move {
2812                    // Wait 10 seconds — long enough for the agent to finish its
2813                    // full tool chain and send the final answer.
2814                    tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
2815
2816                    // Atomic check-and-remove: only one task gets the value
2817                    let to_voice = pending.lock().ok().and_then(|mut pv| {
2818                        if let Some((_, ts)) = pv.get(&recipient) {
2819                            if ts.elapsed().as_secs() >= 8 {
2820                                return pv.remove(&recipient).map(|(text, _)| text);
2821                            }
2822                        }
2823                        None
2824                    });
2825
2826                    if let Some(text) = to_voice {
2827                        if let Ok(mut vc) = voice_chats.lock() {
2828                            vc.remove(&recipient);
2829                        }
2830                        match Self::synthesize_and_send_voice(
2831                            &api_base,
2832                            &bot_token,
2833                            &chat_id_owned,
2834                            thread_id_owned.as_deref(),
2835                            &text,
2836                            &tts_config,
2837                        )
2838                        .await
2839                        {
2840                            Ok(()) => {
2841                                tracing::info!("Telegram: voice reply sent ({} chars)", text.len());
2842                            }
2843                            Err(e) => {
2844                                tracing::warn!("Telegram: TTS voice reply failed: {e}");
2845                            }
2846                        }
2847                    }
2848                });
2849            }
2850        }
2851
2852        // Always send text reply (voice chat gets both text and voice)
2853        let (text_without_markers, attachments) = parse_attachment_markers(&content);
2854
2855        if !attachments.is_empty() {
2856            if !text_without_markers.is_empty() {
2857                self.send_text_chunks(&text_without_markers, chat_id, thread_id)
2858                    .await?;
2859            }
2860
2861            for attachment in &attachments {
2862                self.send_attachment(chat_id, thread_id, attachment).await?;
2863            }
2864
2865            return Ok(());
2866        }
2867
2868        if let Some(attachment) = parse_path_only_attachment(&content) {
2869            self.send_attachment(chat_id, thread_id, &attachment)
2870                .await?;
2871            return Ok(());
2872        }
2873
2874        self.send_text_chunks(&content, chat_id, thread_id).await
2875    }
2876
2877    async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> anyhow::Result<()> {
2878        let mut offset: i64 = 0;
2879
2880        if self.mention_only {
2881            let _ = self.get_bot_username().await;
2882        }
2883
2884        tracing::info!("Telegram channel listening for messages...");
2885
2886        // Startup probe: claim the getUpdates slot before entering the long-poll loop.
2887        // A previous daemon's 30-second poll may still be active on Telegram's server.
2888        // We retry with timeout=0 until we receive a successful (non-409) response,
2889        // confirming the slot is ours. This prevents the long-poll loop from entering
2890        // a self-sustaining 409 cycle where each rejected request is immediately retried.
2891        loop {
2892            let url = self.api_url("getUpdates");
2893            let probe = serde_json::json!({
2894                "offset": offset,
2895                "timeout": 0,
2896                "allowed_updates": ["message"]
2897            });
2898            match self.http_client().post(&url).json(&probe).send().await {
2899                Err(e) => {
2900                    tracing::warn!("Telegram startup probe error: {e}; retrying in 5s");
2901                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2902                }
2903                Ok(resp) => {
2904                    match resp.json::<serde_json::Value>().await {
2905                        Err(e) => {
2906                            tracing::warn!(
2907                                "Telegram startup probe parse error: {e}; retrying in 5s"
2908                            );
2909                            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2910                        }
2911                        Ok(data) => {
2912                            let ok = data
2913                                .get("ok")
2914                                .and_then(serde_json::Value::as_bool)
2915                                .unwrap_or(false);
2916                            if ok {
2917                                // Slot claimed — advance offset past any queued updates.
2918                                if let Some(results) =
2919                                    data.get("result").and_then(serde_json::Value::as_array)
2920                                {
2921                                    for update in results {
2922                                        if let Some(uid) = update
2923                                            .get("update_id")
2924                                            .and_then(serde_json::Value::as_i64)
2925                                        {
2926                                            offset = uid + 1;
2927                                        }
2928                                    }
2929                                }
2930                                break; // Probe succeeded; enter the long-poll loop.
2931                            }
2932
2933                            let error_code = data
2934                                .get("error_code")
2935                                .and_then(serde_json::Value::as_i64)
2936                                .unwrap_or_default();
2937                            if error_code == 409 {
2938                                tracing::debug!("Startup probe: slot busy (409), retrying in 5s");
2939                            } else {
2940                                let desc = data
2941                                    .get("description")
2942                                    .and_then(serde_json::Value::as_str)
2943                                    .unwrap_or("unknown");
2944                                tracing::warn!(
2945                                    "Startup probe: API error {error_code}: {desc}; retrying in 5s"
2946                                );
2947                            }
2948                            tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2949                        }
2950                    }
2951                }
2952            }
2953        }
2954
2955        tracing::debug!("Startup probe succeeded; entering main long-poll loop.");
2956
2957        loop {
2958            if self.mention_only {
2959                let missing_username = self.bot_username.lock().is_none();
2960                if missing_username {
2961                    let _ = self.get_bot_username().await;
2962                }
2963            }
2964
2965            let url = self.api_url("getUpdates");
2966            let body = serde_json::json!({
2967                "offset": offset,
2968                "timeout": 30,
2969                "allowed_updates": ["message"]
2970            });
2971
2972            let resp = match self.http_client().post(&url).json(&body).send().await {
2973                Ok(r) => r,
2974                Err(e) => {
2975                    tracing::warn!("Telegram poll error: {e}");
2976                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2977                    continue;
2978                }
2979            };
2980
2981            let data: serde_json::Value = match resp.json().await {
2982                Ok(d) => d,
2983                Err(e) => {
2984                    tracing::warn!("Telegram parse error: {e}");
2985                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
2986                    continue;
2987                }
2988            };
2989
2990            let ok = data
2991                .get("ok")
2992                .and_then(serde_json::Value::as_bool)
2993                .unwrap_or(true);
2994            if !ok {
2995                let error_code = data
2996                    .get("error_code")
2997                    .and_then(serde_json::Value::as_i64)
2998                    .unwrap_or_default();
2999                let description = data
3000                    .get("description")
3001                    .and_then(serde_json::Value::as_str)
3002                    .unwrap_or("unknown Telegram API error");
3003
3004                if error_code == 409 {
3005                    tracing::warn!(
3006                        "Telegram polling conflict (409): {description}. \
3007Ensure only one `construct` process is using this bot token."
3008                    );
3009                    // Back off for 35 seconds — longer than Telegram's 30-second poll
3010                    // timeout — so any competing session (e.g. a stale connection from
3011                    // a previous daemon) has time to expire before we retry.
3012                    tokio::time::sleep(std::time::Duration::from_secs(35)).await;
3013                } else {
3014                    tracing::warn!(
3015                        "Telegram getUpdates API error (code={}): {description}",
3016                        error_code
3017                    );
3018                    tokio::time::sleep(std::time::Duration::from_secs(5)).await;
3019                }
3020                continue;
3021            }
3022
3023            if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) {
3024                for update in results {
3025                    // Advance offset past this update
3026                    if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) {
3027                        offset = uid + 1;
3028                    }
3029
3030                    let msg = if let Some(m) = self.parse_update_message(update) {
3031                        m
3032                    } else if let Some(m) = self.try_parse_voice_message(update).await {
3033                        m
3034                    } else if let Some(m) = self.try_parse_attachment_message(update).await {
3035                        m
3036                    } else {
3037                        Box::pin(self.handle_unauthorized_message(update)).await;
3038                        continue;
3039                    };
3040
3041                    if self.ack_reactions {
3042                        if let Some((reaction_chat_id, reaction_message_id)) =
3043                            Self::extract_update_message_target(update)
3044                        {
3045                            self.try_add_ack_reaction_nonblocking(
3046                                reaction_chat_id,
3047                                reaction_message_id,
3048                            );
3049                        }
3050                    }
3051
3052                    // Human approval keyword intercept — check before typing
3053                    // indicator and agent forwarding. The approval must be a
3054                    // reply (via Telegram's native reply-to) to the prompt
3055                    // message we captured when the workflow paused.
3056                    let reply_to_message_id = update
3057                        .pointer("/message/reply_to_message/message_id")
3058                        .and_then(|v| v.as_i64())
3059                        .or_else(|| {
3060                            update
3061                                .pointer("/channel_post/reply_to_message/message_id")
3062                                .and_then(|v| v.as_i64())
3063                        });
3064                    if self.try_intercept_approval(
3065                        &msg.reply_target,
3066                        reply_to_message_id,
3067                        &msg.content,
3068                        &msg.sender,
3069                    ) {
3070                        continue;
3071                    }
3072
3073                    // Send "typing" indicator immediately when we receive a message
3074                    let typing_body = serde_json::json!({
3075                        "chat_id": &msg.reply_target,
3076                        "action": "typing"
3077                    });
3078                    let _ = self
3079                        .http_client()
3080                        .post(self.api_url("sendChatAction"))
3081                        .json(&typing_body)
3082                        .send()
3083                        .await; // Ignore errors for typing indicator
3084
3085                    if tx.send(msg).await.is_err() {
3086                        return Ok(());
3087                    }
3088                }
3089            }
3090        }
3091    }
3092
3093    async fn health_check(&self) -> bool {
3094        let timeout_duration = Duration::from_secs(5);
3095
3096        match tokio::time::timeout(
3097            timeout_duration,
3098            self.http_client().get(self.api_url("getMe")).send(),
3099        )
3100        .await
3101        {
3102            Ok(Ok(resp)) => resp.status().is_success(),
3103            Ok(Err(e)) => {
3104                tracing::debug!("Telegram health check failed: {e}");
3105                false
3106            }
3107            Err(_) => {
3108                tracing::debug!("Telegram health check timed out after 5s");
3109                false
3110            }
3111        }
3112    }
3113
3114    async fn start_typing(&self, recipient: &str) -> anyhow::Result<()> {
3115        self.stop_typing(recipient).await?;
3116
3117        let client = self.http_client();
3118        let url = self.api_url("sendChatAction");
3119        let chat_id = recipient.to_string();
3120
3121        let handle = tokio::spawn(async move {
3122            loop {
3123                let body = serde_json::json!({
3124                    "chat_id": &chat_id,
3125                    "action": "typing"
3126                });
3127                let _ = client.post(&url).json(&body).send().await;
3128                // Telegram typing indicator expires after 5s; refresh at 4s
3129                tokio::time::sleep(Duration::from_secs(4)).await;
3130            }
3131        });
3132
3133        let mut guard = self.typing_handle.lock();
3134        *guard = Some(handle);
3135
3136        Ok(())
3137    }
3138
3139    async fn stop_typing(&self, _recipient: &str) -> anyhow::Result<()> {
3140        let mut guard = self.typing_handle.lock();
3141        if let Some(handle) = guard.take() {
3142            handle.abort();
3143        }
3144        Ok(())
3145    }
3146}
3147
3148#[cfg(test)]
3149mod tests {
3150    use super::*;
3151
3152    #[test]
3153    fn telegram_channel_name() {
3154        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3155        assert_eq!(ch.name(), "telegram");
3156    }
3157
3158    #[test]
3159    fn random_telegram_ack_reaction_is_from_pool() {
3160        for _ in 0..128 {
3161            let emoji = random_telegram_ack_reaction();
3162            assert!(TELEGRAM_ACK_REACTIONS.contains(&emoji));
3163        }
3164    }
3165
3166    #[test]
3167    fn telegram_ack_reaction_request_shape() {
3168        let body = build_telegram_ack_reaction_request("-100200300", 42, "⚡️");
3169        assert_eq!(body["chat_id"], "-100200300");
3170        assert_eq!(body["message_id"], 42);
3171        assert_eq!(body["reaction"][0]["type"], "emoji");
3172        assert_eq!(body["reaction"][0]["emoji"], "⚡️");
3173    }
3174
3175    #[test]
3176    fn telegram_extract_update_message_target_parses_ids() {
3177        let update = serde_json::json!({
3178            "update_id": 1,
3179            "message": {
3180                "message_id": 99,
3181                "chat": { "id": -100_123_456 }
3182            }
3183        });
3184
3185        let target = TelegramChannel::extract_update_message_target(&update);
3186        assert_eq!(target, Some(("-100123456".to_string(), 99)));
3187    }
3188
3189    #[test]
3190    fn typing_handle_starts_as_none() {
3191        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3192        let guard = ch.typing_handle.lock();
3193        assert!(guard.is_none());
3194    }
3195
3196    #[tokio::test]
3197    async fn stop_typing_clears_handle() {
3198        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3199
3200        // Manually insert a dummy handle
3201        {
3202            let mut guard = ch.typing_handle.lock();
3203            *guard = Some(tokio::spawn(async {
3204                tokio::time::sleep(Duration::from_secs(60)).await;
3205            }));
3206        }
3207
3208        // stop_typing should abort and clear
3209        ch.stop_typing("123").await.unwrap();
3210
3211        let guard = ch.typing_handle.lock();
3212        assert!(guard.is_none());
3213    }
3214
3215    #[tokio::test]
3216    async fn start_typing_replaces_previous_handle() {
3217        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3218
3219        // Insert a dummy handle first
3220        {
3221            let mut guard = ch.typing_handle.lock();
3222            *guard = Some(tokio::spawn(async {
3223                tokio::time::sleep(Duration::from_secs(60)).await;
3224            }));
3225        }
3226
3227        // start_typing should abort the old handle and set a new one
3228        let _ = ch.start_typing("123").await;
3229
3230        let guard = ch.typing_handle.lock();
3231        assert!(guard.is_some());
3232    }
3233
3234    #[test]
3235    fn supports_draft_updates_respects_stream_mode() {
3236        let off = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3237        assert!(!off.supports_draft_updates());
3238
3239        let partial = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
3240            .with_streaming(StreamMode::Partial, 750);
3241        assert!(partial.supports_draft_updates());
3242        assert_eq!(partial.draft_update_interval_ms, 750);
3243    }
3244
3245    #[tokio::test]
3246    async fn send_draft_returns_none_when_stream_mode_off() {
3247        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3248        let id = ch
3249            .send_draft(&SendMessage::new("draft", "123"))
3250            .await
3251            .unwrap();
3252        assert!(id.is_none());
3253    }
3254
3255    #[tokio::test]
3256    async fn update_draft_rate_limit_short_circuits_network() {
3257        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
3258            .with_streaming(StreamMode::Partial, 60_000);
3259        ch.last_draft_edit
3260            .lock()
3261            .insert("123".to_string(), std::time::Instant::now());
3262
3263        let result = ch.update_draft("123", "42", "delta text").await;
3264        assert!(result.is_ok());
3265    }
3266
3267    #[tokio::test]
3268    async fn update_draft_utf8_truncation_is_safe_for_multibyte_text() {
3269        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
3270            .with_streaming(StreamMode::Partial, 0);
3271        let long_emoji_text = "😀".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 20);
3272
3273        // Invalid message_id returns early after building display_text.
3274        // This asserts truncation never panics on UTF-8 boundaries.
3275        let result = ch
3276            .update_draft("123", "not-a-number", &long_emoji_text)
3277            .await;
3278        assert!(result.is_ok());
3279    }
3280
3281    #[tokio::test]
3282    async fn finalize_draft_invalid_message_id_falls_back_to_chunk_send() {
3283        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
3284            .with_streaming(StreamMode::Partial, 0);
3285        let long_text = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 64);
3286
3287        // For oversized text + invalid draft message_id, finalize_draft should
3288        // fall back to chunked send instead of returning early.
3289        let result = ch.finalize_draft("123", "not-a-number", &long_text).await;
3290        assert!(result.is_err());
3291    }
3292
3293    #[test]
3294    fn telegram_api_url() {
3295        let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
3296        assert_eq!(
3297            ch.api_url("getMe"),
3298            "https://api.telegram.org/bot123:ABC/getMe"
3299        );
3300    }
3301
3302    #[test]
3303    fn telegram_markdown_to_html_escapes_quotes_in_link_href() {
3304        let rendered = TelegramChannel::markdown_to_telegram_html(
3305            "[click](https://example.com?q=\"x\"&a='b')",
3306        );
3307        assert_eq!(
3308            rendered,
3309            "<a href=\"https://example.com?q=&quot;x&quot;&amp;a=&#39;b&#39;\">click</a>"
3310        );
3311    }
3312
3313    #[test]
3314    fn telegram_markdown_to_html_escapes_quotes_in_plain_text() {
3315        let rendered = TelegramChannel::markdown_to_telegram_html("say \"hi\" & <tag> 'ok'");
3316        assert_eq!(
3317            rendered,
3318            "say &quot;hi&quot; &amp; &lt;tag&gt; &#39;ok&#39;"
3319        );
3320    }
3321
3322    #[test]
3323    fn telegram_markdown_to_html_code_block_drops_language_attribute() {
3324        let rendered = TelegramChannel::markdown_to_telegram_html(
3325            "```rust\" onclick=\"alert(1)\nlet x = 1;\n```",
3326        );
3327        assert_eq!(rendered, "<pre><code>let x = 1;</code></pre>");
3328        assert!(!rendered.contains("language-"));
3329        assert!(!rendered.contains("onclick"));
3330    }
3331
3332    #[test]
3333    fn telegram_user_allowed_wildcard() {
3334        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
3335        assert!(ch.is_user_allowed("anyone"));
3336    }
3337
3338    #[test]
3339    fn telegram_user_allowed_specific() {
3340        let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], false);
3341        assert!(ch.is_user_allowed("alice"));
3342        assert!(!ch.is_user_allowed("eve"));
3343    }
3344
3345    #[test]
3346    fn telegram_user_allowed_with_at_prefix_in_config() {
3347        let ch = TelegramChannel::new("t".into(), vec!["@alice".into()], false);
3348        assert!(ch.is_user_allowed("alice"));
3349    }
3350
3351    #[test]
3352    fn telegram_user_denied_empty() {
3353        let ch = TelegramChannel::new("t".into(), vec![], false);
3354        assert!(!ch.is_user_allowed("anyone"));
3355    }
3356
3357    #[test]
3358    fn telegram_user_exact_match_not_substring() {
3359        let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false);
3360        assert!(!ch.is_user_allowed("alice_bot"));
3361        assert!(!ch.is_user_allowed("alic"));
3362        assert!(!ch.is_user_allowed("malice"));
3363    }
3364
3365    #[test]
3366    fn telegram_user_empty_string_denied() {
3367        let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false);
3368        assert!(!ch.is_user_allowed(""));
3369    }
3370
3371    #[test]
3372    fn telegram_user_case_sensitive() {
3373        let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], false);
3374        assert!(ch.is_user_allowed("Alice"));
3375        assert!(!ch.is_user_allowed("alice"));
3376        assert!(!ch.is_user_allowed("ALICE"));
3377    }
3378
3379    #[test]
3380    fn telegram_wildcard_with_specific_users() {
3381        let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], false);
3382        assert!(ch.is_user_allowed("alice"));
3383        assert!(ch.is_user_allowed("bob"));
3384        assert!(ch.is_user_allowed("anyone"));
3385    }
3386
3387    #[test]
3388    fn telegram_user_allowed_by_numeric_id_identity() {
3389        let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], false);
3390        assert!(ch.is_any_user_allowed(["unknown", "123456789"]));
3391    }
3392
3393    #[test]
3394    fn telegram_user_denied_when_none_of_identities_match() {
3395        let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], false);
3396        assert!(!ch.is_any_user_allowed(["unknown", "123456789"]));
3397    }
3398
3399    #[test]
3400    fn telegram_pairing_enabled_with_empty_allowlist() {
3401        let ch = TelegramChannel::new("t".into(), vec![], false);
3402        assert!(ch.pairing_code_active());
3403    }
3404
3405    #[test]
3406    fn telegram_pairing_disabled_with_nonempty_allowlist() {
3407        let ch = TelegramChannel::new("t".into(), vec!["alice".into()], false);
3408        assert!(!ch.pairing_code_active());
3409    }
3410
3411    #[test]
3412    fn telegram_extract_bind_code_plain_command() {
3413        assert_eq!(
3414            TelegramChannel::extract_bind_code("/bind 123456"),
3415            Some("123456")
3416        );
3417    }
3418
3419    #[test]
3420    fn telegram_extract_bind_code_supports_bot_mention() {
3421        assert_eq!(
3422            TelegramChannel::extract_bind_code("/bind@construct_bot 654321"),
3423            Some("654321")
3424        );
3425    }
3426
3427    #[test]
3428    fn telegram_extract_bind_code_rejects_invalid_forms() {
3429        assert_eq!(TelegramChannel::extract_bind_code("/bind"), None);
3430        assert_eq!(TelegramChannel::extract_bind_code("/start"), None);
3431    }
3432
3433    #[test]
3434    fn parse_attachment_markers_extracts_multiple_types() {
3435        let message = "Here are files [IMAGE:/tmp/a.png] and [DOCUMENT:https://example.com/a.pdf]";
3436        let (cleaned, attachments) = parse_attachment_markers(message);
3437
3438        assert_eq!(cleaned, "Here are files  and");
3439        assert_eq!(attachments.len(), 2);
3440        assert_eq!(attachments[0].kind, TelegramAttachmentKind::Image);
3441        assert_eq!(attachments[0].target, "/tmp/a.png");
3442        assert_eq!(attachments[1].kind, TelegramAttachmentKind::Document);
3443        assert_eq!(attachments[1].target, "https://example.com/a.pdf");
3444    }
3445
3446    #[test]
3447    fn parse_attachment_markers_keeps_invalid_markers_in_text() {
3448        let message = "Report [UNKNOWN:/tmp/a.bin]";
3449        let (cleaned, attachments) = parse_attachment_markers(message);
3450
3451        assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
3452        assert!(attachments.is_empty());
3453    }
3454
3455    #[test]
3456    fn parse_path_only_attachment_detects_existing_file() {
3457        let dir = tempfile::tempdir().unwrap();
3458        let image_path = dir.path().join("snap.png");
3459        std::fs::write(&image_path, b"fake-png").unwrap();
3460
3461        let parsed = parse_path_only_attachment(image_path.to_string_lossy().as_ref())
3462            .expect("expected attachment");
3463
3464        assert_eq!(parsed.kind, TelegramAttachmentKind::Image);
3465        assert_eq!(parsed.target, image_path.to_string_lossy());
3466    }
3467
3468    #[test]
3469    fn parse_path_only_attachment_rejects_sentence_text() {
3470        assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none());
3471    }
3472
3473    #[test]
3474    fn infer_attachment_kind_from_target_detects_document_extension() {
3475        assert_eq!(
3476            infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"),
3477            Some(TelegramAttachmentKind::Document)
3478        );
3479    }
3480
3481    #[test]
3482    fn parse_update_message_uses_chat_id_as_reply_target() {
3483        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
3484        let update = serde_json::json!({
3485            "update_id": 1,
3486            "message": {
3487                "message_id": 33,
3488                "text": "hello",
3489                "from": {
3490                    "id": 555,
3491                    "username": "alice"
3492                },
3493                "chat": {
3494                    "id": -100_200_300
3495                }
3496            }
3497        });
3498
3499        let msg = ch
3500            .parse_update_message(&update)
3501            .expect("message should parse");
3502
3503        assert_eq!(msg.sender, "alice");
3504        assert_eq!(msg.reply_target, "-100200300");
3505        assert_eq!(msg.content, "hello");
3506        assert_eq!(msg.id, "telegram_-100200300_33");
3507    }
3508
3509    #[test]
3510    fn parse_update_message_allows_numeric_id_without_username() {
3511        let ch = TelegramChannel::new("token".into(), vec!["555".into()], false);
3512        let update = serde_json::json!({
3513            "update_id": 2,
3514            "message": {
3515                "message_id": 9,
3516                "text": "ping",
3517                "from": {
3518                    "id": 555
3519                },
3520                "chat": {
3521                    "id": 12345
3522                }
3523            }
3524        });
3525
3526        let msg = ch
3527            .parse_update_message(&update)
3528            .expect("numeric allowlist should pass");
3529
3530        assert_eq!(msg.sender, "555");
3531        assert_eq!(msg.reply_target, "12345");
3532    }
3533
3534    #[test]
3535    fn parse_update_message_extracts_thread_id_for_forum_topic() {
3536        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
3537        let update = serde_json::json!({
3538            "update_id": 3,
3539            "message": {
3540                "message_id": 42,
3541                "text": "hello from topic",
3542                "from": {
3543                    "id": 555,
3544                    "username": "alice"
3545                },
3546                "chat": {
3547                    "id": -100_200_300
3548                },
3549                "message_thread_id": 789
3550            }
3551        });
3552
3553        let msg = ch
3554            .parse_update_message(&update)
3555            .expect("message with thread_id should parse");
3556
3557        assert_eq!(msg.sender, "alice");
3558        assert_eq!(msg.reply_target, "-100200300:789");
3559        assert_eq!(msg.content, "hello from topic");
3560        assert_eq!(msg.id, "telegram_-100200300_42");
3561    }
3562
3563    // ── File sending API URL tests ──────────────────────────────────
3564
3565    #[test]
3566    fn telegram_api_url_send_document() {
3567        let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
3568        assert_eq!(
3569            ch.api_url("sendDocument"),
3570            "https://api.telegram.org/bot123:ABC/sendDocument"
3571        );
3572    }
3573
3574    #[test]
3575    fn telegram_api_url_send_photo() {
3576        let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
3577        assert_eq!(
3578            ch.api_url("sendPhoto"),
3579            "https://api.telegram.org/bot123:ABC/sendPhoto"
3580        );
3581    }
3582
3583    #[test]
3584    fn telegram_api_url_send_video() {
3585        let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
3586        assert_eq!(
3587            ch.api_url("sendVideo"),
3588            "https://api.telegram.org/bot123:ABC/sendVideo"
3589        );
3590    }
3591
3592    #[test]
3593    fn telegram_api_url_send_audio() {
3594        let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
3595        assert_eq!(
3596            ch.api_url("sendAudio"),
3597            "https://api.telegram.org/bot123:ABC/sendAudio"
3598        );
3599    }
3600
3601    #[test]
3602    fn telegram_api_url_send_voice() {
3603        let ch = TelegramChannel::new("123:ABC".into(), vec![], false);
3604        assert_eq!(
3605            ch.api_url("sendVoice"),
3606            "https://api.telegram.org/bot123:ABC/sendVoice"
3607        );
3608    }
3609
3610    // ── File sending integration tests (with mock server) ──────────
3611
3612    #[tokio::test]
3613    async fn telegram_send_document_bytes_builds_correct_form() {
3614        // This test verifies the method doesn't panic and handles bytes correctly
3615        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3616        let file_bytes = b"Hello, this is a test file content".to_vec();
3617
3618        // The actual API call will fail (no real server), but we verify the method exists
3619        // and handles the input correctly up to the network call
3620        let result = ch
3621            .send_document_bytes("123456", None, file_bytes, "test.txt", Some("Test caption"))
3622            .await;
3623
3624        // Should fail with network error, not a panic or type error
3625        assert!(result.is_err());
3626        let err = result.unwrap_err().to_string();
3627        // Error should be network-related, not a code bug
3628        assert!(
3629            err.contains("error") || err.contains("failed") || err.contains("connect"),
3630            "Expected network error, got: {err}"
3631        );
3632    }
3633
3634    #[tokio::test]
3635    async fn telegram_send_photo_bytes_builds_correct_form() {
3636        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3637        // Minimal valid PNG header bytes
3638        let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A];
3639
3640        let result = ch
3641            .send_photo_bytes("123456", None, file_bytes, "test.png", None)
3642            .await;
3643
3644        assert!(result.is_err());
3645    }
3646
3647    #[tokio::test]
3648    async fn telegram_send_document_by_url_builds_correct_json() {
3649        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3650
3651        let result = ch
3652            .send_document_by_url(
3653                "123456",
3654                None,
3655                "https://example.com/file.pdf",
3656                Some("PDF doc"),
3657            )
3658            .await;
3659
3660        assert!(result.is_err());
3661    }
3662
3663    #[tokio::test]
3664    async fn telegram_send_photo_by_url_builds_correct_json() {
3665        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3666
3667        let result = ch
3668            .send_photo_by_url("123456", None, "https://example.com/image.jpg", None)
3669            .await;
3670
3671        assert!(result.is_err());
3672    }
3673
3674    // ── File path handling tests ────────────────────────────────────
3675
3676    #[tokio::test]
3677    async fn telegram_send_document_nonexistent_file() {
3678        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3679        let path = Path::new("/nonexistent/path/to/file.txt");
3680
3681        let result = ch.send_document("123456", None, path, None).await;
3682
3683        assert!(result.is_err());
3684        let err = result.unwrap_err().to_string();
3685        // Should fail with file not found error
3686        assert!(
3687            err.contains("No such file") || err.contains("not found") || err.contains("os error"),
3688            "Expected file not found error, got: {err}"
3689        );
3690    }
3691
3692    #[tokio::test]
3693    async fn telegram_send_photo_nonexistent_file() {
3694        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3695        let path = Path::new("/nonexistent/path/to/photo.jpg");
3696
3697        let result = ch.send_photo("123456", None, path, None).await;
3698
3699        assert!(result.is_err());
3700    }
3701
3702    #[tokio::test]
3703    async fn telegram_send_video_nonexistent_file() {
3704        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3705        let path = Path::new("/nonexistent/path/to/video.mp4");
3706
3707        let result = ch.send_video("123456", None, path, None).await;
3708
3709        assert!(result.is_err());
3710    }
3711
3712    #[tokio::test]
3713    async fn telegram_send_audio_nonexistent_file() {
3714        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3715        let path = Path::new("/nonexistent/path/to/audio.mp3");
3716
3717        let result = ch.send_audio("123456", None, path, None).await;
3718
3719        assert!(result.is_err());
3720    }
3721
3722    #[tokio::test]
3723    async fn telegram_send_voice_nonexistent_file() {
3724        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3725        let path = Path::new("/nonexistent/path/to/voice.ogg");
3726
3727        let result = ch.send_voice("123456", None, path, None).await;
3728
3729        assert!(result.is_err());
3730    }
3731
3732    // ── Message splitting tests ─────────────────────────────────────
3733
3734    #[test]
3735    fn telegram_split_short_message() {
3736        let msg = "Hello, world!";
3737        let chunks = split_message_for_telegram(msg);
3738        assert_eq!(chunks.len(), 1);
3739        assert_eq!(chunks[0], msg);
3740    }
3741
3742    #[test]
3743    fn telegram_split_exact_limit() {
3744        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH);
3745        let chunks = split_message_for_telegram(&msg);
3746        assert_eq!(chunks.len(), 1);
3747        assert_eq!(chunks[0].len(), TELEGRAM_MAX_MESSAGE_LENGTH);
3748    }
3749
3750    #[test]
3751    fn telegram_split_over_limit() {
3752        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 100);
3753        let chunks = split_message_for_telegram(&msg);
3754        assert_eq!(chunks.len(), 2);
3755        assert!(chunks[0].len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
3756        assert!(chunks[1].len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
3757    }
3758
3759    #[test]
3760    fn telegram_split_at_word_boundary() {
3761        let msg = format!(
3762            "{} more text here",
3763            "word ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5)
3764        );
3765        let chunks = split_message_for_telegram(&msg);
3766        assert!(chunks.len() >= 2);
3767        // First chunk should end with a complete word (space at the end)
3768        for chunk in &chunks[..chunks.len() - 1] {
3769            assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
3770        }
3771    }
3772
3773    #[test]
3774    fn telegram_split_at_newline() {
3775        let text_block = "Line of text\n".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 13 + 1);
3776        let chunks = split_message_for_telegram(&text_block);
3777        assert!(chunks.len() >= 2);
3778        for chunk in chunks {
3779            assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
3780        }
3781    }
3782
3783    #[test]
3784    fn telegram_split_preserves_content() {
3785        let msg = "test ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5 + 100);
3786        let chunks = split_message_for_telegram(&msg);
3787        let rejoined = chunks.join("");
3788        assert_eq!(rejoined, msg);
3789    }
3790
3791    #[test]
3792    fn telegram_split_empty_message() {
3793        let chunks = split_message_for_telegram("");
3794        assert_eq!(chunks.len(), 1);
3795        assert_eq!(chunks[0], "");
3796    }
3797
3798    #[test]
3799    fn telegram_split_very_long_message() {
3800        let msg = "x".repeat(TELEGRAM_MAX_MESSAGE_LENGTH * 3);
3801        let chunks = split_message_for_telegram(&msg);
3802        assert!(chunks.len() >= 3);
3803        for chunk in chunks {
3804            assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
3805        }
3806    }
3807
3808    // ── Caption handling tests ──────────────────────────────────────
3809
3810    #[tokio::test]
3811    async fn telegram_send_document_bytes_with_caption() {
3812        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3813        let file_bytes = b"test content".to_vec();
3814
3815        // With caption
3816        let result = ch
3817            .send_document_bytes(
3818                "123456",
3819                None,
3820                file_bytes.clone(),
3821                "test.txt",
3822                Some("My caption"),
3823            )
3824            .await;
3825        assert!(result.is_err()); // Network error expected
3826
3827        // Without caption
3828        let result = ch
3829            .send_document_bytes("123456", None, file_bytes, "test.txt", None)
3830            .await;
3831        assert!(result.is_err()); // Network error expected
3832    }
3833
3834    #[tokio::test]
3835    async fn telegram_send_photo_bytes_with_caption() {
3836        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3837        let file_bytes = vec![0x89, 0x50, 0x4E, 0x47];
3838
3839        // With caption
3840        let result = ch
3841            .send_photo_bytes(
3842                "123456",
3843                None,
3844                file_bytes.clone(),
3845                "test.png",
3846                Some("Photo caption"),
3847            )
3848            .await;
3849        assert!(result.is_err());
3850
3851        // Without caption
3852        let result = ch
3853            .send_photo_bytes("123456", None, file_bytes, "test.png", None)
3854            .await;
3855        assert!(result.is_err());
3856    }
3857
3858    // ── Empty/edge case tests ───────────────────────────────────────
3859
3860    #[tokio::test]
3861    async fn telegram_send_document_bytes_empty_file() {
3862        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3863        let file_bytes: Vec<u8> = vec![];
3864
3865        let result = ch
3866            .send_document_bytes("123456", None, file_bytes, "empty.txt", None)
3867            .await;
3868
3869        // Should not panic, will fail at API level
3870        assert!(result.is_err());
3871    }
3872
3873    #[tokio::test]
3874    async fn telegram_send_document_bytes_empty_filename() {
3875        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3876        let file_bytes = b"content".to_vec();
3877
3878        let result = ch
3879            .send_document_bytes("123456", None, file_bytes, "", None)
3880            .await;
3881
3882        // Should not panic
3883        assert!(result.is_err());
3884    }
3885
3886    #[tokio::test]
3887    async fn telegram_send_document_bytes_empty_chat_id() {
3888        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false);
3889        let file_bytes = b"content".to_vec();
3890
3891        let result = ch
3892            .send_document_bytes("", None, file_bytes, "test.txt", None)
3893            .await;
3894
3895        // Should not panic
3896        assert!(result.is_err());
3897    }
3898
3899    // ── Message ID edge cases ─────────────────────────────────────
3900
3901    #[test]
3902    fn telegram_message_id_format_includes_chat_and_message_id() {
3903        // Verify that message IDs follow the format: telegram_{chat_id}_{message_id}
3904        let chat_id = "123456";
3905        let message_id = 789;
3906        let expected_id = format!("telegram_{chat_id}_{message_id}");
3907        assert_eq!(expected_id, "telegram_123456_789");
3908    }
3909
3910    #[test]
3911    fn telegram_message_id_is_deterministic() {
3912        // Same chat_id + same message_id = same ID (prevents duplicates after restart)
3913        let chat_id = "123456";
3914        let message_id = 789;
3915        let id1 = format!("telegram_{chat_id}_{message_id}");
3916        let id2 = format!("telegram_{chat_id}_{message_id}");
3917        assert_eq!(id1, id2);
3918    }
3919
3920    #[test]
3921    fn telegram_message_id_different_message_different_id() {
3922        // Different message IDs produce different IDs
3923        let chat_id = "123456";
3924        let id1 = format!("telegram_{chat_id}_789");
3925        let id2 = format!("telegram_{chat_id}_790");
3926        assert_ne!(id1, id2);
3927    }
3928
3929    #[test]
3930    fn telegram_message_id_different_chat_different_id() {
3931        // Different chats produce different IDs even with same message_id
3932        let message_id = 789;
3933        let id1 = format!("telegram_123456_{message_id}");
3934        let id2 = format!("telegram_789012_{message_id}");
3935        assert_ne!(id1, id2);
3936    }
3937
3938    #[test]
3939    fn telegram_message_id_no_uuid_randomness() {
3940        // Verify format doesn't contain random UUID components
3941        let chat_id = "123456";
3942        let message_id = 789;
3943        let id = format!("telegram_{chat_id}_{message_id}");
3944        assert!(!id.contains('-')); // No UUID dashes
3945        assert!(id.starts_with("telegram_"));
3946    }
3947
3948    #[test]
3949    fn telegram_message_id_handles_zero_message_id() {
3950        // Edge case: message_id can be 0 (fallback/missing case)
3951        let chat_id = "123456";
3952        let message_id = 0;
3953        let id = format!("telegram_{chat_id}_{message_id}");
3954        assert_eq!(id, "telegram_123456_0");
3955    }
3956
3957    // ── Tool call tag stripping tests ───────────────────────────────────
3958
3959    #[test]
3960    fn strip_tool_call_tags_removes_standard_tags() {
3961        let input =
3962            "Hello <tool>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool> world";
3963        let result = strip_tool_call_tags(input);
3964        assert_eq!(result, "Hello  world");
3965    }
3966
3967    #[test]
3968    fn strip_tool_call_tags_removes_alias_tags() {
3969        let input = "Hello <toolcall>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</toolcall> world";
3970        let result = strip_tool_call_tags(input);
3971        assert_eq!(result, "Hello  world");
3972    }
3973
3974    #[test]
3975    fn strip_tool_call_tags_removes_dash_tags() {
3976        let input = "Hello <tool-call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool-call> world";
3977        let result = strip_tool_call_tags(input);
3978        assert_eq!(result, "Hello  world");
3979    }
3980
3981    #[test]
3982    fn strip_tool_call_tags_removes_tool_call_tags() {
3983        let input = "Hello <tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"ls\"}}</tool_call> world";
3984        let result = strip_tool_call_tags(input);
3985        assert_eq!(result, "Hello  world");
3986    }
3987
3988    #[test]
3989    fn strip_tool_call_tags_removes_invoke_tags() {
3990        let input = "Hello <invoke>{\"name\":\"shell\",\"arguments\":{\"command\":\"date\"}}</invoke> world";
3991        let result = strip_tool_call_tags(input);
3992        assert_eq!(result, "Hello  world");
3993    }
3994
3995    #[test]
3996    fn strip_tool_call_tags_handles_multiple_tags() {
3997        let input = "Start <tool>a</tool> middle <tool>b</tool> end";
3998        let result = strip_tool_call_tags(input);
3999        assert_eq!(result, "Start  middle  end");
4000    }
4001
4002    #[test]
4003    fn strip_tool_call_tags_handles_mixed_tags() {
4004        let input = "A <tool>a</tool> B <toolcall>b</toolcall> C <tool-call>c</tool-call> D";
4005        let result = strip_tool_call_tags(input);
4006        assert_eq!(result, "A  B  C  D");
4007    }
4008
4009    #[test]
4010    fn strip_tool_call_tags_preserves_normal_text() {
4011        let input = "Hello world! This is a test.";
4012        let result = strip_tool_call_tags(input);
4013        assert_eq!(result, "Hello world! This is a test.");
4014    }
4015
4016    #[test]
4017    fn strip_tool_call_tags_handles_unclosed_tags() {
4018        let input = "Hello <tool>world";
4019        let result = strip_tool_call_tags(input);
4020        assert_eq!(result, "Hello <tool>world");
4021    }
4022
4023    #[test]
4024    fn strip_tool_call_tags_handles_unclosed_tool_call_with_json() {
4025        let input =
4026            "Status:\n<tool_call>\n{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}";
4027        let result = strip_tool_call_tags(input);
4028        assert_eq!(result, "Status:");
4029    }
4030
4031    #[test]
4032    fn strip_tool_call_tags_handles_mismatched_close_tag() {
4033        let input =
4034            "<tool_call>{\"name\":\"shell\",\"arguments\":{\"command\":\"uptime\"}}</arg_value>";
4035        let result = strip_tool_call_tags(input);
4036        assert_eq!(result, "");
4037    }
4038
4039    #[test]
4040    fn strip_tool_call_tags_cleans_extra_newlines() {
4041        let input = "Hello\n\n<tool>\ntest\n</tool>\n\n\nworld";
4042        let result = strip_tool_call_tags(input);
4043        assert_eq!(result, "Hello\n\nworld");
4044    }
4045
4046    #[test]
4047    fn strip_tool_call_tags_handles_empty_input() {
4048        let input = "";
4049        let result = strip_tool_call_tags(input);
4050        assert_eq!(result, "");
4051    }
4052
4053    #[test]
4054    fn strip_tool_call_tags_handles_only_tags() {
4055        let input = "<tool>{\"name\":\"test\"}</tool>";
4056        let result = strip_tool_call_tags(input);
4057        assert_eq!(result, "");
4058    }
4059
4060    #[test]
4061    fn telegram_contains_bot_mention_finds_mention() {
4062        assert!(TelegramChannel::contains_bot_mention(
4063            "Hello @mybot",
4064            "mybot"
4065        ));
4066        assert!(TelegramChannel::contains_bot_mention(
4067            "@mybot help",
4068            "mybot"
4069        ));
4070        assert!(TelegramChannel::contains_bot_mention(
4071            "Hey @mybot how are you?",
4072            "mybot"
4073        ));
4074        assert!(TelegramChannel::contains_bot_mention(
4075            "Hello @MyBot, can you help?",
4076            "mybot"
4077        ));
4078    }
4079
4080    #[test]
4081    fn telegram_contains_bot_mention_no_false_positives() {
4082        assert!(!TelegramChannel::contains_bot_mention(
4083            "Hello @otherbot",
4084            "mybot"
4085        ));
4086        assert!(!TelegramChannel::contains_bot_mention(
4087            "Hello mybot",
4088            "mybot"
4089        ));
4090        assert!(!TelegramChannel::contains_bot_mention(
4091            "Hello @mybot2",
4092            "mybot"
4093        ));
4094        assert!(!TelegramChannel::contains_bot_mention("", "mybot"));
4095    }
4096
4097    #[test]
4098    fn telegram_normalize_incoming_content_strips_mention() {
4099        let result = TelegramChannel::normalize_incoming_content("@mybot hello", "mybot");
4100        assert_eq!(result, Some("hello".to_string()));
4101    }
4102
4103    #[test]
4104    fn telegram_normalize_incoming_content_handles_multiple_mentions() {
4105        let result = TelegramChannel::normalize_incoming_content("@mybot @mybot test", "mybot");
4106        assert_eq!(result, Some("test".to_string()));
4107    }
4108
4109    #[test]
4110    fn telegram_normalize_incoming_content_returns_none_for_empty() {
4111        let result = TelegramChannel::normalize_incoming_content("@mybot", "mybot");
4112        assert_eq!(result, None);
4113    }
4114
4115    #[test]
4116    fn parse_update_message_mention_only_group_requires_exact_mention() {
4117        let ch = TelegramChannel::new("token".into(), vec!["*".into()], true);
4118        {
4119            let mut cache = ch.bot_username.lock();
4120            *cache = Some("mybot".to_string());
4121        }
4122
4123        let update = serde_json::json!({
4124            "update_id": 10,
4125            "message": {
4126                "message_id": 44,
4127                "text": "hello @mybot2",
4128                "from": {
4129                    "id": 555,
4130                    "username": "alice"
4131                },
4132                "chat": {
4133                    "id": -100_200_300,
4134                    "type": "group"
4135                }
4136            }
4137        });
4138
4139        assert!(ch.parse_update_message(&update).is_none());
4140    }
4141
4142    #[test]
4143    fn parse_update_message_mention_only_group_strips_mention_and_drops_empty() {
4144        let ch = TelegramChannel::new("token".into(), vec!["*".into()], true);
4145        {
4146            let mut cache = ch.bot_username.lock();
4147            *cache = Some("mybot".to_string());
4148        }
4149
4150        let update = serde_json::json!({
4151            "update_id": 11,
4152            "message": {
4153                "message_id": 45,
4154                "text": "Hi @MyBot status please",
4155                "from": {
4156                    "id": 555,
4157                    "username": "alice"
4158                },
4159                "chat": {
4160                    "id": -100_200_300,
4161                    "type": "group"
4162                }
4163            }
4164        });
4165
4166        let parsed = ch
4167            .parse_update_message(&update)
4168            .expect("mention should parse");
4169        assert_eq!(parsed.content, "Hi status please");
4170
4171        let empty_update = serde_json::json!({
4172            "update_id": 12,
4173            "message": {
4174                "message_id": 46,
4175                "text": "@mybot",
4176                "from": {
4177                    "id": 555,
4178                    "username": "alice"
4179                },
4180                "chat": {
4181                    "id": -100_200_300,
4182                    "type": "group"
4183                }
4184            }
4185        });
4186
4187        assert!(ch.parse_update_message(&empty_update).is_none());
4188    }
4189
4190    #[test]
4191    fn telegram_is_group_message_detects_groups() {
4192        let group_msg = serde_json::json!({
4193            "chat": { "type": "group" }
4194        });
4195        assert!(TelegramChannel::is_group_message(&group_msg));
4196
4197        let supergroup_msg = serde_json::json!({
4198            "chat": { "type": "supergroup" }
4199        });
4200        assert!(TelegramChannel::is_group_message(&supergroup_msg));
4201
4202        let private_msg = serde_json::json!({
4203            "chat": { "type": "private" }
4204        });
4205        assert!(!TelegramChannel::is_group_message(&private_msg));
4206    }
4207
4208    #[test]
4209    fn telegram_mention_only_enabled_by_config() {
4210        let ch = TelegramChannel::new("token".into(), vec!["*".into()], true);
4211        assert!(ch.mention_only);
4212
4213        let ch_disabled = TelegramChannel::new("token".into(), vec!["*".into()], false);
4214        assert!(!ch_disabled.mention_only);
4215    }
4216
4217    // ─────────────────────────────────────────────────────────────────────
4218    // TG6: Channel platform limit edge cases for Telegram (4096 char limit)
4219    // Prevents: Pattern 6 — issues #574, #499
4220    // ─────────────────────────────────────────────────────────────────────
4221
4222    #[test]
4223    fn telegram_split_code_block_at_boundary() {
4224        let mut msg = String::new();
4225        msg.push_str("```python\n");
4226        msg.push_str(&"x".repeat(4085));
4227        msg.push_str("\n```\nMore text after code block");
4228        let parts = split_message_for_telegram(&msg);
4229        assert!(
4230            parts.len() >= 2,
4231            "code block spanning boundary should split"
4232        );
4233        for part in &parts {
4234            assert!(
4235                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
4236                "each part must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
4237                part.len()
4238            );
4239        }
4240    }
4241
4242    #[test]
4243    fn telegram_split_single_long_word() {
4244        let long_word = "a".repeat(5000);
4245        let parts = split_message_for_telegram(&long_word);
4246        assert!(parts.len() >= 2, "word exceeding limit must be split");
4247        for part in &parts {
4248            assert!(
4249                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
4250                "hard-split part must be <= {TELEGRAM_MAX_MESSAGE_LENGTH}, got {}",
4251                part.len()
4252            );
4253        }
4254        let reassembled: String = parts.join("");
4255        assert_eq!(reassembled, long_word);
4256    }
4257
4258    #[test]
4259    fn telegram_split_exactly_at_limit_no_split() {
4260        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH);
4261        let parts = split_message_for_telegram(&msg);
4262        assert_eq!(parts.len(), 1, "message exactly at limit should not split");
4263    }
4264
4265    #[test]
4266    fn telegram_split_one_over_limit() {
4267        let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 1);
4268        let parts = split_message_for_telegram(&msg);
4269        assert!(parts.len() >= 2, "message 1 char over limit must split");
4270    }
4271
4272    #[test]
4273    fn telegram_split_many_short_lines() {
4274        let msg: String = (0..1000).fold(String::new(), |mut acc, i| {
4275            let _ = writeln!(acc, "line {i}");
4276            acc
4277        });
4278        let parts = split_message_for_telegram(&msg);
4279        for part in &parts {
4280            assert!(
4281                part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH,
4282                "short-line batch must be <= limit"
4283            );
4284        }
4285    }
4286
4287    #[test]
4288    fn telegram_split_only_whitespace() {
4289        let msg = "   \n\n\t  ";
4290        let parts = split_message_for_telegram(msg);
4291        assert!(parts.len() <= 1);
4292    }
4293
4294    #[test]
4295    fn telegram_split_emoji_at_boundary() {
4296        let mut msg = "a".repeat(4094);
4297        msg.push_str("🎉🎊"); // 4096 chars total
4298        let parts = split_message_for_telegram(&msg);
4299        for part in &parts {
4300            // The function splits on character count, not byte count
4301            assert!(
4302                part.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH,
4303                "emoji boundary split must respect limit"
4304            );
4305        }
4306    }
4307
4308    #[test]
4309    fn telegram_split_consecutive_newlines() {
4310        let mut msg = "a".repeat(4090);
4311        msg.push_str("\n\n\n\n\n\n");
4312        msg.push_str(&"b".repeat(100));
4313        let parts = split_message_for_telegram(&msg);
4314        for part in &parts {
4315            assert!(part.len() <= TELEGRAM_MAX_MESSAGE_LENGTH);
4316        }
4317    }
4318
4319    #[test]
4320    fn parse_voice_metadata_extracts_voice() {
4321        let msg = serde_json::json!({
4322            "voice": {
4323                "file_id": "abc123",
4324                "duration": 5
4325            }
4326        });
4327        let (file_id, dur) = TelegramChannel::parse_voice_metadata(&msg).unwrap();
4328        assert_eq!(file_id, "abc123");
4329        assert_eq!(dur, 5);
4330    }
4331
4332    #[test]
4333    fn parse_voice_metadata_extracts_audio() {
4334        let msg = serde_json::json!({
4335            "audio": {
4336                "file_id": "audio456",
4337                "duration": 30
4338            }
4339        });
4340        let (file_id, dur) = TelegramChannel::parse_voice_metadata(&msg).unwrap();
4341        assert_eq!(file_id, "audio456");
4342        assert_eq!(dur, 30);
4343    }
4344
4345    #[test]
4346    fn parse_voice_metadata_returns_none_for_text() {
4347        let msg = serde_json::json!({
4348            "text": "hello"
4349        });
4350        assert!(TelegramChannel::parse_voice_metadata(&msg).is_none());
4351    }
4352
4353    #[test]
4354    fn parse_voice_metadata_defaults_duration_to_zero() {
4355        let msg = serde_json::json!({
4356            "voice": {
4357                "file_id": "no_dur"
4358            }
4359        });
4360        let (_, dur) = TelegramChannel::parse_voice_metadata(&msg).unwrap();
4361        assert_eq!(dur, 0);
4362    }
4363
4364    // ─────────────────────────────────────────────────────────────────────
4365    // extract_sender_info tests
4366    // ─────────────────────────────────────────────────────────────────────
4367
4368    #[test]
4369    fn extract_sender_info_with_username() {
4370        let msg = serde_json::json!({
4371            "from": { "id": 123, "username": "alice" }
4372        });
4373        let (username, sender_id, identity) = TelegramChannel::extract_sender_info(&msg);
4374        assert_eq!(username, "alice");
4375        assert_eq!(sender_id, Some("123".to_string()));
4376        assert_eq!(identity, "alice");
4377    }
4378
4379    #[test]
4380    fn extract_sender_info_without_username() {
4381        let msg = serde_json::json!({
4382            "from": { "id": 42 }
4383        });
4384        let (username, sender_id, identity) = TelegramChannel::extract_sender_info(&msg);
4385        assert_eq!(username, "unknown");
4386        assert_eq!(sender_id, Some("42".to_string()));
4387        assert_eq!(identity, "42");
4388    }
4389
4390    // ─────────────────────────────────────────────────────────────────────
4391    // extract_reply_context tests
4392    // ─────────────────────────────────────────────────────────────────────
4393
4394    #[test]
4395    fn extract_reply_context_text_message() {
4396        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
4397        let msg = serde_json::json!({
4398            "reply_to_message": {
4399                "from": { "username": "alice" },
4400                "text": "Hello world"
4401            }
4402        });
4403        let ctx = ch.extract_reply_context(&msg).unwrap();
4404        assert_eq!(ctx, "> @alice:\n> Hello world");
4405    }
4406
4407    #[test]
4408    fn extract_reply_context_voice_message() {
4409        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
4410        let msg = serde_json::json!({
4411            "reply_to_message": {
4412                "from": { "username": "bob" },
4413                "voice": { "file_id": "abc", "duration": 5 }
4414            }
4415        });
4416        let ctx = ch.extract_reply_context(&msg).unwrap();
4417        assert_eq!(ctx, "> @bob:\n> [Voice message]");
4418    }
4419
4420    #[test]
4421    fn extract_reply_context_no_reply() {
4422        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
4423        let msg = serde_json::json!({
4424            "text": "just a regular message"
4425        });
4426        assert!(ch.extract_reply_context(&msg).is_none());
4427    }
4428
4429    #[test]
4430    fn extract_reply_context_no_username_uses_first_name() {
4431        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
4432        let msg = serde_json::json!({
4433            "reply_to_message": {
4434                "from": { "id": 999, "first_name": "Charlie" },
4435                "text": "Hi there"
4436            }
4437        });
4438        let ctx = ch.extract_reply_context(&msg).unwrap();
4439        assert_eq!(ctx, "> @Charlie:\n> Hi there");
4440    }
4441
4442    #[test]
4443    fn extract_reply_context_voice_with_cached_transcription() {
4444        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
4445        // Pre-populate transcription cache
4446        ch.voice_transcriptions
4447            .lock()
4448            .insert("100:42".to_string(), "Hello from voice".to_string());
4449        let msg = serde_json::json!({
4450            "chat": { "id": 100 },
4451            "reply_to_message": {
4452                "message_id": 42,
4453                "from": { "username": "bob" },
4454                "voice": { "file_id": "abc", "duration": 5 }
4455            }
4456        });
4457        let ctx = ch.extract_reply_context(&msg).unwrap();
4458        assert_eq!(ctx, "> @bob:\n> [Voice] Hello from voice");
4459    }
4460
4461    #[test]
4462    fn parse_update_message_includes_reply_context() {
4463        let ch = TelegramChannel::new("t".into(), vec!["*".into()], false);
4464        let update = serde_json::json!({
4465            "message": {
4466                "message_id": 10,
4467                "text": "translate this",
4468                "from": { "id": 1, "username": "alice" },
4469                "chat": { "id": 100, "type": "private" },
4470                "reply_to_message": {
4471                    "from": { "username": "bot" },
4472                    "text": "Bonjour le monde"
4473                }
4474            }
4475        });
4476        let parsed = ch.parse_update_message(&update).unwrap();
4477        assert!(
4478            parsed.content.starts_with("> @bot:"),
4479            "content should start with quote: {}",
4480            parsed.content
4481        );
4482        assert!(
4483            parsed.content.contains("translate this"),
4484            "content should contain user text"
4485        );
4486        assert!(
4487            parsed.content.contains("Bonjour le monde"),
4488            "content should contain quoted text"
4489        );
4490    }
4491
4492    #[test]
4493    fn with_transcription_sets_config_when_enabled() {
4494        let mut tc = crate::config::TranscriptionConfig::default();
4495        tc.enabled = true;
4496        tc.api_key = Some("test_key".to_string());
4497
4498        let ch =
4499            TelegramChannel::new("token".into(), vec!["*".into()], false).with_transcription(tc);
4500        assert!(ch.transcription.is_some());
4501        assert!(ch.transcription_manager.is_some());
4502    }
4503
4504    #[test]
4505    fn with_transcription_skips_when_disabled() {
4506        let tc = crate::config::TranscriptionConfig::default(); // enabled = false
4507        let ch =
4508            TelegramChannel::new("token".into(), vec!["*".into()], false).with_transcription(tc);
4509        assert!(ch.transcription.is_none());
4510        assert!(ch.transcription_manager.is_none());
4511    }
4512
4513    #[tokio::test]
4514    async fn try_parse_voice_message_returns_none_when_transcription_disabled() {
4515        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
4516        let update = serde_json::json!({
4517            "message": {
4518                "message_id": 1,
4519                "voice": { "file_id": "voice_file", "duration": 4 },
4520                "from": { "id": 123, "username": "alice" },
4521                "chat": { "id": 456, "type": "private" }
4522            }
4523        });
4524
4525        let parsed = ch.try_parse_voice_message(&update).await;
4526        assert!(parsed.is_none());
4527    }
4528
4529    #[tokio::test]
4530    async fn try_parse_voice_message_skips_when_duration_exceeds_limit() {
4531        let mut tc = crate::config::TranscriptionConfig::default();
4532        tc.enabled = true;
4533        tc.api_key = Some("test_key".to_string());
4534        tc.max_duration_secs = 5;
4535
4536        let ch =
4537            TelegramChannel::new("token".into(), vec!["*".into()], false).with_transcription(tc);
4538        let update = serde_json::json!({
4539            "message": {
4540                "message_id": 2,
4541                "voice": { "file_id": "voice_file", "duration": 30 },
4542                "from": { "id": 123, "username": "alice" },
4543                "chat": { "id": 456, "type": "private" }
4544            }
4545        });
4546
4547        let parsed = ch.try_parse_voice_message(&update).await;
4548        assert!(parsed.is_none());
4549    }
4550
4551    #[tokio::test]
4552    async fn try_parse_voice_message_rejects_unauthorized_sender_before_download() {
4553        let mut tc = crate::config::TranscriptionConfig::default();
4554        tc.enabled = true;
4555        tc.api_key = Some("test_key".to_string());
4556        tc.max_duration_secs = 120;
4557
4558        let ch = TelegramChannel::new("token".into(), vec!["alice".into()], false)
4559            .with_transcription(tc);
4560        let update = serde_json::json!({
4561            "message": {
4562                "message_id": 3,
4563                "voice": { "file_id": "voice_file", "duration": 4 },
4564                "from": { "id": 999, "username": "bob" },
4565                "chat": { "id": 456, "type": "private" }
4566            }
4567        });
4568
4569        let parsed = ch.try_parse_voice_message(&update).await;
4570        assert!(parsed.is_none());
4571        assert!(ch.voice_transcriptions.lock().is_empty());
4572    }
4573
4574    // ─────────────────────────────────────────────────────────────────────
4575    // Live e2e: voice transcription via Groq Whisper + reply cache lookup
4576    // ─────────────────────────────────────────────────────────────────────
4577
4578    /// Live test: voice transcription via Groq Whisper + reply cache lookup.
4579    ///
4580    /// Loads a pre-recorded MP3 fixture ("hello"), sends it to Groq Whisper
4581    /// API, verifies the transcription contains "hello", then caches it and
4582    /// checks that `extract_reply_context` returns the cached text instead
4583    /// of the `[Voice message]` fallback placeholder.
4584    ///
4585    /// Skipped automatically when `GROQ_API_KEY` is not set.
4586    /// Run: `GROQ_API_KEY=<key> cargo test --lib -- telegram::tests::e2e_live_voice_transcription_and_reply_cache --ignored`
4587    #[tokio::test]
4588    #[ignore = "requires GROQ_API_KEY environment variable"]
4589    async fn e2e_live_voice_transcription_and_reply_cache() {
4590        if std::env::var("GROQ_API_KEY").is_err() {
4591            eprintln!("GROQ_API_KEY not set — skipping live voice transcription test");
4592            return;
4593        }
4594
4595        // 1. Load pre-recorded fixture (TTS-generated "hello", ~7 KB MP3)
4596        let fixture_path =
4597            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/hello.mp3");
4598        let audio_data = std::fs::read(&fixture_path)
4599            .unwrap_or_else(|e| panic!("Failed to read fixture {}: {e}", fixture_path.display()));
4600        assert!(
4601            audio_data.len() > 1000,
4602            "fixture too small ({} bytes), likely corrupt",
4603            audio_data.len()
4604        );
4605
4606        // 2. Call TranscriptionManager.transcribe() — real Groq Whisper API
4607        let config = crate::config::TranscriptionConfig {
4608            enabled: true,
4609            ..Default::default()
4610        };
4611        let manager = crate::channels::transcription::TranscriptionManager::new(&config)
4612            .expect("TranscriptionManager::new should succeed with valid GROQ_API_KEY");
4613        let transcript: String = manager
4614            .transcribe(&audio_data, "hello.mp3")
4615            .await
4616            .expect("transcribe should succeed with valid GROQ_API_KEY");
4617
4618        // 3. Verify Whisper actually recognized "hello"
4619        assert!(
4620            transcript.to_lowercase().contains("hello"),
4621            "expected transcription to contain 'hello', got: '{transcript}'"
4622        );
4623
4624        // 4. Create TelegramChannel, insert transcription into voice_transcriptions cache
4625        let ch = TelegramChannel::new("test_token".into(), vec!["*".into()], false);
4626        let chat_id: i64 = 12345;
4627        let message_id: i64 = 67;
4628        let cache_key = format!("{chat_id}:{message_id}");
4629        ch.voice_transcriptions
4630            .lock()
4631            .insert(cache_key, transcript.clone());
4632
4633        // 5. Build reply message with voice + message_id + chat.id
4634        let msg = serde_json::json!({
4635            "chat": { "id": chat_id },
4636            "reply_to_message": {
4637                "message_id": message_id,
4638                "from": { "username": "construct_user" },
4639                "voice": { "file_id": "test_file", "duration": 1 }
4640            }
4641        });
4642
4643        // 6. Verify extract_reply_context returns cached transcription
4644        let ctx = ch
4645            .extract_reply_context(&msg)
4646            .expect("extract_reply_context should return Some for voice reply");
4647
4648        assert!(
4649            ctx.contains(&format!("[Voice] {transcript}")),
4650            "expected cached transcription in reply context, got: {ctx}"
4651        );
4652
4653        // Must NOT contain the fallback placeholder
4654        assert!(
4655            !ctx.contains("[Voice message]"),
4656            "context should use cached transcription, not fallback placeholder, got: {ctx}"
4657        );
4658    }
4659
4660    // ── IncomingAttachment / parse_attachment_metadata tests ─────────
4661
4662    #[test]
4663    fn parse_attachment_metadata_detects_document() {
4664        let message = serde_json::json!({
4665            "document": {
4666                "file_id": "BQACAgIAAxk",
4667                "file_name": "report.pdf",
4668                "file_size": 12345
4669            }
4670        });
4671        let att = TelegramChannel::parse_attachment_metadata(&message).unwrap();
4672        assert_eq!(att.kind, IncomingAttachmentKind::Document);
4673        assert_eq!(att.file_id, "BQACAgIAAxk");
4674        assert_eq!(att.file_name.as_deref(), Some("report.pdf"));
4675        assert_eq!(att.file_size, Some(12345));
4676        assert!(att.caption.is_none());
4677    }
4678
4679    #[test]
4680    fn parse_attachment_metadata_detects_photo() {
4681        let message = serde_json::json!({
4682            "photo": [
4683                {"file_id": "small_id", "file_size": 100, "width": 90, "height": 90},
4684                {"file_id": "medium_id", "file_size": 500, "width": 320, "height": 320},
4685                {"file_id": "large_id", "file_size": 2000, "width": 800, "height": 800}
4686            ]
4687        });
4688        let att = TelegramChannel::parse_attachment_metadata(&message).unwrap();
4689        assert_eq!(att.kind, IncomingAttachmentKind::Photo);
4690        assert_eq!(att.file_id, "large_id");
4691        assert_eq!(att.file_size, Some(2000));
4692        assert!(att.file_name.is_none());
4693    }
4694
4695    #[test]
4696    fn parse_attachment_metadata_extracts_caption() {
4697        // Document with caption
4698        let doc_msg = serde_json::json!({
4699            "document": {
4700                "file_id": "doc_id",
4701                "file_name": "data.csv"
4702            },
4703            "caption": "Monthly report"
4704        });
4705        let att = TelegramChannel::parse_attachment_metadata(&doc_msg).unwrap();
4706        assert_eq!(att.caption.as_deref(), Some("Monthly report"));
4707
4708        // Photo with caption
4709        let photo_msg = serde_json::json!({
4710            "photo": [
4711                {"file_id": "photo_id", "file_size": 1000}
4712            ],
4713            "caption": "Look at this"
4714        });
4715        let att = TelegramChannel::parse_attachment_metadata(&photo_msg).unwrap();
4716        assert_eq!(att.caption.as_deref(), Some("Look at this"));
4717    }
4718
4719    #[test]
4720    fn parse_attachment_metadata_document_without_optional_fields() {
4721        let message = serde_json::json!({
4722            "document": {
4723                "file_id": "doc_no_name"
4724            }
4725        });
4726        let att = TelegramChannel::parse_attachment_metadata(&message).unwrap();
4727        assert_eq!(att.kind, IncomingAttachmentKind::Document);
4728        assert_eq!(att.file_id, "doc_no_name");
4729        assert!(att.file_name.is_none());
4730        assert!(att.file_size.is_none());
4731        assert!(att.caption.is_none());
4732    }
4733
4734    #[test]
4735    fn parse_attachment_metadata_returns_none_for_text() {
4736        let message = serde_json::json!({
4737            "text": "Hello world"
4738        });
4739        assert!(TelegramChannel::parse_attachment_metadata(&message).is_none());
4740    }
4741
4742    #[test]
4743    fn parse_attachment_metadata_returns_none_for_voice() {
4744        let message = serde_json::json!({
4745            "voice": {
4746                "file_id": "voice_id",
4747                "duration": 5
4748            }
4749        });
4750        assert!(TelegramChannel::parse_attachment_metadata(&message).is_none());
4751    }
4752
4753    #[test]
4754    fn parse_attachment_metadata_empty_photo_array() {
4755        let message = serde_json::json!({
4756            "photo": []
4757        });
4758        assert!(TelegramChannel::parse_attachment_metadata(&message).is_none());
4759    }
4760
4761    #[test]
4762    fn with_workspace_dir_sets_field() {
4763        let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], false)
4764            .with_workspace_dir(std::path::PathBuf::from("/tmp/test_workspace"));
4765        assert_eq!(
4766            ch.workspace_dir.as_deref(),
4767            Some(std::path::Path::new("/tmp/test_workspace"))
4768        );
4769    }
4770
4771    #[test]
4772    fn telegram_max_file_download_bytes_is_20mb() {
4773        assert_eq!(TELEGRAM_MAX_FILE_DOWNLOAD_BYTES, 20 * 1024 * 1024);
4774    }
4775
4776    // ── Attachment content format tests ──────────────────────────────
4777
4778    /// Photo attachments with image extension must use `[IMAGE:/path]` marker
4779    /// so the multimodal pipeline validates vision capability on the provider.
4780    #[test]
4781    fn attachment_photo_content_uses_image_marker() {
4782        let local_path = std::path::Path::new("/tmp/workspace/photo_123_45.jpg");
4783        let local_filename = "photo_123_45.jpg";
4784
4785        let content =
4786            format_attachment_content(IncomingAttachmentKind::Photo, local_filename, local_path);
4787
4788        assert_eq!(content, "[IMAGE:/tmp/workspace/photo_123_45.jpg]");
4789        assert!(content.starts_with("[IMAGE:"));
4790        assert!(content.ends_with(']'));
4791    }
4792
4793    /// Document attachments keep `[Document: name] /path` format.
4794    #[test]
4795    fn attachment_document_content_uses_document_label() {
4796        let local_path = std::path::Path::new("/tmp/workspace/report.pdf");
4797        let local_filename = "report.pdf";
4798
4799        let content =
4800            format_attachment_content(IncomingAttachmentKind::Document, local_filename, local_path);
4801
4802        assert_eq!(content, "[Document: report.pdf] /tmp/workspace/report.pdf");
4803        assert!(!content.contains("[IMAGE:"));
4804    }
4805
4806    /// Markdown files must never produce `[IMAGE:]` markers (issue #1274).
4807    #[test]
4808    fn markdown_file_never_produces_image_marker() {
4809        let local_path = std::path::Path::new("/tmp/workspace/telegram_files/notes.md");
4810        let local_filename = "notes.md";
4811
4812        // Even if Telegram misclassifies as Photo, extension guard prevents [IMAGE:].
4813        let content =
4814            format_attachment_content(IncomingAttachmentKind::Photo, local_filename, local_path);
4815        assert!(
4816            !content.contains("[IMAGE:"),
4817            "markdown must not get [IMAGE:] marker: {content}"
4818        );
4819        assert!(content.starts_with("[Document:"));
4820
4821        // As Document, it should also be correct.
4822        let content_doc =
4823            format_attachment_content(IncomingAttachmentKind::Document, local_filename, local_path);
4824        assert!(
4825            !content_doc.contains("[IMAGE:"),
4826            "markdown document must not get [IMAGE:] marker: {content_doc}"
4827        );
4828    }
4829
4830    /// Non-image files classified as Photo fall back to `[Document:]` format.
4831    #[test]
4832    fn non_image_photo_falls_back_to_document_format() {
4833        for (filename, ext_path) in [
4834            ("file.md", "/tmp/ws/file.md"),
4835            ("file.txt", "/tmp/ws/file.txt"),
4836            ("file.pdf", "/tmp/ws/file.pdf"),
4837            ("file.csv", "/tmp/ws/file.csv"),
4838            ("file.json", "/tmp/ws/file.json"),
4839            ("file.zip", "/tmp/ws/file.zip"),
4840            ("file", "/tmp/ws/file"),
4841        ] {
4842            let path = std::path::Path::new(ext_path);
4843            let content = format_attachment_content(IncomingAttachmentKind::Photo, filename, path);
4844            assert!(
4845                !content.contains("[IMAGE:"),
4846                "{filename}: non-image file should not get [IMAGE:] marker, got: {content}"
4847            );
4848            assert!(
4849                content.starts_with("[Document:"),
4850                "{filename}: should use [Document:] format, got: {content}"
4851            );
4852        }
4853    }
4854
4855    /// All recognized image extensions produce `[IMAGE:]` when classified as Photo.
4856    #[test]
4857    fn image_extensions_produce_image_marker() {
4858        for ext in ["png", "jpg", "jpeg", "gif", "webp", "bmp"] {
4859            let filename = format!("photo_1_2.{ext}");
4860            let path_str = format!("/tmp/ws/{filename}");
4861            let path = std::path::Path::new(&path_str);
4862            let content = format_attachment_content(IncomingAttachmentKind::Photo, &filename, path);
4863            assert!(
4864                content.starts_with("[IMAGE:"),
4865                "{ext}: image should get [IMAGE:] marker, got: {content}"
4866            );
4867        }
4868    }
4869
4870    /// Multimodal pipeline must return 0 image markers for document-formatted
4871    /// content — even for a file misclassified as Photo (issue #1274).
4872    #[test]
4873    fn markdown_attachment_not_detected_by_multimodal_image_markers() {
4874        let content = format_attachment_content(
4875            IncomingAttachmentKind::Photo,
4876            "notes.md",
4877            std::path::Path::new("/tmp/ws/notes.md"),
4878        );
4879        let messages = vec![crate::providers::ChatMessage::user(content)];
4880        assert_eq!(
4881            crate::multimodal::count_image_markers(&messages),
4882            0,
4883            "markdown file must not trigger image marker detection"
4884        );
4885    }
4886
4887    /// `is_image_extension` helper recognizes image formats and rejects others.
4888    #[test]
4889    fn is_image_extension_recognizes_images() {
4890        assert!(is_image_extension(std::path::Path::new("photo.png")));
4891        assert!(is_image_extension(std::path::Path::new("photo.jpg")));
4892        assert!(is_image_extension(std::path::Path::new("photo.jpeg")));
4893        assert!(is_image_extension(std::path::Path::new("photo.gif")));
4894        assert!(is_image_extension(std::path::Path::new("photo.webp")));
4895        assert!(is_image_extension(std::path::Path::new("photo.bmp")));
4896        assert!(is_image_extension(std::path::Path::new("PHOTO.PNG")));
4897
4898        assert!(!is_image_extension(std::path::Path::new("file.md")));
4899        assert!(!is_image_extension(std::path::Path::new("file.txt")));
4900        assert!(!is_image_extension(std::path::Path::new("file.pdf")));
4901        assert!(!is_image_extension(std::path::Path::new("file.csv")));
4902        assert!(!is_image_extension(std::path::Path::new("file")));
4903    }
4904
4905    /// `count_image_markers` from the multimodal module must detect the
4906    /// `[IMAGE:]` marker produced by photo attachment formatting.
4907    #[test]
4908    fn photo_image_marker_detected_by_multimodal() {
4909        let photo_content = "[IMAGE:/tmp/workspace/photo_1_2.jpg]";
4910        let messages = vec![crate::providers::ChatMessage::user(
4911            photo_content.to_string(),
4912        )];
4913        let count = crate::multimodal::count_image_markers(&messages);
4914        assert_eq!(
4915            count, 1,
4916            "multimodal should detect exactly one image marker"
4917        );
4918    }
4919
4920    /// Photo with caption: `[IMAGE:/path]\n\nCaption text`.
4921    #[test]
4922    fn photo_image_marker_with_caption() {
4923        let local_path = std::path::Path::new("/tmp/workspace/photo_1_2.jpg");
4924        let mut content = format!("[IMAGE:{}]", local_path.display());
4925        let caption = "Look at this screenshot";
4926        use std::fmt::Write;
4927        let _ = write!(content, "\n\n{caption}");
4928
4929        assert_eq!(
4930            content,
4931            "[IMAGE:/tmp/workspace/photo_1_2.jpg]\n\nLook at this screenshot"
4932        );
4933
4934        // Multimodal pipeline still detects the marker.
4935        let messages = vec![crate::providers::ChatMessage::user(content)];
4936        assert_eq!(crate::multimodal::count_image_markers(&messages), 1);
4937    }
4938
4939    // ── E2E: attachment saves file and formats content ───────────────
4940
4941    /// Full pipeline test: simulate file download → save to workspace →
4942    /// verify content format for both document and photo attachments.
4943    #[test]
4944    fn e2e_attachment_saves_file_and_formats_content() {
4945        let workspace = tempfile::tempdir().expect("create temp workspace");
4946
4947        // ── Document attachment ──────────────────────────────────────
4948        let doc_filename = "report.pdf";
4949        let doc_path = workspace.path().join(doc_filename);
4950        // Simulate downloaded file.
4951        std::fs::write(&doc_path, b"%PDF-1.4 fake").expect("write doc fixture");
4952        assert!(doc_path.exists(), "document file must exist on disk");
4953
4954        let doc_content =
4955            format_attachment_content(IncomingAttachmentKind::Document, doc_filename, &doc_path);
4956        assert!(
4957            doc_content.starts_with("[Document: report.pdf]"),
4958            "document label format mismatch: {doc_content}"
4959        );
4960        // Multimodal must NOT detect image markers in document content.
4961        let doc_msgs = vec![crate::providers::ChatMessage::user(doc_content)];
4962        assert_eq!(
4963            crate::multimodal::count_image_markers(&doc_msgs),
4964            0,
4965            "document content must not contain image markers"
4966        );
4967
4968        // ── Photo attachment ─────────────────────────────────────────
4969        let photo_filename = "photo_99_1.jpg";
4970        let photo_path = workspace.path().join(photo_filename);
4971        // Copy the JPEG fixture.
4972        let fixture =
4973            std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/fixtures/test_photo.jpg");
4974        std::fs::copy(&fixture, &photo_path).expect("copy photo fixture");
4975        assert!(photo_path.exists(), "photo file must exist on disk");
4976
4977        let photo_content =
4978            format_attachment_content(IncomingAttachmentKind::Photo, photo_filename, &photo_path);
4979        assert!(
4980            photo_content.starts_with("[IMAGE:"),
4981            "photo must use [IMAGE:] marker: {photo_content}"
4982        );
4983        assert!(
4984            photo_content.ends_with(']'),
4985            "photo marker must close with ]: {photo_content}"
4986        );
4987
4988        // Multimodal detects the marker.
4989        let photo_msgs = vec![crate::providers::ChatMessage::user(photo_content.clone())];
4990        assert_eq!(
4991            crate::multimodal::count_image_markers(&photo_msgs),
4992            1,
4993            "multimodal must detect exactly one image marker in photo content"
4994        );
4995
4996        // ── Photo with caption ───────────────────────────────────────
4997        let mut captioned = photo_content;
4998        use std::fmt::Write;
4999        let _ = write!(captioned, "\n\nCheck this out");
5000        let cap_msgs = vec![crate::providers::ChatMessage::user(captioned.clone())];
5001        assert_eq!(
5002            crate::multimodal::count_image_markers(&cap_msgs),
5003            1,
5004            "caption must not break image marker detection"
5005        );
5006        assert!(
5007            captioned.contains("Check this out"),
5008            "caption text must be present in content"
5009        );
5010
5011        // ── Markdown file sent as Photo (issue #1274) ────────────────
5012        let md_filename = "notes.md";
5013        let md_path = workspace.path().join(md_filename);
5014        std::fs::write(&md_path, b"# Hello\nSome markdown").expect("write md fixture");
5015        let md_content =
5016            format_attachment_content(IncomingAttachmentKind::Photo, md_filename, &md_path);
5017        assert!(
5018            !md_content.contains("[IMAGE:"),
5019            "markdown must not get [IMAGE:] marker: {md_content}"
5020        );
5021        let md_msgs = vec![crate::providers::ChatMessage::user(md_content)];
5022        assert_eq!(
5023            crate::multimodal::count_image_markers(&md_msgs),
5024            0,
5025            "markdown file must not trigger image marker detection"
5026        );
5027    }
5028
5029    // ── Groq provider rejects photo with vision error ────────────────
5030
5031    /// Verify that the Groq provider (OpenAI-compatible) does not support
5032    /// vision, so the existing `count_image_markers > 0 && !supports_vision()`
5033    /// guard in `agent/loop_.rs` will reject photo messages.
5034    #[test]
5035    fn groq_provider_rejects_photo_with_vision_error() {
5036        use crate::providers::Provider;
5037        use crate::providers::compatible::{AuthStyle, OpenAiCompatibleProvider};
5038
5039        let groq = OpenAiCompatibleProvider::new(
5040            "Groq",
5041            "https://api.groq.com/openai",
5042            Some("fake_key"),
5043            AuthStyle::Bearer,
5044        );
5045
5046        // Groq must not support vision.
5047        assert!(
5048            !groq.supports_vision(),
5049            "Groq provider must not support vision"
5050        );
5051
5052        // Build a message with an [IMAGE:] marker (as photo attachment would).
5053        let messages = vec![crate::providers::ChatMessage::user(
5054            "[IMAGE:/tmp/photo.jpg]\n\nDescribe this image".to_string(),
5055        )];
5056        let marker_count = crate::multimodal::count_image_markers(&messages);
5057        assert_eq!(marker_count, 1, "must detect image marker in photo content");
5058
5059        // The combination of marker_count > 0 && !supports_vision() means
5060        // the agent loop will return ProviderCapabilityError before calling
5061        // the provider, and the channel will send "⚠️ Error: ..." to the user.
5062    }
5063
5064    #[test]
5065    fn ack_reactions_defaults_to_true() {
5066        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
5067        assert!(ch.ack_reactions);
5068    }
5069
5070    #[test]
5071    fn with_ack_reactions_false_disables_reactions() {
5072        let ch =
5073            TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(false);
5074        assert!(!ch.ack_reactions);
5075    }
5076
5077    #[test]
5078    fn with_ack_reactions_true_keeps_reactions() {
5079        let ch =
5080            TelegramChannel::new("token".into(), vec!["*".into()], false).with_ack_reactions(true);
5081        assert!(ch.ack_reactions);
5082    }
5083
5084    // ── Forwarded message tests ─────────────────────────────────────
5085
5086    #[test]
5087    fn parse_update_message_forwarded_from_user_with_username() {
5088        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
5089        let update = serde_json::json!({
5090            "update_id": 100,
5091            "message": {
5092                "message_id": 50,
5093                "text": "Check this out",
5094                "from": { "id": 1, "username": "alice" },
5095                "chat": { "id": 999 },
5096                "forward_from": {
5097                    "id": 42,
5098                    "first_name": "Bob",
5099                    "username": "bob"
5100                },
5101                "forward_date": 1_700_000_000
5102            }
5103        });
5104
5105        let msg = ch
5106            .parse_update_message(&update)
5107            .expect("forwarded message should parse");
5108        assert_eq!(msg.content, "[Forwarded from @bob] Check this out");
5109    }
5110
5111    #[test]
5112    fn parse_update_message_forwarded_from_channel() {
5113        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
5114        let update = serde_json::json!({
5115            "update_id": 101,
5116            "message": {
5117                "message_id": 51,
5118                "text": "Breaking news",
5119                "from": { "id": 1, "username": "alice" },
5120                "chat": { "id": 999 },
5121                "forward_from_chat": {
5122                    "id": -1_001_234_567_890_i64,
5123                    "title": "Daily News",
5124                    "username": "dailynews",
5125                    "type": "channel"
5126                },
5127                "forward_date": 1_700_000_000
5128            }
5129        });
5130
5131        let msg = ch
5132            .parse_update_message(&update)
5133            .expect("channel-forwarded message should parse");
5134        assert_eq!(
5135            msg.content,
5136            "[Forwarded from channel: Daily News] Breaking news"
5137        );
5138    }
5139
5140    #[test]
5141    fn parse_update_message_forwarded_hidden_sender() {
5142        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
5143        let update = serde_json::json!({
5144            "update_id": 102,
5145            "message": {
5146                "message_id": 52,
5147                "text": "Secret tip",
5148                "from": { "id": 1, "username": "alice" },
5149                "chat": { "id": 999 },
5150                "forward_sender_name": "Hidden User",
5151                "forward_date": 1_700_000_000
5152            }
5153        });
5154
5155        let msg = ch
5156            .parse_update_message(&update)
5157            .expect("hidden-sender forwarded message should parse");
5158        assert_eq!(msg.content, "[Forwarded from Hidden User] Secret tip");
5159    }
5160
5161    #[test]
5162    fn parse_update_message_non_forwarded_unaffected() {
5163        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
5164        let update = serde_json::json!({
5165            "update_id": 103,
5166            "message": {
5167                "message_id": 53,
5168                "text": "Normal message",
5169                "from": { "id": 1, "username": "alice" },
5170                "chat": { "id": 999 }
5171            }
5172        });
5173
5174        let msg = ch
5175            .parse_update_message(&update)
5176            .expect("non-forwarded message should parse");
5177        assert_eq!(msg.content, "Normal message");
5178    }
5179
5180    #[test]
5181    fn parse_update_message_forwarded_from_user_no_username() {
5182        let ch = TelegramChannel::new("token".into(), vec!["*".into()], false);
5183        let update = serde_json::json!({
5184            "update_id": 104,
5185            "message": {
5186                "message_id": 54,
5187                "text": "Hello there",
5188                "from": { "id": 1, "username": "alice" },
5189                "chat": { "id": 999 },
5190                "forward_from": {
5191                    "id": 77,
5192                    "first_name": "Charlie"
5193                },
5194                "forward_date": 1_700_000_000
5195            }
5196        });
5197
5198        let msg = ch
5199            .parse_update_message(&update)
5200            .expect("forwarded message without username should parse");
5201        assert_eq!(msg.content, "[Forwarded from Charlie] Hello there");
5202    }
5203
5204    #[test]
5205    fn forwarded_photo_attachment_has_attribution() {
5206        // Verify that format_forward_attribution produces correct prefix
5207        // for a photo message (the actual download is async, so we test the
5208        // helper directly with a photo-bearing message structure).
5209        let message = serde_json::json!({
5210            "message_id": 60,
5211            "from": { "id": 1, "username": "alice" },
5212            "chat": { "id": 999 },
5213            "photo": [
5214                { "file_id": "abc123", "file_unique_id": "u1", "width": 320, "height": 240 }
5215            ],
5216            "forward_from": {
5217                "id": 42,
5218                "username": "bob"
5219            },
5220            "forward_date": 1_700_000_000
5221        });
5222
5223        let attr =
5224            TelegramChannel::format_forward_attribution(&message).expect("should detect forward");
5225        assert_eq!(attr, "[Forwarded from @bob] ");
5226
5227        // Simulate what try_parse_attachment_message does after building content
5228        let photo_content = "[IMAGE:/tmp/photo.jpg]".to_string();
5229        let content = format!("{attr}{photo_content}");
5230        assert_eq!(content, "[Forwarded from @bob] [IMAGE:/tmp/photo.jpg]");
5231    }
5232}