diff --git a/src/channels/telegram.rs b/src/channels/telegram.rs index b256b65..471e710 100644 --- a/src/channels/telegram.rs +++ b/src/channels/telegram.rs @@ -3,6 +3,7 @@ use async_trait::async_trait; use reqwest::multipart::{Form, Part}; use std::path::Path; use std::time::Duration; +use tokio::process::Command; /// Telegram's maximum message length for text messages const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096; @@ -179,14 +180,19 @@ fn parse_attachment_markers(message: &str) -> (String, Vec) } /// Telegram channel — long-polls the Bot API for updates -pub struct TelegramChannel { workspace_dir: std::path::PathBuf, +pub struct TelegramChannel { + workspace_dir: std::path::PathBuf, bot_token: String, allowed_users: Vec, client: reqwest::Client, } impl TelegramChannel { - pub fn new(bot_token: String, allowed_users: Vec, workspace_dir: Option) -> Self { + pub fn new( + bot_token: String, + allowed_users: Vec, + workspace_dir: Option, + ) -> Self { Self { bot_token, allowed_users, @@ -196,7 +202,14 @@ impl TelegramChannel { } fn api_url(&self, method: &str) -> String { - format!("https://api.telegram.org/bot{}/{method}", self.bot_token) + format!("https://api.telegram.org/bot{}/{}", self.bot_token, method) + } + + fn file_url(&self, file_path: &str) -> String { + format!( + "https://api.telegram.org/file/bot{}/{}", + self.bot_token, file_path + ) } fn is_user_allowed(&self, username: &str) -> bool { @@ -210,6 +223,131 @@ impl TelegramChannel { identities.into_iter().any(|id| self.is_user_allowed(id)) } + /// Download a voice/audio file from Telegram and return the local path + async fn download_voice_file(&self, file_id: &str) -> anyhow::Result { + // Get file path from Telegram + let get_file_url = self.api_url("getFile"); + let body = serde_json::json!({ + "file_id": file_id + }); + + let resp = self + .client + .post(&get_file_url) + .json(&body) + .send() + .await?; + + if !resp.status().is_success() { + let err = resp.text().await?; + anyhow::bail!("Telegram getFile failed: {}", err); + } + + let data: serde_json::Value = resp.json().await?; + let file_path = data + .get("result") + .and_then(|r| r.get("file_path")) + .and_then(|p| p.as_str()) + .ok_or_else(|| anyhow::anyhow!("No file_path in Telegram response"))?; + + // Download the file + let download_url = self.file_url(file_path); + let file_resp = self.client.get(&download_url).send().await?; + + if !file_resp.status().is_success() { + anyhow::bail!("Failed to download voice file: {}", file_resp.status()); + } + + // Save to workspace with unique name + let file_name = format!("voice_{}.ogg", chrono::Utc::now().format("%Y%m%d_%H%M%S_%f")); + let local_path = self.workspace_dir.join(&file_name); + let bytes = file_resp.bytes().await?; + tokio::fs::write(&local_path, &bytes).await?; + + tracing::info!("Downloaded voice file to: {:?}", local_path); + Ok(local_path) + } + + /// Transcribe a voice file using faster-whisper + async fn transcribe_voice_file(&self, audio_path: &std::path::Path) -> anyhow::Result { + let script = r#" +import sys +import json + +def transcribe(audio_path): + from faster_whisper import WhisperModel + + model = WhisperModel("base", device="cpu", compute_type="int8") + segments, info = model.transcribe(audio_path, beam_size=5) + + text_parts = [] + for segment in segments: + text_parts.append(segment.text.strip()) + + result = { + "language": info.language, + "duration": round(info.duration, 2), + "text": " ".join(text_parts) + } + + print(json.dumps(result)) + +if __name__ == "__main__": + transcribe(sys.argv[1]) +"#; + + let output = Command::new("python3") + .arg("-c") + .arg(script) + .arg(audio_path) + .output() + .await; + + match output { + Ok(result) => { + if result.status.success() { + let stdout = String::from_utf8_lossy(&result.stdout); + match serde_json::from_str::(&stdout) { + Ok(json) => { + let text = json + .get("text") + .and_then(|t| t.as_str()) + .unwrap_or("") + .to_string(); + let language = json + .get("language") + .and_then(|l| l.as_str()) + .unwrap_or("unknown"); + let duration = json + .get("duration") + .and_then(|d| d.as_f64()) + .unwrap_or(0.0); + + tracing::info!( + "Transcribed voice message: {:.1}s, language: {}", + duration, + language + ); + Ok(text) + } + Err(_) => { + // Fallback: return raw stdout + Ok(stdout.trim().to_string()) + } + } + } else { + let stderr = String::from_utf8_lossy(&result.stderr); + tracing::warn!("Voice transcription failed: {}", stderr); + Err(anyhow::anyhow!("Transcription failed: {}", stderr.trim())) + } + } + Err(e) => { + tracing::warn!("Failed to run transcription: {}", e); + Err(anyhow::anyhow!("Failed to run transcription: {}", e)) + } + } + } + fn parse_update_message(&self, update: &serde_json::Value) -> Option { let message = update.get("message")?; @@ -241,8 +379,9 @@ impl TelegramChannel { if !self.is_any_user_allowed(identities.iter().copied()) { tracing::warn!( - "Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \ + "Telegram: ignoring message from unauthorized user: username={}, user_id={}. \ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.", + username, user_id.as_deref().unwrap_or("unknown") ); return None; @@ -260,7 +399,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch .unwrap_or(0); Some(ChannelMessage { - id: format!("telegram_{chat_id}_{message_id}"), + id: format!("telegram_{}_{}", chat_id, message_id), sender: sender_identity, reply_target: chat_id, content: text.to_string(), @@ -272,17 +411,80 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch }) } + /// Parse voice/audio messages from Telegram update (returns file_id and metadata) + fn parse_voice_message( + &self, + update: &serde_json::Value, + ) -> Option<(String, String, String, i64)> { + let message = update.get("message")?; + + // Check for voice message + let file_id = if let Some(voice) = message.get("voice") { + voice.get("file_id").and_then(|v| v.as_str())? + } else if let Some(audio) = message.get("audio") { + // Also support audio messages + audio.get("file_id").and_then(|v| v.as_str())? + } else { + return None; + }; + + let username = message + .get("from") + .and_then(|from| from.get("username")) + .and_then(serde_json::Value::as_str) + .unwrap_or("unknown") + .to_string(); + + let user_id = message + .get("from") + .and_then(|from| from.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string()); + + let sender_identity = if username == "unknown" { + user_id.clone().unwrap_or_else(|| "unknown".to_string()) + } else { + username.clone() + }; + + let mut identities = vec![username.as_str()]; + if let Some(id) = user_id.as_deref() { + identities.push(id); + } + + if !self.is_any_user_allowed(identities.iter().copied()) { + tracing::warn!( + "Telegram: ignoring voice message from unauthorized user: {}", + sender_identity + ); + return None; + } + + let chat_id = message + .get("chat") + .and_then(|chat| chat.get("id")) + .and_then(serde_json::Value::as_i64) + .map(|id| id.to_string())?; + + let message_id = message + .get("message_id") + .and_then(serde_json::Value::as_i64) + .unwrap_or(0); + + Some((file_id.to_string(), sender_identity, chat_id, message_id)) + } + async fn send_text_chunks(&self, message: &str, chat_id: &str) -> anyhow::Result<()> { let chunks = split_message_for_telegram(message); for (index, chunk) in chunks.iter().enumerate() { let text = if chunks.len() > 1 { if index == 0 { - format!("{chunk}\n\n(continues...)") + format!("{}\n\n(continues...)", chunk) } else if index == chunks.len() - 1 { - format!("(continued)\n\n{chunk}") + format!("(continued)\n\n{}", chunk) } else { - format!("(continued)\n\n{chunk}\n\n(continues...)") + format!("(continued)\n\n{}\n\n(continues...)", chunk) } } else { chunk.to_string() @@ -372,10 +574,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram {method} by URL failed: {err}"); + anyhow::bail!("Telegram {} by URL failed: {}", method, err); } - tracing::info!("Telegram {method} sent to {chat_id}: {url}"); + tracing::info!("Telegram {} sent to {}: {}", method, chat_id, url); Ok(()) } @@ -408,7 +610,7 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch let path = Path::new(target); if !path.exists() { - anyhow::bail!("Telegram attachment path not found: {target}"); + anyhow::bail!("Telegram attachment path not found: {}", target); } match attachment.kind { @@ -452,10 +654,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendDocument failed: {err}"); + anyhow::bail!("Telegram sendDocument failed: {}", err); } - tracing::info!("Telegram document sent to {chat_id}: {file_name}"); + tracing::info!("Telegram document sent to {}: {}", chat_id, file_name); Ok(()) } @@ -486,10 +688,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendDocument failed: {err}"); + anyhow::bail!("Telegram sendDocument failed: {}", err); } - tracing::info!("Telegram document sent to {chat_id}: {file_name}"); + tracing::info!("Telegram document sent to {}: {}", chat_id, file_name); Ok(()) } @@ -525,10 +727,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendPhoto failed: {err}"); + anyhow::bail!("Telegram sendPhoto failed: {}", err); } - tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); + tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name); Ok(()) } @@ -559,10 +761,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendPhoto failed: {err}"); + anyhow::bail!("Telegram sendPhoto failed: {}", err); } - tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); + tracing::info!("Telegram photo sent to {}: {}", chat_id, file_name); Ok(()) } @@ -598,10 +800,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendVideo failed: {err}"); + anyhow::bail!("Telegram sendVideo failed: {}", err); } - tracing::info!("Telegram video sent to {chat_id}: {file_name}"); + tracing::info!("Telegram video sent to {}: {}", chat_id, file_name); Ok(()) } @@ -637,10 +839,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendAudio failed: {err}"); + anyhow::bail!("Telegram sendAudio failed: {}", err); } - tracing::info!("Telegram audio sent to {chat_id}: {file_name}"); + tracing::info!("Telegram audio sent to {}: {}", chat_id, file_name); Ok(()) } @@ -676,10 +878,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendVoice failed: {err}"); + anyhow::bail!("Telegram sendVoice failed: {}", err); } - tracing::info!("Telegram voice sent to {chat_id}: {file_name}"); + tracing::info!("Telegram voice sent to {}: {}", chat_id, file_name); Ok(()) } @@ -708,10 +910,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendDocument by URL failed: {err}"); + anyhow::bail!("Telegram sendDocument by URL failed: {}", err); } - tracing::info!("Telegram document (URL) sent to {chat_id}: {url}"); + tracing::info!("Telegram document (URL) sent to {}: {}", chat_id, url); Ok(()) } @@ -740,10 +942,10 @@ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --ch if !resp.status().is_success() { let err = resp.text().await?; - anyhow::bail!("Telegram sendPhoto by URL failed: {err}"); + anyhow::bail!("Telegram sendPhoto by URL failed: {}", err); } - tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}"); + tracing::info!("Telegram photo (URL) sent to {}: {}", chat_id, url); Ok(()) } @@ -827,7 +1029,7 @@ impl Channel for TelegramChannel { let resp = match self.client.post(&url).json(&body).send().await { Ok(r) => r, Err(e) => { - tracing::warn!("Telegram poll error: {e}"); + tracing::warn!("Telegram poll error: {}", e); tokio::time::sleep(std::time::Duration::from_secs(5)).await; continue; } @@ -836,7 +1038,7 @@ impl Channel for TelegramChannel { let data: serde_json::Value = match resp.json().await { Ok(d) => d, Err(e) => { - tracing::warn!("Telegram parse error: {e}"); + tracing::warn!("Telegram parse error: {}", e); tokio::time::sleep(std::time::Duration::from_secs(5)).await; continue; } @@ -849,24 +1051,81 @@ impl Channel for TelegramChannel { offset = uid + 1; } - let Some(msg) = self.parse_update_message(update) else { + // First, try to parse as text message + if let Some(msg) = self.parse_update_message(update) { + // Send "typing" indicator immediately when we receive a message + let typing_body = serde_json::json!({ + "chat_id": &msg.reply_target, + "action": "typing" + }); + let _ = self + .client + .post(self.api_url("sendChatAction")) + .json(&typing_body) + .send() + .await; + + if tx.send(msg).await.is_err() { + return Ok(()); + } continue; - }; + } - // Send "typing" indicator immediately when we receive a message - let typing_body = serde_json::json!({ - "chat_id": &msg.reply_target, - "action": "typing" - }); - let _ = self - .client - .post(self.api_url("sendChatAction")) - .json(&typing_body) - .send() - .await; // Ignore errors for typing indicator + // Then, try to parse as voice/audio message + if let Some((file_id, sender, chat_id, message_id)) = + self.parse_voice_message(update) + { + // Send "typing" indicator + let typing_body = serde_json::json!({ + "chat_id": &chat_id, + "action": "typing" + }); + let _ = self + .client + .post(self.api_url("sendChatAction")) + .json(&typing_body) + .send() + .await; - if tx.send(msg).await.is_err() { - return Ok(()); + // Download and transcribe the voice file + let transcription = match self.download_voice_file(&file_id).await { + Ok(audio_path) => { + let result = self.transcribe_voice_file(&audio_path).await; + // Clean up the downloaded file + let _ = tokio::fs::remove_file(&audio_path).await; + result + } + Err(e) => { + tracing::warn!("Failed to download voice file: {}", e); + Err(e) + } + }; + + let content = match transcription { + Ok(text) if !text.is_empty() => { + format!("[Voice message transcription]\n{}", text) + } + Ok(_) => "[Voice message - empty transcription]".to_string(), + Err(e) => { + format!("[Voice message - transcription failed: {}]", e) + } + }; + + let msg = ChannelMessage { + id: format!("telegram_{}_{}", chat_id, message_id), + sender, + reply_target: chat_id, + content, + channel: "telegram".to_string(), + timestamp: std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(), + }; + + if tx.send(msg).await.is_err() { + return Ok(()); + } } } } @@ -884,7 +1143,7 @@ impl Channel for TelegramChannel { { Ok(Ok(resp)) => resp.status().is_success(), Ok(Err(e)) => { - tracing::debug!("Telegram health check failed: {e}"); + tracing::debug!("Telegram health check failed: {}", e); false } Err(_) => { @@ -901,41 +1160,50 @@ mod tests { #[test] fn telegram_channel_name() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); assert_eq!(ch.name(), "telegram"); } #[test] fn telegram_api_url() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); assert_eq!( ch.api_url("getMe"), "https://api.telegram.org/bot123:ABC/getMe" ); } + #[test] + fn telegram_file_url() { + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); + assert_eq!( + ch.file_url("voice/file.ogg"), + "https://api.telegram.org/file/bot123:ABC/voice/file.ogg" + ); + } + #[test] fn telegram_user_allowed_wildcard() { - let ch = TelegramChannel::new("t".into(), vec!["*".into()]); + let ch = TelegramChannel::new("t".into(), vec!["*".into()], None); assert!(ch.is_user_allowed("anyone")); } #[test] fn telegram_user_allowed_specific() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()], None); assert!(ch.is_user_allowed("alice")); assert!(!ch.is_user_allowed("eve")); } #[test] fn telegram_user_denied_empty() { - let ch = TelegramChannel::new("t".into(), vec![]); + let ch = TelegramChannel::new("t".into(), vec![], None); assert!(!ch.is_user_allowed("anyone")); } #[test] fn telegram_user_exact_match_not_substring() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None); assert!(!ch.is_user_allowed("alice_bot")); assert!(!ch.is_user_allowed("alic")); assert!(!ch.is_user_allowed("malice")); @@ -943,13 +1211,13 @@ mod tests { #[test] fn telegram_user_empty_string_denied() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into()], None); assert!(!ch.is_user_allowed("")); } #[test] fn telegram_user_case_sensitive() { - let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]); + let ch = TelegramChannel::new("t".into(), vec!["Alice".into()], None); assert!(ch.is_user_allowed("Alice")); assert!(!ch.is_user_allowed("alice")); assert!(!ch.is_user_allowed("ALICE")); @@ -957,7 +1225,7 @@ mod tests { #[test] fn telegram_wildcard_with_specific_users() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()], None); assert!(ch.is_user_allowed("alice")); assert!(ch.is_user_allowed("bob")); assert!(ch.is_user_allowed("anyone")); @@ -965,13 +1233,13 @@ mod tests { #[test] fn telegram_user_allowed_by_numeric_id_identity() { - let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]); + let ch = TelegramChannel::new("t".into(), vec!["123456789".into()], None); assert!(ch.is_any_user_allowed(["unknown", "123456789"])); } #[test] fn telegram_user_denied_when_none_of_identities_match() { - let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()]); + let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "987654321".into()], None); assert!(!ch.is_any_user_allowed(["unknown", "123456789"])); } @@ -1025,7 +1293,7 @@ mod tests { #[test] fn parse_update_message_uses_chat_id_as_reply_target() { - let ch = TelegramChannel::new("token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("token".into(), vec!["*".into()], None); let update = serde_json::json!({ "update_id": 1, "message": { @@ -1053,7 +1321,7 @@ mod tests { #[test] fn parse_update_message_allows_numeric_id_without_username() { - let ch = TelegramChannel::new("token".into(), vec!["555".into()]); + let ch = TelegramChannel::new("token".into(), vec!["555".into()], None); let update = serde_json::json!({ "update_id": 2, "message": { @@ -1076,11 +1344,63 @@ mod tests { assert_eq!(msg.reply_target, "12345"); } + #[test] + fn parse_voice_message_extracts_file_id() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], None); + let update = serde_json::json!({ + "update_id": 3, + "message": { + "message_id": 42, + "voice": { + "file_id": "AwADBAADbXXXXXXXX", + "duration": 5 + }, + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": 12345 + } + } + }); + + let result = ch.parse_voice_message(&update); + assert!(result.is_some()); + let (file_id, sender, chat_id, message_id) = result.unwrap(); + assert_eq!(file_id, "AwADBAADbXXXXXXXX"); + assert_eq!(sender, "alice"); + assert_eq!(chat_id, "12345"); + assert_eq!(message_id, 42); + } + + #[test] + fn parse_voice_message_returns_none_for_text_message() { + let ch = TelegramChannel::new("token".into(), vec!["*".into()], None); + let update = serde_json::json!({ + "update_id": 4, + "message": { + "message_id": 10, + "text": "hello", + "from": { + "id": 555, + "username": "alice" + }, + "chat": { + "id": 12345 + } + } + }); + + let result = ch.parse_voice_message(&update); + assert!(result.is_none()); + } + // ── File sending API URL tests ────────────────────────────────── #[test] fn telegram_api_url_send_document() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); assert_eq!( ch.api_url("sendDocument"), "https://api.telegram.org/bot123:ABC/sendDocument" @@ -1089,7 +1409,7 @@ mod tests { #[test] fn telegram_api_url_send_photo() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); assert_eq!( ch.api_url("sendPhoto"), "https://api.telegram.org/bot123:ABC/sendPhoto" @@ -1098,7 +1418,7 @@ mod tests { #[test] fn telegram_api_url_send_video() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); assert_eq!( ch.api_url("sendVideo"), "https://api.telegram.org/bot123:ABC/sendVideo" @@ -1107,7 +1427,7 @@ mod tests { #[test] fn telegram_api_url_send_audio() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); assert_eq!( ch.api_url("sendAudio"), "https://api.telegram.org/bot123:ABC/sendAudio" @@ -1116,7 +1436,7 @@ mod tests { #[test] fn telegram_api_url_send_voice() { - let ch = TelegramChannel::new("123:ABC".into(), vec![]); + let ch = TelegramChannel::new("123:ABC".into(), vec![], None); assert_eq!( ch.api_url("sendVoice"), "https://api.telegram.org/bot123:ABC/sendVoice" @@ -1128,7 +1448,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_builds_correct_form() { // This test verifies the method doesn't panic and handles bytes correctly - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let file_bytes = b"Hello, this is a test file content".to_vec(); // The actual API call will fail (no real server), but we verify the method exists @@ -1143,13 +1463,14 @@ mod tests { // Error should be network-related, not a code bug assert!( err.contains("error") || err.contains("failed") || err.contains("connect"), - "Expected network error, got: {err}" + "Expected network error, got: {}", + err ); } #[tokio::test] async fn telegram_send_photo_bytes_builds_correct_form() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); // Minimal valid PNG header bytes let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; @@ -1162,7 +1483,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_by_url_builds_correct_json() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let result = ch .send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc")) @@ -1173,7 +1494,7 @@ mod tests { #[tokio::test] async fn telegram_send_photo_by_url_builds_correct_json() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let result = ch .send_photo_by_url("123456", "https://example.com/image.jpg", None) @@ -1186,7 +1507,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let path = Path::new("/nonexistent/path/to/file.txt"); let result = ch.send_document("123456", path, None).await; @@ -1196,13 +1517,14 @@ mod tests { // Should fail with file not found error assert!( err.contains("No such file") || err.contains("not found") || err.contains("os error"), - "Expected file not found error, got: {err}" + "Expected file not found error, got: {}", + err ); } #[tokio::test] async fn telegram_send_photo_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let path = Path::new("/nonexistent/path/to/photo.jpg"); let result = ch.send_photo("123456", path, None).await; @@ -1212,7 +1534,7 @@ mod tests { #[tokio::test] async fn telegram_send_video_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let path = Path::new("/nonexistent/path/to/video.mp4"); let result = ch.send_video("123456", path, None).await; @@ -1222,7 +1544,7 @@ mod tests { #[tokio::test] async fn telegram_send_audio_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let path = Path::new("/nonexistent/path/to/audio.mp3"); let result = ch.send_audio("123456", path, None).await; @@ -1232,7 +1554,7 @@ mod tests { #[tokio::test] async fn telegram_send_voice_nonexistent_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let path = Path::new("/nonexistent/path/to/voice.ogg"); let result = ch.send_voice("123456", path, None).await; @@ -1320,7 +1642,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_with_caption() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let file_bytes = b"test content".to_vec(); // With caption @@ -1338,7 +1660,7 @@ mod tests { #[tokio::test] async fn telegram_send_photo_bytes_with_caption() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let file_bytes = vec![0x89, 0x50, 0x4E, 0x47]; // With caption @@ -1363,7 +1685,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_empty_file() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let file_bytes: Vec = vec![]; let result = ch @@ -1376,7 +1698,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_empty_filename() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let file_bytes = b"content".to_vec(); let result = ch.send_document_bytes("123456", file_bytes, "", None).await; @@ -1387,7 +1709,7 @@ mod tests { #[tokio::test] async fn telegram_send_document_bytes_empty_chat_id() { - let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); + let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()], None); let file_bytes = b"content".to_vec(); let result = ch @@ -1405,7 +1727,7 @@ mod tests { // Verify that message IDs follow the format: telegram_{chat_id}_{message_id} let chat_id = "123456"; let message_id = 789; - let expected_id = format!("telegram_{chat_id}_{message_id}"); + let expected_id = format!("telegram_{}_{}", chat_id, message_id); assert_eq!(expected_id, "telegram_123456_789"); } @@ -1414,8 +1736,8 @@ mod tests { // Same chat_id + same message_id = same ID (prevents duplicates after restart) let chat_id = "123456"; let message_id = 789; - let id1 = format!("telegram_{chat_id}_{message_id}"); - let id2 = format!("telegram_{chat_id}_{message_id}"); + let id1 = format!("telegram_{}_{}", chat_id, message_id); + let id2 = format!("telegram_{}_{}", chat_id, message_id); assert_eq!(id1, id2); } @@ -1423,8 +1745,8 @@ mod tests { fn telegram_message_id_different_message_different_id() { // Different message IDs produce different IDs let chat_id = "123456"; - let id1 = format!("telegram_{chat_id}_789"); - let id2 = format!("telegram_{chat_id}_790"); + let id1 = format!("telegram_{}_789", chat_id); + let id2 = format!("telegram_{}_790", chat_id); assert_ne!(id1, id2); } @@ -1432,8 +1754,8 @@ mod tests { fn telegram_message_id_different_chat_different_id() { // Different chats produce different IDs even with same message_id let message_id = 789; - let id1 = format!("telegram_123456_{message_id}"); - let id2 = format!("telegram_789012_{message_id}"); + let id1 = format!("telegram_123456_{}", message_id); + let id2 = format!("telegram_789012_{}", message_id); assert_ne!(id1, id2); } @@ -1442,7 +1764,7 @@ mod tests { // Verify format doesn't contain random UUID components let chat_id = "123456"; let message_id = 789; - let id = format!("telegram_{chat_id}_{message_id}"); + let id = format!("telegram_{}_{}", chat_id, message_id); assert!(!id.contains('-')); // No UUID dashes assert!(id.starts_with("telegram_")); } @@ -1452,7 +1774,7 @@ mod tests { // Edge case: message_id can be 0 (fallback/missing case) let chat_id = "123456"; let message_id = 0; - let id = format!("telegram_{chat_id}_{message_id}"); + let id = format!("telegram_{}_{}", chat_id, message_id); assert_eq!(id, "telegram_123456_0"); } }