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