1457 lines
46 KiB
Rust
1457 lines
46 KiB
Rust
use super::traits::{Channel, ChannelMessage};
|
|
use async_trait::async_trait;
|
|
use reqwest::multipart::{Form, Part};
|
|
use std::path::Path;
|
|
use std::time::Duration;
|
|
|
|
/// 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<String> {
|
|
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
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
enum TelegramAttachmentKind {
|
|
Image,
|
|
Document,
|
|
Video,
|
|
Audio,
|
|
Voice,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
struct TelegramAttachment {
|
|
kind: TelegramAttachmentKind,
|
|
target: String,
|
|
}
|
|
|
|
impl TelegramAttachmentKind {
|
|
fn from_marker(marker: &str) -> Option<Self> {
|
|
match marker.trim().to_ascii_uppercase().as_str() {
|
|
"IMAGE" | "PHOTO" => Some(Self::Image),
|
|
"DOCUMENT" | "FILE" => Some(Self::Document),
|
|
"VIDEO" => Some(Self::Video),
|
|
"AUDIO" => Some(Self::Audio),
|
|
"VOICE" => Some(Self::Voice),
|
|
_ => None,
|
|
}
|
|
}
|
|
}
|
|
|
|
fn is_http_url(target: &str) -> bool {
|
|
target.starts_with("http://") || target.starts_with("https://")
|
|
}
|
|
|
|
fn infer_attachment_kind_from_target(target: &str) -> Option<TelegramAttachmentKind> {
|
|
let normalized = target
|
|
.split('?')
|
|
.next()
|
|
.unwrap_or(target)
|
|
.split('#')
|
|
.next()
|
|
.unwrap_or(target);
|
|
|
|
let extension = Path::new(normalized)
|
|
.extension()
|
|
.and_then(|ext| ext.to_str())?
|
|
.to_ascii_lowercase();
|
|
|
|
match extension.as_str() {
|
|
"png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" => Some(TelegramAttachmentKind::Image),
|
|
"mp4" | "mov" | "mkv" | "avi" | "webm" => Some(TelegramAttachmentKind::Video),
|
|
"mp3" | "m4a" | "wav" | "flac" => Some(TelegramAttachmentKind::Audio),
|
|
"ogg" | "oga" | "opus" => Some(TelegramAttachmentKind::Voice),
|
|
"pdf" | "txt" | "md" | "csv" | "json" | "zip" | "tar" | "gz" | "doc" | "docx" | "xls"
|
|
| "xlsx" | "ppt" | "pptx" => Some(TelegramAttachmentKind::Document),
|
|
_ => None,
|
|
}
|
|
}
|
|
|
|
fn parse_path_only_attachment(message: &str) -> Option<TelegramAttachment> {
|
|
let trimmed = message.trim();
|
|
if trimmed.is_empty() || trimmed.contains('\n') {
|
|
return None;
|
|
}
|
|
|
|
let candidate = trimmed.trim_matches(|c| matches!(c, '`' | '"' | '\''));
|
|
if candidate.chars().any(char::is_whitespace) {
|
|
return None;
|
|
}
|
|
|
|
let candidate = candidate.strip_prefix("file://").unwrap_or(candidate);
|
|
let kind = infer_attachment_kind_from_target(candidate)?;
|
|
|
|
if !is_http_url(candidate) && !Path::new(candidate).exists() {
|
|
return None;
|
|
}
|
|
|
|
Some(TelegramAttachment {
|
|
kind,
|
|
target: candidate.to_string(),
|
|
})
|
|
}
|
|
|
|
fn parse_attachment_markers(message: &str) -> (String, Vec<TelegramAttachment>) {
|
|
let mut cleaned = String::with_capacity(message.len());
|
|
let mut attachments = Vec::new();
|
|
let mut cursor = 0;
|
|
|
|
while cursor < message.len() {
|
|
let Some(open_rel) = message[cursor..].find('[') else {
|
|
cleaned.push_str(&message[cursor..]);
|
|
break;
|
|
};
|
|
|
|
let open = cursor + open_rel;
|
|
cleaned.push_str(&message[cursor..open]);
|
|
|
|
let Some(close_rel) = message[open..].find(']') else {
|
|
cleaned.push_str(&message[open..]);
|
|
break;
|
|
};
|
|
|
|
let close = open + close_rel;
|
|
let marker = &message[open + 1..close];
|
|
|
|
let parsed = marker.split_once(':').and_then(|(kind, target)| {
|
|
let kind = TelegramAttachmentKind::from_marker(kind)?;
|
|
let target = target.trim();
|
|
if target.is_empty() {
|
|
return None;
|
|
}
|
|
Some(TelegramAttachment {
|
|
kind,
|
|
target: target.to_string(),
|
|
})
|
|
});
|
|
|
|
if let Some(attachment) = parsed {
|
|
attachments.push(attachment);
|
|
} else {
|
|
cleaned.push_str(&message[open..=close]);
|
|
}
|
|
|
|
cursor = close + 1;
|
|
}
|
|
|
|
(cleaned.trim().to_string(), attachments)
|
|
}
|
|
|
|
/// Telegram channel — long-polls the Bot API for updates
|
|
pub struct TelegramChannel {
|
|
bot_token: String,
|
|
allowed_users: Vec<String>,
|
|
client: reqwest::Client,
|
|
}
|
|
|
|
impl TelegramChannel {
|
|
pub fn new(bot_token: String, allowed_users: Vec<String>) -> 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<Item = &'a str>,
|
|
{
|
|
identities.into_iter().any(|id| self.is_user_allowed(id))
|
|
}
|
|
|
|
fn parse_update_message(&self, update: &serde_json::Value) -> Option<ChannelMessage> {
|
|
let message = update.get("message")?;
|
|
|
|
let text = message.get("text").and_then(serde_json::Value::as_str)?;
|
|
|
|
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 message from unauthorized user: username={username}, user_id={}. \
|
|
Allowlist Telegram @username or numeric user ID, then run `zeroclaw onboard --channels-only`.",
|
|
user_id.as_deref().unwrap_or("unknown")
|
|
);
|
|
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(ChannelMessage {
|
|
id: format!("telegram_{chat_id}_{message_id}"),
|
|
sender: sender_identity,
|
|
reply_target: 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(),
|
|
})
|
|
}
|
|
|
|
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...)")
|
|
} else if index == 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() {
|
|
if index < 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"
|
|
);
|
|
|
|
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
|
|
);
|
|
}
|
|
|
|
if index < chunks.len() - 1 {
|
|
tokio::time::sleep(Duration::from_millis(100)).await;
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_media_by_url(
|
|
&self,
|
|
method: &str,
|
|
media_field: &str,
|
|
chat_id: &str,
|
|
url: &str,
|
|
caption: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
let mut body = serde_json::json!({
|
|
"chat_id": chat_id,
|
|
});
|
|
body[media_field] = serde_json::Value::String(url.to_string());
|
|
|
|
if let Some(cap) = caption {
|
|
body["caption"] = serde_json::Value::String(cap.to_string());
|
|
}
|
|
|
|
let resp = self
|
|
.client
|
|
.post(self.api_url(method))
|
|
.json(&body)
|
|
.send()
|
|
.await?;
|
|
|
|
if !resp.status().is_success() {
|
|
let err = resp.text().await?;
|
|
anyhow::bail!("Telegram {method} by URL failed: {err}");
|
|
}
|
|
|
|
tracing::info!("Telegram {method} sent to {chat_id}: {url}");
|
|
Ok(())
|
|
}
|
|
|
|
async fn send_attachment(
|
|
&self,
|
|
chat_id: &str,
|
|
attachment: &TelegramAttachment,
|
|
) -> anyhow::Result<()> {
|
|
let target = attachment.target.trim();
|
|
|
|
if is_http_url(target) {
|
|
return match attachment.kind {
|
|
TelegramAttachmentKind::Image => {
|
|
self.send_photo_by_url(chat_id, target, None).await
|
|
}
|
|
TelegramAttachmentKind::Document => {
|
|
self.send_document_by_url(chat_id, target, None).await
|
|
}
|
|
TelegramAttachmentKind::Video => {
|
|
self.send_video_by_url(chat_id, target, None).await
|
|
}
|
|
TelegramAttachmentKind::Audio => {
|
|
self.send_audio_by_url(chat_id, target, None).await
|
|
}
|
|
TelegramAttachmentKind::Voice => {
|
|
self.send_voice_by_url(chat_id, target, None).await
|
|
}
|
|
};
|
|
}
|
|
|
|
let path = Path::new(target);
|
|
if !path.exists() {
|
|
anyhow::bail!("Telegram attachment path not found: {target}");
|
|
}
|
|
|
|
match attachment.kind {
|
|
TelegramAttachmentKind::Image => self.send_photo(chat_id, path, None).await,
|
|
TelegramAttachmentKind::Document => self.send_document(chat_id, path, None).await,
|
|
TelegramAttachmentKind::Video => self.send_video(chat_id, path, None).await,
|
|
TelegramAttachmentKind::Audio => self.send_audio(chat_id, path, None).await,
|
|
TelegramAttachmentKind::Voice => self.send_voice(chat_id, path, None).await,
|
|
}
|
|
}
|
|
|
|
/// 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<u8>,
|
|
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<u8>,
|
|
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(())
|
|
}
|
|
|
|
/// Send a video by URL (Telegram will download it)
|
|
pub async fn send_video_by_url(
|
|
&self,
|
|
chat_id: &str,
|
|
url: &str,
|
|
caption: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
self.send_media_by_url("sendVideo", "video", chat_id, url, caption)
|
|
.await
|
|
}
|
|
|
|
/// Send an audio file by URL (Telegram will download it)
|
|
pub async fn send_audio_by_url(
|
|
&self,
|
|
chat_id: &str,
|
|
url: &str,
|
|
caption: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
self.send_media_by_url("sendAudio", "audio", chat_id, url, caption)
|
|
.await
|
|
}
|
|
|
|
/// Send a voice message by URL (Telegram will download it)
|
|
pub async fn send_voice_by_url(
|
|
&self,
|
|
chat_id: &str,
|
|
url: &str,
|
|
caption: Option<&str>,
|
|
) -> anyhow::Result<()> {
|
|
self.send_media_by_url("sendVoice", "voice", chat_id, url, caption)
|
|
.await
|
|
}
|
|
}
|
|
|
|
#[async_trait]
|
|
impl Channel for TelegramChannel {
|
|
fn name(&self) -> &str {
|
|
"telegram"
|
|
}
|
|
|
|
async fn send(&self, message: &str, chat_id: &str) -> anyhow::Result<()> {
|
|
let (text_without_markers, attachments) = parse_attachment_markers(message);
|
|
|
|
if !attachments.is_empty() {
|
|
if !text_without_markers.is_empty() {
|
|
self.send_text_chunks(&text_without_markers, chat_id)
|
|
.await?;
|
|
}
|
|
|
|
for attachment in &attachments {
|
|
self.send_attachment(chat_id, attachment).await?;
|
|
}
|
|
|
|
return Ok(());
|
|
}
|
|
|
|
if let Some(attachment) = parse_path_only_attachment(message) {
|
|
self.send_attachment(chat_id, &attachment).await?;
|
|
return Ok(());
|
|
}
|
|
|
|
self.send_text_chunks(message, chat_id).await
|
|
}
|
|
|
|
async fn listen(&self, tx: tokio::sync::mpsc::Sender<ChannelMessage>) -> 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(msg) = self.parse_update_message(update) else {
|
|
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
|
|
|
|
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"]));
|
|
}
|
|
|
|
#[test]
|
|
fn parse_attachment_markers_extracts_multiple_types() {
|
|
let message = "Here are files [IMAGE:/tmp/a.png] and [DOCUMENT:https://example.com/a.pdf]";
|
|
let (cleaned, attachments) = parse_attachment_markers(message);
|
|
|
|
assert_eq!(cleaned, "Here are files and");
|
|
assert_eq!(attachments.len(), 2);
|
|
assert_eq!(attachments[0].kind, TelegramAttachmentKind::Image);
|
|
assert_eq!(attachments[0].target, "/tmp/a.png");
|
|
assert_eq!(attachments[1].kind, TelegramAttachmentKind::Document);
|
|
assert_eq!(attachments[1].target, "https://example.com/a.pdf");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_attachment_markers_keeps_invalid_markers_in_text() {
|
|
let message = "Report [UNKNOWN:/tmp/a.bin]";
|
|
let (cleaned, attachments) = parse_attachment_markers(message);
|
|
|
|
assert_eq!(cleaned, "Report [UNKNOWN:/tmp/a.bin]");
|
|
assert!(attachments.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_only_attachment_detects_existing_file() {
|
|
let dir = tempfile::tempdir().unwrap();
|
|
let image_path = dir.path().join("snap.png");
|
|
std::fs::write(&image_path, b"fake-png").unwrap();
|
|
|
|
let parsed = parse_path_only_attachment(image_path.to_string_lossy().as_ref())
|
|
.expect("expected attachment");
|
|
|
|
assert_eq!(parsed.kind, TelegramAttachmentKind::Image);
|
|
assert_eq!(parsed.target, image_path.to_string_lossy());
|
|
}
|
|
|
|
#[test]
|
|
fn parse_path_only_attachment_rejects_sentence_text() {
|
|
assert!(parse_path_only_attachment("Screenshot saved to /tmp/snap.png").is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn infer_attachment_kind_from_target_detects_document_extension() {
|
|
assert_eq!(
|
|
infer_attachment_kind_from_target("https://example.com/files/specs.pdf?download=1"),
|
|
Some(TelegramAttachmentKind::Document)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn parse_update_message_uses_chat_id_as_reply_target() {
|
|
let ch = TelegramChannel::new("token".into(), vec!["*".into()]);
|
|
let update = serde_json::json!({
|
|
"update_id": 1,
|
|
"message": {
|
|
"message_id": 33,
|
|
"text": "hello",
|
|
"from": {
|
|
"id": 555,
|
|
"username": "alice"
|
|
},
|
|
"chat": {
|
|
"id": -100200300
|
|
}
|
|
}
|
|
});
|
|
|
|
let msg = ch
|
|
.parse_update_message(&update)
|
|
.expect("message should parse");
|
|
|
|
assert_eq!(msg.sender, "alice");
|
|
assert_eq!(msg.reply_target, "-100200300");
|
|
assert_eq!(msg.content, "hello");
|
|
assert_eq!(msg.id, "telegram_-100200300_33");
|
|
}
|
|
|
|
#[test]
|
|
fn parse_update_message_allows_numeric_id_without_username() {
|
|
let ch = TelegramChannel::new("token".into(), vec!["555".into()]);
|
|
let update = serde_json::json!({
|
|
"update_id": 2,
|
|
"message": {
|
|
"message_id": 9,
|
|
"text": "ping",
|
|
"from": {
|
|
"id": 555
|
|
},
|
|
"chat": {
|
|
"id": 12345
|
|
}
|
|
}
|
|
});
|
|
|
|
let msg = ch
|
|
.parse_update_message(&update)
|
|
.expect("numeric allowlist should pass");
|
|
|
|
assert_eq!(msg.sender, "555");
|
|
assert_eq!(msg.reply_target, "12345");
|
|
}
|
|
|
|
// ── 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 + 1);
|
|
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<u8> = 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());
|
|
}
|
|
|
|
// ── Message ID edge cases ─────────────────────────────────────
|
|
|
|
#[test]
|
|
fn telegram_message_id_format_includes_chat_and_message_id() {
|
|
// 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}");
|
|
assert_eq!(expected_id, "telegram_123456_789");
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_message_id_is_deterministic() {
|
|
// 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}");
|
|
assert_eq!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
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");
|
|
assert_ne!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
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}");
|
|
assert_ne!(id1, id2);
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_message_id_no_uuid_randomness() {
|
|
// Verify format doesn't contain random UUID components
|
|
let chat_id = "123456";
|
|
let message_id = 789;
|
|
let id = format!("telegram_{chat_id}_{message_id}");
|
|
assert!(!id.contains('-')); // No UUID dashes
|
|
assert!(id.starts_with("telegram_"));
|
|
}
|
|
|
|
#[test]
|
|
fn telegram_message_id_handles_zero_message_id() {
|
|
// 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}");
|
|
assert_eq!(id, "telegram_123456_0");
|
|
}
|
|
}
|