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/crates/openab-core/src/config.rs b/crates/openab-core/src/config.rs index ab218bbf2..1de665741 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,106 @@ 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. + /// + /// 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 + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .or_else(|| std::env::var("TELEGRAM_BOT_TOKEN").ok()), + secret_token: self + .secret_token + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .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 + .as_ref() + .filter(|s| !s.is_empty()) + .cloned() + .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 +1507,144 @@ mod tests { use super::*; use std::io::Write; + #[test] + 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()), + 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"); + + // --- Scenario 2: All unset → built-in defaults --- + 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); + } + + 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"); + + // --- 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"); + 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"); + + // --- 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); + + // --- Scenario 5: STREAMING "false" → Some(false) --- + std::env::set_var("TELEGRAM_STREAMING", "false"); + assert_eq!(TelegramConfig::default().resolve().streaming, Some(false)); + + // --- 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"); + + 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 + + // --- Cleanup --- + 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..50750e744 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,28 @@ impl AppState { client, } } + + /// Apply resolved `[telegram]` config values, overriding the env-derived + /// 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 --- @@ -402,6 +431,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/docs/telegram.md b/docs/telegram.md index 420b5cfa7..6e2d59b5c 100644 --- a/docs/telegram.md +++ b/docs/telegram.md @@ -64,7 +64,58 @@ 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) + +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 = "${TELEGRAM_BOT_TOKEN}" # or use aws-sm:// secret ref (see below) +``` + +**Full example** with all available fields: + +```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` | + +> **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 diff --git a/src/main.rs b/src/main.rs index ecc5ce2c0..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()); @@ -517,7 +518,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(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 + }; + let gw_state = Arc::new(gw_state_inner); // Build axum router with platform webhook routes let mut app = axum::Router::new() @@ -525,8 +545,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 }