From 9edc666ee39d4cafd2927f305f09bef45bb6f09b Mon Sep 17 00:00:00 2001 From: Illia Polosukhin Date: Thu, 5 Feb 2026 00:05:32 -0800 Subject: [PATCH] Simplify Telegram channel config with host-injected tunnel/webhook settings The WASM channel no longer needs its own polling_enabled/tunnel_url settings. Instead, the host injects tunnel_url and webhook_secret into the channel config at runtime before start() is called. Changes: - Add update_config() method to WasmChannel for runtime config injection - Simplify TelegramConfig to only have bot_username, respond_to_all_group_messages - Host injects tunnel_url (from Settings) and webhook_secret (from secrets store) - Channel checks if tunnel_url is present to determine webhook vs polling mode - Add delete_webhook() for clean transition to polling mode when no tunnel Co-Authored-By: Claude Opus 4.5 --- channels-src/telegram/src/lib.rs | 112 ++++++++++++++++++++++--------- src/channels/wasm/wrapper.rs | 33 ++++++++- src/main.rs | 32 ++++++++- 3 files changed, 141 insertions(+), 36 deletions(-) diff --git a/channels-src/telegram/src/lib.rs b/channels-src/telegram/src/lib.rs index 8a828d1b..8b5c7517 100644 --- a/channels-src/telegram/src/lib.rs +++ b/channels-src/telegram/src/lib.rs @@ -177,7 +177,11 @@ struct TelegramMessageMetadata { is_private: bool, } -/// Channel configuration from capabilities file. +/// Channel configuration injected by host. +/// +/// The host injects runtime values like tunnel_url and webhook_secret. +/// The channel doesn't need to know about polling vs webhook mode - it just +/// checks if tunnel_url is set to determine behavior. #[derive(Debug, Deserialize)] struct TelegramConfig { /// Bot username (without @) for mention detection in groups. @@ -188,30 +192,17 @@ struct TelegramConfig { #[serde(default)] respond_to_all_group_messages: bool, - /// Whether to use polling instead of webhooks. - /// Automatically disabled if tunnel_url is set. - #[serde(default)] - polling_enabled: bool, - - /// Polling interval in milliseconds (if polling enabled). - #[serde(default = "default_poll_interval")] - poll_interval_ms: u32, - - /// Public tunnel URL for webhook mode (e.g., "https://abc123.ngrok.io"). + /// Public tunnel URL for webhook mode (injected by host from global settings). /// When set, webhook mode is enabled and polling is disabled. #[serde(default)] tunnel_url: Option, - /// Secret token for webhook validation. + /// Secret token for webhook validation (injected by host from secrets store). /// Telegram will include this in the X-Telegram-Bot-Api-Secret-Token header. #[serde(default)] webhook_secret: Option, } -fn default_poll_interval() -> u32 { - 30000 // 30 seconds (minimum allowed) -} - // ============================================================================ // Channel Implementation // ============================================================================ @@ -220,6 +211,11 @@ struct TelegramChannel; impl Guest for TelegramChannel { fn on_start(config_json: String) -> Result { + channel_host::log( + channel_host::LogLevel::Debug, + &format!("Telegram channel config: {}", config_json), + ); + let config: TelegramConfig = serde_json::from_str(&config_json) .map_err(|e| format!("Failed to parse config: {}", e))?; @@ -232,21 +228,21 @@ impl Guest for TelegramChannel { ); } - // Determine mode: webhook or polling - // Webhook mode is enabled if tunnel_url is set, which disables polling + // Mode is determined by whether the host injected a tunnel_url + // If tunnel is configured, use webhooks. Otherwise, use polling. let webhook_mode = config.tunnel_url.is_some(); if webhook_mode { channel_host::log( channel_host::LogLevel::Info, - "Webhook mode enabled (polling disabled)", + "Webhook mode enabled (tunnel configured)", ); // Register webhook with Telegram API if let Some(ref tunnel_url) = config.tunnel_url { channel_host::log( channel_host::LogLevel::Info, - &format!("Registering webhook with Telegram API: {}", tunnel_url), + &format!("Registering webhook: {}/webhook/telegram", tunnel_url), ); if let Err(e) = register_webhook(tunnel_url, config.webhook_secret.as_deref()) { @@ -254,28 +250,35 @@ impl Guest for TelegramChannel { channel_host::LogLevel::Error, &format!("Failed to register webhook: {}", e), ); - // Continue anyway, the host will start the server - // and Telegram will eventually receive updates } } + } else { + channel_host::log( + channel_host::LogLevel::Info, + "Polling mode enabled (no tunnel configured)", + ); + + // Delete any existing webhook before polling + // Telegram doesn't allow getUpdates while a webhook is active + if let Err(e) = delete_webhook() { + channel_host::log( + channel_host::LogLevel::Warn, + &format!("Failed to delete webhook (may not exist): {}", e), + ); + } } - // Configure polling only if not in webhook mode and polling is enabled - let poll = if !webhook_mode && config.polling_enabled { - channel_host::log( - channel_host::LogLevel::Info, - &format!("Polling enabled (interval: {}ms)", config.poll_interval_ms.max(30000)), - ); + // Configure polling only if not in webhook mode + let poll = if !webhook_mode { Some(PollConfig { - interval_ms: config.poll_interval_ms.max(30000), // Enforce minimum + interval_ms: 30000, // 30 seconds minimum enabled: true, }) } else { None }; - // Webhook secret validation is handled by the host (X-Telegram-Bot-Api-Secret-Token header) - // The require_secret flag tells the host to validate the secret_validated field + // Webhook secret validation is handled by the host let require_secret = config.webhook_secret.is_some(); Ok(ChannelConfig { @@ -507,9 +510,54 @@ impl Guest for TelegramChannel { } // ============================================================================ -// Webhook Registration +// Webhook Management // ============================================================================ +/// Delete any existing webhook with Telegram API. +/// +/// Called during on_start() when switching to polling mode. +/// Telegram doesn't allow getUpdates while a webhook is active. +fn delete_webhook() -> Result<(), String> { + let headers = serde_json::json!({ + "Content-Type": "application/json" + }); + + let result = channel_host::http_request( + "POST", + "https://api.telegram.org/bot{TELEGRAM_BOT_TOKEN}/deleteWebhook", + &headers.to_string(), + None, + ); + + match result { + Ok(response) => { + if response.status != 200 { + let body_str = String::from_utf8_lossy(&response.body); + return Err(format!("HTTP {}: {}", response.status, body_str)); + } + + let api_response: TelegramApiResponse = + serde_json::from_slice(&response.body) + .map_err(|e| format!("Failed to parse response: {}", e))?; + + if !api_response.ok { + return Err(format!( + "Telegram API error: {}", + api_response.description.unwrap_or_else(|| "unknown".to_string()) + )); + } + + channel_host::log( + channel_host::LogLevel::Info, + "Webhook deleted successfully (switching to polling mode)", + ); + + Ok(()) + } + Err(e) => Err(format!("HTTP request failed: {}", e)), + } +} + /// Register webhook URL with Telegram API. /// /// Called during on_start() when tunnel_url is configured. diff --git a/src/channels/wasm/wrapper.rs b/src/channels/wasm/wrapper.rs index e811e072..e3acf888 100644 --- a/src/channels/wasm/wrapper.rs +++ b/src/channels/wasm/wrapper.rs @@ -373,7 +373,8 @@ pub struct WasmChannel { capabilities: ChannelCapabilities, /// Channel configuration JSON (passed to on_start). - config_json: String, + /// Wrapped in RwLock to allow updating before start. + config_json: RwLock, /// Channel configuration returned by on_start. channel_config: RwLock>, @@ -420,7 +421,7 @@ impl WasmChannel { runtime, prepared, capabilities, - config_json, + config_json: RwLock::new(config_json), channel_config: RwLock::new(None), message_tx: Arc::new(RwLock::new(None)), pending_responses: RwLock::new(HashMap::new()), @@ -432,6 +433,32 @@ impl WasmChannel { } } + /// Update the channel config before starting. + /// + /// Merges the provided values into the existing config JSON. + /// Call this before `start()` to inject runtime values like tunnel_url. + pub async fn update_config(&self, updates: HashMap) { + let mut config_guard = self.config_json.write().await; + + // Parse existing config + let mut config: HashMap = + serde_json::from_str(&config_guard).unwrap_or_default(); + + // Merge updates + for (key, value) in updates { + config.insert(key, value); + } + + // Serialize back + *config_guard = serde_json::to_string(&config).unwrap_or_else(|_| "{}".to_string()); + + tracing::debug!( + channel = %self.name, + config = %*config_guard, + "Updated channel config" + ); + } + /// Set a credential for URL injection. pub async fn set_credential(&self, name: &str, value: String) { self.credentials @@ -590,7 +617,7 @@ impl WasmChannel { let runtime = Arc::clone(&self.runtime); let prepared = Arc::clone(&self.prepared); let capabilities = self.capabilities.clone(); - let config_json = self.config_json.clone(); + let config_json = self.config_json.read().await.clone(); let timeout = self.runtime.config().callback_timeout; let channel_name = self.name.clone(); let credentials = self.get_credentials().await; diff --git a/src/main.rs b/src/main.rs index 720a40f6..78944fe2 100644 --- a/src/main.rs +++ b/src/main.rs @@ -399,6 +399,36 @@ async fn main() -> anyhow::Result<()> { let channel_arc = Arc::new(loaded.channel); + // Inject runtime config into the channel (tunnel_url, webhook_secret) + // This must be done before start() is called + { + let mut config_updates = std::collections::HashMap::new(); + + if let Some(ref tunnel_url) = config.tunnel.public_url { + config_updates.insert( + "tunnel_url".to_string(), + serde_json::Value::String(tunnel_url.clone()), + ); + } + + if let Some(ref secret) = webhook_secret { + config_updates.insert( + "webhook_secret".to_string(), + serde_json::Value::String(secret.clone()), + ); + } + + if !config_updates.is_empty() { + channel_arc.update_config(config_updates).await; + tracing::info!( + channel = %channel_name, + has_tunnel = config.tunnel.public_url.is_some(), + has_webhook_secret = webhook_secret.is_some(), + "Injected runtime config into channel" + ); + } + } + tracing::info!( channel = %channel_name, has_webhook_secret = webhook_secret.is_some(), @@ -410,7 +440,7 @@ async fn main() -> anyhow::Result<()> { .register( Arc::clone(&channel_arc), endpoints, - webhook_secret, + webhook_secret.clone(), secret_header, ) .await;