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
15const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096;
17const TELEGRAM_CONTINUATION_OVERHEAD: usize = 30;
20const TELEGRAM_ACK_REACTIONS: &[&str] = &["⚡️", "👌", "👀", "🔥", "👍"];
21
22#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34enum IncomingAttachmentKind {
35 Document,
36 Photo,
37}
38const TELEGRAM_BIND_COMMAND: &str = "/bind";
39
40fn 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 remaining.chars().count() <= TELEGRAM_MAX_MESSAGE_LENGTH {
56 chunks.push(remaining.to_string());
57 break;
58 }
59
60 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 let search_area = &remaining[..hard_split];
71
72 if let Some(pos) = search_area.rfind('\n') {
74 if search_area[..pos].chars().count() >= chunk_limit / 2 {
76 pos + 1
77 } else {
78 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
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
157fn 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
170fn 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
246fn 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
314const TELEGRAM_MAX_FILE_DOWNLOAD_BYTES: u64 = 20 * 1024 * 1024;
316
317pub 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 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 proxy_url: Option<String>,
343 approval_registry: Option<std::sync::Arc<crate::gateway::approval_registry::ApprovalRegistry>>,
345 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 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 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 pub fn with_ack_reactions(mut self, enabled: bool) -> Self {
503 self.ack_reactions = enabled;
504 self
505 }
506
507 pub fn with_proxy_url(mut self, proxy_url: Option<String>) -> Self {
509 self.proxy_url = proxy_url;
510 self
511 }
512
513 pub fn with_workspace_dir(mut self, dir: std::path::PathBuf) -> Self {
515 self.workspace_dir = Some(dir);
516 self
517 }
518
519 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 pub fn with_api_base(mut self, api_base: String) -> Self {
533 self.api_base = api_base;
534 self
535 }
536
537 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 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 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 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 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 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 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 fn parse_attachment_metadata(message: &serde_json::Value) -> Option<IncomingAttachment> {
1106 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 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 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 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 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 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 let local_filename = match &attachment.file_name {
1236 Some(name) => name.clone(),
1237 None => {
1238 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 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 if let Some(quote) = self.extract_reply_context(message) {
1264 content = format!("{quote}\n\n{content}");
1265 }
1266
1267 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 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 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 if let Ok(mut vc) = self.voice_chats.lock() {
1378 vc.insert(reply_target.clone());
1379 }
1380
1381 {
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 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 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 fn format_forward_attribution(message: &serde_json::Value) -> Option<String> {
1445 if let Some(from_chat) = message.get("forward_from_chat") {
1446 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 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 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 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 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 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 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 let content = if let Some(attr) = Self::format_forward_attribution(message) {
1598 format!("{attr}{content}")
1599 } else {
1600 content
1601 };
1602
1603 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 async fn resolve_photo_data_uri(&self, file_id: &str) -> anyhow::Result<String> {
1626 use base64::Engine as _;
1627
1628 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 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 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 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 result_lines.push(trimmed_line.to_string());
1683 continue;
1684 }
1685
1686 let mut line_out = String::new();
1687
1688 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 let mut i = 0;
1700 let bytes = line.as_bytes();
1701 let len = bytes.len();
1702 while i < len {
1703 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 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 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 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; 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 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 let ch = line[i..].chars().next().unwrap();
1771 match ch {
1772 '<' => line_out.push_str("<"),
1773 '>' => line_out.push_str(">"),
1774 '&' => line_out.push_str("&"),
1775 '"' => line_out.push_str("""),
1776 '\'' => line_out.push_str("'"),
1777 _ => line_out.push(ch),
1778 }
1779 i += ch.len_utf8();
1780 }
1781 result_lines.push(line_out);
1782 }
1783
1784 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 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('&', "&")
1820 .replace('<', "<")
1821 .replace('>', ">")
1822 .replace('"', """)
1823 .replace('\'', "'")
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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 {
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 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 self.last_draft_edit.lock().remove(&chat_id);
2584
2585 let (text_without_markers, attachments) = parse_attachment_markers(text);
2587
2588 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 !attachments.is_empty() {
2600 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 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 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.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 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 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 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 let content = strip_tool_call_tags(&message.content);
2768
2769 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 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 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 tokio::time::sleep(tokio::time::Duration::from_secs(10)).await;
2815
2816 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 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 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 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; }
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 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 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 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 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; 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 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 {
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 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 {
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 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 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 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="x"&a='b'\">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 "hi" & <tag> 'ok'"
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 #[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 #[tokio::test]
3613 async fn telegram_send_document_bytes_builds_correct_form() {
3614 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 let result = ch
3621 .send_document_bytes("123456", None, file_bytes, "test.txt", Some("Test caption"))
3622 .await;
3623
3624 assert!(result.is_err());
3626 let err = result.unwrap_err().to_string();
3627 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 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 #[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 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 #[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 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 #[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 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()); let result = ch
3829 .send_document_bytes("123456", None, file_bytes, "test.txt", None)
3830 .await;
3831 assert!(result.is_err()); }
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 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 let result = ch
3853 .send_photo_bytes("123456", None, file_bytes, "test.png", None)
3854 .await;
3855 assert!(result.is_err());
3856 }
3857
3858 #[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 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 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 assert!(result.is_err());
3897 }
3898
3899 #[test]
3902 fn telegram_message_id_format_includes_chat_and_message_id() {
3903 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 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 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 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 let chat_id = "123456";
3942 let message_id = 789;
3943 let id = format!("telegram_{chat_id}_{message_id}");
3944 assert!(!id.contains('-')); assert!(id.starts_with("telegram_"));
3946 }
3947
3948 #[test]
3949 fn telegram_message_id_handles_zero_message_id() {
3950 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 #[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 #[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("🎉🎊"); let parts = split_message_for_telegram(&msg);
4299 for part in &parts {
4300 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 #[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 #[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 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(); 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 #[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 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 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 assert!(
4620 transcript.to_lowercase().contains("hello"),
4621 "expected transcription to contain 'hello', got: '{transcript}'"
4622 );
4623
4624 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 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 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 assert!(
4655 !ctx.contains("[Voice message]"),
4656 "context should use cached transcription, not fallback placeholder, got: {ctx}"
4657 );
4658 }
4659
4660 #[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 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 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 #[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 #[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 #[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 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 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 #[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 #[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 #[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 #[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 #[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 #[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 let messages = vec![crate::providers::ChatMessage::user(content)];
4936 assert_eq!(crate::multimodal::count_image_markers(&messages), 1);
4937 }
4938
4939 #[test]
4944 fn e2e_attachment_saves_file_and_formats_content() {
4945 let workspace = tempfile::tempdir().expect("create temp workspace");
4946
4947 let doc_filename = "report.pdf";
4949 let doc_path = workspace.path().join(doc_filename);
4950 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 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 let photo_filename = "photo_99_1.jpg";
4970 let photo_path = workspace.path().join(photo_filename);
4971 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 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 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 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 #[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 assert!(
5048 !groq.supports_vision(),
5049 "Groq provider must not support vision"
5050 );
5051
5052 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 }
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 #[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 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 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}