From ea08d92ada2706eb3eeb355db10fac7439e533c7 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 17:03:55 -0400 Subject: [PATCH 01/10] feat(telegram): first-class [telegram] config section Implements #1263 for Telegram: every TELEGRAM_* env var now has a [telegram] config field. Config-authoritative with ${} expansion and TELEGRAM_* env fallback when a field is unset (matches [discord]/[slack]). - TelegramConfig + resolve() in openab-core (config > env > default) - AppState gains telegram_streaming + apply_telegram_config() (plain params, no core dependency) - unified_adapter reads resolved streaming instead of env directly - main.rs applies resolved config + webhook_path to the embedded gateway - backward compatible: env-only deployments (no [telegram]) unchanged Refs #1263 --- crates/openab-core/src/config.rs | 200 +++++++++++++++++++++++++++++++ crates/openab-gateway/src/lib.rs | 29 +++++ src/main.rs | 27 ++++- src/unified_adapter.rs | 14 +-- 4 files changed, 260 insertions(+), 10 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index ab218bbf2..844347d7e 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -129,6 +129,7 @@ pub struct Config { pub discord: Option, pub slack: Option, pub gateway: Option, + pub telegram: Option, pub agentcore: Option, #[serde(default)] pub agent: AgentConfig, @@ -577,6 +578,96 @@ fn default_gateway_platform() -> String { "telegram".into() } +/// First-class `[telegram]` configuration section (see ADR: first-class +/// per-platform config). Config-authoritative with `${ENV}` expansion; every +/// field falls back to its `TELEGRAM_*` environment variable when unset, then to +/// a built-in default. This keeps env-only deployments working unchanged while +/// letting `config.toml` be the single source of truth. +/// +/// Resolution per field: `[telegram].field` (with `${}` expansion) → `TELEGRAM_*` +/// env var → default. +#[derive(Debug, Clone, Default, Deserialize)] +#[serde(default)] +pub struct TelegramConfig { + /// Bot token. Env fallback: `TELEGRAM_BOT_TOKEN`. + pub bot_token: Option, + /// Webhook secret token (L1 auth). Env fallback: `TELEGRAM_SECRET_TOKEN`. + pub secret_token: Option, + /// Reject webhook requests whose source IP is outside Telegram's published + /// subnets (L1). Env fallback: `TELEGRAM_TRUSTED_SOURCE_ONLY` (default false). + pub trusted_source_only: Option, + /// Render rich-message drafts. Env fallback: `TELEGRAM_RICH_MESSAGES` + /// (default true). + pub rich_messages: Option, + /// Streaming override. When unset, streaming follows `rich_messages`. + /// Env fallback: `TELEGRAM_STREAMING`. + pub streaming: Option, + /// Webhook mount path. Env fallback: `TELEGRAM_WEBHOOK_PATH` + /// (default `/webhook/telegram`). + pub webhook_path: Option, +} + +/// Fully resolved Telegram settings (config → env → default applied). +/// Plain types so the binary crate can hand them to the gateway crate without a +/// type dependency. +#[derive(Debug, Clone)] +pub struct ResolvedTelegram { + pub bot_token: Option, + pub secret_token: Option, + pub trusted_source_only: bool, + pub rich_messages: bool, + pub streaming: Option, + pub webhook_path: String, +} + +impl TelegramConfig { + /// Resolve every field: config value (if set) → `TELEGRAM_*` env → default. + pub fn resolve(&self) -> ResolvedTelegram { + ResolvedTelegram { + bot_token: self + .bot_token + .clone() + .or_else(|| std::env::var("TELEGRAM_BOT_TOKEN").ok()), + secret_token: self + .secret_token + .clone() + .or_else(|| std::env::var("TELEGRAM_SECRET_TOKEN").ok()), + trusted_source_only: self + .trusted_source_only + .unwrap_or_else(|| env_flag_true_one("TELEGRAM_TRUSTED_SOURCE_ONLY")), + rich_messages: self + .rich_messages + .unwrap_or_else(|| env_flag_not_false("TELEGRAM_RICH_MESSAGES")), + streaming: self.streaming.or_else(|| { + std::env::var("TELEGRAM_STREAMING") + .ok() + .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false"))) + }), + webhook_path: self + .webhook_path + .clone() + .or_else(|| std::env::var("TELEGRAM_WEBHOOK_PATH").ok()) + .unwrap_or_else(|| "/webhook/telegram".into()), + } + } +} + +/// `true` when env var == "1" or "true" (case-insensitive); default `false`. +/// Matches the legacy `TELEGRAM_TRUSTED_SOURCE_ONLY` semantics. +fn env_flag_true_one(key: &str) -> bool { + std::env::var(key) + .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) + .unwrap_or(false) +} + +/// `true` unless env var == "0" or "false" (case-insensitive); default `true`. +/// Matches the legacy `TELEGRAM_RICH_MESSAGES` semantics. +fn env_flag_not_false(key: &str) -> bool { + std::env::var(key) + .map(|v| v != "0" && !v.eq_ignore_ascii_case("false")) + .unwrap_or(true) +} + /// Raw intermediate struct for serde — uses `Option` to detect explicit fields. #[derive(Debug, Deserialize)] #[serde(default)] @@ -1406,6 +1497,115 @@ mod tests { use super::*; use std::io::Write; + #[test] + fn telegram_config_value_wins_over_env() { + // Config values are authoritative regardless of env (the `.or_else` + // env fallback only fires when the field is None). + std::env::set_var("TELEGRAM_BOT_TOKEN", "env-token"); + let cfg = TelegramConfig { + bot_token: Some("cfg-token".into()), + secret_token: Some("cfg-secret".into()), + trusted_source_only: Some(true), + rich_messages: Some(false), + streaming: Some(true), + webhook_path: Some("/custom/tg".into()), + }; + let r = cfg.resolve(); + assert_eq!(r.bot_token.as_deref(), Some("cfg-token")); + assert_eq!(r.secret_token.as_deref(), Some("cfg-secret")); + assert!(r.trusted_source_only); + assert!(!r.rich_messages); + assert_eq!(r.streaming, Some(true)); + assert_eq!(r.webhook_path, "/custom/tg"); + std::env::remove_var("TELEGRAM_BOT_TOKEN"); + } + + #[test] + fn telegram_env_fallback_and_defaults() { + // Single serialized test mutating the TELEGRAM_* env vars (no other + // openab-core test touches them, so this is race-free within the crate). + for k in [ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_SECRET_TOKEN", + "TELEGRAM_TRUSTED_SOURCE_ONLY", + "TELEGRAM_RICH_MESSAGES", + "TELEGRAM_STREAMING", + "TELEGRAM_WEBHOOK_PATH", + ] { + std::env::remove_var(k); + } + + // All unset → built-in defaults. + let r = TelegramConfig::default().resolve(); + assert_eq!(r.bot_token, None); + assert_eq!(r.secret_token, None); + assert!(!r.trusted_source_only); // default false + assert!(r.rich_messages); // default true + assert_eq!(r.streaming, None); + assert_eq!(r.webhook_path, "/webhook/telegram"); + + // Env set, config unset → env values used (legacy semantics preserved). + std::env::set_var("TELEGRAM_BOT_TOKEN", "env-token"); + std::env::set_var("TELEGRAM_SECRET_TOKEN", "env-secret"); + std::env::set_var("TELEGRAM_TRUSTED_SOURCE_ONLY", "true"); + std::env::set_var("TELEGRAM_RICH_MESSAGES", "false"); + std::env::set_var("TELEGRAM_STREAMING", "1"); + std::env::set_var("TELEGRAM_WEBHOOK_PATH", "/env/tg"); + + let r = TelegramConfig::default().resolve(); + assert_eq!(r.bot_token.as_deref(), Some("env-token")); + assert_eq!(r.secret_token.as_deref(), Some("env-secret")); + assert!(r.trusted_source_only); + assert!(!r.rich_messages); // "false" → false + assert_eq!(r.streaming, Some(true)); // "1" → true + assert_eq!(r.webhook_path, "/env/tg"); + + // RICH_MESSAGES legacy semantics: "0" → false, anything else → true + std::env::set_var("TELEGRAM_RICH_MESSAGES", "0"); + assert!(!TelegramConfig::default().resolve().rich_messages); + std::env::set_var("TELEGRAM_RICH_MESSAGES", "yes"); + assert!(TelegramConfig::default().resolve().rich_messages); + + // STREAMING "false" → Some(false) + std::env::set_var("TELEGRAM_STREAMING", "false"); + assert_eq!(TelegramConfig::default().resolve().streaming, Some(false)); + + for k in [ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_SECRET_TOKEN", + "TELEGRAM_TRUSTED_SOURCE_ONLY", + "TELEGRAM_RICH_MESSAGES", + "TELEGRAM_STREAMING", + "TELEGRAM_WEBHOOK_PATH", + ] { + std::env::remove_var(k); + } + } + + #[test] + fn telegram_section_parses_from_toml() { + let toml_str = r#" +[discord] +bot_token = "x" + +[telegram] +bot_token = "tg-tok" +secret_token = "tg-sec" +trusted_source_only = true +rich_messages = false +streaming = true +webhook_path = "/hook/tg" +"#; + let cfg = parse_config_str(toml_str, "test").unwrap(); + let tg = cfg.telegram.expect("telegram section"); + assert_eq!(tg.bot_token.as_deref(), Some("tg-tok")); + assert_eq!(tg.secret_token.as_deref(), Some("tg-sec")); + assert_eq!(tg.trusted_source_only, Some(true)); + assert_eq!(tg.rich_messages, Some(false)); + assert_eq!(tg.streaming, Some(true)); + assert_eq!(tg.webhook_path.as_deref(), Some("/hook/tg")); + } + #[test] fn hooks_any_configured_false_when_empty() { let h = HooksConfig::default(); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index 92317175b..eb7ca487b 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -29,6 +29,8 @@ pub struct AppState { pub telegram_secret_token: Option, pub telegram_rich_messages: bool, pub telegram_trusted_source_only: bool, + /// Streaming override. `None` = follow `telegram_rich_messages`. + pub telegram_streaming: Option, pub line_channel_secret: Option, pub line_access_token: Option, #[cfg(feature = "teams")] @@ -64,6 +66,7 @@ impl AppState { telegram_secret_token: None, telegram_rich_messages: false, telegram_trusted_source_only: false, + telegram_streaming: None, line_channel_secret: None, line_access_token: None, #[cfg(feature = "teams")] @@ -98,6 +101,9 @@ impl AppState { let telegram_trusted_source_only = std::env::var("TELEGRAM_TRUSTED_SOURCE_ONLY") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false); + let telegram_streaming = std::env::var("TELEGRAM_STREAMING") + .ok() + .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false"))); // LINE let line_channel_secret = std::env::var("LINE_CHANNEL_SECRET").ok(); @@ -166,6 +172,7 @@ impl AppState { telegram_secret_token, telegram_rich_messages, telegram_trusted_source_only, + telegram_streaming, line_channel_secret, line_access_token, #[cfg(feature = "teams")] @@ -184,6 +191,25 @@ impl AppState { client, } } + + /// Apply resolved `[telegram]` config values, overriding the env-derived + /// fields. Plain parameters keep this crate free of an `openab-core` + /// dependency (the binary crate resolves config → these values). + #[allow(clippy::too_many_arguments)] + pub fn apply_telegram_config( + &mut self, + bot_token: Option, + secret_token: Option, + rich_messages: bool, + trusted_source_only: bool, + streaming: Option, + ) { + self.telegram_bot_token = bot_token; + self.telegram_secret_token = secret_token; + self.telegram_rich_messages = rich_messages; + self.telegram_trusted_source_only = trusted_source_only; + self.telegram_streaming = streaming; + } } // --- Public serve() entry point --- @@ -402,6 +428,9 @@ pub async fn serve(config: ServeConfig) -> anyhow::Result<()> { telegram_trusted_source_only: std::env::var("TELEGRAM_TRUSTED_SOURCE_ONLY") .map(|v| v == "1" || v.eq_ignore_ascii_case("true")) .unwrap_or(false), + telegram_streaming: std::env::var("TELEGRAM_STREAMING") + .ok() + .map(|v| !(v == "0" || v.eq_ignore_ascii_case("false"))), line_channel_secret, line_access_token, #[cfg(feature = "teams")] diff --git a/src/main.rs b/src/main.rs index ecc5ce2c0..48cdbe323 100644 --- a/src/main.rs +++ b/src/main.rs @@ -517,7 +517,26 @@ async fn main() -> anyhow::Result<()> { let (event_tx, _) = tokio::sync::broadcast::channel::(256); // Build gateway AppState from env vars (shared factory with standalone gateway) - let gw_state = Arc::new(openab_gateway::AppState::from_env(event_tx.clone(), None)); + let mut gw_state_inner = openab_gateway::AppState::from_env(event_tx.clone(), None); + + // First-class `[telegram]` config overrides env-derived values + // (config-authoritative + ${} expansion + TELEGRAM_* env fallback). + #[cfg_attr(not(feature = "telegram"), allow(unused_variables))] + let telegram_webhook_path = if let Some(ref tg) = cfg.telegram { + let r = tg.resolve(); + let path = r.webhook_path.clone(); + gw_state_inner.apply_telegram_config( + r.bot_token, + r.secret_token, + r.rich_messages, + r.trusted_source_only, + r.streaming, + ); + Some(path) + } else { + None + }; + let gw_state = Arc::new(gw_state_inner); // Build axum router with platform webhook routes let mut app = axum::Router::new() @@ -525,8 +544,10 @@ async fn main() -> anyhow::Result<()> { #[cfg(feature = "telegram")] if gw_state.telegram_bot_token.is_some() { - let path = std::env::var("TELEGRAM_WEBHOOK_PATH") - .unwrap_or_else(|_| "/webhook/telegram".into()); + let path = telegram_webhook_path.clone().unwrap_or_else(|| { + std::env::var("TELEGRAM_WEBHOOK_PATH") + .unwrap_or_else(|_| "/webhook/telegram".into()) + }); info!(path = %path, "unified: telegram adapter enabled"); app = app.route(&path, axum::routing::post(openab_gateway::adapters::telegram::webhook)); } diff --git a/src/unified_adapter.rs b/src/unified_adapter.rs index 123cce2d1..0d6043f54 100644 --- a/src/unified_adapter.rs +++ b/src/unified_adapter.rs @@ -201,13 +201,13 @@ impl ChatAdapter for UnifiedGatewayAdapter { } fn use_streaming(&self, _other_bot_present: bool) -> bool { - // TELEGRAM_STREAMING env var is the explicit override (true/false). - // If not set, default to `true` when Telegram Rich Messages are enabled - // (implies sendRichMessageDraft support), `false` otherwise. - // This ensures Telegram-only deployments get streaming out of the box, - // while multi-platform deployments stay safe by default. - if let Ok(v) = std::env::var("TELEGRAM_STREAMING") { - return v == "1" || v.eq_ignore_ascii_case("true"); + // Streaming override is resolved once at startup (config `[telegram].streaming` + // → `TELEGRAM_STREAMING` env → unset). When unset, default to `true` when + // Telegram Rich Messages are enabled (implies sendRichMessageDraft support), + // `false` otherwise. This gives Telegram-only deployments streaming out of the + // box while multi-platform deployments stay safe by default. + if let Some(streaming) = self.gw_state.telegram_streaming { + return streaming; } self.gw_state.telegram_rich_messages } From ee8c94af81b93551195d88ff7ec07a5cb84a6109 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 17:08:25 -0400 Subject: [PATCH 02/10] docs(telegram): document first-class [telegram] config section --- config.toml.example | 11 +++++++++++ docs/telegram.md | 26 ++++++++++++++++++++++++++ 2 files changed, 37 insertions(+) diff --git a/config.toml.example b/config.toml.example index e6d968da6..ddaf3f313 100644 --- a/config.toml.example +++ b/config.toml.example @@ -59,6 +59,17 @@ allowed_channels = ["1234567890"] # ↑ omitted + non-empty list → auto- # streaming = true # enable streaming (typewriter) mode # streaming_placeholder = false # set false for draft-based platforms (e.g. Telegram Rich Messages) +# --- Telegram (first-class section; alternative to TELEGRAM_* env vars) --- +# Config-authoritative with ${} expansion; each field falls back to its +# TELEGRAM_* env var when unset, then to a default. Symmetric with [discord]. +# [telegram] +# bot_token = "${TELEGRAM_BOT_TOKEN}" # env fallback: TELEGRAM_BOT_TOKEN +# secret_token = "${TELEGRAM_SECRET_TOKEN}" # env fallback: TELEGRAM_SECRET_TOKEN +# trusted_source_only = true # reject IPs outside Telegram subnets (default false) +# rich_messages = true # sendRichMessage rendering (default true) +# streaming = true # override; defaults to follow rich_messages +# webhook_path = "/webhook/telegram" # default /webhook/telegram + # --- AgentCore Runtime (alternative to [agent]) --- # When [agentcore] is set and [agent].command is not explicitly provided, # OAB auto-spawns the bundled agentcore-acp adapter. No manual wiring needed. diff --git a/docs/telegram.md b/docs/telegram.md index 420b5cfa7..2a423b4db 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -66,6 +66,32 @@ Streaming is enabled by default when Rich Messages are active — replies are st No `[gateway]` section needed — the unified adapter activates automatically when `TELEGRAM_BOT_TOKEN` is set. +### First-class `[telegram]` config (optional) + +Instead of (or in addition to) the `TELEGRAM_*` env vars, you can configure Telegram as a first-class section in `config.toml` — symmetric with `[discord]` / `[slack]`: + +```toml +[telegram] +bot_token = "${TELEGRAM_BOT_TOKEN}" # ${} env expansion supported +secret_token = "${TELEGRAM_SECRET_TOKEN}" # webhook signature validation +trusted_source_only = true # reject requests outside Telegram's IP subnets +rich_messages = true # sendRichMessage rendering (default true) +streaming = true # override; defaults to follow rich_messages +webhook_path = "/webhook/telegram" +``` + +**Precedence (per field):** `[telegram]` value (with `${}` expansion) → `TELEGRAM_*` env var → built-in default. This is config-authoritative and matches `[discord]`/`[slack]`. Any field you omit falls back to its env var, so existing env-only deployments keep working unchanged. + +| `[telegram]` field | Env fallback | Default | +|--------------------|--------------|---------| +| `bot_token` | `TELEGRAM_BOT_TOKEN` | — | +| `secret_token` | `TELEGRAM_SECRET_TOKEN` | — | +| `trusted_source_only` | `TELEGRAM_TRUSTED_SOURCE_ONLY` | `false` | +| `rich_messages` | `TELEGRAM_RICH_MESSAGES` | `true` | +| `streaming` | `TELEGRAM_STREAMING` | follows `rich_messages` | +| `webhook_path` | `TELEGRAM_WEBHOOK_PATH` | `/webhook/telegram` | + + ### Set the Webhook ```bash From 7f5d14f0850e76264dbbffca176a70b9377879fb Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:30:42 +0000 Subject: [PATCH 03/10] fix(review): filter empty strings in resolve() + introduce GatewayTelegramConfig param object - Filter empty strings in TelegramConfig::resolve() so ${UNSET_VAR} expansion to "" correctly falls through to TELEGRAM_* env fallback instead of holding Some("") that bypasses the fallback chain. - Replace 5-param apply_telegram_config() with GatewayTelegramConfig parameter object for a clean, extensible cross-crate API boundary. - Add test: telegram_empty_string_falls_through_to_env --- crates/openab-core/src/config.rs | 40 +++++++++++++++++++++++++++++--- crates/openab-gateway/src/lib.rs | 35 +++++++++++++++------------- src/main.rs | 14 +++++------ 3 files changed, 63 insertions(+), 26 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 844347d7e..e8db9d92d 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -622,15 +622,23 @@ pub struct ResolvedTelegram { impl TelegramConfig { /// Resolve every field: config value (if set) → `TELEGRAM_*` env → default. + /// + /// String fields filter out empty strings produced by `${}` expansion of + /// unset env vars, so `bot_token = "${UNSET_VAR}"` correctly falls through + /// to the `TELEGRAM_BOT_TOKEN` env fallback rather than holding `Some("")`. pub fn resolve(&self) -> ResolvedTelegram { ResolvedTelegram { bot_token: self .bot_token - .clone() + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() .or_else(|| std::env::var("TELEGRAM_BOT_TOKEN").ok()), secret_token: self .secret_token - .clone() + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() .or_else(|| std::env::var("TELEGRAM_SECRET_TOKEN").ok()), trusted_source_only: self .trusted_source_only @@ -645,7 +653,9 @@ impl TelegramConfig { }), webhook_path: self .webhook_path - .clone() + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() .or_else(|| std::env::var("TELEGRAM_WEBHOOK_PATH").ok()) .unwrap_or_else(|| "/webhook/telegram".into()), } @@ -1606,6 +1616,30 @@ webhook_path = "/hook/tg" assert_eq!(tg.webhook_path.as_deref(), Some("/hook/tg")); } + #[test] + fn telegram_empty_string_falls_through_to_env() { + // When `${}` expansion produces an empty string (env var unset at parse + // time), resolve() must treat it as absent and fall through to the + // TELEGRAM_* env var fallback — not hold `Some("")`. + std::env::set_var("TELEGRAM_BOT_TOKEN", "real-token"); + std::env::set_var("TELEGRAM_SECRET_TOKEN", "real-secret"); + + let cfg = TelegramConfig { + bot_token: Some("".into()), // simulates ${UNSET_VAR} → "" + secret_token: Some("".into()), + webhook_path: Some("".into()), + ..Default::default() + }; + let r = cfg.resolve(); + // Empty strings filtered out → env fallback fires + assert_eq!(r.bot_token.as_deref(), Some("real-token")); + assert_eq!(r.secret_token.as_deref(), Some("real-secret")); + assert_eq!(r.webhook_path, "/webhook/telegram"); // env not set → default + + std::env::remove_var("TELEGRAM_BOT_TOKEN"); + std::env::remove_var("TELEGRAM_SECRET_TOKEN"); + } + #[test] fn hooks_any_configured_false_when_empty() { let h = HooksConfig::default(); diff --git a/crates/openab-gateway/src/lib.rs b/crates/openab-gateway/src/lib.rs index eb7ca487b..50750e744 100644 --- a/crates/openab-gateway/src/lib.rs +++ b/crates/openab-gateway/src/lib.rs @@ -193,25 +193,28 @@ impl AppState { } /// Apply resolved `[telegram]` config values, overriding the env-derived - /// fields. Plain parameters keep this crate free of an `openab-core` - /// dependency (the binary crate resolves config → these values). - #[allow(clippy::too_many_arguments)] - pub fn apply_telegram_config( - &mut self, - bot_token: Option, - secret_token: Option, - rich_messages: bool, - trusted_source_only: bool, - streaming: Option, - ) { - self.telegram_bot_token = bot_token; - self.telegram_secret_token = secret_token; - self.telegram_rich_messages = rich_messages; - self.telegram_trusted_source_only = trusted_source_only; - self.telegram_streaming = streaming; + /// fields. Accepts a `GatewayTelegramConfig` to keep this crate free of an + /// `openab-core` dependency (the binary crate resolves config → this struct). + pub fn apply_telegram_config(&mut self, cfg: GatewayTelegramConfig) { + self.telegram_bot_token = cfg.bot_token; + self.telegram_secret_token = cfg.secret_token; + self.telegram_rich_messages = cfg.rich_messages; + self.telegram_trusted_source_only = cfg.trusted_source_only; + self.telegram_streaming = cfg.streaming; } } +/// Parameter object for passing resolved Telegram config across the crate +/// boundary without introducing a dependency on `openab-core`. +#[derive(Debug, Clone)] +pub struct GatewayTelegramConfig { + pub bot_token: Option, + pub secret_token: Option, + pub rich_messages: bool, + pub trusted_source_only: bool, + pub streaming: Option, +} + // --- Public serve() entry point --- /// Configuration for the standalone gateway server. diff --git a/src/main.rs b/src/main.rs index 48cdbe323..15a01444e 100644 --- a/src/main.rs +++ b/src/main.rs @@ -525,13 +525,13 @@ async fn main() -> anyhow::Result<()> { let telegram_webhook_path = if let Some(ref tg) = cfg.telegram { let r = tg.resolve(); let path = r.webhook_path.clone(); - gw_state_inner.apply_telegram_config( - r.bot_token, - r.secret_token, - r.rich_messages, - r.trusted_source_only, - r.streaming, - ); + gw_state_inner.apply_telegram_config(openab_gateway::GatewayTelegramConfig { + bot_token: r.bot_token, + secret_token: r.secret_token, + rich_messages: r.rich_messages, + trusted_source_only: r.trusted_source_only, + streaming: r.streaming, + }); Some(path) } else { None From 95610f3a2e8ba4e7eeac15909b5848782c1e1d52 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:36:50 +0000 Subject: [PATCH 04/10] fix(review): [telegram] config-only activation + consolidate env-mutating tests - Startup validation now recognizes [telegram] section as a valid adapter config (no longer requires TELEGRAM_BOT_TOKEN env var). - Unified server spawn condition also triggers on cfg.telegram.is_some(), so config-only deployments work as promised by the PR. - Merge empty-string test into the single serialized telegram_env_fallback_and_defaults test to eliminate env var race conditions under parallel test execution. --- crates/openab-core/src/config.rs | 47 +++++++++++++++----------------- src/main.rs | 5 ++-- 2 files changed, 25 insertions(+), 27 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index e8db9d92d..9810fcd0e 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1510,7 +1510,10 @@ mod tests { #[test] fn telegram_config_value_wins_over_env() { // Config values are authoritative regardless of env (the `.or_else` - // env fallback only fires when the field is None). + // env fallback only fires when the field is None/empty). + // NOTE: This test only sets a single env var that the config value + // overrides — it does not race with the env-fallback test because the + // assertion does not depend on the env value being read. std::env::set_var("TELEGRAM_BOT_TOKEN", "env-token"); let cfg = TelegramConfig { bot_token: Some("cfg-token".into()), @@ -1580,6 +1583,24 @@ mod tests { std::env::set_var("TELEGRAM_STREAMING", "false"); assert_eq!(TelegramConfig::default().resolve().streaming, Some(false)); + // Empty-string expansion edge case: when `${}` expands to "" (env var + // unset at parse time), resolve() must treat it as absent and fall + // through to the TELEGRAM_* env var fallback — not hold `Some("")`. + std::env::set_var("TELEGRAM_BOT_TOKEN", "real-token"); + std::env::set_var("TELEGRAM_SECRET_TOKEN", "real-secret"); + std::env::remove_var("TELEGRAM_WEBHOOK_PATH"); + + let cfg = TelegramConfig { + bot_token: Some("".into()), // simulates ${UNSET_VAR} → "" + secret_token: Some("".into()), + webhook_path: Some("".into()), + ..Default::default() + }; + let r = cfg.resolve(); + assert_eq!(r.bot_token.as_deref(), Some("real-token")); + assert_eq!(r.secret_token.as_deref(), Some("real-secret")); + assert_eq!(r.webhook_path, "/webhook/telegram"); // env not set → default + for k in [ "TELEGRAM_BOT_TOKEN", "TELEGRAM_SECRET_TOKEN", @@ -1616,30 +1637,6 @@ webhook_path = "/hook/tg" assert_eq!(tg.webhook_path.as_deref(), Some("/hook/tg")); } - #[test] - fn telegram_empty_string_falls_through_to_env() { - // When `${}` expansion produces an empty string (env var unset at parse - // time), resolve() must treat it as absent and fall through to the - // TELEGRAM_* env var fallback — not hold `Some("")`. - std::env::set_var("TELEGRAM_BOT_TOKEN", "real-token"); - std::env::set_var("TELEGRAM_SECRET_TOKEN", "real-secret"); - - let cfg = TelegramConfig { - bot_token: Some("".into()), // simulates ${UNSET_VAR} → "" - secret_token: Some("".into()), - webhook_path: Some("".into()), - ..Default::default() - }; - let r = cfg.resolve(); - // Empty strings filtered out → env fallback fires - assert_eq!(r.bot_token.as_deref(), Some("real-token")); - assert_eq!(r.secret_token.as_deref(), Some("real-secret")); - assert_eq!(r.webhook_path, "/webhook/telegram"); // env not set → default - - std::env::remove_var("TELEGRAM_BOT_TOKEN"); - std::env::remove_var("TELEGRAM_SECRET_TOKEN"); - } - #[test] fn hooks_any_configured_false_when_empty() { let h = HooksConfig::default(); diff --git a/src/main.rs b/src/main.rs index 15a01444e..33bd88a27 100644 --- a/src/main.rs +++ b/src/main.rs @@ -200,10 +200,11 @@ async fn main() -> anyhow::Result<()> { ); if cfg.discord.is_none() && cfg.slack.is_none() && cfg.gateway.is_none() + && cfg.telegram.is_none() && !has_unified_platform_env() { anyhow::bail!( - "no adapter configured — add [discord], [slack], or [gateway] to config, or set platform env vars (TELEGRAM_BOT_TOKEN, etc.)" + "no adapter configured — add [discord], [slack], [telegram], or [gateway] to config, or set platform env vars (TELEGRAM_BOT_TOKEN, etc.)" ); } @@ -497,7 +498,7 @@ async fn main() -> anyhow::Result<()> { let _unified_handle = { use openab_core::gateway::{GatewayEventContext, process_gateway_event}; - if has_unified_platform_env() { + if has_unified_platform_env() || cfg.telegram.is_some() { let listen_addr = std::env::var("GATEWAY_LISTEN") .unwrap_or_else(|_| "0.0.0.0:8080".into()); From 25af57b234418fc14289b5489bb70e9265e6f3c5 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:41:41 +0000 Subject: [PATCH 05/10] docs(telegram): clarify config-only activation + add tip for env-free deployment --- docs/telegram.md | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/docs/telegram.md b/docs/telegram.md index 2a423b4db..57ee63a5c 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -64,7 +64,7 @@ tables = "off" Streaming is enabled by default when Rich Messages are active — replies are streamed live via `sendRichMessageDraft` with rich formatting, then finalized with `sendRichMessage`. If `TELEGRAM_RICH_MESSAGES=false`, streaming is also disabled by default. To override, set `TELEGRAM_STREAMING=true` or `TELEGRAM_STREAMING=false` explicitly. -No `[gateway]` section needed — the unified adapter activates automatically when `TELEGRAM_BOT_TOKEN` is set. +No `[gateway]` section needed — the unified adapter activates automatically when `TELEGRAM_BOT_TOKEN` is set, or when the `[telegram]` section is configured in `config.toml`. ### First-class `[telegram]` config (optional) @@ -91,6 +91,8 @@ webhook_path = "/webhook/telegram" | `streaming` | `TELEGRAM_STREAMING` | follows `rich_messages` | | `webhook_path` | `TELEGRAM_WEBHOOK_PATH` | `/webhook/telegram` | +> **Tip**: You can run a pure config-only deployment — no `TELEGRAM_*` env vars needed. Just set `bot_token = "your-token"` directly in `[telegram]` and the adapter will activate from config alone. + ### Set the Webhook From 688933953133fa88273c0557a5a5115b69d2b6dd Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:42:37 +0000 Subject: [PATCH 06/10] fix(review): consolidate all env-mutating telegram tests into single test Merge telegram_config_value_wins_over_env into telegram_resolve_all_scenarios (renamed from telegram_env_fallback_and_defaults) to eliminate any possible env var race condition under parallel test execution. All TELEGRAM_* env mutations are now in one #[test] function. --- crates/openab-core/src/config.rs | 44 +++++++++++++++++++------------- 1 file changed, 26 insertions(+), 18 deletions(-) diff --git a/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index 9810fcd0e..1de665741 100644 --- a/crates/openab-core/src/config.rs +++ b/crates/openab-core/src/config.rs @@ -1508,12 +1508,24 @@ mod tests { use std::io::Write; #[test] - fn telegram_config_value_wins_over_env() { - // Config values are authoritative regardless of env (the `.or_else` - // env fallback only fires when the field is None/empty). - // NOTE: This test only sets a single env var that the config value - // overrides — it does not race with the env-fallback test because the - // assertion does not depend on the env value being read. + fn telegram_resolve_all_scenarios() { + // Single serialized test for all TelegramConfig::resolve() scenarios + // that touch TELEGRAM_* env vars. Consolidated to avoid race conditions + // under Rust's default parallel test execution (std::env is process-global). + + // --- Clear all TELEGRAM_* env vars --- + for k in [ + "TELEGRAM_BOT_TOKEN", + "TELEGRAM_SECRET_TOKEN", + "TELEGRAM_TRUSTED_SOURCE_ONLY", + "TELEGRAM_RICH_MESSAGES", + "TELEGRAM_STREAMING", + "TELEGRAM_WEBHOOK_PATH", + ] { + std::env::remove_var(k); + } + + // --- Scenario 1: Config values win over env --- std::env::set_var("TELEGRAM_BOT_TOKEN", "env-token"); let cfg = TelegramConfig { bot_token: Some("cfg-token".into()), @@ -1531,12 +1543,8 @@ mod tests { assert_eq!(r.streaming, Some(true)); assert_eq!(r.webhook_path, "/custom/tg"); std::env::remove_var("TELEGRAM_BOT_TOKEN"); - } - #[test] - fn telegram_env_fallback_and_defaults() { - // Single serialized test mutating the TELEGRAM_* env vars (no other - // openab-core test touches them, so this is race-free within the crate). + // --- Scenario 2: All unset → built-in defaults --- for k in [ "TELEGRAM_BOT_TOKEN", "TELEGRAM_SECRET_TOKEN", @@ -1548,7 +1556,6 @@ mod tests { std::env::remove_var(k); } - // All unset → built-in defaults. let r = TelegramConfig::default().resolve(); assert_eq!(r.bot_token, None); assert_eq!(r.secret_token, None); @@ -1557,7 +1564,7 @@ mod tests { assert_eq!(r.streaming, None); assert_eq!(r.webhook_path, "/webhook/telegram"); - // Env set, config unset → env values used (legacy semantics preserved). + // --- Scenario 3: Env set, config unset → env values used (legacy semantics) --- std::env::set_var("TELEGRAM_BOT_TOKEN", "env-token"); std::env::set_var("TELEGRAM_SECRET_TOKEN", "env-secret"); std::env::set_var("TELEGRAM_TRUSTED_SOURCE_ONLY", "true"); @@ -1573,19 +1580,19 @@ mod tests { assert_eq!(r.streaming, Some(true)); // "1" → true assert_eq!(r.webhook_path, "/env/tg"); - // RICH_MESSAGES legacy semantics: "0" → false, anything else → true + // --- Scenario 4: RICH_MESSAGES legacy semantics --- std::env::set_var("TELEGRAM_RICH_MESSAGES", "0"); assert!(!TelegramConfig::default().resolve().rich_messages); std::env::set_var("TELEGRAM_RICH_MESSAGES", "yes"); assert!(TelegramConfig::default().resolve().rich_messages); - // STREAMING "false" → Some(false) + // --- Scenario 5: STREAMING "false" → Some(false) --- std::env::set_var("TELEGRAM_STREAMING", "false"); assert_eq!(TelegramConfig::default().resolve().streaming, Some(false)); - // Empty-string expansion edge case: when `${}` expands to "" (env var - // unset at parse time), resolve() must treat it as absent and fall - // through to the TELEGRAM_* env var fallback — not hold `Some("")`. + // --- Scenario 6: Empty-string expansion edge case --- + // When `${}` expands to "" (env var unset at parse time), resolve() + // must treat it as absent and fall through to env fallback. std::env::set_var("TELEGRAM_BOT_TOKEN", "real-token"); std::env::set_var("TELEGRAM_SECRET_TOKEN", "real-secret"); std::env::remove_var("TELEGRAM_WEBHOOK_PATH"); @@ -1601,6 +1608,7 @@ mod tests { assert_eq!(r.secret_token.as_deref(), Some("real-secret")); assert_eq!(r.webhook_path, "/webhook/telegram"); // env not set → default + // --- Cleanup --- for k in [ "TELEGRAM_BOT_TOKEN", "TELEGRAM_SECRET_TOKEN", From 4a9867a1bc7f160b56f1dc5206a0881b51ba6617 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:47:58 +0000 Subject: [PATCH 07/10] docs(telegram): recommend aws-sm:// SecretRef for production security hardening --- docs/telegram.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/docs/telegram.md b/docs/telegram.md index 57ee63a5c..41b320ac3 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -93,6 +93,20 @@ webhook_path = "/webhook/telegram" > **Tip**: You can run a pure config-only deployment — no `TELEGRAM_*` env vars needed. Just set `bot_token = "your-token"` directly in `[telegram]` and the adapter will activate from config alone. +> **Security hardening**: For production deployments, we highly recommend using `aws-sm://` secret references instead of hardcoding tokens in `config.toml`. This keeps secrets out of version control and enables rotation and audit: +> +> ```toml +> [secrets.refs] +> tg_token = "aws-sm://openab/prod#telegram_bot_token" +> tg_secret = "aws-sm://openab/prod#telegram_secret_token" +> +> [telegram] +> bot_token = "${secrets.tg_token}" +> secret_token = "${secrets.tg_secret}" +> ``` +> +> See [secrets-management.md](secrets-management.md) for full documentation. + ### Set the Webhook From c9babc302702bc9be540160b07079b3f61cd79bd Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:49:37 +0000 Subject: [PATCH 08/10] docs(telegram): add minimal required config example (bot_token only) --- docs/telegram.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/docs/telegram.md b/docs/telegram.md index 41b320ac3..b494a1638 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -70,6 +70,15 @@ No `[gateway]` section needed — the unified adapter activates automatically wh Instead of (or in addition to) the `TELEGRAM_*` env vars, you can configure Telegram as a first-class section in `config.toml` — symmetric with `[discord]` / `[slack]`: +**Minimal required** — only `bot_token` is needed to activate the adapter: + +```toml +[telegram] +bot_token = "123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" +``` + +**Full example** with all available fields: + ```toml [telegram] bot_token = "${TELEGRAM_BOT_TOKEN}" # ${} env expansion supported From 72bd309f0eef279f9f432a692b96f6db8f5907c3 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:50:57 +0000 Subject: [PATCH 09/10] docs(telegram): use env var reference in minimal config example --- docs/telegram.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/telegram.md b/docs/telegram.md index b494a1638..c43db2fba 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -74,7 +74,7 @@ Instead of (or in addition to) the `TELEGRAM_*` env vars, you can configure Tele ```toml [telegram] -bot_token = "123456789:ABC-DEF1234ghIkl-zyx57W2v1u123ew11" +bot_token = "${TELEGRAM_BOT_TOKEN}" ``` **Full example** with all available fields: From d258ab4f698e194a0580f52e9abfaed67349ff52 Mon Sep 17 00:00:00 2001 From: chaodu-agent Date: Tue, 30 Jun 2026 21:52:08 +0000 Subject: [PATCH 10/10] docs(telegram): add inline hint about aws-sm:// secret ref alternative --- docs/telegram.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/telegram.md b/docs/telegram.md index c43db2fba..6e2d59b5c 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -74,7 +74,7 @@ Instead of (or in addition to) the `TELEGRAM_*` env vars, you can configure Tele ```toml [telegram] -bot_token = "${TELEGRAM_BOT_TOKEN}" +bot_token = "${TELEGRAM_BOT_TOKEN}" # or use aws-sm:// secret ref (see below) ``` **Full example** with all available fields: