use super::traits::{Channel, ChannelMessage}; use async_trait::async_trait; use reqwest::multipart::{Form, Part}; use std::path::Path; use std::time::Duration; use uuid::Uuid; /// Telegram's maximum message length for text messages const TELEGRAM_MAX_MESSAGE_LENGTH: usize = 4096; /// Split a message into chunks that respect Telegram's 4096 character limit. /// Tries to split at word boundaries when possible, and handles continuation. fn split_message_for_telegram(message: &str) -> Vec { if message.len() <= TELEGRAM_MAX_MESSAGE_LENGTH { return vec![message.to_string()]; } let mut chunks = Vec::new(); let mut remaining = message; while !remaining.is_empty() { let chunk_end = if remaining.len() <= TELEGRAM_MAX_MESSAGE_LENGTH { remaining.len() } else { // Try to find a good break point (newline, then space) let search_area = &remaining[..TELEGRAM_MAX_MESSAGE_LENGTH]; // Prefer splitting at newline if let Some(pos) = search_area.rfind('\n') { // Don't split if the newline is too close to the start if pos >= TELEGRAM_MAX_MESSAGE_LENGTH / 2 { pos + 1 } else { // Try space as fallback search_area.rfind(' ').unwrap_or(TELEGRAM_MAX_MESSAGE_LENGTH) + 1 } } else if let Some(pos) = search_area.rfind(' ') { pos + 1 } else { // Hard split at the limit TELEGRAM_MAX_MESSAGE_LENGTH } }; chunks.push(remaining[..chunk_end].to_string()); remaining = &remaining[chunk_end..]; } chunks } /// Telegram channel — long-polls the Bot API for updates pub struct TelegramChannel { bot_token: String, allowed_users: Vec, client: reqwest::Client, } impl TelegramChannel { pub fn new(bot_token: String, allowed_users: Vec) -> Self { Self { bot_token, allowed_users, client: reqwest::Client::new(), } } fn api_url(&self, method: &str) -> String { format!("https://api.telegram.org/bot{}/{method}", self.bot_token) } fn is_user_allowed(&self, username: &str) -> bool { self.allowed_users.iter().any(|u| u == "*" || u == username) } fn is_any_user_allowed<'a, I>(&self, identities: I) -> bool where I: IntoIterator, { identities.into_iter().any(|id| self.is_user_allowed(id)) } /// Send a document/file to a Telegram chat pub async fn send_document( &self, chat_id: &str, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { let file_name = file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("file"); let file_bytes = tokio::fs::read(file_path).await?; let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("document", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendDocument")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendDocument failed: {err}"); } tracing::info!("Telegram document sent to {chat_id}: {file_name}"); Ok(()) } /// Send a document from bytes (in-memory) to a Telegram chat pub async fn send_document_bytes( &self, chat_id: &str, file_bytes: Vec, file_name: &str, caption: Option<&str>, ) -> anyhow::Result<()> { let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("document", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendDocument")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendDocument failed: {err}"); } tracing::info!("Telegram document sent to {chat_id}: {file_name}"); Ok(()) } /// Send a photo to a Telegram chat pub async fn send_photo( &self, chat_id: &str, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { let file_name = file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("photo.jpg"); let file_bytes = tokio::fs::read(file_path).await?; let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("photo", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendPhoto")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendPhoto failed: {err}"); } tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); Ok(()) } /// Send a photo from bytes (in-memory) to a Telegram chat pub async fn send_photo_bytes( &self, chat_id: &str, file_bytes: Vec, file_name: &str, caption: Option<&str>, ) -> anyhow::Result<()> { let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("photo", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendPhoto")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendPhoto failed: {err}"); } tracing::info!("Telegram photo sent to {chat_id}: {file_name}"); Ok(()) } /// Send a video to a Telegram chat pub async fn send_video( &self, chat_id: &str, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { let file_name = file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("video.mp4"); let file_bytes = tokio::fs::read(file_path).await?; let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("video", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendVideo")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendVideo failed: {err}"); } tracing::info!("Telegram video sent to {chat_id}: {file_name}"); Ok(()) } /// Send an audio file to a Telegram chat pub async fn send_audio( &self, chat_id: &str, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { let file_name = file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("audio.mp3"); let file_bytes = tokio::fs::read(file_path).await?; let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("audio", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendAudio")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendAudio failed: {err}"); } tracing::info!("Telegram audio sent to {chat_id}: {file_name}"); Ok(()) } /// Send a voice message to a Telegram chat pub async fn send_voice( &self, chat_id: &str, file_path: &Path, caption: Option<&str>, ) -> anyhow::Result<()> { let file_name = file_path .file_name() .and_then(|n| n.to_str()) .unwrap_or("voice.ogg"); let file_bytes = tokio::fs::read(file_path).await?; let part = Part::bytes(file_bytes).file_name(file_name.to_string()); let mut form = Form::new() .text("chat_id", chat_id.to_string()) .part("voice", part); if let Some(cap) = caption { form = form.text("caption", cap.to_string()); } let resp = self .client .post(self.api_url("sendVoice")) .multipart(form) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendVoice failed: {err}"); } tracing::info!("Telegram voice sent to {chat_id}: {file_name}"); Ok(()) } /// Send a file by URL (Telegram will download it) pub async fn send_document_by_url( &self, chat_id: &str, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { let mut body = serde_json::json!({ "chat_id": chat_id, "document": url }); if let Some(cap) = caption { body["caption"] = serde_json::Value::String(cap.to_string()); } let resp = self .client .post(self.api_url("sendDocument")) .json(&body) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendDocument by URL failed: {err}"); } tracing::info!("Telegram document (URL) sent to {chat_id}: {url}"); Ok(()) } /// Send a photo by URL (Telegram will download it) pub async fn send_photo_by_url( &self, chat_id: &str, url: &str, caption: Option<&str>, ) -> anyhow::Result<()> { let mut body = serde_json::json!({ "chat_id": chat_id, "photo": url }); if let Some(cap) = caption { body["caption"] = serde_json::Value::String(cap.to_string()); } let resp = self .client .post(self.api_url("sendPhoto")) .json(&body) .send() .await?; if !resp.status().is_success() { let err = resp.text().await?; anyhow::bail!("Telegram sendPhoto by URL failed: {err}"); } tracing::info!("Telegram photo (URL) sent to {chat_id}: {url}"); Ok(()) } } #[async_trait] impl Channel for TelegramChannel { fn name(&self) -> &str { "telegram" } async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> { // Split message if it exceeds Telegram's 4096 character limit let chunks = split_message_for_telegram(message); for (i, chunk) in chunks.iter().enumerate() { // Add continuation marker for multi-part messages let text = if chunks.len() > 1 { if i == 0 { format!("{chunk}\n\n(continues...)") } else if i == chunks.len() - 1 { format!("(continued)\n\n{chunk}") } else { format!("(continued)\n\n{chunk}\n\n(continues...)") } } else { chunk.to_string() }; let markdown_body = serde_json::json!({ "chat_id": chat_id, "text": text, "parse_mode": "Markdown" }); let markdown_resp = self .client .post(self.api_url("sendMessage")) .json(&markdown_body) .send() .await?; if markdown_resp.status().is_success() { // Small delay between chunks to avoid rate limiting if i < chunks.len() - 1 { tokio::time::sleep(Duration::from_millis(100)).await; } continue; } let markdown_status = markdown_resp.status(); let markdown_err = markdown_resp.text().await.unwrap_or_default(); tracing::warn!( status = ?markdown_status, "Telegram sendMessage with Markdown failed; retrying without parse_mode" ); // Retry without parse_mode as a compatibility fallback. let plain_body = serde_json::json!({ "chat_id": chat_id, "text": text, }); let plain_resp = self .client .post(self.api_url("sendMessage")) .json(&plain_body) .send() .await?; if !plain_resp.status().is_success() { let plain_status = plain_resp.status(); let plain_err = plain_resp.text().await.unwrap_or_default(); anyhow::bail!( "Telegram sendMessage failed (markdown {}: {}; plain {}: {})", markdown_status, markdown_err, plain_status, plain_err ); } // Small delay between chunks to avoid rate limiting if i < chunks.len() - 1 { tokio::time::sleep(Duration::from_millis(100)).await; } } Ok(()) } async fn listen(&self, tx: tokio::sync::mpsc::Sender) -> anyhow::Result<()> { let mut offset: i64 = 0; tracing::info!("Telegram channel listening for messages..."); loop { let url = self.api_url("getUpdates"); let body = serde_json::json!({ "offset": offset, "timeout": 30, "allowed_updates": ["message"] }); let resp = match self.client.post(&url).json(&body).send().await { Ok(r) => r, Err(e) => { tracing::warn!("Telegram poll error: {e}"); tokio::time::sleep(std::time::Duration::from_secs(5)).await; continue; } }; let data: serde_json::Value = match resp.json().await { Ok(d) => d, Err(e) => { tracing::warn!("Telegram parse error: {e}"); tokio::time::sleep(std::time::Duration::from_secs(5)).await; continue; } }; if let Some(results) = data.get("result").and_then(serde_json::Value::as_array) { for update in results { // Advance offset past this update if let Some(uid) = update.get("update_id").and_then(serde_json::Value::as_i64) { offset = uid + 1; } let Some(message) = update.get("message") else { continue; }; let Some(text) = message.get("text").and_then(serde_json::Value::as_str) else { continue; }; let username_opt = message .get("from") .and_then(|f| f.get("username")) .and_then(|u| u.as_str()); let username = username_opt.unwrap_or("unknown"); let user_id = message .get("from") .and_then(|f| f.get("id")) .and_then(serde_json::Value::as_i64); let user_id_str = user_id.map(|id| id.to_string()); let mut identities = vec![username]; if let Some(ref id) = user_id_str { identities.push(id.as_str()); } if !self.is_any_user_allowed(identities.iter().copied()) { tracing::warn!( "Telegram: ignoring message from unauthorized user: username={username}, user_id={}. \ Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.", user_id_str.as_deref().unwrap_or("unknown") ); continue; } let chat_id = message .get("chat") .and_then(|c| c.get("id")) .and_then(serde_json::Value::as_i64) .map(|id| id.to_string()); let Some(chat_id) = chat_id else { tracing::warn!("Telegram: missing chat_id in message, skipping"); continue; }; // Send "typing" indicator immediately when we receive a message 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; // Ignore errors for typing indicator let msg = ChannelMessage { id: Uuid::new_v4().to_string(), sender: chat_id, content: text.to_string(), 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(()); } } } } } async fn health_check(&self) -> bool { let timeout_duration = Duration::from_secs(5); match tokio::time::timeout( timeout_duration, self.client.get(self.api_url("getMe")).send(), ) .await { Ok(Ok(resp)) => resp.status().is_success(), Ok(Err(e)) => { tracing::debug!("Telegram health check failed: {e}"); false } Err(_) => { tracing::debug!("Telegram health check timed out after 5s"); false } } } } #[cfg(test)] mod tests { use super::*; #[test] fn telegram_channel_name() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); assert_eq!(ch.name(), "telegram"); } #[test] fn telegram_api_url() { let ch = TelegramChannel::new("123:ABC".into(), vec![]); assert_eq!( ch.api_url("getMe"), "https://api.telegram.org/bot123:ABC/getMe" ); } #[test] fn telegram_user_allowed_wildcard() { let ch = TelegramChannel::new("t".into(), vec!["*".into()]); assert!(ch.is_user_allowed("anyone")); } #[test] fn telegram_user_allowed_specific() { let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "bob".into()]); 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![]); assert!(!ch.is_user_allowed("anyone")); } #[test] fn telegram_user_exact_match_not_substring() { let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); assert!(!ch.is_user_allowed("alice_bot")); assert!(!ch.is_user_allowed("alic")); assert!(!ch.is_user_allowed("malice")); } #[test] fn telegram_user_empty_string_denied() { let ch = TelegramChannel::new("t".into(), vec!["alice".into()]); assert!(!ch.is_user_allowed("")); } #[test] fn telegram_user_case_sensitive() { let ch = TelegramChannel::new("t".into(), vec!["Alice".into()]); assert!(ch.is_user_allowed("Alice")); assert!(!ch.is_user_allowed("alice")); assert!(!ch.is_user_allowed("ALICE")); } #[test] fn telegram_wildcard_with_specific_users() { let ch = TelegramChannel::new("t".into(), vec!["alice".into(), "*".into()]); assert!(ch.is_user_allowed("alice")); assert!(ch.is_user_allowed("bob")); assert!(ch.is_user_allowed("anyone")); } #[test] fn telegram_user_allowed_by_numeric_id_identity() { let ch = TelegramChannel::new("t".into(), vec!["123456789".into()]); 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()]); assert!(!ch.is_any_user_allowed(["unknown", "123456789"])); } // ── File sending API URL tests ────────────────────────────────── #[test] fn telegram_api_url_send_document() { let ch = TelegramChannel::new("123:ABC".into(), vec![]); assert_eq!( ch.api_url("sendDocument"), "https://api.telegram.org/bot123:ABC/sendDocument" ); } #[test] fn telegram_api_url_send_photo() { let ch = TelegramChannel::new("123:ABC".into(), vec![]); assert_eq!( ch.api_url("sendPhoto"), "https://api.telegram.org/bot123:ABC/sendPhoto" ); } #[test] fn telegram_api_url_send_video() { let ch = TelegramChannel::new("123:ABC".into(), vec![]); assert_eq!( ch.api_url("sendVideo"), "https://api.telegram.org/bot123:ABC/sendVideo" ); } #[test] fn telegram_api_url_send_audio() { let ch = TelegramChannel::new("123:ABC".into(), vec![]); assert_eq!( ch.api_url("sendAudio"), "https://api.telegram.org/bot123:ABC/sendAudio" ); } #[test] fn telegram_api_url_send_voice() { let ch = TelegramChannel::new("123:ABC".into(), vec![]); assert_eq!( ch.api_url("sendVoice"), "https://api.telegram.org/bot123:ABC/sendVoice" ); } // ── File sending integration tests (with mock server) ────────── #[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 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 // and handles the input correctly up to the network call let result = ch .send_document_bytes("123456", file_bytes, "test.txt", Some("Test caption")) .await; // Should fail with network error, not a panic or type error assert!(result.is_err()); let err = result.unwrap_err().to_string(); // Error should be network-related, not a code bug assert!( err.contains("error") || err.contains("failed") || err.contains("connect"), "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()]); // Minimal valid PNG header bytes let file_bytes = vec![0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A]; let result = ch .send_photo_bytes("123456", file_bytes, "test.png", None) .await; assert!(result.is_err()); } #[tokio::test] async fn telegram_send_document_by_url_builds_correct_json() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let result = ch .send_document_by_url("123456", "https://example.com/file.pdf", Some("PDF doc")) .await; assert!(result.is_err()); } #[tokio::test] async fn telegram_send_photo_by_url_builds_correct_json() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let result = ch .send_photo_by_url("123456", "https://example.com/image.jpg", None) .await; assert!(result.is_err()); } // ── File path handling tests ──────────────────────────────────── #[tokio::test] async fn telegram_send_document_nonexistent_file() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/file.txt"); let result = ch.send_document("123456", path, None).await; assert!(result.is_err()); let err = result.unwrap_err().to_string(); // 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}" ); } #[tokio::test] async fn telegram_send_photo_nonexistent_file() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/photo.jpg"); let result = ch.send_photo("123456", path, None).await; assert!(result.is_err()); } #[tokio::test] async fn telegram_send_video_nonexistent_file() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/video.mp4"); let result = ch.send_video("123456", path, None).await; assert!(result.is_err()); } #[tokio::test] async fn telegram_send_audio_nonexistent_file() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/audio.mp3"); let result = ch.send_audio("123456", path, None).await; assert!(result.is_err()); } #[tokio::test] async fn telegram_send_voice_nonexistent_file() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let path = Path::new("/nonexistent/path/to/voice.ogg"); let result = ch.send_voice("123456", path, None).await; assert!(result.is_err()); } // ── Message splitting tests ───────────────────────────────────── #[test] fn telegram_split_short_message() { let msg = "Hello, world!"; let chunks = split_message_for_telegram(msg); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0], msg); } #[test] fn telegram_split_exact_limit() { let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH); let chunks = split_message_for_telegram(&msg); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0].len(), TELEGRAM_MAX_MESSAGE_LENGTH); } #[test] fn telegram_split_over_limit() { let msg = "a".repeat(TELEGRAM_MAX_MESSAGE_LENGTH + 100); let chunks = split_message_for_telegram(&msg); assert_eq!(chunks.len(), 2); assert!(chunks[0].len() <= TELEGRAM_MAX_MESSAGE_LENGTH); assert!(chunks[1].len() <= TELEGRAM_MAX_MESSAGE_LENGTH); } #[test] fn telegram_split_at_word_boundary() { let msg = format!( "{} more text here", "word ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5) ); let chunks = split_message_for_telegram(&msg); assert!(chunks.len() >= 2); // First chunk should end with a complete word (space at the end) for chunk in &chunks[..chunks.len() - 1] { assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); } } #[test] fn telegram_split_at_newline() { let text_block = "Line of text\n".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 13); let chunks = split_message_for_telegram(&text_block); assert!(chunks.len() >= 2); for chunk in chunks { assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); } } #[test] fn telegram_split_preserves_content() { let msg = "test ".repeat(TELEGRAM_MAX_MESSAGE_LENGTH / 5 + 100); let chunks = split_message_for_telegram(&msg); let rejoined = chunks.join(""); assert_eq!(rejoined, msg); } #[test] fn telegram_split_empty_message() { let chunks = split_message_for_telegram(""); assert_eq!(chunks.len(), 1); assert_eq!(chunks[0], ""); } #[test] fn telegram_split_very_long_message() { let msg = "x".repeat(TELEGRAM_MAX_MESSAGE_LENGTH * 3); let chunks = split_message_for_telegram(&msg); assert!(chunks.len() >= 3); for chunk in chunks { assert!(chunk.len() <= TELEGRAM_MAX_MESSAGE_LENGTH); } } // ── Caption handling tests ────────────────────────────────────── #[tokio::test] async fn telegram_send_document_bytes_with_caption() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let file_bytes = b"test content".to_vec(); // With caption let result = ch .send_document_bytes("123456", file_bytes.clone(), "test.txt", Some("My caption")) .await; assert!(result.is_err()); // Network error expected // Without caption let result = ch .send_document_bytes("123456", file_bytes, "test.txt", None) .await; assert!(result.is_err()); // Network error expected } #[tokio::test] async fn telegram_send_photo_bytes_with_caption() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let file_bytes = vec![0x89, 0x50, 0x4E, 0x47]; // With caption let result = ch .send_photo_bytes( "123456", file_bytes.clone(), "test.png", Some("Photo caption"), ) .await; assert!(result.is_err()); // Without caption let result = ch .send_photo_bytes("123456", file_bytes, "test.png", None) .await; assert!(result.is_err()); } // ── Empty/edge case tests ─────────────────────────────────────── #[tokio::test] async fn telegram_send_document_bytes_empty_file() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let file_bytes: Vec = vec![]; let result = ch .send_document_bytes("123456", file_bytes, "empty.txt", None) .await; // Should not panic, will fail at API level assert!(result.is_err()); } #[tokio::test] async fn telegram_send_document_bytes_empty_filename() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let file_bytes = b"content".to_vec(); let result = ch.send_document_bytes("123456", file_bytes, "", None).await; // Should not panic assert!(result.is_err()); } #[tokio::test] async fn telegram_send_document_bytes_empty_chat_id() { let ch = TelegramChannel::new("fake-token".into(), vec!["*".into()]); let file_bytes = b"content".to_vec(); let result = ch .send_document_bytes("", file_bytes, "test.txt", None) .await; // Should not panic assert!(result.is_err()); } }