diff --git a/src/channels/wasm/schema.rs b/src/channels/wasm/schema.rs index b5081426..4f3007b0 100644 --- a/src/channels/wasm/schema.rs +++ b/src/channels/wasm/schema.rs @@ -139,6 +139,13 @@ impl ChannelCapabilitiesFile { serde_json::to_string(&self.config).unwrap_or_else(|_| "{}".to_string()) } + /// Whether this channel declares owner/pairing gating in its config. + pub fn requires_binding(&self) -> bool { + ["owner_id", "dm_policy", "allow_from"] + .iter() + .any(|key| self.config.contains_key(*key)) + } + /// Get the webhook secret header name for this channel. /// /// Returns the configured header name from capabilities, or a sensible default. @@ -569,6 +576,34 @@ mod tests { assert_eq!(caps.workspace_prefix, "integrations/custom/"); } + #[test] + fn test_requires_binding_detects_dm_owner_fields() { + let telegram = ChannelCapabilitiesFile::from_json( + r#"{ + "name": "telegram", + "config": { + "owner_id": null, + "dm_policy": "pairing", + "allow_from": [] + } + }"#, + ) + .unwrap(); + assert!(telegram.requires_binding()); + + let wechat = ChannelCapabilitiesFile::from_json( + r#"{ + "name": "wechat", + "config": { + "base_url": "https://ilinkai.weixin.qq.com", + "bot_type": "3" + } + }"#, + ) + .unwrap(); + assert!(!wechat.requires_binding()); + } + #[test] fn test_emit_rate_limit() { let json = r#"{ diff --git a/src/channels/web/handlers/extensions.rs b/src/channels/web/handlers/extensions.rs index d705591e..2a650ab7 100644 --- a/src/channels/web/handlers/extensions.rs +++ b/src/channels/web/handlers/extensions.rs @@ -47,6 +47,7 @@ pub async fn extensions_list_handler( &ext, has_paired, owner_bound_channels.contains(&ext.name), + ext.requires_binding, ) } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { Some(if ext.active { diff --git a/src/channels/web/server.rs b/src/channels/web/server.rs index c3d78987..682ff3b2 100644 --- a/src/channels/web/server.rs +++ b/src/channels/web/server.rs @@ -2050,6 +2050,7 @@ async fn extensions_list_handler( &ext, has_paired, owner_bound_channels.contains(&ext.name), + ext.requires_binding, ) } else if ext.kind == crate::extensions::ExtensionKind::ChannelRelay { Some(if ext.active { @@ -2986,12 +2987,13 @@ mod tests { tools: Vec::new(), needs_setup: true, has_auth: false, + requires_binding: true, installed: true, activation_error: None, version: None, }; - let owner_bound = classify_wasm_channel_activation(&ext, false, true); + let owner_bound = classify_wasm_channel_activation(&ext, false, true, ext.requires_binding); if owner_bound != Some(ExtensionActivationStatus::Active) { return Err(format!( "owner-bound channel should be active, got {:?}", @@ -2999,7 +3001,7 @@ mod tests { )); } - let unbound = classify_wasm_channel_activation(&ext, false, false); + let unbound = classify_wasm_channel_activation(&ext, false, false, ext.requires_binding); if unbound != Some(ExtensionActivationStatus::Pairing) { return Err(format!( "unbound channel should be pairing, got {:?}", @@ -3023,12 +3025,13 @@ mod tests { tools: Vec::new(), needs_setup: true, has_auth: false, + requires_binding: false, installed: true, activation_error: None, version: None, }; - let status = classify_wasm_channel_activation(&ext, false, false); + let status = classify_wasm_channel_activation(&ext, false, false, ext.requires_binding); if status != Some(ExtensionActivationStatus::Active) { return Err(format!( "wechat should be active after QR login, got {:?}", @@ -3052,13 +3055,14 @@ mod tests { tools: Vec::new(), needs_setup: true, has_auth: false, + requires_binding: false, installed: true, activation_error: None, version: None, }; let status = if relay.kind == crate::extensions::ExtensionKind::WasmChannel { - classify_wasm_channel_activation(&relay, false, false) + classify_wasm_channel_activation(&relay, false, false, relay.requires_binding) } else if relay.kind == crate::extensions::ExtensionKind::ChannelRelay { Some(if relay.active { ExtensionActivationStatus::Active diff --git a/src/channels/web/static/app.js b/src/channels/web/static/app.js index 4e3ca755..0b8ed33c 100644 --- a/src/channels/web/static/app.js +++ b/src/channels/web/static/app.js @@ -3058,12 +3058,12 @@ function showConfigureModal(name) { const setupFields = Array.isArray(setup.fields) ? setup.fields : []; const interactiveLogin = setup.interactive_login || null; if (secrets.length === 0 && setupFields.length === 0 && !interactiveLogin) { - showToast('No configuration needed for ' + name, 'info'); + showToast(I18n.t('extensions.noConfigNeeded', { name: name }), 'info'); return; } renderConfigureModal(name, secrets, setupFields, interactiveLogin); }) - .catch((err) => showToast('Failed to load setup: ' + err.message, 'error')); + .catch((err) => showToast(I18n.t('error.loadFailed', { message: err.message }), 'error')); } function renderConfigureModal(name, secrets, setupFields, interactiveLogin) { @@ -3092,10 +3092,10 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) { modal.appendChild(hint); } - if (interactiveLogin && interactiveLogin.instructions) { + if (interactiveLogin) { const hint = document.createElement('div'); hint.className = 'configure-hint'; - hint.textContent = interactiveLogin.instructions; + hint.textContent = interactiveLoginHintText(name, interactiveLogin); modal.appendChild(hint); } @@ -3193,7 +3193,7 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) { } if (interactiveLogin) { - modal.appendChild(renderInteractiveLoginPanel()); + modal.appendChild(renderInteractiveLoginPanel(name)); } const error = document.createElement('div'); @@ -3220,7 +3220,8 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) { if (interactiveLogin) { const loginBtn = document.createElement('button'); loginBtn.className = 'btn-ext activate'; - loginBtn.textContent = interactiveLogin.button_label || 'Connect'; + loginBtn.dataset.defaultLabel = interactiveLoginDefaultLabel(name, interactiveLogin); + loginBtn.textContent = loginBtn.dataset.defaultLabel; loginBtn.dataset.interactiveLogin = 'true'; loginBtn.addEventListener('click', () => startInteractiveLogin(name, overlay)); actions.appendChild(loginBtn); @@ -3244,25 +3245,27 @@ function renderConfigureModal(name, secrets, setupFields, interactiveLogin) { } } -function renderInteractiveLoginPanel() { +function renderInteractiveLoginPanel(name) { const panel = document.createElement('div'); panel.className = 'configure-qr-login'; panel.style.display = 'none'; const title = document.createElement('div'); title.className = 'configure-verification-title'; - title.textContent = 'Open WeChat QR Page'; + title.textContent = + name === 'wechat' ? I18n.t('config.wechatQrTitle') : I18n.t('auth.connect'); panel.appendChild(title); const status = document.createElement('div'); status.className = 'configure-verification-instructions'; - status.textContent = 'The QR flow opens in a separate tab.'; + status.textContent = interactiveLoginStatusText(name, null); status.dataset.qrStatus = 'true'; panel.appendChild(status); const link = document.createElement('a'); link.className = 'configure-verification-link'; - link.textContent = 'Open QR Page'; + link.textContent = + name === 'wechat' ? I18n.t('config.wechatQrOpen') : I18n.t('auth.connect'); link.target = '_blank'; link.rel = 'noreferrer noopener'; link.style.display = 'none'; @@ -3272,6 +3275,41 @@ function renderInteractiveLoginPanel() { return panel; } +function interactiveLoginHintText(name, interactiveLogin) { + if (name === 'wechat') return I18n.t('config.wechatHint'); + return (interactiveLogin && interactiveLogin.instructions) || ''; +} + +function interactiveLoginDefaultLabel(name, interactiveLogin) { + if (name === 'wechat') return I18n.t('config.wechatConnect'); + return (interactiveLogin && interactiveLogin.button_label) || I18n.t('auth.connect'); +} + +function interactiveLoginWaitingLabel(name) { + if (name === 'wechat') return I18n.t('config.wechatWaiting'); + return I18n.t('status.connecting'); +} + +function interactiveLoginStatusText(name, res) { + if (name !== 'wechat') return (res && res.message) || ''; + if (!res) return I18n.t('config.wechatQrIntro'); + + switch (res.status) { + case 'pending': + return res.qr_code_url ? I18n.t('config.wechatQrReady') : I18n.t('config.wechatQrWaiting'); + case 'scanned': + return I18n.t('config.wechatQrScanned'); + case 'refreshed': + return I18n.t('config.wechatQrRefreshed'); + case 'succeeded': + return I18n.t('config.wechatConnected'); + case 'failed': + return res.message || I18n.t('config.wechatQrFailed'); + default: + return res.message || I18n.t('config.wechatQrIntro'); + } +} + function getInteractiveLoginButton(overlay) { return overlay && overlay.querySelector('.configure-actions button[data-interactive-login="true"]'); } @@ -3283,14 +3321,17 @@ function getInteractiveLoginPanel(overlay) { function updateInteractiveLoginPanel(overlay, res) { const panel = getInteractiveLoginPanel(overlay); if (!panel) return; + const name = overlay && overlay.dataset ? overlay.dataset.extensionName : ''; const status = panel.querySelector('[data-qr-status="true"]'); const link = panel.querySelector('[data-qr-link="true"]'); panel.style.display = ''; if (status) { - status.textContent = res.status === 'refreshed' - ? 'The QR page was refreshed. Open it again if needed.' - : 'The QR flow opens in a separate tab.'; + if (name === 'wechat' && res.status === 'refreshed') { + status.textContent = I18n.t('config.wechatQrRefreshedHint'); + } else { + status.textContent = interactiveLoginStatusText(name, res); + } } if (link && res.qr_code_url) { @@ -3303,16 +3344,17 @@ function setInteractiveLoginBusy(overlay, busy, label) { const loginBtn = getInteractiveLoginButton(overlay); if (!loginBtn) return; loginBtn.disabled = !!busy; - if (label) { - loginBtn.textContent = label; - } + loginBtn.textContent = label || loginBtn.dataset.defaultLabel || I18n.t('auth.connect'); } function startInteractiveLogin(name, overlay) { if (!overlay || !document.body.contains(overlay)) return; clearConfigureInlineError(overlay); - setConfigureInlineStatus(overlay, 'Preparing WeChat QR page...'); - setInteractiveLoginBusy(overlay, true, 'Waiting for scan...'); + setConfigureInlineStatus( + overlay, + name === 'wechat' ? I18n.t('config.wechatPreparingQr') : I18n.t('status.connecting'), + ); + setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name)); apiFetch('/api/extensions/' + encodeURIComponent(name) + '/login/start', { method: 'POST', @@ -3321,21 +3363,27 @@ function startInteractiveLogin(name, overlay) { .then((res) => { if (!overlay || !document.body.contains(overlay)) return; if (!res.success || !res.session_id) { - setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); - setConfigureInlineError(overlay, res.message || 'Failed to start interactive login'); + setInteractiveLoginBusy(overlay, false); + setConfigureInlineError( + overlay, + res.message || I18n.t('config.interactiveLoginStartFailed'), + ); setConfigureInlineStatus(overlay, ''); return; } overlay.dataset.interactiveLoginSessionId = res.session_id; updateInteractiveLoginPanel(overlay, res); - setConfigureInlineStatus(overlay, res.message || ''); + setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res)); pollInteractiveLogin(name, overlay, res.session_id); }) .catch((err) => { if (!overlay || !document.body.contains(overlay)) return; - setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); - setConfigureInlineError(overlay, err.message || 'Failed to start interactive login'); + setInteractiveLoginBusy(overlay, false); + setConfigureInlineError( + overlay, + err.message || I18n.t('config.interactiveLoginStartFailed'), + ); setConfigureInlineStatus(overlay, ''); }); } @@ -3352,16 +3400,12 @@ function pollInteractiveLogin(name, overlay, sessionId) { if (!overlay || !document.body.contains(overlay)) return; if (overlay.dataset.interactiveLoginSessionId !== sessionId) return; - if (res.qr_code_url) { - updateInteractiveLoginPanel(overlay, res); - } - if (res.message) { - setConfigureInlineStatus(overlay, res.message); - } + updateInteractiveLoginPanel(overlay, res); + setConfigureInlineStatus(overlay, interactiveLoginStatusText(name, res)); if (res.status === 'pending' || res.status === 'scanned' || res.status === 'refreshed') { if (res.status === 'refreshed') { - setInteractiveLoginBusy(overlay, true, 'Waiting for scan...'); + setInteractiveLoginBusy(overlay, true, interactiveLoginWaitingLabel(name)); } window.setTimeout(function() { pollInteractiveLogin(name, overlay, sessionId); @@ -3371,20 +3415,20 @@ function pollInteractiveLogin(name, overlay, sessionId) { if (res.success && res.activated) { closeConfigureModal(name); - showToast(res.message || (name + ' connected successfully'), 'success'); + showToast(res.message || I18n.t('config.connectedSuccess', { name: name }), 'success'); refreshCurrentSettingsTab(); return; } - setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); - setConfigureInlineError(overlay, res.message || 'Interactive login failed'); + setInteractiveLoginBusy(overlay, false); + setConfigureInlineError(overlay, res.message || I18n.t('config.interactiveLoginFailed')); setConfigureInlineStatus(overlay, ''); }) .catch((err) => { if (!overlay || !document.body.contains(overlay)) return; if (overlay.dataset.interactiveLoginSessionId !== sessionId) return; - setInteractiveLoginBusy(overlay, false, 'Connect WeChat'); - setConfigureInlineError(overlay, err.message || 'Interactive login failed'); + setInteractiveLoginBusy(overlay, false); + setConfigureInlineError(overlay, err.message || I18n.t('config.interactiveLoginFailed')); setConfigureInlineStatus(overlay, ''); }); } diff --git a/src/channels/web/static/i18n/en.js b/src/channels/web/static/i18n/en.js index d2f5911e..86544fd8 100644 --- a/src/channels/web/static/i18n/en.js +++ b/src/channels/web/static/i18n/en.js @@ -361,6 +361,23 @@ I18n.register('en', { 'config.telegramStartOver': 'Start over', 'config.telegramStartOverHint': 'Telegram verification did not complete. Click Start over to generate a new code and try again.', 'config.telegramOpenBot': 'Open bot in Telegram', + 'config.wechatHint': 'Open the WeChat QR page in a new tab, then scan and confirm in WeChat.', + 'config.wechatConnect': 'Open QR Page', + 'config.wechatWaiting': 'Waiting for scan...', + 'config.wechatPreparingQr': 'Preparing WeChat QR page...', + 'config.wechatQrTitle': 'Open WeChat QR Page', + 'config.wechatQrOpen': 'Open QR Page', + 'config.wechatQrIntro': 'The QR flow opens in a separate tab.', + 'config.wechatQrReady': 'QR page is ready. Open it in a new tab, then scan and confirm in WeChat.', + 'config.wechatQrWaiting': 'Preparing the WeChat QR page...', + 'config.wechatQrScanned': 'QR scanned. Confirm the login in WeChat.', + 'config.wechatQrRefreshed': 'QR page refreshed.', + 'config.wechatQrRefreshedHint': 'The previous QR page expired. Open the new page and scan again.', + 'config.wechatConnected': 'WeChat connected.', + 'config.wechatQrFailed': 'WeChat connection failed.', + 'config.interactiveLoginStartFailed': 'Failed to start interactive login', + 'config.interactiveLoginFailed': 'Interactive login failed', + 'config.connectedSuccess': '{name} connected successfully', 'config.optional': ' (optional)', 'config.alreadySet': '(already set — leave empty to keep)', 'config.alreadyConfigured': 'Already configured', diff --git a/src/channels/web/static/i18n/zh-CN.js b/src/channels/web/static/i18n/zh-CN.js index b40e148d..9a0a7807 100644 --- a/src/channels/web/static/i18n/zh-CN.js +++ b/src/channels/web/static/i18n/zh-CN.js @@ -360,6 +360,24 @@ I18n.register('zh-CN', { 'config.telegramCommandLabel': '请在 Telegram 中发送:', 'config.telegramStartOver': '重新开始', 'config.telegramStartOverHint': 'Telegram 验证未完成。点击“重新开始”以生成新的验证码并重试。', + 'config.telegramOpenBot': '在 Telegram 中打开机器人', + 'config.wechatHint': '在新标签页打开微信扫码页,然后在微信里扫码并确认。', + 'config.wechatConnect': '打开扫码页', + 'config.wechatWaiting': '等待扫码中...', + 'config.wechatPreparingQr': '正在准备微信扫码页...', + 'config.wechatQrTitle': '打开微信扫码页', + 'config.wechatQrOpen': '打开扫码页', + 'config.wechatQrIntro': '扫码流程会在新标签页中打开。', + 'config.wechatQrReady': '扫码页已就绪。请在新标签页打开后,用微信扫码并确认。', + 'config.wechatQrWaiting': '正在准备微信扫码页...', + 'config.wechatQrScanned': '已扫码,请在微信中确认登录。', + 'config.wechatQrRefreshed': '扫码页已刷新。', + 'config.wechatQrRefreshedHint': '之前的扫码页已过期,请打开新页面重新扫码。', + 'config.wechatConnected': '微信已连接。', + 'config.wechatQrFailed': '微信连接失败。', + 'config.interactiveLoginStartFailed': '启动交互式登录失败', + 'config.interactiveLoginFailed': '交互式登录失败', + 'config.connectedSuccess': '{name} 连接成功', 'config.optional': '(可选)', 'config.alreadySet': '(已设置 — 留空以保持不变)', 'config.alreadyConfigured': '已配置', diff --git a/src/channels/web/types.rs b/src/channels/web/types.rs index a1e89cc2..540eb4f7 100644 --- a/src/channels/web/types.rs +++ b/src/channels/web/types.rs @@ -456,6 +456,7 @@ pub fn classify_wasm_channel_activation( ext: &crate::extensions::InstalledExtension, has_paired: bool, has_owner_binding: bool, + requires_binding: bool, ) -> Option { if ext.kind != crate::extensions::ExtensionKind::WasmChannel { return None; @@ -466,12 +467,7 @@ pub fn classify_wasm_channel_activation( } else if !ext.authenticated { ExtensionActivationStatus::Installed } else if ext.active { - // WeChat QR login already proves channel ownership and does not have a - // separate owner-binding/pairing phase like Telegram DM verification. - if ext.name == crate::extensions::wechat_login::WECHAT_CHANNEL_NAME - || has_paired - || has_owner_binding - { + if !requires_binding || has_paired || has_owner_binding { ExtensionActivationStatus::Active } else { ExtensionActivationStatus::Pairing diff --git a/src/extensions/manager.rs b/src/extensions/manager.rs index 785f0786..5c9fb570 100644 --- a/src/extensions/manager.rs +++ b/src/extensions/manager.rs @@ -1344,6 +1344,7 @@ impl ExtensionManager { tools, needs_setup: false, has_auth: false, + requires_binding: false, installed: true, activation_error: None, version: None, @@ -1395,6 +1396,7 @@ impl ExtensionManager { tools: if active { vec![name] } else { Vec::new() }, needs_setup: auth_state == ToolAuthState::NeedsSetup, has_auth: auth_state != ToolAuthState::NoAuth, + requires_binding: false, installed: true, activation_error: None, version, @@ -1424,20 +1426,25 @@ impl ExtensionManager { .get_with_kind(&name, Some(ExtensionKind::WasmChannel)) .await; let display_name = registry_entry.as_ref().map(|e| e.display_name.clone()); - let version = if let Some(ref cap_path) = discovered.capabilities_path { - tokio::fs::read(cap_path) - .await - .ok() - .and_then(|bytes| { - crate::channels::wasm::ChannelCapabilitiesFile::from_bytes( - &bytes, - ) + let (version, requires_binding) = + if let Some(ref cap_path) = discovered.capabilities_path { + tokio::fs::read(cap_path) + .await .ok() - }) - .and_then(|cap| cap.version) - } else { - None - }; + .and_then(|bytes| { + crate::channels::wasm::ChannelCapabilitiesFile::from_bytes( + &bytes, + ) + .ok() + }) + .map(|cap| { + let requires_binding = cap.requires_binding(); + (cap.version, requires_binding) + }) + } else { + None + } + .unwrap_or((None, false)); let version = version.or_else(|| registry_entry.and_then(|e| e.version.clone())); extensions.push(InstalledExtension { @@ -1451,6 +1458,7 @@ impl ExtensionManager { tools: Vec::new(), needs_setup: auth_state == ToolAuthState::NeedsSetup, has_auth: auth_state != ToolAuthState::NoAuth, + requires_binding, installed: true, activation_error, version, @@ -1489,6 +1497,7 @@ impl ExtensionManager { tools: Vec::new(), needs_setup: false, has_auth: true, + requires_binding: false, installed: true, activation_error, version: None, @@ -1523,6 +1532,7 @@ impl ExtensionManager { tools: Vec::new(), needs_setup: false, has_auth: false, + requires_binding: false, installed: false, activation_error: None, version: entry.version, diff --git a/src/extensions/mod.rs b/src/extensions/mod.rs index 96bf242b..21566574 100644 --- a/src/extensions/mod.rs +++ b/src/extensions/mod.rs @@ -553,6 +553,10 @@ pub struct InstalledExtension { /// Whether this extension has an auth configuration (OAuth or manual token). #[serde(default)] pub has_auth: bool, + /// Whether this extension still needs owner binding / pairing before it should + /// be treated as fully active in the UI. + #[serde(default)] + pub requires_binding: bool, /// Whether this extension is installed locally (false = available in registry but not installed). #[serde(default = "default_true")] pub installed: bool, @@ -1003,6 +1007,7 @@ mod tests { tools: vec!["send_email".to_string(), "read_inbox".to_string()], needs_setup: true, has_auth: true, + requires_binding: false, installed: false, activation_error: Some("token expired".to_string()), version: None,