diff --git a/config.toml.example b/config.toml.example index 7b3f2a9..2ea1e38 100644 --- a/config.toml.example +++ b/config.toml.example @@ -11,6 +11,11 @@ assistant_root = "~/Code/assistant" bot_token = "replace-with-the-token-from-BotFather" # Replace this once with your numeric Telegram user ID. allow_user_ids = [123456789] +# Optional. Local Bot API server (default: public api.telegram.org). +# base_url = "http://127.0.0.1:8081/bot" +# base_file_url = "http://127.0.0.1:8081/file/bot" +# Optional. Raise above 20 MiB when using a local Bot API for larger voice files. +# max_audio_bytes = 104857600 # Slack alternative. See docs/slack.md. # channel = "slack" diff --git a/docs/configuration.md b/docs/configuration.md index d8207d4..1e2cb60 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -225,6 +225,9 @@ requests. Review [permissions and security](security.md) before enabling jobs. | `telegram.bot_token` | `TELEGRAM_BOT_TOKEN` fallback | Private Bot API token; the environment value is used when this is omitted | | `telegram.allow_user_ids` | `[]` | Trusted numeric sender IDs | | `telegram.allow_chat_ids` | `[]` | Trusted numeric private-chat IDs | +| `telegram.base_url` | `https://api.telegram.org/bot` | Bot API method base URL; point at a local Bot API server such as `http://127.0.0.1:8081/bot` | +| `telegram.base_file_url` | `https://api.telegram.org/file/bot` | Bot API file download base URL; for a local server use `http://127.0.0.1:8081/file/bot` | +| `telegram.max_audio_bytes` | `20971520` (20 MiB) | Maximum inbound voice download size; raise for a self-hosted Bot API that accepts larger files | ### Slack diff --git a/docs/telegram.md b/docs/telegram.md index 0a962cb..3e4cb48 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -37,6 +37,27 @@ thread = "telegram:dm:123456789" agent = "claude" ``` +## Local Bot API Server + +By default Push talks to `https://api.telegram.org`. To use a self-hosted +[Telegram Bot API server](https://github.com/tdlib/telegram-bot-api) (for +example to download files larger than the public 20 MB limit), point Push at +that server: + +```toml +[telegram] +bot_token = "replace-with-the-token-from-BotFather" +allow_user_ids = [123456789] +base_url = "http://127.0.0.1:8081/bot" +base_file_url = "http://127.0.0.1:8081/file/bot" +max_audio_bytes = 104857600 +``` + +Trailing slashes on the base URLs are stripped. Keep the local Bot API process +running before `push doctor` or `push`. Omitting these keys keeps the public +defaults. With `telegram-bot-api --local`, `getFile` returns absolute filesystem +paths; Push reads those files directly instead of using `base_file_url`. + `push init` creates a new config with owner-only mode `0600` on Unix. An environment variable remains supported through `TELEGRAM_BOT_TOKEN`. Push never prints the token. Run: @@ -84,10 +105,19 @@ passes the normal allowlist, transcribes it with `gpt-4o-transcribe`, sends the transcript through the selected coding agent, then returns the answer as both text and an Opus voice note generated by `gpt-4o-mini-tts`. -There is no local Whisper, FFmpeg, or other audio dependency. Audio stays in -memory and is limited to 20 MB. If neither `voice.openai_api_key` nor -`OPENAI_API_KEY` is set, normal text messages keep working and voice notes -receive an actionable text reply. API or speech generation errors also fall +Telegram `audio` messages and audio `document` files (for example long phone +recordings) take a different path: Push downloads the file into +`$PUSH_HOME/cache/inbound-audio/`, replaces the inbound content with that path +plus a short transcription handoff prompt, and lets the agent run its local +pipeline/skill. Those files do not use OpenAI speech-to-text and do not trigger +spoken replies. + +There is no local Whisper, FFmpeg, or other audio dependency inside Push itself. +Voice-note audio stays in memory and is limited to 20 MB for the OpenAI path. +File downloads honor `telegram.max_audio_bytes` (raise this when using a +self-hosted Bot API for large recordings). If neither `voice.openai_api_key` nor +`OPENAI_API_KEY` is set, normal text and audio-file handoffs keep working; voice +notes receive an actionable text reply. API or speech generation errors also fall back to text without stopping the gateway. The spoken reply is AI-generated. Voice-note audio is sent to OpenAI for processing, so review OpenAI's data controls before enabling this feature. @@ -100,10 +130,11 @@ channel can add those transport operations without changing the OpenAI layer. An incoming Telegram update reaches the agent only when all of these are true: -- it is a normal text message or voice note in a private chat +- it is a normal text message, voice note, audio message, or audio document in a + private chat - its numeric sender id is in `telegram.allow_user_ids`, or its numeric chat id is in `telegram.allow_chat_ids` -- the message contains non-empty text or a voice attachment +- the message contains non-empty text/caption or an accepted audio attachment Group chats, channels, group forum topics, edited messages, and other update types are out of scope and ignored. The private-chat thread key is diff --git a/src/channel.rs b/src/channel.rs index 3306ffe..512e014 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -23,6 +23,9 @@ pub struct InboundVoice { pub filename: String, /// Channels that already have the bytes may provide them directly. pub data: Option>, + /// When true, Push downloads the file to disk and hands the path to the + /// agent instead of cloud speech-to-text (Telegram audio/document files). + pub agent_handoff: bool, } #[derive(Debug, Clone)] @@ -165,6 +168,11 @@ impl Channel { .ok_or_else(|| anyhow::anyhow!("Telegram bot token is not configured"))?, cfg.telegram_allow_user_ids.clone(), cfg.telegram_allow_chat_ids.clone(), + crate::telegram::TelegramEndpoints { + base_url: cfg.telegram_base_url.clone(), + base_file_url: cfg.telegram_base_file_url.clone(), + max_audio_bytes: cfg.telegram_max_audio_bytes, + }, ))), ChannelKind::Slack => Ok(Self::Slack(Slack::new( cfg.slack_app_token() @@ -755,7 +763,12 @@ mod tests { } fn telegram() -> Channel { - Channel::Telegram(Telegram::new("secret".to_string(), vec![7], vec![9])) + Channel::Telegram(Telegram::new( + "secret".to_string(), + vec![7], + vec![9], + crate::telegram::TelegramEndpoints::default(), + )) } fn slack() -> Channel { diff --git a/src/config.rs b/src/config.rs index 0d075da..99c2e7d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -14,6 +14,9 @@ pub const TELEGRAM_BOT_TOKEN_ENV: &str = "TELEGRAM_BOT_TOKEN"; pub const SLACK_APP_TOKEN_ENV: &str = "SLACK_APP_TOKEN"; pub const SLACK_BOT_TOKEN_ENV: &str = "SLACK_BOT_TOKEN"; pub const DEFAULT_VOICE_NAME: &str = "cedar"; +pub const DEFAULT_TELEGRAM_BASE_URL: &str = "https://api.telegram.org/bot"; +pub const DEFAULT_TELEGRAM_BASE_FILE_URL: &str = "https://api.telegram.org/file/bot"; +pub const DEFAULT_TELEGRAM_MAX_AUDIO_BYTES: usize = 20 * 1024 * 1024; const SUPPORTED_VOICE_NAMES: &[&str] = &[ "alloy", "ash", "ballad", "coral", "echo", "fable", "nova", "onyx", "sage", "shimmer", "verse", "marin", "cedar", @@ -62,6 +65,12 @@ pub struct Config { pub telegram_allow_user_ids: Vec, #[serde(default)] pub telegram_allow_chat_ids: Vec, + #[serde(default = "default_telegram_base_url")] + pub telegram_base_url: String, + #[serde(default = "default_telegram_base_file_url")] + pub telegram_base_file_url: String, + #[serde(default = "default_telegram_max_audio_bytes")] + pub telegram_max_audio_bytes: usize, #[serde(default)] pub slack_app_token: Option, #[serde(default)] @@ -223,6 +232,9 @@ impl Config { ("bot_token", "telegram_bot_token"), ("allow_user_ids", "telegram_allow_user_ids"), ("allow_chat_ids", "telegram_allow_chat_ids"), + ("base_url", "telegram_base_url"), + ("base_file_url", "telegram_base_file_url"), + ("max_audio_bytes", "telegram_max_audio_bytes"), ], )?; flatten_provider_section( @@ -288,6 +300,8 @@ impl Config { c.assistant_root = assistant_root.to_string_lossy().to_string(); c.assistant_dir = c.assistant_root.clone(); c.jobs_dir = assistant_root.join("jobs").to_string_lossy().to_string(); + c.telegram_base_url = normalize_telegram_api_base(&c.telegram_base_url); + c.telegram_base_file_url = normalize_telegram_api_base(&c.telegram_base_file_url); validate_runtime_outside_assistant(&c)?; c.validate()?; c.config_path = config_path.to_string_lossy().to_string(); @@ -536,6 +550,15 @@ impl Config { { bail!("telegram.bot_token cannot be empty"); } + if self.telegram_base_url.trim().is_empty() { + bail!("telegram.base_url cannot be empty"); + } + if self.telegram_base_file_url.trim().is_empty() { + bail!("telegram.base_file_url cannot be empty"); + } + if self.telegram_max_audio_bytes == 0 { + bail!("telegram.max_audio_bytes must be positive"); + } } ChannelKind::Slack => { if self.slack_allow_user_ids.is_empty() @@ -882,6 +905,19 @@ fn default_agent() -> String { fn default_voice_name() -> String { DEFAULT_VOICE_NAME.to_string() } +fn default_telegram_base_url() -> String { + DEFAULT_TELEGRAM_BASE_URL.to_string() +} +fn default_telegram_base_file_url() -> String { + DEFAULT_TELEGRAM_BASE_FILE_URL.to_string() +} +fn default_telegram_max_audio_bytes() -> usize { + DEFAULT_TELEGRAM_MAX_AUDIO_BYTES +} + +fn normalize_telegram_api_base(value: &str) -> String { + value.trim().trim_end_matches('/').to_string() +} fn default_jobs_dir() -> String { "~/.push/jobs".to_string() } @@ -914,6 +950,9 @@ mod tests { telegram_bot_token: None, telegram_allow_user_ids: Vec::new(), telegram_allow_chat_ids: Vec::new(), + telegram_base_url: DEFAULT_TELEGRAM_BASE_URL.to_string(), + telegram_base_file_url: DEFAULT_TELEGRAM_BASE_FILE_URL.to_string(), + telegram_max_audio_bytes: DEFAULT_TELEGRAM_MAX_AUDIO_BYTES, slack_app_token: None, slack_bot_token: None, slack_allow_user_ids: Vec::new(), @@ -1048,4 +1087,39 @@ mod tests { let _ = std::fs::remove_dir_all(assistant); let _ = std::fs::remove_dir_all(outside); } + + #[test] + fn telegram_local_bot_api_settings_parse_and_normalize() { + let root = temp_dir("config-telegram-local-api"); + let config_path = root.join("config.toml"); + let assistant = root.join("assistant"); + std::fs::create_dir_all(&assistant).unwrap(); + std::fs::write( + &config_path, + format!( + r#" +channel = "telegram" +agent = "pi" +assistant_root = "{assistant}" + +[telegram] +bot_token = "secret" +allow_user_ids = [1] +base_url = "http://127.0.0.1:8081/bot/" +base_file_url = "http://127.0.0.1:8081/file/bot/" +max_audio_bytes = 104857600 +"#, + assistant = assistant.display() + ), + ) + .unwrap(); + let cfg = Config::load_with_paths( + config_path.to_str().unwrap(), + PushPaths::from_root(root.join("runtime")).unwrap(), + ) + .unwrap(); + assert_eq!(cfg.telegram_base_url, "http://127.0.0.1:8081/bot"); + assert_eq!(cfg.telegram_base_file_url, "http://127.0.0.1:8081/file/bot"); + assert_eq!(cfg.telegram_max_audio_bytes, 104857600); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 33a3bcc..ee7f639 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -598,11 +598,18 @@ impl Gateway { continue; } if let Some((thread, target)) = self.channel.accept(m) { - let reply_with_voice = m.voice.is_some(); - let message_text = if reply_with_voice { - "[Voice message]".to_string() - } else { - m.text.trim().to_string() + let reply_with_voice = m.voice.as_ref().is_some_and(|voice| !voice.agent_handoff); + let message_text = match m.voice.as_ref() { + Some(voice) if voice.agent_handoff => { + let caption = m.text.trim(); + if caption.is_empty() { + "[Audio file]".to_string() + } else { + caption.to_string() + } + } + Some(_) => "[Voice message]".to_string(), + None => m.text.trim().to_string(), }; let approval_origin = self.channel.approval_origin(m, &thread); let approval = if reply_with_voice { diff --git a/src/gateway/tests.rs b/src/gateway/tests.rs index 21218bf..df5e2d0 100644 --- a/src/gateway/tests.rs +++ b/src/gateway/tests.rs @@ -3021,6 +3021,9 @@ fn test_config(state_path: &str, _sessions_dir: &str, assistant_dir: &str) -> Co telegram_bot_token: None, telegram_allow_user_ids: Vec::new(), telegram_allow_chat_ids: Vec::new(), + telegram_base_url: crate::config::DEFAULT_TELEGRAM_BASE_URL.to_string(), + telegram_base_file_url: crate::config::DEFAULT_TELEGRAM_BASE_FILE_URL.to_string(), + telegram_max_audio_bytes: crate::config::DEFAULT_TELEGRAM_MAX_AUDIO_BYTES, slack_app_token: None, slack_bot_token: None, slack_allow_user_ids: Vec::new(), @@ -3177,6 +3180,7 @@ fn telegram_voice_message(row_id: i64, user_id: i64, chat_id: i64) -> RawMessage mime_type: "audio/ogg".to_string(), filename: "voice.ogg".to_string(), data: Some(vec![1, 2, 3]), + agent_handoff: false, }); message } diff --git a/src/gateway/worker.rs b/src/gateway/worker.rs index 458184f..6268cad 100644 --- a/src/gateway/worker.rs +++ b/src/gateway/worker.rs @@ -93,8 +93,8 @@ where ctx.audit .failed(event, job.row_id, &job.thread, Some(job.backend), detail), ); - let delivery = record_and_deliver(ctx, &job, OutboundOrigin::Gateway, reply).await; - report_delivery(ctx, &job, delivery, reply, event, "deliver voice fallback"); + let delivery = record_and_deliver(ctx, &job, OutboundOrigin::Gateway, &reply).await; + report_delivery(ctx, &job, delivery, &reply, event, "deliver voice fallback"); return; } } @@ -568,7 +568,7 @@ async fn interrupt(cancel: &mut watch::Receiver, row_id: i64) { enum VoicePreparationError { User { event: &'static str, - reply: &'static str, + reply: String, detail: String, }, History(anyhow::Error), @@ -579,20 +579,23 @@ async fn prepare_voice(ctx: &Ctx, job: &Job) -> std::result::Result MAX_AUDIO_BYTES) { return Err(VoicePreparationError::User { event: "voice_too_large", - reply: "That voice message is too large. The limit is 20 MB.", + reply: "That voice message is too large. The limit is 20 MB.".to_string(), detail: "voice message exceeds the 20 MB limit".to_string(), }); } let Some(voice) = &ctx.voice else { return Err(VoicePreparationError::User { event: "voice_not_configured", - reply: "Voice messages are unavailable. Set voice.openai_api_key in config or OPENAI_API_KEY, restart Push, or send text instead.", + reply: "Voice messages are unavailable. Set voice.openai_api_key in config or OPENAI_API_KEY, restart Push, or send text instead.".to_string(), detail: "OpenAI API key is not configured".to_string(), }); }; @@ -602,7 +605,8 @@ async fn prepare_voice(ctx: &Ctx, job: &Job) -> std::result::Result std::result::Result std::result::Result std::result::Result { + let max_bytes = ctx.cfg.telegram_max_audio_bytes; + if attachment.file_size.is_some_and(|size| size > max_bytes) { + return Err(VoicePreparationError::User { + event: "voice_too_large", + reply: format!( + "That audio file is too large. The configured limit is {max_bytes} bytes." + ), + detail: format!("audio file exceeds the {max_bytes} byte limit"), + }); + } + let clip = ctx + .channel + .download_voice(attachment) + .await + .map_err(|error| VoicePreparationError::User { + event: "voice_download_failed", + reply: "I could not download that audio file. Please try again or send text." + .to_string(), + detail: format!("audio download failed: {error:#}"), + })?; + if clip.bytes.len() > max_bytes { + return Err(VoicePreparationError::User { + event: "voice_too_large", + reply: format!( + "That audio file is too large. The configured limit is {max_bytes} bytes." + ), + detail: format!("audio file exceeds the {max_bytes} byte limit"), + }); + } + + let dir = ctx.cfg.paths.cache.join("inbound-audio"); + std::fs::create_dir_all(&dir).map_err(|error| VoicePreparationError::User { + event: "voice_download_failed", + reply: "I could not save that audio file locally. Please try again or send text." + .to_string(), + detail: format!("create inbound-audio dir failed: {error}"), + })?; + let filename = safe_audio_filename(&clip.filename); + let path = dir.join(format!("{}_{filename}", job.row_id)); + std::fs::write(&path, &clip.bytes).map_err(|error| VoicePreparationError::User { + event: "voice_download_failed", + reply: "I could not save that audio file locally. Please try again or send text." + .to_string(), + detail: format!("write inbound audio failed: {error}"), + })?; + + let caption = job.text.trim(); + let caption = if caption.is_empty() || caption == "[Audio file]" { + String::new() + } else { + format!("\n\nCaption from Telegram:\n{caption}") + }; + let prompt = format!( + "Telegram audio file saved to:\n{}\n\nTranscribe it with the local audio transcription pipeline/skill (background run; do not wait for completion). Confirm when started.{caption}", + path.display() + ); + ctx.history + .lock() + .unwrap() + .replace_inbound_content(job.inbound_id, &prompt) + .map_err(VoicePreparationError::History)?; + Ok(prompt) +} + +fn safe_audio_filename(name: &str) -> String { + let base = std::path::Path::new(name) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or("audio.bin"); + let cleaned: String = base + .chars() + .map(|ch| { + if ch.is_ascii_alphanumeric() || ch == '.' || ch == '-' || ch == '_' { + ch + } else { + '_' + } + }) + .collect(); + if cleaned.is_empty() { + "audio.bin".to_string() + } else { + cleaned + } +} + /// Error and completion labels for one gateway-authored reply flow. struct ReplyLabels { record: &'static str, diff --git a/src/telegram.rs b/src/telegram.rs index e85fbdd..be106a1 100644 --- a/src/telegram.rs +++ b/src/telegram.rs @@ -11,12 +11,45 @@ use serde::Deserialize; use serde_json::{json, Value}; use crate::channel::{InboundVoice, RawMessage}; -use crate::voice::{AudioClip, MAX_AUDIO_BYTES}; +use crate::config::{ + DEFAULT_TELEGRAM_BASE_FILE_URL, DEFAULT_TELEGRAM_BASE_URL, DEFAULT_TELEGRAM_MAX_AUDIO_BYTES, +}; +use crate::voice::AudioClip; pub const TEXT_LIMIT: usize = 4096; const LONG_POLL_SECONDS: u64 = 25; const HTTP_TIMEOUT_SECONDS: u64 = LONG_POLL_SECONDS + 10; +/// Telegram Bot API endpoint settings. Defaults match the public api.telegram.org hosts. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TelegramEndpoints { + pub base_url: String, + pub base_file_url: String, + pub max_audio_bytes: usize, +} + +impl Default for TelegramEndpoints { + fn default() -> Self { + Self { + base_url: DEFAULT_TELEGRAM_BASE_URL.to_string(), + base_file_url: DEFAULT_TELEGRAM_BASE_FILE_URL.to_string(), + max_audio_bytes: DEFAULT_TELEGRAM_MAX_AUDIO_BYTES, + } + } +} + +fn bot_method_url(base_url: &str, token: &str, method: &str) -> String { + format!("{base_url}{token}/{method}") +} + +fn bot_file_url(base_file_url: &str, token: &str, file_path: &str) -> String { + format!("{base_file_url}{token}/{file_path}") +} + +fn audio_limit_message(max_audio_bytes: usize) -> String { + format!("Telegram voice message exceeds the configured {max_audio_bytes} byte limit") +} + struct TransportResponse { status: u16, body: Value, @@ -46,6 +79,9 @@ trait Transport: Send + Sync { struct ReqwestTransport { client: reqwest::Client, + base_url: String, + base_file_url: String, + max_audio_bytes: usize, } impl Transport for ReqwestTransport { @@ -56,7 +92,7 @@ impl Transport for ReqwestTransport { body: Value, ) -> TransportFuture<'a> { Box::pin(async move { - let url = format!("https://api.telegram.org/bot{token}/{method}"); + let url = bot_method_url(&self.base_url, token, method); let response = self .client .post(url) @@ -75,7 +111,8 @@ impl Transport for ReqwestTransport { fn download<'a>(&'a self, token: &'a str, file_path: &'a str) -> BytesFuture<'a> { Box::pin(async move { - let url = format!("https://api.telegram.org/file/bot{token}/{file_path}"); + let url = bot_file_url(&self.base_file_url, token, file_path); + let max_audio_bytes = self.max_audio_bytes; let mut response = self .client .get(url) @@ -89,23 +126,23 @@ impl Transport for ReqwestTransport { } if response .content_length() - .is_some_and(|size| size > MAX_AUDIO_BYTES as u64) + .is_some_and(|size| size > max_audio_bytes as u64) { - bail!("Telegram voice message exceeds the 20 MB limit"); + bail!("{}", audio_limit_message(max_audio_bytes)); } let mut bytes = Vec::with_capacity( response .content_length() .unwrap_or_default() - .min(MAX_AUDIO_BYTES as u64) as usize, + .min(max_audio_bytes as u64) as usize, ); while let Some(chunk) = response .chunk() .await .context("read Telegram voice message")? { - if bytes.len().saturating_add(chunk.len()) > MAX_AUDIO_BYTES { - bail!("Telegram voice message exceeds the 20 MB limit"); + if bytes.len().saturating_add(chunk.len()) > max_audio_bytes { + bail!("{}", audio_limit_message(max_audio_bytes)); } bytes.extend_from_slice(&chunk); } @@ -120,7 +157,7 @@ impl Transport for ReqwestTransport { clip: &'a AudioClip, ) -> TransportFuture<'a> { Box::pin(async move { - let url = format!("https://api.telegram.org/bot{token}/sendVoice"); + let url = bot_method_url(&self.base_url, token, "sendVoice"); let mut form = reqwest::multipart::Form::new(); let object = payload .as_object() @@ -158,17 +195,27 @@ pub struct Telegram { token: Arc, allow_user_ids: Arc>, allow_chat_ids: Arc>, + max_audio_bytes: usize, transport: Arc, } impl Telegram { - pub fn new(token: String, allow_user_ids: Vec, allow_chat_ids: Vec) -> Self { + pub fn new( + token: String, + allow_user_ids: Vec, + allow_chat_ids: Vec, + endpoints: TelegramEndpoints, + ) -> Self { Self { token: Arc::from(token), allow_user_ids: Arc::new(allow_user_ids.into_iter().collect()), allow_chat_ids: Arc::new(allow_chat_ids.into_iter().collect()), + max_audio_bytes: endpoints.max_audio_bytes, transport: Arc::new(ReqwestTransport { client: reqwest::Client::new(), + base_url: endpoints.base_url, + base_file_url: endpoints.base_file_url, + max_audio_bytes: endpoints.max_audio_bytes, }), } } @@ -179,11 +226,29 @@ impl Telegram { allow_user_ids: Vec, allow_chat_ids: Vec, transport: Arc, + ) -> Self { + Self::with_transport_limit( + token, + allow_user_ids, + allow_chat_ids, + DEFAULT_TELEGRAM_MAX_AUDIO_BYTES, + transport, + ) + } + + #[cfg(test)] + fn with_transport_limit( + token: String, + allow_user_ids: Vec, + allow_chat_ids: Vec, + max_audio_bytes: usize, + transport: Arc, ) -> Self { Self { token: Arc::from(token), allow_user_ids: Arc::new(allow_user_ids.into_iter().collect()), allow_chat_ids: Arc::new(allow_chat_ids.into_iter().collect()), + max_audio_bytes, transport, } } @@ -306,8 +371,11 @@ impl Telegram { } pub async fn download_voice(&self, voice: &InboundVoice) -> Result { - if voice.file_size.is_some_and(|size| size > MAX_AUDIO_BYTES) { - bail!("Telegram voice message exceeds the 20 MB limit"); + if voice + .file_size + .is_some_and(|size| size > self.max_audio_bytes) + { + bail!("{}", audio_limit_message(self.max_audio_bytes)); } let transport_response = self .transport @@ -326,7 +394,19 @@ impl Telegram { .result .context("Telegram getFile omitted the file path")? .file_path; - let bytes = self.transport.download(&self.token, &file_path).await?; + // Local Bot API (--local) returns an absolute filesystem path and does not + // serve those files over /file/bot…; read them directly. + // ponytail: upgrade to a Transport::read_local hook if another channel needs it. + let path = std::path::Path::new(&file_path); + let bytes = if path.is_absolute() { + std::fs::read(path) + .with_context(|| format!("read local Telegram Bot API file {}", path.display()))? + } else { + self.transport.download(&self.token, &file_path).await? + }; + if bytes.len() > self.max_audio_bytes { + bail!("{}", audio_limit_message(self.max_audio_bytes)); + } Ok(AudioClip { bytes, filename: voice.filename.clone(), @@ -463,6 +543,8 @@ impl Update { thread_id: None, }; }; + let voice = inbound_audio_attachment(&message); + let text = message.text.or(message.caption).unwrap_or_default(); RawMessage { row_id: self.update_id, provider_event_id: None, @@ -473,14 +555,8 @@ impl Update { .unwrap_or_default(), chat_identifier: message.chat.id.to_string(), is_group: message.chat.kind != "private", - text: message.text.unwrap_or_default(), - voice: message.voice.map(|voice| InboundVoice { - locator: voice.file_id, - file_size: voice.file_size, - mime_type: voice.mime_type.unwrap_or_else(|| "audio/ogg".to_string()), - filename: "voice.ogg".to_string(), - data: None, - }), + text, + voice, is_from_me: false, is_supported: true, thread_id: message.message_thread_id, @@ -488,6 +564,77 @@ impl Update { } } +fn inbound_audio_attachment(message: &TelegramMessage) -> Option { + if let Some(voice) = &message.voice { + return Some(InboundVoice { + locator: voice.file_id.clone(), + file_size: voice.file_size, + mime_type: voice + .mime_type + .clone() + .unwrap_or_else(|| "audio/ogg".to_string()), + filename: "voice.ogg".to_string(), + data: None, + agent_handoff: false, + }); + } + if let Some(audio) = &message.audio { + return Some(InboundVoice { + locator: audio.file_id.clone(), + file_size: audio.file_size, + mime_type: audio + .mime_type + .clone() + .unwrap_or_else(|| "audio/mpeg".to_string()), + filename: audio + .file_name + .clone() + .unwrap_or_else(|| "audio.mp3".to_string()), + data: None, + agent_handoff: true, + }); + } + if let Some(document) = &message.document { + if is_audio_document(document) { + return Some(InboundVoice { + locator: document.file_id.clone(), + file_size: document.file_size, + mime_type: document + .mime_type + .clone() + .unwrap_or_else(|| "application/octet-stream".to_string()), + filename: document + .file_name + .clone() + .unwrap_or_else(|| "audio.bin".to_string()), + data: None, + agent_handoff: true, + }); + } + } + None +} + +fn is_audio_document(document: &TelegramDocument) -> bool { + if document + .mime_type + .as_deref() + .is_some_and(|mime| mime.starts_with("audio/")) + { + return true; + } + let name = document + .file_name + .as_deref() + .unwrap_or_default() + .to_ascii_lowercase(); + [ + ".ogg", ".oga", ".mp3", ".m4a", ".wav", ".flac", ".aac", ".opus", ".wma", + ] + .iter() + .any(|ext| name.ends_with(ext)) +} + #[derive(Deserialize)] struct TelegramMessage { #[serde(default)] @@ -496,8 +643,14 @@ struct TelegramMessage { #[serde(default)] text: Option, #[serde(default)] + caption: Option, + #[serde(default)] voice: Option, #[serde(default)] + audio: Option, + #[serde(default)] + document: Option, + #[serde(default)] message_thread_id: Option, } @@ -510,6 +663,28 @@ struct TelegramVoice { mime_type: Option, } +#[derive(Deserialize)] +struct TelegramAudio { + file_id: String, + #[serde(default)] + file_size: Option, + #[serde(default)] + mime_type: Option, + #[serde(default)] + file_name: Option, +} + +#[derive(Deserialize)] +struct TelegramDocument { + file_id: String, + #[serde(default)] + file_size: Option, + #[serde(default)] + mime_type: Option, + #[serde(default)] + file_name: Option, +} + #[derive(Default, Deserialize)] struct TelegramFile { file_path: String, @@ -734,7 +909,12 @@ mod tests { #[test] fn allowlist_accepts_user_or_chat_id() { - let telegram = Telegram::new("secret".to_string(), vec![7], vec![9]); + let telegram = Telegram::new( + "secret".to_string(), + vec![7], + vec![9], + TelegramEndpoints::default(), + ); let mut message = Update { update_id: 1, message: Some(TelegramMessage { @@ -744,7 +924,10 @@ mod tests { kind: "private".to_string(), }, text: Some("hi".to_string()), + caption: None, voice: None, + audio: None, + document: None, message_thread_id: None, }), } @@ -768,11 +951,14 @@ mod tests { kind: "private".to_string(), }, text: None, + caption: None, voice: Some(TelegramVoice { file_id: "file-123".to_string(), file_size: Some(42), mime_type: Some("audio/ogg".to_string()), }), + audio: None, + document: None, message_thread_id: None, }), } @@ -782,9 +968,89 @@ mod tests { let voice = message.voice.unwrap(); assert_eq!(voice.locator, "file-123"); assert_eq!(voice.file_size, Some(42)); + assert!(!voice.agent_handoff); assert!(voice.data.is_none()); } + #[test] + fn parses_audio_and_audio_documents_as_agent_handoff() { + let audio = Update { + update_id: 3, + message: Some(TelegramMessage { + from: Some(User { id: 7 }), + chat: Chat { + id: 7, + kind: "private".to_string(), + }, + text: None, + caption: Some("planning".to_string()), + voice: None, + audio: Some(TelegramAudio { + file_id: "audio-1".to_string(), + file_size: Some(99), + mime_type: Some("audio/mp4".to_string()), + file_name: Some("meeting.m4a".to_string()), + }), + document: None, + message_thread_id: None, + }), + } + .into_raw(); + assert_eq!(audio.text, "planning"); + let handoff = audio.voice.unwrap(); + assert!(handoff.agent_handoff); + assert_eq!(handoff.locator, "audio-1"); + assert_eq!(handoff.filename, "meeting.m4a"); + + let document = Update { + update_id: 4, + message: Some(TelegramMessage { + from: Some(User { id: 7 }), + chat: Chat { + id: 7, + kind: "private".to_string(), + }, + text: None, + caption: None, + voice: None, + audio: None, + document: Some(TelegramDocument { + file_id: "doc-1".to_string(), + file_size: Some(12), + mime_type: None, + file_name: Some("Grabadora.mp3".to_string()), + }), + message_thread_id: None, + }), + } + .into_raw(); + assert!(document.voice.unwrap().agent_handoff); + + let pdf = Update { + update_id: 5, + message: Some(TelegramMessage { + from: Some(User { id: 7 }), + chat: Chat { + id: 7, + kind: "private".to_string(), + }, + text: None, + caption: None, + voice: None, + audio: None, + document: Some(TelegramDocument { + file_id: "doc-2".to_string(), + file_size: Some(12), + mime_type: Some("application/pdf".to_string()), + file_name: Some("notes.pdf".to_string()), + }), + message_thread_id: None, + }), + } + .into_raw(); + assert!(pdf.voice.is_none()); + } + #[tokio::test] async fn downloads_voice_by_file_id_and_returns_generic_audio() { let fake = Arc::new(FakeTransport::with_responses(vec![json!({ @@ -800,6 +1066,7 @@ mod tests { mime_type: "audio/ogg".to_string(), filename: "voice.ogg".to_string(), data: None, + agent_handoff: false, }; let clip = telegram.download_voice(&voice).await.unwrap(); @@ -813,6 +1080,32 @@ mod tests { ); } + #[tokio::test] + async fn reads_absolute_local_bot_api_paths_from_disk() { + let file = crate::test_support::temp_path("local-bot-api-audio.bin"); + std::fs::write(&file, b"local-bytes").unwrap(); + let fake = Arc::new(FakeTransport::with_responses(vec![json!({ + "ok": true, + "result": {"file_path": file.to_string_lossy()} + })])); + let telegram = + Telegram::with_transport("secret".to_string(), vec![7], vec![], fake.clone()); + let voice = InboundVoice { + locator: "file-local".to_string(), + file_size: Some(11), + mime_type: "audio/mp4".to_string(), + filename: "meeting.m4a".to_string(), + data: None, + agent_handoff: true, + }; + + let clip = telegram.download_voice(&voice).await.unwrap(); + + assert_eq!(clip.bytes, b"local-bytes"); + assert!(fake.download_paths.lock().unwrap().is_empty()); + let _ = std::fs::remove_file(&file); + } + #[tokio::test] async fn uploads_opus_voice_to_the_exact_topic() { let fake = Arc::new(FakeTransport::with_responses(vec![json!({ @@ -1078,4 +1371,24 @@ mod tests { .iter() .all(|chunk| chunk.encode_utf16().count() <= TEXT_LIMIT)); } + + #[test] + fn bot_urls_use_configured_bases() { + assert_eq!( + bot_method_url("https://api.telegram.org/bot", "tok", "getUpdates"), + "https://api.telegram.org/bottok/getUpdates" + ); + assert_eq!( + bot_file_url("https://api.telegram.org/file/bot", "tok", "voice.oga"), + "https://api.telegram.org/file/bottok/voice.oga" + ); + assert_eq!( + bot_method_url("http://127.0.0.1:8081/bot", "tok", "getUpdates"), + "http://127.0.0.1:8081/bottok/getUpdates" + ); + assert_eq!( + bot_file_url("http://127.0.0.1:8081/file/bot", "tok", "path/file.bin"), + "http://127.0.0.1:8081/file/bottok/path/file.bin" + ); + } } diff --git a/src/test_support.rs b/src/test_support.rs index e979b18..5fe9e59 100644 --- a/src/test_support.rs +++ b/src/test_support.rs @@ -54,6 +54,9 @@ pub fn test_config() -> crate::config::Config { telegram_bot_token: None, telegram_allow_user_ids: Vec::new(), telegram_allow_chat_ids: Vec::new(), + telegram_base_url: crate::config::DEFAULT_TELEGRAM_BASE_URL.to_string(), + telegram_base_file_url: crate::config::DEFAULT_TELEGRAM_BASE_FILE_URL.to_string(), + telegram_max_audio_bytes: crate::config::DEFAULT_TELEGRAM_MAX_AUDIO_BYTES, slack_app_token: None, slack_bot_token: None, slack_allow_user_ids: Vec::new(),