From d99ce91ab96a860cebc88a8e27012f01e2ce4247 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Sun, 2 Aug 2026 17:23:04 +0200 Subject: [PATCH] feat(telegram): configurable Bot API base URLs and audio size limit Allow self-hosted telegram-bot-api endpoints and larger voice downloads while keeping public api.telegram.org defaults unchanged. Co-authored-by: Cursor --- config.toml.example | 5 ++ docs/configuration.md | 3 ++ docs/telegram.md | 20 +++++++ src/channel.rs | 12 ++++- src/config.rs | 74 ++++++++++++++++++++++++++ src/gateway/tests.rs | 3 ++ src/telegram.rs | 119 +++++++++++++++++++++++++++++++++++++----- src/test_support.rs | 3 ++ 8 files changed, 225 insertions(+), 14 deletions(-) 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..1b9f473 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -37,6 +37,26 @@ 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. + `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: diff --git a/src/channel.rs b/src/channel.rs index 3306ffe..2e777ac 100644 --- a/src/channel.rs +++ b/src/channel.rs @@ -165,6 +165,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 +760,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/tests.rs b/src/gateway/tests.rs index 21218bf..2e70a1e 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(), diff --git a/src/telegram.rs b/src/telegram.rs index e85fbdd..5064931 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 @@ -734,7 +802,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 { @@ -1078,4 +1151,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(),