feat(telegram): add voice message auto-transcription
Some checks failed
CI / Detect Change Scope (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
Some checks failed
CI / Detect Change Scope (push) Has been cancelled
CI / Format & Lint (push) Has been cancelled
CI / Lint Strict Delta (push) Has been cancelled
Docker / PR Docker Smoke (push) Has been cancelled
Docker / Build and Push Docker Image (push) Has been cancelled
Rust Package Security Audit / Security Audit (push) Has been cancelled
Rust Package Security Audit / License & Supply Chain (push) Has been cancelled
CI / Test (push) Has been cancelled
CI / Build (Smoke) (push) Has been cancelled
CI / Docs-Only Fast Path (push) Has been cancelled
CI / Non-Rust Fast Path (push) Has been cancelled
CI / Docs Quality (push) Has been cancelled
CI / CI Required Gate (push) Has been cancelled
Stale / stale (push) Has been cancelled
- Add download_voice_file method to download voice/audio from Telegram - Add transcribe_voice_file method using faster-whisper - Add parse_voice_message to detect voice/audio messages - Modify listen() to process voice messages and include transcription - Add file_url helper for Telegram file downloads - Add tests for voice message parsing
This commit is contained in:
@@ -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<TelegramAttachment>)
|
||||
}
|
||||
|
||||
/// 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<String>,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
impl TelegramChannel {
|
||||
pub fn new(bot_token: String, allowed_users: Vec<String>, workspace_dir: Option<std::path::PathBuf>) -> Self {
|
||||
pub fn new(
|
||||
bot_token: String,
|
||||
allowed_users: Vec<String>,
|
||||
workspace_dir: Option<std::path::PathBuf>,
|
||||
) -> 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<std::path::PathBuf> {
|
||||
// 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<String> {
|
||||
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::<serde_json::Value>(&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<ChannelMessage> {
|
||||
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<u8> = 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");
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user