diff --git a/FEATURE_PARITY.md b/FEATURE_PARITY.md index db4ab92a4c..0cda8caaac 100644 --- a/FEATURE_PARITY.md +++ b/FEATURE_PARITY.md @@ -68,7 +68,7 @@ This document tracks feature parity between IronClaw (Rust implementation) and O | REPL (simple) | ✅ | ✅ | - | For testing | | WASM channels | ❌ | ✅ | - | IronClaw innovation | | WhatsApp | ✅ | ❌ | P1 | Baileys (Web), same-phone mode with echo detection | -| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics | +| Telegram | ✅ | ✅ | - | WASM channel(MTProto), DM pairing, caption, /start, bot_username, DM topics, setup-time owner verification | | Discord | ✅ | ❌ | P2 | discord.js, thread parent binding inheritance | | Signal | ✅ | ✅ | P2 | signal-cli daemonPC, SSE listener HTTP/JSON-R, user/group allowlists, DM pairing | | Slack | ✅ | ✅ | - | WASM tool | diff --git a/src/channels/wasm/mod.rs b/src/channels/wasm/mod.rs index 0d4a6c3f66..dba843417d 100644 --- a/src/channels/wasm/mod.rs +++ b/src/channels/wasm/mod.rs @@ -90,6 +90,7 @@ pub mod setup; pub(crate) mod signature; #[allow(dead_code)] pub(crate) mod storage; +mod telegram_host_config; mod wrapper; // Core types @@ -107,4 +108,5 @@ pub use schema::{ ChannelCapabilitiesFile, ChannelConfig, SecretSetupSchema, SetupSchema, WebhookSchema, }; pub use setup::{WasmChannelSetup, inject_channel_credentials, setup_wasm_channels}; +pub(crate) use telegram_host_config::{TELEGRAM_CHANNEL_NAME, bot_username_setting_key}; pub use wrapper::{HttpResponse, SharedWasmChannel, WasmChannel}; diff --git a/src/channels/wasm/setup.rs b/src/channels/wasm/setup.rs index b9deb5261e..9c0c3f33a4 100644 --- a/src/channels/wasm/setup.rs +++ b/src/channels/wasm/setup.rs @@ -7,8 +7,9 @@ use std::collections::HashSet; use std::sync::Arc; use crate::channels::wasm::{ - LoadedChannel, RegisteredEndpoint, SharedWasmChannel, WasmChannel, WasmChannelLoader, - WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, create_wasm_channel_router, + LoadedChannel, RegisteredEndpoint, SharedWasmChannel, TELEGRAM_CHANNEL_NAME, WasmChannel, + WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, WasmChannelRuntimeConfig, + bot_username_setting_key, create_wasm_channel_router, }; use crate::config::Config; use crate::db::Database; @@ -48,7 +49,7 @@ pub async fn setup_wasm_channels( let mut loader = WasmChannelLoader::new( Arc::clone(&runtime), Arc::clone(&pairing_store), - settings_store, + settings_store.clone(), ); if let Some(secrets) = secrets_store { loader = loader.with_secrets_store(Arc::clone(secrets)); @@ -70,7 +71,14 @@ pub async fn setup_wasm_channels( let mut channel_names: Vec = Vec::new(); for loaded in results.loaded { - let (name, channel) = register_channel(loaded, config, secrets_store, &wasm_router).await; + let (name, channel) = register_channel( + loaded, + config, + secrets_store, + settings_store.as_ref(), + &wasm_router, + ) + .await; channel_names.push(name.clone()); channels.push((name, channel)); } @@ -104,6 +112,7 @@ async fn register_channel( loaded: LoadedChannel, config: &Config, secrets_store: &Option>, + settings_store: Option<&Arc>, wasm_router: &Arc, ) -> (String, Box) { let channel_name = loaded.name().to_string(); @@ -161,6 +170,15 @@ async fn register_channel( config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); } + if channel_name == TELEGRAM_CHANNEL_NAME + && let Some(store) = settings_store + && let Ok(Some(serde_json::Value::String(username))) = store + .get_setting("default", &bot_username_setting_key(&channel_name)) + .await + && !username.trim().is_empty() + { + config_updates.insert("bot_username".to_string(), serde_json::json!(username)); + } // Inject channel-specific secrets into config for channels that need // credentials in API request bodies (e.g., Feishu token exchange). // The credential injection system only replaces placeholders in URLs diff --git a/src/channels/wasm/telegram_host_config.rs b/src/channels/wasm/telegram_host_config.rs new file mode 100644 index 0000000000..79c27c0bfc --- /dev/null +++ b/src/channels/wasm/telegram_host_config.rs @@ -0,0 +1,6 @@ +pub const TELEGRAM_CHANNEL_NAME: &str = "telegram"; +const TELEGRAM_BOT_USERNAME_SETTING_PREFIX: &str = "channels.wasm_channel_bot_usernames"; + +pub fn bot_username_setting_key(channel_name: &str) -> String { + format!("{TELEGRAM_BOT_USERNAME_SETTING_PREFIX}.{channel_name}") +} diff --git a/src/channels/web/handlers/chat.rs b/src/channels/web/handlers/chat.rs index 909a252cf4..5cb2b9ea1b 100644 --- a/src/channels/web/handlers/chat.rs +++ b/src/channels/web/handlers/chat.rs @@ -162,15 +162,30 @@ pub async fn chat_auth_token_handler( .await { Ok(result) => { - clear_auth_mode(&state).await; + let mut resp = ActionResponse::ok(result.message.clone()); + resp.activated = Some(result.activated); + resp.auth_url = result.auth_url.clone(); + resp.verification = result.verification.clone(); + resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone()); - state.sse.broadcast(SseEvent::AuthCompleted { - extension_name: req.extension_name.clone(), - success: true, - message: result.message.clone(), - }); + if result.verification.is_some() { + state.sse.broadcast(SseEvent::AuthRequired { + extension_name: req.extension_name.clone(), + instructions: Some(result.message), + auth_url: None, + setup_url: None, + }); + } else { + clear_auth_mode(&state).await; + + state.sse.broadcast(SseEvent::AuthCompleted { + extension_name: req.extension_name.clone(), + success: true, + message: result.message, + }); + } - Ok(Json(ActionResponse::ok(result.message))) + Ok(Json(resp)) } Err(e) => { let msg = e.to_string(); diff --git a/src/channels/web/handlers/extensions.rs b/src/channels/web/handlers/extensions.rs index 3c490eac1a..855fba3ed9 100644 --- a/src/channels/web/handlers/extensions.rs +++ b/src/channels/web/handlers/extensions.rs @@ -25,34 +25,34 @@ pub async fn extensions_list_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let pairing_store = crate::pairing::PairingStore::new(); + let mut owner_bound_channels = std::collections::HashSet::new(); + for ext in &installed { + if ext.kind == crate::extensions::ExtensionKind::WasmChannel + && ext_mgr.has_wasm_channel_owner_binding(&ext.name).await + { + owner_bound_channels.insert(ext.name.clone()); + } + } let extensions = installed .into_iter() .map(|ext| { let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { - Some(if ext.activation_error.is_some() { - "failed".to_string() - } else if !ext.authenticated { - "installed".to_string() - } else if ext.active { - let has_paired = pairing_store - .read_allow_from(&ext.name) - .map(|list| !list.is_empty()) - .unwrap_or(false); - if has_paired { - "active".to_string() - } else { - "pairing".to_string() - } - } else { - "configured".to_string() - }) + let has_paired = pairing_store + .read_allow_from(&ext.name) + .map(|list| !list.is_empty()) + .unwrap_or(false); + crate::channels::web::types::classify_wasm_channel_activation( + &ext, + has_paired, + owner_bound_channels.contains(&ext.name), + ) } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { Some(if ext.active { - "active".to_string() + crate::channels::web::types::ExtensionActivationStatus::Active } else if ext.authenticated { - "configured".to_string() + crate::channels::web::types::ExtensionActivationStatus::Configured } else { - "installed".to_string() + crate::channels::web::types::ExtensionActivationStatus::Installed }) } else { None diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index e8cb33c220..fb8c93ae23 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -1163,19 +1163,43 @@ async fn chat_auth_token_handler( .configure_token(&req.extension_name, &req.token) .await { - Ok(result) if result.activated => { - // Clear auth mode on the active thread - clear_auth_mode(&state).await; + Ok(result) => { + let mut resp = if result.verification.is_some() || result.activated { + ActionResponse::ok(result.message.clone()) + } else { + ActionResponse::fail(result.message.clone()) + }; + resp.activated = Some(result.activated); + resp.auth_url = result.auth_url.clone(); + resp.verification = result.verification.clone(); + resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone()); - state.sse.broadcast(SseEvent::AuthCompleted { - extension_name: req.extension_name.clone(), - success: true, - message: result.message.clone(), - }); + if result.verification.is_some() { + state.sse.broadcast(SseEvent::AuthRequired { + extension_name: req.extension_name.clone(), + instructions: Some(result.message), + auth_url: None, + setup_url: None, + }); + } else if result.activated { + // Clear auth mode on the active thread + clear_auth_mode(&state).await; + + state.sse.broadcast(SseEvent::AuthCompleted { + extension_name: req.extension_name.clone(), + success: true, + message: result.message, + }); + } else { + state.sse.broadcast(SseEvent::AuthCompleted { + extension_name: req.extension_name.clone(), + success: false, + message: result.message, + }); + } - Ok(Json(ActionResponse::ok(result.message))) + Ok(Json(resp)) } - Ok(result) => Ok(Json(ActionResponse::fail(result.message))), Err(e) => { let msg = e.to_string(); // Re-emit auth_required for retry on validation errors @@ -1818,29 +1842,34 @@ async fn extensions_list_handler( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; let pairing_store = crate::pairing::PairingStore::new(); + let mut owner_bound_channels = std::collections::HashSet::new(); + for ext in &installed { + if ext.kind == crate::extensions::ExtensionKind::WasmChannel + && ext_mgr.has_wasm_channel_owner_binding(&ext.name).await + { + owner_bound_channels.insert(ext.name.clone()); + } + } let extensions = installed .into_iter() .map(|ext| { let activation_status = if ext.kind == crate::extensions::ExtensionKind::WasmChannel { - Some(if ext.activation_error.is_some() { - "failed".to_string() - } else if !ext.authenticated { - // No credentials configured yet. - "installed".to_string() - } else if ext.active { - // Check pairing status for active channels. - let has_paired = pairing_store - .read_allow_from(&ext.name) - .map(|list| !list.is_empty()) - .unwrap_or(false); - if has_paired { - "active".to_string() - } else { - "pairing".to_string() - } + let has_paired = pairing_store + .read_allow_from(&ext.name) + .map(|list| !list.is_empty()) + .unwrap_or(false); + crate::channels::web::types::classify_wasm_channel_activation( + &ext, + has_paired, + owner_bound_channels.contains(&ext.name), + ) + } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { + Some(if ext.active { + ExtensionActivationStatus::Active + } else if ext.authenticated { + ExtensionActivationStatus::Configured } else { - // Authenticated but not yet active. - "configured".to_string() + ExtensionActivationStatus::Installed }) } else { None @@ -2205,20 +2234,31 @@ async fn extensions_setup_submit_handler( match ext_mgr.configure(&name, &req.secrets).await { Ok(result) => { - // Broadcast completion status so chat UI can dismiss success cases while - // leaving failed auth/configuration flows visible for correction. - state.sse.broadcast(SseEvent::AuthCompleted { - extension_name: name.clone(), - success: result.activated, - message: result.message.clone(), - }); - let mut resp = if result.activated { + let mut resp = if result.verification.is_some() || result.activated { ActionResponse::ok(result.message) } else { ActionResponse::fail(result.message) }; resp.activated = Some(result.activated); - resp.auth_url = result.auth_url; + resp.auth_url = result.auth_url.clone(); + resp.verification = result.verification.clone(); + resp.instructions = result.verification.as_ref().map(|v| v.instructions.clone()); + if result.verification.is_some() { + state.sse.broadcast(SseEvent::AuthRequired { + extension_name: name.clone(), + instructions: resp.instructions.clone(), + auth_url: None, + setup_url: None, + }); + } else { + // Broadcast auth_completed so the chat UI can dismiss any in-progress + // auth card or setup modal that was triggered by tool_auth/tool_activate. + state.sse.broadcast(SseEvent::AuthCompleted { + extension_name: name.clone(), + success: result.activated, + message: resp.message.clone(), + }); + } Ok(Json(resp)) } Err(e) => Ok(Json(ActionResponse::fail(e.to_string()))), @@ -2743,7 +2783,11 @@ struct GatewayStatusResponse { #[cfg(test)] mod tests { use super::*; + use crate::channels::web::types::{ + ExtensionActivationStatus, classify_wasm_channel_activation, + }; use crate::cli::oauth_defaults; + use crate::extensions::{ExtensionKind, InstalledExtension}; use crate::testing::credentials::TEST_GATEWAY_CRYPTO_KEY; #[test] @@ -2822,6 +2866,85 @@ mod tests { assert!(turns.is_empty()); } + #[test] + fn test_wasm_channel_activation_status_owner_bound_counts_as_active() -> Result<(), String> { + let ext = InstalledExtension { + name: "telegram".to_string(), + kind: ExtensionKind::WasmChannel, + display_name: Some("Telegram".to_string()), + description: None, + url: None, + authenticated: true, + active: true, + tools: Vec::new(), + needs_setup: true, + has_auth: false, + installed: true, + activation_error: None, + version: None, + }; + + let owner_bound = classify_wasm_channel_activation(&ext, false, true); + if owner_bound != Some(ExtensionActivationStatus::Active) { + return Err(format!( + "owner-bound channel should be active, got {:?}", + owner_bound + )); + } + + let unbound = classify_wasm_channel_activation(&ext, false, false); + if unbound != Some(ExtensionActivationStatus::Pairing) { + return Err(format!( + "unbound channel should be pairing, got {:?}", + unbound + )); + } + + Ok(()) + } + + #[test] + fn test_channel_relay_activation_status_is_preserved() -> Result<(), String> { + let relay = InstalledExtension { + name: "signal".to_string(), + kind: ExtensionKind::ChannelRelay, + display_name: Some("Signal".to_string()), + description: None, + url: None, + authenticated: true, + active: false, + tools: Vec::new(), + needs_setup: true, + has_auth: false, + installed: true, + activation_error: None, + version: None, + }; + + let status = if relay.kind == crate::extensions::ExtensionKind::WasmChannel { + classify_wasm_channel_activation(&relay, false, false) + } else if relay.kind == crate::extensions::ExtensionKind::ChannelRelay { + Some(if relay.active { + ExtensionActivationStatus::Active + } else if relay.authenticated { + ExtensionActivationStatus::Configured + } else { + ExtensionActivationStatus::Installed + }) + } else { + None + }; + + if status != Some(ExtensionActivationStatus::Configured) { + return Err(format!( + "channel relay should retain configured status, got {:?}", + status + )); + } + + Ok(()) + } + // --- OAuth callback handler tests --- /// Build a minimal `GatewayState` for testing the OAuth callback handler. diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index d32968a9a3..127c18fa0c 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -2723,6 +2723,13 @@ function renderConfigureModal(name, secrets) { header.textContent = I18n.t('config.title', { name: name }); modal.appendChild(header); + if (name === 'telegram') { + const hint = document.createElement('div'); + hint.className = 'configure-hint'; + hint.textContent = I18n.t('config.telegramOwnerHint'); + modal.appendChild(hint); + } + const form = document.createElement('div'); form.className = 'configure-form'; @@ -2796,6 +2803,46 @@ function renderConfigureModal(name, secrets) { if (fields.length > 0) fields[0].input.focus(); } +function renderTelegramVerificationChallenge(overlay, verification) { + if (!overlay || !verification) return; + const modal = overlay.querySelector('.configure-modal'); + if (!modal) return; + + let panel = modal.querySelector('.configure-verification'); + if (!panel) { + panel = document.createElement('div'); + panel.className = 'configure-verification'; + modal.insertBefore(panel, modal.querySelector('.configure-actions')); + } + + panel.innerHTML = ''; + + const title = document.createElement('div'); + title.className = 'configure-verification-title'; + title.textContent = I18n.t('config.telegramChallengeTitle'); + panel.appendChild(title); + + const instructions = document.createElement('div'); + instructions.className = 'configure-verification-instructions'; + instructions.textContent = verification.instructions; + panel.appendChild(instructions); + + const code = document.createElement('code'); + code.className = 'configure-verification-code'; + code.textContent = verification.code; + panel.appendChild(code); + + if (verification.deep_link) { + const link = document.createElement('a'); + link.className = 'configure-verification-link'; + link.href = verification.deep_link; + link.target = '_blank'; + link.rel = 'noreferrer noopener'; + link.textContent = I18n.t('config.telegramOpenBot'); + panel.appendChild(link); + } +} + function submitConfigureModal(name, fields) { const secrets = {}; for (const f of fields) { @@ -2808,6 +2855,10 @@ function submitConfigureModal(name, fields) { const overlay = getConfigureOverlay(name) || document.querySelector('.configure-overlay'); var btns = overlay ? overlay.querySelectorAll('.configure-actions button') : []; btns.forEach(function(b) { b.disabled = true; }); + if (overlay && name === 'telegram') { + const submitBtn = overlay.querySelector('.configure-actions button.btn-ext.activate'); + if (submitBtn) submitBtn.textContent = I18n.t('config.telegramOwnerWaiting'); + } apiFetch('/api/extensions/' + encodeURIComponent(name) + '/setup', { method: 'POST', @@ -2815,6 +2866,16 @@ function submitConfigureModal(name, fields) { }) .then((res) => { if (res.success) { + if (res.verification && name === 'telegram') { + btns.forEach(function(b) { b.disabled = false; }); + renderTelegramVerificationChallenge(overlay, res.verification); + fields.forEach(function(f) { f.input.value = ''; }); + const submitBtn = overlay.querySelector('.configure-actions button.btn-ext.activate'); + if (submitBtn) submitBtn.textContent = I18n.t('config.telegramVerifyOwner'); + showToast(res.message || res.verification.instructions, 'info'); + return; + } + closeConfigureModal(); if (res.auth_url) { showAuthCard({ @@ -2830,11 +2891,29 @@ function submitConfigureModal(name, fields) { } else { // Keep modal open so the user can correct their input and retry. btns.forEach(function(b) { b.disabled = false; }); + if (name === 'telegram') { + const submitBtn = overlay && overlay.querySelector('.configure-actions button.btn-ext.activate'); + const hasVerification = overlay && overlay.querySelector('.configure-verification'); + if (submitBtn) { + submitBtn.textContent = hasVerification + ? I18n.t('config.telegramVerifyOwner') + : I18n.t('config.save'); + } + } showToast(res.message || 'Configuration failed', 'error'); } }) .catch((err) => { btns.forEach(function(b) { b.disabled = false; }); + if (name === 'telegram') { + const submitBtn = overlay && overlay.querySelector('.configure-actions button.btn-ext.activate'); + const hasVerification = overlay && overlay.querySelector('.configure-verification'); + if (submitBtn) { + submitBtn.textContent = hasVerification + ? I18n.t('config.telegramVerifyOwner') + : I18n.t('config.save'); + } + } showToast('Configuration failed: ' + err.message, 'error'); }); } diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index b637f14484..42e996da0a 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -342,6 +342,11 @@ I18n.register('en', { // Configure 'config.title': 'Configure {name}', + 'config.telegramOwnerHint': 'After saving, IronClaw will show a one-time code. Send `/start CODE` to your bot in Telegram, then click Verify owner.', + 'config.telegramChallengeTitle': 'Telegram owner verification', + 'config.telegramOwnerWaiting': 'Waiting for Telegram owner verification...', + 'config.telegramVerifyOwner': 'Verify owner', + 'config.telegramOpenBot': 'Open bot in Telegram', 'config.optional': ' (optional)', 'config.alreadySet': '(already set — leave empty to keep)', 'config.alreadyConfigured': 'Already configured', diff --git a/src/channels/web/static/style.css b/src/channels/web/static/style.css index 0ba5766f1d..44fd91762f 100644 --- a/src/channels/web/static/style.css +++ b/src/channels/web/static/style.css @@ -2896,6 +2896,62 @@ body { color: var(--text-primary); } +.configure-hint { + margin: 0 0 16px 0; + padding: 10px 12px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); + color: var(--text-secondary); + font-size: 13px; + line-height: 1.5; +} + +.configure-verification { + display: flex; + flex-direction: column; + gap: 10px; + margin: 16px 0 0 0; + padding: 12px; + border-radius: 8px; + background: var(--bg-secondary); + border: 1px solid var(--border); +} + +.configure-verification-title { + font-size: 13px; + font-weight: 600; + color: var(--text-primary); +} + +.configure-verification-instructions { + font-size: 13px; + line-height: 1.5; + color: var(--text-secondary); +} + +.configure-verification-code { + display: inline-block; + width: fit-content; + padding: 6px 10px; + border-radius: 6px; + background: rgba(255, 255, 255, 0.06); + border: 1px solid var(--border); + color: var(--text-primary); + font-size: 13px; +} + +.configure-verification-link { + width: fit-content; + color: var(--accent, var(--text-link, #4ea3ff)); + font-size: 13px; + text-decoration: none; +} + +.configure-verification-link:hover { + text-decoration: underline; +} + .configure-form { display: flex; flex-direction: column; diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index 129a70717c..3fad9f3525 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -410,6 +410,40 @@ pub struct TransitionInfo { // --- Extensions --- +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum ExtensionActivationStatus { + Installed, + Configured, + Pairing, + Active, + Failed, +} + +pub fn classify_wasm_channel_activation( + ext: &crate::extensions::InstalledExtension, + has_paired: bool, + has_owner_binding: bool, +) -> Option { + if ext.kind != crate::extensions::ExtensionKind::WasmChannel { + return None; + } + + Some(if ext.activation_error.is_some() { + ExtensionActivationStatus::Failed + } else if !ext.authenticated { + ExtensionActivationStatus::Installed + } else if ext.active { + if has_paired || has_owner_binding { + ExtensionActivationStatus::Active + } else { + ExtensionActivationStatus::Pairing + } + } else { + ExtensionActivationStatus::Configured + }) +} + #[derive(Debug, Serialize)] pub struct ExtensionInfo { pub name: String, @@ -428,9 +462,9 @@ pub struct ExtensionInfo { /// Whether this extension has an auth configuration (OAuth or manual token). #[serde(default)] pub has_auth: bool, - /// WASM channel activation status: "installed", "configured", "active", "failed". + /// WASM channel activation status. #[serde(skip_serializing_if = "Option::is_none")] - pub activation_status: Option, + pub activation_status: Option, /// Human-readable error when activation_status is "failed". #[serde(skip_serializing_if = "Option::is_none")] pub activation_error: Option, @@ -503,6 +537,9 @@ pub struct ActionResponse { /// Whether the channel was successfully activated after setup. #[serde(skip_serializing_if = "Option::is_none")] pub activated: Option, + /// Pending manual verification challenge (for Telegram owner binding, etc.). + #[serde(skip_serializing_if = "Option::is_none")] + pub verification: Option, } impl ActionResponse { @@ -514,6 +551,7 @@ impl ActionResponse { awaiting_token: None, instructions: None, activated: None, + verification: None, } } @@ -525,6 +563,7 @@ impl ActionResponse { awaiting_token: None, instructions: None, activated: None, + verification: None, } } } diff --git a/src/channels/web/ws.rs b/src/channels/web/ws.rs index 7287902e2f..7bf50e52a9 100644 --- a/src/channels/web/ws.rs +++ b/src/channels/web/ws.rs @@ -265,14 +265,25 @@ async fn handle_client_message( if let Some(ref ext_mgr) = state.extension_manager { match ext_mgr.configure_token(&extension_name, &token).await { Ok(result) => { - crate::channels::web::server::clear_auth_mode(state).await; - state - .sse - .broadcast(crate::channels::web::types::SseEvent::AuthCompleted { - extension_name, - success: true, - message: result.message, - }); + if result.verification.is_some() { + state.sse.broadcast( + crate::channels::web::types::SseEvent::AuthRequired { + extension_name: extension_name.clone(), + instructions: Some(result.message), + auth_url: None, + setup_url: None, + }, + ); + } else { + crate::channels::web::server::clear_auth_mode(state).await; + state.sse.broadcast( + crate::channels::web::types::SseEvent::AuthCompleted { + extension_name, + success: true, + message: result.message, + }, + ); + } } Err(e) => { let msg = format!("Auth failed: {}", e); diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 5ca3117103..d63ae446a0 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -10,16 +10,17 @@ use std::sync::Arc; use tokio::sync::RwLock; -use crate::channels::ChannelManager; use crate::channels::wasm::{ - RegisteredEndpoint, SharedWasmChannel, WasmChannelLoader, WasmChannelRouter, WasmChannelRuntime, + LoadedChannel, RegisteredEndpoint, SharedWasmChannel, TELEGRAM_CHANNEL_NAME, WasmChannelLoader, + WasmChannelRouter, WasmChannelRuntime, bot_username_setting_key, }; +use crate::channels::{ChannelManager, OutgoingResponse}; use crate::extensions::discovery::OnlineDiscovery; use crate::extensions::registry::ExtensionRegistry; use crate::extensions::{ ActivateResult, AuthResult, ConfigureResult, ExtensionError, ExtensionKind, ExtensionSource, InstallResult, InstalledExtension, RegistryEntry, ResultSource, SearchResult, ToolAuthState, - UpgradeOutcome, UpgradeResult, + UpgradeOutcome, UpgradeResult, VerificationChallenge, }; use crate::hooks::HookRegistry; use crate::pairing::PairingStore; @@ -56,6 +57,202 @@ struct ChannelRuntimeState { wasm_channel_owner_ids: std::collections::HashMap, } +#[cfg(test)] +type TestWasmChannelLoader = + Arc Result + Send + Sync>; +#[cfg(test)] +type TestTelegramBindingResolver = + Arc) -> Result + Send + Sync>; + +const TELEGRAM_OWNER_BIND_TIMEOUT_SECS: u64 = 120; +const TELEGRAM_OWNER_BIND_CHALLENGE_TTL_SECS: u64 = 300; +const TELEGRAM_GET_UPDATES_TIMEOUT_SECS: u64 = 25; +const TELEGRAM_OWNER_BIND_CODE_LEN: usize = 8; + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TelegramBindingData { + owner_id: i64, + bot_username: Option, + binding_state: TelegramOwnerBindingState, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum TelegramOwnerBindingState { + Existing, + VerifiedNow, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct PendingTelegramVerificationChallenge { + code: String, + bot_username: Option, + expires_at_unix: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +enum TelegramBindingResult { + Bound(TelegramBindingData), + Pending(VerificationChallenge), +} + +fn telegram_request_error(action: &'static str, error: &reqwest::Error) -> ExtensionError { + tracing::warn!( + action, + status = error.status().map(|status| status.as_u16()), + is_timeout = error.is_timeout(), + is_connect = error.is_connect(), + "Telegram API request failed" + ); + ExtensionError::Other(format!("Telegram {action} request failed")) +} + +fn telegram_response_parse_error(action: &'static str, error: &reqwest::Error) -> ExtensionError { + tracing::warn!( + action, + status = error.status().map(|status| status.as_u16()), + is_timeout = error.is_timeout(), + "Telegram API response parse failed" + ); + ExtensionError::Other(format!("Failed to parse Telegram {action} response")) +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramGetMeResponse { + ok: bool, + #[serde(default)] + result: Option, + #[serde(default)] + description: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramGetMeUser { + #[serde(default)] + username: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramGetUpdatesResponse { + ok: bool, + #[serde(default)] + result: Vec, + #[serde(default)] + description: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramUpdate { + update_id: i64, + #[serde(default)] + message: Option, + #[serde(default)] + edited_message: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramMessage { + chat: TelegramChat, + #[serde(default)] + from: Option, + #[serde(default)] + text: Option, +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramChat { + #[serde(rename = "type")] + chat_type: String, +} + +#[derive(Debug, serde::Deserialize)] +struct TelegramUser { + id: i64, + is_bot: bool, +} + +fn build_wasm_channel_runtime_config_updates( + tunnel_url: Option<&str>, + webhook_secret: Option<&str>, + owner_id: Option, +) -> HashMap { + let mut config_updates = HashMap::new(); + + if let Some(tunnel_url) = tunnel_url { + config_updates.insert( + "tunnel_url".to_string(), + serde_json::Value::String(tunnel_url.to_string()), + ); + } + + if let Some(secret) = webhook_secret { + config_updates.insert( + "webhook_secret".to_string(), + serde_json::Value::String(secret.to_string()), + ); + } + + if let Some(owner_id) = owner_id { + config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); + } + + config_updates +} + +fn channel_auth_instructions( + channel_name: &str, + secret: &crate::channels::wasm::SecretSetupSchema, +) -> String { + if channel_name == TELEGRAM_CHANNEL_NAME && secret.name == "telegram_bot_token" { + return format!( + "{} After you submit it, IronClaw will show a one-time verification code. Send `/start CODE` to your bot in Telegram, then verify again to bind the owner.", + secret.prompt + ); + } + + secret.prompt.clone() +} + +fn unix_timestamp_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn generate_telegram_verification_code() -> String { + use rand::Rng; + rand::thread_rng() + .sample_iter(&rand::distributions::Alphanumeric) + .take(TELEGRAM_OWNER_BIND_CODE_LEN) + .map(char::from) + .collect::() + .to_lowercase() +} + +fn telegram_verification_deep_link(bot_username: Option<&str>, code: &str) -> Option { + bot_username + .filter(|username| !username.trim().is_empty()) + .map(|username| format!("https://t.me/{username}?start={code}")) +} + +fn telegram_verification_instructions(bot_username: Option<&str>, code: &str) -> String { + if let Some(username) = bot_username.filter(|username| !username.trim().is_empty()) { + return format!("Send `/start {code}` to @{username}, then click Verify owner."); + } + + format!("Send `/start {code}` to your Telegram bot, then click Verify owner.") +} + +fn telegram_message_matches_verification_code(text: &str, code: &str) -> bool { + let trimmed = text.trim(); + trimmed == code + || trimmed == format!("/start {code}") + || trimmed + .split_whitespace() + .map(|token| token.trim_matches(|c: char| !c.is_ascii_alphanumeric() && c != '-')) + .any(|token| token == code) +} + /// Central manager for extension lifecycle operations. /// /// # Initialization Order @@ -126,6 +323,11 @@ pub struct ExtensionManager { /// The gateway's own base URL for building OAuth redirect URIs. /// Set by the web gateway at startup via `enable_gateway_mode()`. gateway_base_url: RwLock>, + pending_telegram_verification: RwLock>, + #[cfg(test)] + test_wasm_channel_loader: RwLock>, + #[cfg(test)] + test_telegram_binding_resolver: RwLock>, } /// Sanitize a URL for logging by removing query parameters and credentials. @@ -201,9 +403,24 @@ impl ExtensionManager { relay_config: crate::config::RelayConfig::from_env(), gateway_mode: std::sync::atomic::AtomicBool::new(false), gateway_base_url: RwLock::new(None), + pending_telegram_verification: RwLock::new(HashMap::new()), + #[cfg(test)] + test_wasm_channel_loader: RwLock::new(None), + #[cfg(test)] + test_telegram_binding_resolver: RwLock::new(None), } } + #[cfg(test)] + async fn set_test_wasm_channel_loader(&self, loader: TestWasmChannelLoader) { + *self.test_wasm_channel_loader.write().await = Some(loader); + } + + #[cfg(test)] + async fn set_test_telegram_binding_resolver(&self, resolver: TestTelegramBindingResolver) { + *self.test_telegram_binding_resolver.write().await = Some(resolver); + } + /// Enable gateway mode so OAuth flows return auth URLs to the frontend /// instead of calling `open::that()` on the server. /// @@ -309,17 +526,6 @@ impl ExtensionManager { }); } - /// Set just the channel manager for relay channel hot-activation. - /// - /// Call this when WASM channel runtime is not available but relay channels - /// still need to be hot-added. - /// - /// This must be called before [`ExtensionManager::restore_relay_channels`] - /// unless [`ExtensionManager::set_channel_runtime`] was already called. - pub async fn set_relay_channel_manager(&self, channel_manager: Arc) { - *self.relay_channel_manager.write().await = Some(channel_manager); - } - async fn current_channel_owner_id(&self, name: &str) -> Option { { let rt_guard = self.channel_runtime.read().await; @@ -348,6 +554,131 @@ impl ExtensionManager { } } + async fn set_channel_owner_id(&self, name: &str, owner_id: i64) -> Result<(), ExtensionError> { + if let Some(store) = self.store.as_ref() { + store + .set_setting( + &self.user_id, + &format!("channels.wasm_channel_owner_ids.{name}"), + &serde_json::json!(owner_id), + ) + .await + .map_err(|e| ExtensionError::Config(e.to_string()))?; + } + + let mut rt_guard = self.channel_runtime.write().await; + if let Some(rt) = rt_guard.as_mut() { + rt.wasm_channel_owner_ids.insert(name.to_string(), owner_id); + } + + Ok(()) + } + + async fn load_channel_runtime_config_overrides( + &self, + name: &str, + ) -> HashMap { + let mut overrides = HashMap::new(); + + if name == TELEGRAM_CHANNEL_NAME + && let Some(store) = self.store.as_ref() + && let Ok(Some(serde_json::Value::String(username))) = store + .get_setting(&self.user_id, &bot_username_setting_key(name)) + .await + && !username.trim().is_empty() + { + overrides.insert("bot_username".to_string(), serde_json::json!(username)); + } + + overrides + } + + pub async fn has_wasm_channel_owner_binding(&self, name: &str) -> bool { + self.current_channel_owner_id(name).await.is_some() + } + + async fn get_pending_telegram_verification( + &self, + name: &str, + ) -> Option { + let now = unix_timestamp_secs(); + let mut guard = self.pending_telegram_verification.write().await; + let challenge = guard.get(name).cloned()?; + if challenge.expires_at_unix <= now { + guard.remove(name); + return None; + } + Some(challenge) + } + + async fn set_pending_telegram_verification( + &self, + name: &str, + challenge: PendingTelegramVerificationChallenge, + ) { + self.pending_telegram_verification + .write() + .await + .insert(name.to_string(), challenge); + } + + async fn clear_pending_telegram_verification(&self, name: &str) { + self.pending_telegram_verification + .write() + .await + .remove(name); + } + + async fn issue_telegram_verification_challenge( + &self, + client: &reqwest::Client, + name: &str, + bot_token: &str, + bot_username: Option<&str>, + ) -> Result { + let delete_webhook_url = format!("https://api.telegram.org/bot{bot_token}/deleteWebhook"); + let delete_webhook_resp = client + .post(&delete_webhook_url) + .query(&[("drop_pending_updates", "true")]) + .send() + .await + .map_err(|e| telegram_request_error("deleteWebhook", &e))?; + if !delete_webhook_resp.status().is_success() { + return Err(ExtensionError::Other(format!( + "Telegram deleteWebhook failed (HTTP {})", + delete_webhook_resp.status() + ))); + } + + let challenge = PendingTelegramVerificationChallenge { + code: generate_telegram_verification_code(), + bot_username: bot_username.map(str::to_string), + expires_at_unix: unix_timestamp_secs() + TELEGRAM_OWNER_BIND_CHALLENGE_TTL_SECS, + }; + self.set_pending_telegram_verification(name, challenge.clone()) + .await; + + Ok(VerificationChallenge { + code: challenge.code.clone(), + instructions: telegram_verification_instructions( + challenge.bot_username.as_deref(), + &challenge.code, + ), + deep_link: telegram_verification_deep_link( + challenge.bot_username.as_deref(), + &challenge.code, + ), + }) + } + + /// Set just the channel manager for relay channel hot-activation. + /// + /// Call this when WASM channel runtime is not available but relay channels + /// still need to be hot-added. + pub async fn set_relay_channel_manager(&self, channel_manager: Arc) { + *self.relay_channel_manager.write().await = Some(channel_manager); + } + /// Check if a channel name corresponds to a relay extension (has stored stream token). pub async fn is_relay_channel(&self, name: &str) -> bool { self.secrets @@ -2835,7 +3166,7 @@ impl ExtensionManager { Ok(AuthResult::awaiting_token( name, ExtensionKind::WasmChannel, - secret.prompt.clone(), + channel_auth_instructions(name, secret), cap_file.setup.setup_url.clone(), )) } @@ -3038,7 +3369,13 @@ impl ExtensionManager { // Verify runtime infrastructure is available and clone Arcs so we don't // hold the RwLock guard across awaits. - let (channel_runtime, channel_manager, pairing_store, wasm_channel_router) = { + let ( + channel_runtime, + channel_manager, + pairing_store, + wasm_channel_router, + wasm_channel_owner_ids, + ) = { let rt_guard = self.channel_runtime.read().await; let rt = rt_guard.as_ref().ok_or_else(|| { ExtensionError::ActivationFailed("WASM channel runtime not configured".to_string()) @@ -3048,6 +3385,7 @@ impl ExtensionManager { Arc::clone(&rt.channel_manager), Arc::clone(&rt.pairing_store), Arc::clone(&rt.wasm_channel_router), + rt.wasm_channel_owner_ids.clone(), ) }; @@ -3071,19 +3409,58 @@ impl ExtensionManager { None }; - let settings_store: Option> = - self.store.as_ref().map(|db| Arc::clone(db) as _); - let loader = WasmChannelLoader::new( - Arc::clone(&channel_runtime), - Arc::clone(&pairing_store), - settings_store, + #[cfg(test)] + let loaded = if let Some(loader) = self.test_wasm_channel_loader.read().await.as_ref() { + loader(name)? + } else { + let settings_store: Option> = + self.store.as_ref().map(|db| Arc::clone(db) as _); + let loader = WasmChannelLoader::new( + Arc::clone(&channel_runtime), + Arc::clone(&pairing_store), + settings_store, + ) + .with_secrets_store(Arc::clone(&self.secrets)); + loader + .load_from_files(name, &wasm_path, cap_path_option) + .await + .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))? + }; + + #[cfg(not(test))] + let loaded = { + let settings_store: Option> = + self.store.as_ref().map(|db| Arc::clone(db) as _); + let loader = WasmChannelLoader::new( + Arc::clone(&channel_runtime), + Arc::clone(&pairing_store), + settings_store, + ) + .with_secrets_store(Arc::clone(&self.secrets)); + loader + .load_from_files(name, &wasm_path, cap_path_option) + .await + .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))? + }; + + self.complete_loaded_wasm_channel_activation( + name, + loaded, + &channel_manager, + &wasm_channel_router, + wasm_channel_owner_ids.get(name).copied(), ) - .with_secrets_store(Arc::clone(&self.secrets)); - let loaded = loader - .load_from_files(name, &wasm_path, cap_path_option) - .await - .map_err(|e| ExtensionError::ActivationFailed(e.to_string()))?; + .await + } + async fn complete_loaded_wasm_channel_activation( + &self, + requested_name: &str, + loaded: LoadedChannel, + channel_manager: &Arc, + wasm_channel_router: &Arc, + owner_id: Option, + ) -> Result { let channel_name = loaded.name().to_string(); let webhook_secret_name = loaded.webhook_secret_name(); let secret_header = loaded.webhook_secret_header().map(|s| s.to_string()); @@ -3102,25 +3479,16 @@ impl ExtensionManager { // Inject runtime config (tunnel_url, webhook_secret, owner_id) { - let mut config_updates = std::collections::HashMap::new(); - - if let Some(ref tunnel_url) = self.tunnel_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 let Some(owner_id) = self.current_channel_owner_id(&channel_name).await { - config_updates.insert("owner_id".to_string(), serde_json::json!(owner_id)); - } + let resolved_owner_id = owner_id.or(self.current_channel_owner_id(&channel_name).await); + let mut config_updates = build_wasm_channel_runtime_config_updates( + self.tunnel_url.as_deref(), + webhook_secret.as_deref(), + resolved_owner_id, + ); + config_updates.extend( + self.load_channel_runtime_config_overrides(&channel_name) + .await, + ); if !config_updates.is_empty() { channel_arc.update_config(config_updates).await; @@ -3237,7 +3605,7 @@ impl ExtensionManager { name: channel_name, kind: ExtensionKind::WasmChannel, tools_loaded: Vec::new(), - message: format!("Channel '{}' activated and running", name), + message: format!("Channel '{}' activated and running", requested_name), }) } @@ -3317,6 +3685,14 @@ impl ExtensionManager { .as_ref() .and_then(|f| f.hmac_secret_name().map(|s| s.to_string())); + let mut config_updates = build_wasm_channel_runtime_config_updates( + self.tunnel_url.as_deref(), + None, + self.current_channel_owner_id(name).await, + ); + config_updates.extend(self.load_channel_runtime_config_overrides(name).await); + let mut should_rerun_on_start = false; + // Refresh webhook secret if let Ok(secret) = self .secrets @@ -3326,14 +3702,11 @@ impl ExtensionManager { router .update_secret(name, secret.expose().to_string()) .await; - - // Also inject the webhook_secret into the channel's runtime config - let mut config_updates = std::collections::HashMap::new(); config_updates.insert( "webhook_secret".to_string(), serde_json::Value::String(secret.expose().to_string()), ); - existing_channel.update_config(config_updates).await; + should_rerun_on_start = true; } // Refresh signature key @@ -3373,19 +3746,14 @@ impl ExtensionManager { } } - // Refresh tunnel_url in case it wasn't set at startup - if let Some(ref tunnel_url) = self.tunnel_url { - let mut config_updates = std::collections::HashMap::new(); - config_updates.insert( - "tunnel_url".to_string(), - serde_json::Value::String(tunnel_url.clone()), - ); + if !config_updates.is_empty() { existing_channel.update_config(config_updates).await; + should_rerun_on_start = true; } // Re-call on_start() to trigger webhook registration with the // now-available credentials (e.g., setWebhook for Telegram). - if cred_count > 0 { + if cred_count > 0 || should_rerun_on_start { match existing_channel.call_on_start().await { Ok(_config) => { tracing::info!( @@ -3736,6 +4104,304 @@ impl ExtensionManager { } } + async fn configure_telegram_binding( + &self, + name: &str, + secrets: &std::collections::HashMap, + ) -> Result { + let explicit_token = secrets + .get("telegram_bot_token") + .map(|v| v.trim().to_string()) + .filter(|v| !v.is_empty()); + let bot_token = if let Some(token) = explicit_token.clone() { + token + } else { + match self + .secrets + .get_decrypted(&self.user_id, "telegram_bot_token") + .await + { + Ok(secret) => { + let token = secret.expose().trim().to_string(); + if token.is_empty() { + return Err(ExtensionError::ValidationFailed( + "Telegram bot token is required before owner verification".to_string(), + )); + } + token + } + Err(crate::secrets::SecretError::NotFound(_)) => { + return Err(ExtensionError::ValidationFailed( + "Telegram bot token is required before owner verification".to_string(), + )); + } + Err(err) => { + return Err(ExtensionError::Config(format!( + "Failed to read stored Telegram bot token: {err}" + ))); + } + } + }; + + let existing_owner_id = self.current_channel_owner_id(name).await; + let binding = self + .resolve_telegram_binding(name, &bot_token, existing_owner_id) + .await?; + + match &binding { + TelegramBindingResult::Bound(data) => { + self.set_channel_owner_id(name, data.owner_id).await?; + if let Some(username) = data.bot_username.as_deref() + && let Some(store) = self.store.as_ref() + { + store + .set_setting( + &self.user_id, + &bot_username_setting_key(name), + &serde_json::json!(username), + ) + .await + .map_err(|e| ExtensionError::Config(e.to_string()))?; + } + } + TelegramBindingResult::Pending(challenge) => { + if let Some(deep_link) = challenge.deep_link.as_deref() + && let Some(username) = deep_link + .strip_prefix("https://t.me/") + .and_then(|rest| rest.split('?').next()) + .filter(|value| !value.trim().is_empty()) + && let Some(store) = self.store.as_ref() + { + store + .set_setting( + &self.user_id, + &bot_username_setting_key(name), + &serde_json::json!(username), + ) + .await + .map_err(|e| ExtensionError::Config(e.to_string()))?; + } + } + } + + Ok(binding) + } + + async fn resolve_telegram_binding( + &self, + name: &str, + bot_token: &str, + existing_owner_id: Option, + ) -> Result { + #[cfg(test)] + if let Some(resolver) = self.test_telegram_binding_resolver.read().await.as_ref() { + return resolver(bot_token, existing_owner_id); + } + + let client = reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .map_err(|e| ExtensionError::Other(e.to_string()))?; + + let get_me_url = format!("https://api.telegram.org/bot{bot_token}/getMe"); + let get_me_resp = client + .get(&get_me_url) + .send() + .await + .map_err(|e| telegram_request_error("getMe", &e))?; + let get_me_status = get_me_resp.status(); + if !get_me_status.is_success() { + return Err(ExtensionError::ValidationFailed(format!( + "Telegram token validation failed (HTTP {get_me_status})" + ))); + } + + let get_me: TelegramGetMeResponse = get_me_resp + .json() + .await + .map_err(|e| telegram_response_parse_error("getMe", &e))?; + if !get_me.ok { + return Err(ExtensionError::ValidationFailed( + get_me + .description + .unwrap_or_else(|| "Telegram getMe returned ok=false".to_string()), + )); + } + + let bot_username = get_me + .result + .and_then(|result| result.username) + .filter(|username| !username.trim().is_empty()); + + if let Some(owner_id) = existing_owner_id { + self.clear_pending_telegram_verification(name).await; + return Ok(TelegramBindingResult::Bound(TelegramBindingData { + owner_id, + bot_username: bot_username.clone(), + binding_state: TelegramOwnerBindingState::Existing, + })); + } + + let pending_challenge = self.get_pending_telegram_verification(name).await; + + let challenge = if let Some(challenge) = pending_challenge { + challenge + } else { + return Ok(TelegramBindingResult::Pending( + self.issue_telegram_verification_challenge( + &client, + name, + bot_token, + bot_username.as_deref(), + ) + .await?, + )); + }; + + let now = unix_timestamp_secs(); + if challenge.expires_at_unix <= now { + self.clear_pending_telegram_verification(name).await; + return Ok(TelegramBindingResult::Pending( + self.issue_telegram_verification_challenge( + &client, + name, + bot_token, + bot_username.as_deref(), + ) + .await?, + )); + } + + let deadline = std::time::Instant::now() + + std::time::Duration::from_secs(TELEGRAM_OWNER_BIND_TIMEOUT_SECS); + let mut offset = 0_i64; + + while std::time::Instant::now() < deadline { + let remaining_secs = deadline + .saturating_duration_since(std::time::Instant::now()) + .as_secs() + .max(1); + let poll_timeout_secs = TELEGRAM_GET_UPDATES_TIMEOUT_SECS.min(remaining_secs); + + let resp = client + .get(format!( + "https://api.telegram.org/bot{bot_token}/getUpdates" + )) + .query(&[ + ("offset", offset.to_string()), + ("timeout", poll_timeout_secs.to_string()), + ( + "allowed_updates", + "[\"message\",\"edited_message\"]".to_string(), + ), + ]) + .send() + .await + .map_err(|e| telegram_request_error("getUpdates", &e))?; + + if !resp.status().is_success() { + return Err(ExtensionError::Other(format!( + "Telegram getUpdates failed (HTTP {})", + resp.status() + ))); + } + + let updates: TelegramGetUpdatesResponse = resp + .json() + .await + .map_err(|e| telegram_response_parse_error("getUpdates", &e))?; + + if !updates.ok { + return Err(ExtensionError::Other(updates.description.unwrap_or_else( + || "Telegram getUpdates returned ok=false".to_string(), + ))); + } + + let mut bound_owner_id = None; + for update in updates.result { + offset = offset.max(update.update_id + 1); + let message = update.message.or(update.edited_message); + if let Some(message) = message + && message.chat.chat_type == "private" + && let Some(from) = message.from + && !from.is_bot + && let Some(text) = message.text.as_deref() + && telegram_message_matches_verification_code(text, &challenge.code) + { + bound_owner_id = Some(from.id); + } + } + + if let Some(owner_id) = bound_owner_id { + self.clear_pending_telegram_verification(name).await; + if offset > 0 { + let _ = client + .get(format!( + "https://api.telegram.org/bot{bot_token}/getUpdates" + )) + .query(&[("offset", offset.to_string()), ("timeout", "0".to_string())]) + .send() + .await; + } + + return Ok(TelegramBindingResult::Bound(TelegramBindingData { + owner_id, + bot_username, + binding_state: TelegramOwnerBindingState::VerifiedNow, + })); + } + } + + Err(ExtensionError::ValidationFailed(format!( + "Telegram owner verification timed out. Send `/start {}` to your bot, then click Verify owner again.", + challenge.code + ))) + } + + async fn notify_telegram_owner_verified( + &self, + channel_name: &str, + binding: Option<&TelegramBindingData>, + ) { + let Some(binding) = binding else { + return; + }; + if binding.binding_state != TelegramOwnerBindingState::VerifiedNow { + return; + } + + let channel_manager = { + let rt_guard = self.channel_runtime.read().await; + rt_guard.as_ref().map(|rt| Arc::clone(&rt.channel_manager)) + }; + let Some(channel_manager) = channel_manager else { + tracing::debug!( + channel = channel_name, + owner_id = binding.owner_id, + "Skipping Telegram owner confirmation message because channel runtime is unavailable" + ); + return; + }; + + if let Err(err) = channel_manager + .broadcast( + channel_name, + &binding.owner_id.to_string(), + OutgoingResponse::text( + "Telegram owner verified. This bot is now active and ready for you.", + ), + ) + .await + { + tracing::warn!( + channel = channel_name, + owner_id = binding.owner_id, + error = %err, + "Failed to send Telegram owner verification confirmation" + ); + } + } + /// Save setup secrets for an extension, validating names against the capabilities schema. /// /// Configure secrets for an extension: validate, store, auto-generate, and activate. @@ -3921,6 +4587,26 @@ impl ExtensionManager { } } + let mut telegram_binding = None; + if kind == ExtensionKind::WasmChannel && name == TELEGRAM_CHANNEL_NAME { + match self.configure_telegram_binding(name, secrets).await? { + TelegramBindingResult::Bound(binding) => { + telegram_binding = Some(binding); + } + TelegramBindingResult::Pending(verification) => { + return Ok(ConfigureResult { + message: format!( + "Configuration saved for '{}'. {}", + name, verification.instructions + ), + activated: false, + auth_url: None, + verification: Some(verification), + }); + } + } + } + // For tools, save and attempt auto-activation, then check auth. if kind == ExtensionKind::WasmTool { match self.activate_wasm_tool(name).await { @@ -3972,6 +4658,7 @@ impl ExtensionManager { message, activated: true, auth_url, + verification: None, }); } Err(e) => { @@ -3984,6 +4671,7 @@ impl ExtensionManager { message: format!("Configuration saved for '{}'.", name), activated: false, auth_url: None, + verification: None, }); } } @@ -4001,6 +4689,7 @@ impl ExtensionManager { message: format!("Configuration saved for '{}'.", name), activated: false, auth_url: None, + verification: None, }); } }; @@ -4009,13 +4698,26 @@ impl ExtensionManager { Ok(result) => { self.activation_errors.write().await.remove(name); self.broadcast_extension_status(name, "active", None).await; - Ok(ConfigureResult { - message: format!( + if name == TELEGRAM_CHANNEL_NAME { + self.notify_telegram_owner_verified(name, telegram_binding.as_ref()) + .await; + } + let message = if name == TELEGRAM_CHANNEL_NAME { + format!( + "Configuration saved, Telegram owner verified, and '{}' activated. {}", + name, result.message + ) + } else { + format!( "Configuration saved and '{}' activated. {}", name, result.message - ), + ) + }; + Ok(ConfigureResult { + message, activated: true, auth_url: None, + verification: None, }) } Err(e) => { @@ -4038,6 +4740,7 @@ impl ExtensionManager { ), activated: false, auth_url: None, + verification: None, }) } } @@ -4397,13 +5100,101 @@ fn combine_install_errors( #[cfg(test)] mod tests { + use std::fmt::Debug; use std::sync::Arc; + use async_trait::async_trait; + use futures::stream; + + use crate::channels::wasm::{ + ChannelCapabilities, LoadedChannel, PreparedChannelModule, WasmChannel, WasmChannelRouter, + WasmChannelRuntime, WasmChannelRuntimeConfig, bot_username_setting_key, + }; + use crate::channels::{ + Channel, ChannelManager, IncomingMessage, MessageStream, OutgoingResponse, StatusUpdate, + }; use crate::extensions::ExtensionManager; use crate::extensions::manager::{ - FallbackDecision, combine_install_errors, fallback_decision, infer_kind_from_url, + ChannelRuntimeState, FallbackDecision, TelegramBindingData, TelegramBindingResult, + TelegramOwnerBindingState, build_wasm_channel_runtime_config_updates, + combine_install_errors, fallback_decision, infer_kind_from_url, + telegram_message_matches_verification_code, + }; + use crate::extensions::{ + ExtensionError, ExtensionKind, ExtensionSource, InstallResult, VerificationChallenge, }; - use crate::extensions::{ExtensionError, ExtensionKind, ExtensionSource, InstallResult}; + use crate::pairing::PairingStore; + + fn require(condition: bool, message: impl Into) -> Result<(), String> { + if condition { + Ok(()) + } else { + Err(message.into()) + } + } + + fn require_eq(actual: T, expected: T, label: &str) -> Result<(), String> + where + T: PartialEq + Debug, + { + if actual == expected { + Ok(()) + } else { + Err(format!( + "{label} mismatch: expected {:?}, got {:?}", + expected, actual + )) + } + } + + #[derive(Clone)] + struct RecordingChannel { + name: String, + broadcasts: Arc>>, + } + + #[async_trait] + impl Channel for RecordingChannel { + fn name(&self) -> &str { + &self.name + } + + async fn start(&self) -> Result { + Ok(Box::pin(stream::empty())) + } + + async fn respond( + &self, + _msg: &IncomingMessage, + _response: OutgoingResponse, + ) -> Result<(), crate::error::ChannelError> { + Ok(()) + } + + async fn send_status( + &self, + _status: StatusUpdate, + _metadata: &serde_json::Value, + ) -> Result<(), crate::error::ChannelError> { + Ok(()) + } + + async fn broadcast( + &self, + user_id: &str, + response: OutgoingResponse, + ) -> Result<(), crate::error::ChannelError> { + self.broadcasts + .lock() + .await + .push((user_id.to_string(), response)); + Ok(()) + } + + async fn health_check(&self) -> Result<(), crate::error::ChannelError> { + Ok(()) + } + } #[test] fn test_infer_kind_from_url() { @@ -4786,7 +5577,10 @@ mod tests { std::fs::create_dir_all(&channels_dir).ok(); let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string()); - let crypto = Arc::new(SecretsCrypto::new(master_key).unwrap()); + let crypto = Arc::new( + SecretsCrypto::new(master_key) + .unwrap_or_else(|err| panic!("failed to construct test crypto: {err}")), + ); ExtensionManager::new( Arc::new(McpSessionManager::new()), @@ -4804,6 +5598,56 @@ mod tests { ) } + fn make_test_loaded_channel( + runtime: Arc, + name: &str, + pairing_store: Arc, + ) -> LoadedChannel { + let prepared = Arc::new(PreparedChannelModule::for_testing( + name, + format!("Mock channel: {}", name), + )); + let capabilities = + ChannelCapabilities::for_channel(name).with_path(format!("/webhook/{}", name)); + + LoadedChannel { + channel: WasmChannel::new( + runtime, + prepared, + capabilities, + "{}".to_string(), + pairing_store, + None, + ), + capabilities_file: None, + } + } + + #[test] + fn test_telegram_hot_activation_runtime_config_includes_owner_id() -> Result<(), String> { + let updates = build_wasm_channel_runtime_config_updates( + Some("https://example.test"), + Some("secret-123"), + Some(424242), + ); + + require_eq( + updates.get("tunnel_url"), + Some(&serde_json::json!("https://example.test")), + "tunnel_url", + )?; + require_eq( + updates.get("webhook_secret"), + Some(&serde_json::json!("secret-123")), + "webhook_secret", + )?; + require_eq( + updates.get("owner_id"), + Some(&serde_json::json!(424242)), + "owner_id", + ) + } + #[tokio::test] async fn test_current_channel_owner_id_uses_runtime_state() -> Result<(), String> { let manager = make_manager_with_temp_dirs(); @@ -4837,6 +5681,280 @@ mod tests { Ok(()) } + #[cfg(feature = "libsql")] + #[tokio::test] + async fn test_telegram_hot_activation_configure_uses_mock_loader_and_persists_state() + -> Result<(), String> { + let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?; + let channels_dir = dir.path().join("channels"); + std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?; + std::fs::write(channels_dir.join("telegram.wasm"), b"mock") + .map_err(|err| format!("write wasm: {err}"))?; + std::fs::write( + channels_dir.join("telegram.capabilities.json"), + serde_json::to_vec(&serde_json::json!({ + "type": "channel", + "name": "telegram", + "setup": { + "required_secrets": [ + { + "name": "telegram_bot_token", + "prompt": "Enter your Telegram Bot API token (from @BotFather)", + "optional": false + } + ] + }, + "capabilities": { + "channel": { + "allowed_paths": ["/webhook/telegram"] + } + }, + "config": { + "owner_id": null + } + })) + .map_err(|err| format!("serialize capabilities: {err}"))?, + ) + .map_err(|err| format!("write capabilities: {err}"))?; + + let (db, _db_tmp) = crate::testing::test_db().await; + let manager = { + use crate::secrets::{InMemorySecretsStore, SecretsCrypto}; + use crate::testing::credentials::TEST_CRYPTO_KEY; + use crate::tools::ToolRegistry; + use crate::tools::mcp::process::McpProcessManager; + use crate::tools::mcp::session::McpSessionManager; + + let master_key = secrecy::SecretString::from(TEST_CRYPTO_KEY.to_string()); + let crypto = Arc::new( + SecretsCrypto::new(master_key) + .unwrap_or_else(|err| panic!("failed to construct test crypto: {err}")), + ); + + ExtensionManager::new( + Arc::new(McpSessionManager::new()), + Arc::new(McpProcessManager::new()), + Arc::new(InMemorySecretsStore::new(crypto)), + Arc::new(ToolRegistry::new()), + None, + None, + dir.path().join("tools"), + channels_dir.clone(), + None, + "test".to_string(), + Some(db), + Vec::new(), + ) + }; + + let channel_manager = Arc::new(ChannelManager::new()); + let runtime = Arc::new( + WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing()) + .map_err(|err| format!("runtime: {err}"))?, + ); + let pairing_store = Arc::new(PairingStore::with_base_dir( + dir.path().join("pairing-state"), + )); + let router = Arc::new(WasmChannelRouter::new()); + manager + .set_channel_runtime( + Arc::clone(&channel_manager), + Arc::clone(&runtime), + Arc::clone(&pairing_store), + Arc::clone(&router), + std::collections::HashMap::new(), + ) + .await; + manager + .set_test_wasm_channel_loader(Arc::new({ + let runtime = Arc::clone(&runtime); + let pairing_store = Arc::clone(&pairing_store); + move |name| { + Ok(make_test_loaded_channel( + Arc::clone(&runtime), + name, + Arc::clone(&pairing_store), + )) + } + })) + .await; + manager + .set_test_telegram_binding_resolver(Arc::new(|_token, existing_owner_id| { + if existing_owner_id.is_some() { + return Err(ExtensionError::Other( + "owner binding should be derived during setup".to_string(), + )); + } + Ok(TelegramBindingResult::Bound(TelegramBindingData { + owner_id: 424242, + bot_username: Some("test_hot_bot".to_string()), + binding_state: TelegramOwnerBindingState::VerifiedNow, + })) + })) + .await; + + manager + .activation_errors + .write() + .await + .insert("telegram".to_string(), "stale failure".to_string()); + + let result = manager + .configure( + "telegram", + &std::collections::HashMap::from([( + "telegram_bot_token".to_string(), + "123456789:ABCdefGhI".to_string(), + )]), + ) + .await + .map_err(|err| format!("configure succeeds: {err}"))?; + + require(result.activated, "expected hot activation to succeed")?; + require( + result.message.contains("activated"), + format!("unexpected message: {}", result.message), + )?; + require( + !manager + .activation_errors + .read() + .await + .contains_key("telegram"), + "successful configure should clear stale activation errors", + )?; + require( + manager + .active_channel_names + .read() + .await + .contains("telegram"), + "telegram should be marked active after hot activation", + )?; + require( + channel_manager.get_channel("telegram").await.is_some(), + "telegram should be hot-added to the running channel manager", + )?; + require_eq( + manager.load_persisted_active_channels().await, + vec!["telegram".to_string()], + "persisted active channels", + )?; + require_eq( + manager.current_channel_owner_id("telegram").await, + Some(424242), + "current owner id", + )?; + require( + manager.has_wasm_channel_owner_binding("telegram").await, + "telegram should report an explicit owner binding after setup".to_string(), + )?; + let owner_setting = manager + .store + .as_ref() + .ok_or_else(|| "db-backed manager missing".to_string())? + .get_setting("test", "channels.wasm_channel_owner_ids.telegram") + .await + .map_err(|err| format!("owner_id setting query: {err}"))?; + require_eq( + owner_setting, + Some(serde_json::json!(424242)), + "owner setting", + )?; + let bot_username_setting = manager + .store + .as_ref() + .ok_or_else(|| "db-backed manager missing".to_string())? + .get_setting("test", &bot_username_setting_key("telegram")) + .await + .map_err(|err| format!("bot username setting query: {err}"))?; + require_eq( + bot_username_setting, + Some(serde_json::json!("test_hot_bot")), + "bot username setting", + ) + } + + #[tokio::test] + async fn test_telegram_hot_activation_returns_verification_challenge_before_binding() + -> Result<(), String> { + let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?; + let channels_dir = dir.path().join("channels"); + std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?; + std::fs::write(channels_dir.join("telegram.wasm"), b"mock") + .map_err(|err| format!("write wasm: {err}"))?; + std::fs::write( + channels_dir.join("telegram.capabilities.json"), + serde_json::to_vec(&serde_json::json!({ + "type": "channel", + "name": "telegram", + "setup": { + "required_secrets": [ + { + "name": "telegram_bot_token", + "prompt": "Enter your Telegram Bot API token (from @BotFather)", + "optional": false + } + ] + }, + "capabilities": { + "channel": { + "allowed_paths": ["/webhook/telegram"] + } + } + })) + .map_err(|err| format!("serialize capabilities: {err}"))?, + ) + .map_err(|err| format!("write capabilities: {err}"))?; + + let manager = + make_manager_custom_dirs(dir.path().join("tools"), dir.path().join("channels")); + manager + .set_test_telegram_binding_resolver(Arc::new(|_token, existing_owner_id| { + if existing_owner_id.is_some() { + return Err(ExtensionError::Other( + "owner binding should not exist before verification".to_string(), + )); + } + Ok(TelegramBindingResult::Pending(VerificationChallenge { + code: "iclaw-7qk2m9".to_string(), + instructions: + "Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner." + .to_string(), + deep_link: Some("https://t.me/test_hot_bot?start=iclaw-7qk2m9".to_string()), + })) + })) + .await; + + let result = manager + .configure( + "telegram", + &std::collections::HashMap::from([( + "telegram_bot_token".to_string(), + "123456789:ABCdefGhI".to_string(), + )]), + ) + .await + .map_err(|err| format!("configure returned challenge: {err}"))?; + + require( + !result.activated, + "expected setup to pause for verification", + )?; + require( + result.verification.as_ref().map(|v| v.code.as_str()) == Some("iclaw-7qk2m9"), + "expected verification code in configure result", + )?; + require( + !manager + .active_channel_names + .read() + .await + .contains("telegram"), + "telegram should not activate until owner verification completes", + ) + } + #[cfg(feature = "libsql")] #[tokio::test] async fn test_current_channel_owner_id_uses_store_fallback() -> Result<(), String> { @@ -4924,6 +6042,104 @@ mod tests { Ok(()) } + #[tokio::test] + async fn test_notify_telegram_owner_verified_sends_confirmation_for_new_binding() + -> Result<(), String> { + let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?; + let manager = + make_manager_custom_dirs(dir.path().join("tools"), dir.path().join("channels")); + + let channel_manager = Arc::new(ChannelManager::new()); + let broadcasts = Arc::new(tokio::sync::Mutex::new(Vec::new())); + channel_manager + .add(Box::new(RecordingChannel { + name: "telegram".to_string(), + broadcasts: Arc::clone(&broadcasts), + })) + .await; + + manager + .channel_runtime + .write() + .await + .replace(ChannelRuntimeState { + channel_manager, + wasm_channel_runtime: Arc::new( + WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing()) + .map_err(|err| format!("runtime: {err}"))?, + ), + pairing_store: Arc::new(PairingStore::with_base_dir(dir.path().join("pairing"))), + wasm_channel_router: Arc::new(WasmChannelRouter::new()), + wasm_channel_owner_ids: std::collections::HashMap::new(), + }); + + manager + .notify_telegram_owner_verified( + "telegram", + Some(&TelegramBindingData { + owner_id: 424242, + bot_username: Some("test_hot_bot".to_string()), + binding_state: TelegramOwnerBindingState::VerifiedNow, + }), + ) + .await; + + let sent = broadcasts.lock().await; + require_eq(sent.len(), 1, "broadcast count")?; + require_eq(sent[0].0.clone(), "424242".to_string(), "broadcast user_id")?; + require( + sent[0].1.content.contains("Telegram owner verified"), + "confirmation DM should acknowledge owner verification", + ) + } + + #[tokio::test] + async fn test_notify_telegram_owner_verified_skips_existing_binding() -> Result<(), String> { + let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?; + let manager = + make_manager_custom_dirs(dir.path().join("tools"), dir.path().join("channels")); + + let channel_manager = Arc::new(ChannelManager::new()); + let broadcasts = Arc::new(tokio::sync::Mutex::new(Vec::new())); + channel_manager + .add(Box::new(RecordingChannel { + name: "telegram".to_string(), + broadcasts: Arc::clone(&broadcasts), + })) + .await; + + manager + .channel_runtime + .write() + .await + .replace(ChannelRuntimeState { + channel_manager, + wasm_channel_runtime: Arc::new( + WasmChannelRuntime::new(WasmChannelRuntimeConfig::for_testing()) + .map_err(|err| format!("runtime: {err}"))?, + ), + pairing_store: Arc::new(PairingStore::with_base_dir(dir.path().join("pairing"))), + wasm_channel_router: Arc::new(WasmChannelRouter::new()), + wasm_channel_owner_ids: std::collections::HashMap::new(), + }); + + manager + .notify_telegram_owner_verified( + "telegram", + Some(&TelegramBindingData { + owner_id: 424242, + bot_username: Some("test_hot_bot".to_string()), + binding_state: TelegramOwnerBindingState::Existing, + }), + ) + .await; + + require( + broadcasts.lock().await.is_empty(), + "existing owner bindings should not trigger another confirmation DM", + ) + } + // ── resolve_env_credentials tests ──────────────────────────────────── #[test] @@ -5612,6 +6828,77 @@ mod tests { ); } + #[tokio::test] + async fn test_telegram_auth_instructions_include_owner_verification_guidance() + -> Result<(), String> { + let dir = tempfile::tempdir().map_err(|err| format!("temp dir: {err}"))?; + let channels_dir = dir.path().join("channels"); + std::fs::create_dir_all(&channels_dir).map_err(|err| format!("channels dir: {err}"))?; + + std::fs::write(channels_dir.join("telegram.wasm"), b"\0asm fake") + .map_err(|err| format!("write wasm: {err}"))?; + let caps = serde_json::json!({ + "type": "channel", + "name": "telegram", + "setup": { + "required_secrets": [ + { + "name": "telegram_bot_token", + "prompt": "Enter your Telegram Bot API token (from @BotFather)" + } + ] + } + }); + std::fs::write( + channels_dir.join("telegram.capabilities.json"), + serde_json::to_string(&caps).map_err(|err| format!("serialize caps: {err}"))?, + ) + .map_err(|err| format!("write caps: {err}"))?; + + let mgr = make_manager_custom_dirs(dir.path().join("tools"), channels_dir); + + let result = mgr + .auth("telegram") + .await + .map_err(|err| format!("telegram auth status: {err}"))?; + let instructions = result + .instructions() + .ok_or_else(|| "awaiting token instructions missing".to_string())?; + + require( + instructions.contains("Telegram Bot API token"), + "telegram auth instructions should still ask for the bot token", + )?; + require( + instructions.contains("one-time verification code") + && instructions.contains("/start CODE"), + "telegram auth instructions should explain the owner verification step", + ) + } + + #[test] + fn test_telegram_message_matches_verification_code_variants() -> Result<(), String> { + require( + telegram_message_matches_verification_code("iclaw-7qk2m9", "iclaw-7qk2m9"), + "plain verification code should match", + )?; + require( + telegram_message_matches_verification_code("/start iclaw-7qk2m9", "iclaw-7qk2m9"), + "/start payload should match", + )?; + require( + telegram_message_matches_verification_code( + "Hi! My code is: iclaw-7qk2m9", + "iclaw-7qk2m9", + ), + "conversational message containing the code should match", + )?; + require( + !telegram_message_matches_verification_code("/start something-else", "iclaw-7qk2m9"), + "wrong verification code should not match", + ) + } + #[tokio::test] async fn test_configure_dispatches_activation_by_kind() { // Regression: configure() must dispatch to the correct activation method diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 428d9b42c5..2a4d189f8e 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -453,6 +453,17 @@ pub struct ActivateResult { /// /// Returned by `ExtensionManager::configure()`, the single entrypoint /// for providing secrets to any extension (chat auth, gateway setup, etc.). +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerificationChallenge { + /// One-time code the user must send back to the integration. + pub code: String, + /// Human-readable instructions for completing verification. + pub instructions: String, + /// Deep-link or shortcut URL that prefills the verification payload when supported. + #[serde(skip_serializing_if = "Option::is_none")] + pub deep_link: Option, +} + #[derive(Debug, Clone)] pub struct ConfigureResult { /// Human-readable status message. @@ -461,6 +472,8 @@ pub struct ConfigureResult { pub activated: bool, /// OAuth authorization URL (if OAuth flow was started). pub auth_url: Option, + /// Pending manual verification challenge (for Telegram owner binding, etc.). + pub verification: Option, } fn default_true() -> bool { diff --git a/tests/e2e/conftest.py b/tests/e2e/conftest.py index dced10ea8e..b19c77af1a 100644 --- a/tests/e2e/conftest.py +++ b/tests/e2e/conftest.py @@ -39,6 +39,9 @@ # Temp directory for the libSQL database file (cleaned up automatically) _DB_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-") +# Temp HOME so pairing/allowFrom state never touches the developer's real ~/.ironclaw +_HOME_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-home-") + # Temp directories for WASM extensions. These start empty and are populated by # the install pipeline during tests; fixtures do not pre-populate dev build # artifacts into them. @@ -46,6 +49,42 @@ _WASM_CHANNELS_TMPDIR = tempfile.TemporaryDirectory(prefix="ironclaw-e2e-wasm-channels-") +def _latest_mtime(path: Path) -> float: + """Return the newest mtime under a file or directory.""" + if not path.exists(): + return 0.0 + if path.is_file(): + return path.stat().st_mtime + + latest = path.stat().st_mtime + for root, dirnames, filenames in os.walk(path): + dirnames[:] = [dirname for dirname in dirnames if dirname != "target"] + for name in filenames: + child = Path(root) / name + try: + latest = max(latest, child.stat().st_mtime) + except FileNotFoundError: + continue + return latest + + +def _binary_needs_rebuild(binary: Path) -> bool: + """Rebuild when the binary is missing or older than embedded sources.""" + if not binary.exists(): + return True + + binary_mtime = binary.stat().st_mtime + inputs = [ + ROOT / "Cargo.toml", + ROOT / "Cargo.lock", + ROOT / "build.rs", + ROOT / "providers.json", + ROOT / "src", + ROOT / "channels-src", + ] + return any(_latest_mtime(path) > binary_mtime for path in inputs) + + def _find_free_port() -> int: """Bind to port 0 and return the OS-assigned port.""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: @@ -57,7 +96,7 @@ def _find_free_port() -> int: def ironclaw_binary(): """Ensure ironclaw binary is built. Returns the binary path.""" binary = ROOT / "target" / "debug" / "ironclaw" - if not binary.exists(): + if _binary_needs_rebuild(binary): print("Building ironclaw (this may take a while)...") subprocess.run( ["cargo", "build", "--no-default-features", "--features", "libsql"], @@ -141,10 +180,12 @@ def _wasm_build_symlinks(): async def ironclaw_server(ironclaw_binary, mock_llm_server, wasm_tools_dir): """Start the ironclaw gateway. Yields the base URL.""" gateway_port = _find_free_port() + home_dir = _HOME_TMPDIR.name env = { # Minimal env: PATH for process spawning, HOME for Rust/cargo defaults "PATH": os.environ.get("PATH", "/usr/bin:/bin"), - "HOME": os.environ.get("HOME", "/tmp"), + "HOME": home_dir, + "IRONCLAW_BASE_DIR": os.path.join(home_dir, ".ironclaw"), "RUST_LOG": "ironclaw=info", "RUST_BACKTRACE": "1", "GATEWAY_ENABLED": "true", diff --git a/tests/e2e/scenarios/test_telegram_hot_activation.py b/tests/e2e/scenarios/test_telegram_hot_activation.py new file mode 100644 index 0000000000..833803d650 --- /dev/null +++ b/tests/e2e/scenarios/test_telegram_hot_activation.py @@ -0,0 +1,236 @@ +"""Telegram hot-activation UI coverage.""" + +import asyncio +import json + +from helpers import SEL + +_CONFIGURE_SECRET_INPUT = "input[type='password']" +_CONFIGURE_SAVE_BUTTON = ".configure-actions button.btn-ext.activate" + + +_TELEGRAM_INSTALLED = { + "name": "telegram", + "display_name": "Telegram", + "kind": "wasm_channel", + "description": "Telegram Bot API channel", + "url": None, + "active": False, + "authenticated": False, + "has_auth": False, + "needs_setup": True, + "tools": [], + "activation_status": "installed", + "activation_error": None, +} + +_TELEGRAM_ACTIVE = { + **_TELEGRAM_INSTALLED, + "active": True, + "authenticated": True, + "needs_setup": False, + "activation_status": "active", +} + + +async def go_to_extensions(page): + await page.locator(SEL["tab_button"].format(tab="extensions")).click() + await page.locator(SEL["tab_panel"].format(tab="extensions")).wait_for( + state="visible", timeout=5000 + ) + await page.locator( + f"{SEL['extensions_list']} .empty-state, {SEL['ext_card_installed']}" + ).first.wait_for(state="visible", timeout=8000) + + +async def mock_extension_lists(page, ext_handler): + async def handle_ext_list(route): + path = route.request.url.split("?")[0] + if path.endswith("/api/extensions"): + await ext_handler(route) + else: + await route.continue_() + + async def handle_tools(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"tools": []}), + ) + + async def handle_registry(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"entries": []}), + ) + + # Register the broad route first so the specific endpoints below win. + await page.route("**/api/extensions*", handle_ext_list) + await page.route("**/api/extensions/tools", handle_tools) + await page.route("**/api/extensions/registry", handle_registry) + + +async def wait_for_toast(page, text: str, *, timeout: int = 5000): + await page.locator(SEL["toast"], has_text=text).wait_for( + state="visible", timeout=timeout + ) + + +async def test_telegram_setup_modal_shows_bot_token_field(page): + async def handle_ext_list(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": [_TELEGRAM_INSTALLED]}), + ) + + async def handle_setup(route): + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "secrets": [ + { + "name": "telegram_bot_token", + "prompt": "Enter your Telegram Bot API token (from @BotFather)", + "provided": False, + "optional": False, + "auto_generate": False, + } + ] + } + ), + ) + + await mock_extension_lists(page, handle_ext_list) + await page.route("**/api/extensions/telegram/setup", handle_setup) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.locator(SEL["ext_configure_btn"], has_text="Setup").click() + + modal = page.locator(SEL["configure_modal"]) + await modal.wait_for(state="visible", timeout=5000) + assert "Telegram Bot API token" in await modal.text_content() + assert "IronClaw will show a one-time code" in ( + await modal.text_content() + ) + input_el = modal.locator(_CONFIGURE_SECRET_INPUT) + assert await input_el.count() == 1 + + +async def test_telegram_hot_activation_transitions_installed_to_active(page): + phase = {"value": "installed"} + captured_setup_payloads = [] + post_count = {"value": 0} + + async def handle_ext_list(route): + extensions = { + "installed": [_TELEGRAM_INSTALLED], + "active": [_TELEGRAM_ACTIVE], + }[phase["value"]] + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps({"extensions": extensions}), + ) + + async def handle_setup(route): + if route.request.method == "GET": + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "secrets": [ + { + "name": "telegram_bot_token", + "prompt": "Enter your Telegram Bot API token (from @BotFather)", + "provided": False, + "optional": False, + "auto_generate": False, + } + ] + } + ), + ) + return + + payload = json.loads(route.request.post_data or "{}") + captured_setup_payloads.append(payload) + post_count["value"] += 1 + await asyncio.sleep(0.05) + if post_count["value"] == 1: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "success": True, + "activated": False, + "message": "Configuration saved for 'telegram'. Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.", + "verification": { + "code": "iclaw-7qk2m9", + "instructions": "Send `/start iclaw-7qk2m9` to @test_hot_bot, then click Verify owner.", + "deep_link": "https://t.me/test_hot_bot?start=iclaw-7qk2m9", + }, + } + ), + ) + else: + await route.fulfill( + status=200, + content_type="application/json", + body=json.dumps( + { + "success": True, + "activated": True, + "message": "Configuration saved, Telegram owner verified, and 'telegram' activated. Hot-activated WASM channel", + } + ), + ) + + await mock_extension_lists(page, handle_ext_list) + await page.route("**/api/extensions/telegram/setup", handle_setup) + await go_to_extensions(page) + + card = page.locator(SEL["ext_card_installed"]).first + await card.locator(SEL["ext_configure_btn"], has_text="Setup").click() + + modal = page.locator(SEL["configure_modal"]) + await modal.wait_for(state="visible", timeout=5000) + await modal.locator(_CONFIGURE_SECRET_INPUT).fill("123456789:ABCdefGhI") + await modal.locator(_CONFIGURE_SAVE_BUTTON).click() + await modal.locator(_CONFIGURE_SAVE_BUTTON, has_text="Verify owner").wait_for( + state="visible", timeout=5000 + ) + assert "Verify owner" in ( + await modal.locator(_CONFIGURE_SAVE_BUTTON).text_content() + ) + assert "iclaw-7qk2m9" in (await modal.text_content()) + assert await modal.locator(".configure-verification-link").count() == 1 + + await modal.locator(_CONFIGURE_SAVE_BUTTON).click() + await page.locator(SEL["configure_overlay"]).wait_for(state="hidden", timeout=5000) + + phase["value"] = "active" + await page.evaluate( + """ + handleAuthCompleted({ + extension_name: 'telegram', + success: true, + message: "Configuration saved, Telegram owner verified, and 'telegram' activated. Hot-activated WASM channel", + }); + """ + ) + + await wait_for_toast(page, "Telegram owner verified") + await card.locator(SEL["ext_active_label"]).wait_for(state="visible", timeout=5000) + assert await card.locator(SEL["ext_pairing_label"]).count() == 0 + + assert captured_setup_payloads == [ + {"secrets": {"telegram_bot_token": "123456789:ABCdefGhI"}}, + {"secrets": {}}, + ] diff --git a/tests/telegram_auth_integration.rs b/tests/telegram_auth_integration.rs index 01d246a64b..8b27d8a8c8 100644 --- a/tests/telegram_auth_integration.rs +++ b/tests/telegram_auth_integration.rs @@ -40,8 +40,31 @@ macro_rules! require_telegram_wasm { /// Path to the built Telegram WASM module fn telegram_wasm_path() -> std::path::PathBuf { - std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm") + let local = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm"); + if local.exists() { + return local; + } + + if let Ok(output) = std::process::Command::new("git") + .args(["worktree", "list", "--porcelain"]) + .output() + && output.status.success() + { + let stdout = String::from_utf8_lossy(&output.stdout); + for line in stdout.lines() { + if let Some(path) = line.strip_prefix("worktree ") { + let candidate = std::path::PathBuf::from(path).join( + "channels-src/telegram/target/wasm32-wasip2/release/telegram_channel.wasm", + ); + if candidate.exists() { + return candidate; + } + } + } + } + + local } /// Create a test runtime for WASM channel operations.