Fix WeChat extension status and login i18n

This commit is contained in:
Coffee
2026-03-26 13:18:17 +08:00
parent 7dfc5ddd0d
commit 5b2dbd6ea8
9 changed files with 188 additions and 58 deletions
+35
View File
@@ -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#"{
+1
View File
@@ -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 {
+8 -4
View File
@@ -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
+79 -35
View File
@@ -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, '');
});
}
+17
View File
@@ -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',
+18
View File
@@ -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': '已配置',
+2 -6
View File
@@ -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<ExtensionActivationStatus> {
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
+23 -13
View File
@@ -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,
+5
View File
@@ -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,