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