From 067389d65fb69bc369f3110cecce9f83f476c317 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 04:14:45 +0800 Subject: [PATCH] fix(channels): add generic channel diagnostics --- scripts/dev-api.js | 177 +++++++++++++- src-tauri/src/commands/messaging.rs | 268 ++++++++++++++++++++- src/locales/modules/channels.js | 6 + src/pages/channels.js | 55 +++-- tests/channel-config-normalization.test.js | 79 ++++++ 5 files changed, 560 insertions(+), 25 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 6be57fd..c7318cc 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2578,6 +2578,141 @@ function channelRootHasMessagingCredential(root) { return MESSAGING_CREDENTIAL_FIELDS.some(key => hasConfiguredMessagingValue(root[key])) } +const CHANNEL_DIAG_REQUIRED_FIELDS = { + telegram: [['botToken', 'Bot Token']], + discord: [['token', 'Bot Token']], + feishu: [['appId', 'App ID'], ['appSecret', 'App Secret']], + dingtalk: [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']], + 'dingtalk-connector': [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']], + msteams: [['appId', 'App ID'], ['appPassword', 'App Password']], + signal: [['account', 'Signal 账号']], +} + +function requiredChannelCredentialFields(platform, form = {}) { + const storageKey = platformStorageKey(platform) + if (storageKey === 'slack') { + const mode = String(form.mode || 'socket').trim() || 'socket' + return [ + ['botToken', 'Bot Token'], + mode === 'http' ? ['signingSecret', 'Signing Secret'] : ['appToken', 'App Token'], + ] + } + if (storageKey === 'matrix') { + if (form.accessToken) return [['accessToken', 'Access Token']] + return [['homeserver', 'Homeserver'], ['userId', 'User ID'], ['password', 'Password']] + } + return CHANNEL_DIAG_REQUIRED_FIELDS[storageKey] || [] +} + +function channelDiagnosisCredentialsReady(platform, form = {}) { + const requiredFields = requiredChannelCredentialFields(platform, form) + if (requiredFields.length) { + return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key])) + } + return channelRootHasMessagingCredential(form) +} + +function compactDiagnosticDetails(values = []) { + return values.map(value => String(value || '').trim()).filter(Boolean).join(';') +} + +export function buildOpenClawChannelDiagnosis({ + platform, + accountId = '', + configExists = false, + channelEnabled = true, + form = {}, + verifyResult = null, + verifyError = '', +} = {}) { + const storageKey = platformStorageKey(platform) + const displayPlatform = platformListId(storageKey) + const checks = [] + + checks.push({ + id: 'config_exists', + ok: !!configExists, + title: '渠道配置已保存', + detail: configExists + ? `已读取 channels.${storageKey}${accountId ? `.accounts.${accountId}` : ''} 的配置。` + : `未在 openclaw.json 中找到 ${displayPlatform} 渠道配置,请先在「渠道列表」接入并保存。`, + }) + + checks.push({ + id: 'channel_enabled', + ok: !!channelEnabled, + title: '渠道已启用', + detail: channelEnabled + ? '渠道未被显式禁用,Gateway 重启/重载后会尝试加载。' + : `channels.${storageKey}.enabled 为 false,请先在渠道列表中启用该渠道。`, + }) + + const requiredFields = requiredChannelCredentialFields(storageKey, form) + const missing = requiredFields + .filter(([key]) => !hasConfiguredMessagingValue(form?.[key])) + .map(([, label]) => label) + const hasAnyCredential = channelRootHasMessagingCredential(form) + const credentialOk = requiredFields.length ? missing.length === 0 : hasAnyCredential + checks.push({ + id: 'credentials', + ok: credentialOk, + title: '必要凭证字段', + detail: credentialOk + ? (requiredFields.length + ? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。` + : '已检测到可用凭证字段。') + : (missing.length + ? `缺少 ${missing.join(' / ')},请补齐后保存。` + : '未检测到可用凭证字段,请检查渠道配置。'), + }) + + if (verifyError) { + checks.push({ + id: 'online_verify', + ok: false, + title: '平台在线校验', + detail: verifyError, + }) + } else if (verifyResult) { + const valid = verifyResult.valid === true + const errors = Array.isArray(verifyResult.errors) ? verifyResult.errors : [] + const warnings = Array.isArray(verifyResult.warnings) ? verifyResult.warnings : [] + const details = Array.isArray(verifyResult.details) ? verifyResult.details : [] + checks.push({ + id: 'online_verify', + ok: valid || (!valid && warnings.length > 0 && errors.length === 0), + title: '平台在线校验', + detail: valid + ? (compactDiagnosticDetails(details) || '平台 API 已接受当前凭证。') + : (compactDiagnosticDetails(errors) || compactDiagnosticDetails(warnings) || '该平台暂不支持在线校验。'), + }) + } else { + checks.push({ + id: 'online_verify', + ok: true, + title: '平台在线校验', + detail: '未执行在线校验,仅完成本地配置检查。', + }) + } + + const failed = checks.filter(check => !check.ok) + return { + ok: failed.length === 0, + overallReady: failed.length === 0, + platform: displayPlatform, + accountId: accountId || null, + checks, + userHints: failed.length + ? [ + '先修复未通过的检查项,保存渠道后重启或重载 Gateway。', + '在线校验只能证明平台凭证可用;群聊白名单、机器人邀请和平台回调仍需在对应平台控制台确认。', + ] + : [ + '配置侧检查已通过。若仍收不到消息,请确认 Gateway 已重启、机器人已加入目标会话,并检查 Gateway 日志。', + ], + } +} + function preserveMessagingCredentialRefs(entry, form, current) { delete entry.__secretRefs for (const key of MESSAGING_CREDENTIAL_FIELDS) { @@ -9666,8 +9801,46 @@ const handlers = { // 静默返回未安装即可,UI 会显示"未安装" return { installed: false, version: null, plugin: null } }, - diagnose_channel() { - return { ok: false, error: 'Web 模式暂未实现渠道诊断,请使用桌面客户端' } + async diagnose_channel({ platform, accountId } = {}) { + if (!platform || !String(platform).trim()) throw new Error('platform 不能为空') + const platformId = String(platform).trim() + const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' + const storageKey = platformStorageKey(platformId) + const cfg = readOpenclawConfigOptional() + const channelRoot = cfg.channels?.[storageKey] + const saved = handlers.read_platform_config({ platform: platformId, accountId: normalizedAccountId || null }) + const form = saved?.values || {} + const configExists = !!saved?.exists + const channelEnabled = !channelRoot || channelRoot.enabled !== false + const credentialsReady = channelDiagnosisCredentialsReady(platformId, form) + let verifyResult = null + let verifyError = '' + + if (configExists && credentialsReady) { + try { + verifyResult = await handlers.verify_bot_token({ platform: platformId, form }) + } catch (e) { + verifyError = e?.message || String(e) + } + } + + const result = buildOpenClawChannelDiagnosis({ + platform: platformId, + accountId: normalizedAccountId, + configExists, + channelEnabled, + form, + verifyResult, + verifyError, + }) + if (storageKey === 'qqbot') { + result.userHints = [ + 'Web 模式已完成配置级检查;QQ 插件、Gateway TCP 和 chatCompletions 深度诊断需要在桌面客户端执行。', + ...(result.userHints || []), + ] + result.faqUrl = 'https://q.qq.com/qqbot/openclaw/faq.html' + } + return result }, run_channel_action() { throw new Error('Web 模式暂未实现渠道操作,请使用桌面客户端') diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 2cc189b..952df67 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -200,6 +200,220 @@ fn value_has_messaging_credential(value: &Value) -> bool { .unwrap_or(false) } +fn required_channel_credential_fields( + platform: &str, + form: &Map, +) -> Vec<(&'static str, &'static str)> { + match platform_storage_key(platform) { + "telegram" => vec![("botToken", "Bot Token")], + "discord" => vec![("token", "Bot Token")], + "feishu" => vec![("appId", "App ID"), ("appSecret", "App Secret")], + "dingtalk-connector" => vec![("clientId", "Client ID"), ("clientSecret", "Client Secret")], + "msteams" => vec![("appId", "App ID"), ("appPassword", "App Password")], + "signal" => vec![("account", "Signal 账号")], + "slack" => { + let mode = form_string(form, "mode"); + vec![ + ("botToken", "Bot Token"), + if mode == "http" { + ("signingSecret", "Signing Secret") + } else { + ("appToken", "App Token") + }, + ] + } + "matrix" => { + if has_configured_messaging_value(form.get("accessToken")) { + vec![("accessToken", "Access Token")] + } else { + vec![ + ("homeserver", "Homeserver"), + ("userId", "User ID"), + ("password", "Password"), + ] + } + } + _ => vec![], + } +} + +fn channel_diagnosis_credentials_ready(platform: &str, form: &Map) -> bool { + let required_fields = required_channel_credential_fields(platform, form); + if required_fields.is_empty() { + return channel_root_has_messaging_credential(form); + } + required_fields + .iter() + .all(|(key, _)| has_configured_messaging_value(form.get(*key))) +} + +fn json_string_list(value: Option<&Value>) -> Vec { + value + .and_then(|v| v.as_array()) + .map(|arr| { + arr.iter() + .filter_map(|item| item.as_str()) + .map(str::trim) + .filter(|item| !item.is_empty()) + .map(str::to_string) + .collect() + }) + .unwrap_or_default() +} + +fn compact_diagnostic_details(values: &[String]) -> String { + values + .iter() + .map(|value| value.trim()) + .filter(|value| !value.is_empty()) + .collect::>() + .join(";") +} + +fn build_openclaw_channel_diagnosis( + platform: &str, + account_id: Option<&str>, + config_exists: bool, + channel_enabled: bool, + form: &Map, + verify_result: Option, + verify_error: Option, +) -> Value { + let storage_key = platform_storage_key(platform); + let display_platform = platform_list_id(storage_key); + let account_id = account_id.map(str::trim).filter(|id| !id.is_empty()); + let mut checks = Vec::new(); + + checks.push(json!({ + "id": "config_exists", + "ok": config_exists, + "title": "渠道配置已保存", + "detail": if config_exists { + format!( + "已读取 channels.{}{} 的配置。", + storage_key, + account_id.map(|id| format!(".accounts.{}", id)).unwrap_or_default() + ) + } else { + format!( + "未在 openclaw.json 中找到 {} 渠道配置,请先在「渠道列表」接入并保存。", + display_platform + ) + } + })); + + checks.push(json!({ + "id": "channel_enabled", + "ok": channel_enabled, + "title": "渠道已启用", + "detail": if channel_enabled { + "渠道未被显式禁用,Gateway 重启/重载后会尝试加载。".to_string() + } else { + format!("channels.{}.enabled 为 false,请先在渠道列表中启用该渠道。", storage_key) + } + })); + + let required_fields = required_channel_credential_fields(storage_key, form); + let missing: Vec<&str> = required_fields + .iter() + .filter(|(key, _)| !has_configured_messaging_value(form.get(*key))) + .map(|(_, label)| *label) + .collect(); + let credential_ok = if required_fields.is_empty() { + channel_root_has_messaging_credential(form) + } else { + missing.is_empty() + }; + let required_labels = required_fields + .iter() + .map(|(_, label)| *label) + .collect::>() + .join(" / "); + checks.push(json!({ + "id": "credentials", + "ok": credential_ok, + "title": "必要凭证字段", + "detail": if credential_ok { + if required_fields.is_empty() { + "已检测到可用凭证字段。".to_string() + } else { + format!("已填写 {}。", required_labels) + } + } else if missing.is_empty() { + "未检测到可用凭证字段,请检查渠道配置。".to_string() + } else { + format!("缺少 {},请补齐后保存。", missing.join(" / ")) + } + })); + + if let Some(error) = verify_error.filter(|error| !error.trim().is_empty()) { + checks.push(json!({ + "id": "online_verify", + "ok": false, + "title": "平台在线校验", + "detail": error + })); + } else if let Some(result) = verify_result { + let valid = result.get("valid").and_then(|v| v.as_bool()) == Some(true); + let errors = json_string_list(result.get("errors")); + let warnings = json_string_list(result.get("warnings")); + let details = json_string_list(result.get("details")); + let verify_ok = valid || (!warnings.is_empty() && errors.is_empty()); + checks.push(json!({ + "id": "online_verify", + "ok": verify_ok, + "title": "平台在线校验", + "detail": if valid { + let detail = compact_diagnostic_details(&details); + if detail.is_empty() { + "平台 API 已接受当前凭证。".to_string() + } else { + detail + } + } else { + let detail = compact_diagnostic_details(&errors); + if detail.is_empty() { + let warning_detail = compact_diagnostic_details(&warnings); + if warning_detail.is_empty() { + "该平台暂不支持在线校验。".to_string() + } else { + warning_detail + } + } else { + detail + } + } + })); + } else { + checks.push(json!({ + "id": "online_verify", + "ok": true, + "title": "平台在线校验", + "detail": "未执行在线校验,仅完成本地配置检查。" + })); + } + + let failed_count = checks + .iter() + .filter(|check| check.get("ok").and_then(|v| v.as_bool()) != Some(true)) + .count(); + json!({ + "ok": failed_count == 0, + "overallReady": failed_count == 0, + "platform": display_platform, + "accountId": account_id, + "checks": checks, + "userHints": if failed_count == 0 { + vec!["配置侧检查已通过。若仍收不到消息,请确认 Gateway 已重启、机器人已加入目标会话,并检查 Gateway 日志。"] + } else { + vec![ + "先修复未通过的检查项,保存渠道后重启或重载 Gateway。", + "在线校验只能证明平台凭证可用;群聊白名单、机器人邀请和平台回调仍需在对应平台控制台确认。", + ] + } + }) +} + fn insert_bool_as_string(form: &mut Map, source: &Value, key: &str) { if let Some(v) = source.get(key).and_then(|v| v.as_bool()) { form.insert( @@ -2308,13 +2522,55 @@ pub async fn diagnose_channel( platform: String, account_id: Option, ) -> Result { - match platform.as_str() { - "qqbot" => diagnose_qqbot_channel(account_id).await, - _ => Err(format!( - "暂不支持平台「{}」的深度诊断(当前仅实现 qqbot)", - platform - )), + let platform = platform.trim().to_string(); + if platform.is_empty() { + return Err("platform 不能为空".into()); } + if platform == "qqbot" { + return diagnose_qqbot_channel(account_id).await; + } + + let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({})); + let storage_key = platform_storage_key(&platform); + let normalized_account_id = account_id + .as_deref() + .map(str::trim) + .filter(|id| !id.is_empty()); + let channel_root = cfg.get("channels").and_then(|c| c.get(storage_key)); + let channel_enabled = channel_root + .and_then(|node| node.get("enabled")) + .and_then(|v| v.as_bool()) + .unwrap_or(true); + let saved = + read_platform_config(platform.clone(), normalized_account_id.map(str::to_string)).await?; + let config_exists = saved + .get("exists") + .and_then(|v| v.as_bool()) + .unwrap_or(false); + let form = saved + .get("values") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + let credentials_ready = channel_diagnosis_credentials_ready(&platform, &form); + let (verify_result, verify_error) = if config_exists && credentials_ready { + match verify_bot_token(platform.clone(), Value::Object(form.clone())).await { + Ok(result) => (Some(result), None), + Err(error) => (None, Some(error)), + } + } else { + (None, None) + }; + + Ok(build_openclaw_channel_diagnosis( + &platform, + normalized_account_id, + config_exists, + channel_enabled, + &form, + verify_result, + verify_error, + )) } /// 一键修复 QQ 插件:未安装则安装官方包并重启 Gateway;已安装则补齐 plugins.allow / entries 并重载 Gateway。 diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 63b1aec..ad18805 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -219,6 +219,12 @@ export default { qqRepairBtn: _('一键修复', 'One-Click Repair', '一鍵修复'), qqFaqBtn: _('打开 QQ OpenClaw 常见问题', 'Open QQ OpenClaw FAQ', '開啟 QQ OpenClaw 常见問題'), qqDiagTitle: _('QQ 联通诊断', 'QQ Connectivity Diagnosis', 'QQ 聯通诊斷'), + channelDiagTitle: _('{platform} 联通诊断', '{platform} Connectivity Diagnosis', '{platform} 聯通诊斷'), + channelDiagAllPassed: _('配置侧检查已通过', 'Configuration checks passed', '設定側檢查已通過'), + channelDiagHasFailed: _('存在需要处理的检查项', 'Some checks need attention', '存在需要處理的檢查項'), + channelDiagPassed: _('通过', 'Passed', '通過'), + channelDiagNeedsAction: _('需处理', 'Needs action', '需處理'), + channelDiagNoChecks: _('暂无诊断结果', 'No diagnosis results yet', '暫無诊斷結果'), notes: _('说明', 'Notes', '說明'), processing: _('处理中...', 'Processing...', '處理中...'), repairDone: _('修复完成', 'Repair complete'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 0c39819..886093c 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -1476,6 +1476,39 @@ function showQqDiagnoseModal(result, options = {}) { }) } +function showChannelDiagnoseModal(result, options = {}) { + const platformName = options.platformName || result?.platform || t('channels.channel') + const checks = Array.isArray(result?.checks) ? result.checks : [] + const summaryOk = !!result?.overallReady + const summary = summaryOk + ? `
${icon('check', 14)} ${t('channels.channelDiagAllPassed')}
` + : `
${icon('alert-triangle', 14)} ${t('channels.channelDiagHasFailed')}
` + const list = checks.map(c => { + const ok = !!c.ok + const tone = ok ? 'var(--success)' : 'var(--error)' + const bg = ok ? 'var(--success-muted)' : 'var(--error-muted, rgba(220,38,38,0.1))' + const label = ok ? t('channels.channelDiagPassed') : t('channels.channelDiagNeedsAction') + return `
+
+ ${ok ? icon('check', 14) : icon('x', 14)} + ${escapeAttr(c.title || '')} + ${label} +
+
${escapeAttr(c.detail || '')}
+
` + }).join('') + const hints = (result?.userHints || []).map(h => + `
  • ${escapeAttr(h)}
  • ` + ).join('') + const empty = `
    ${t('channels.channelDiagNoChecks')}
    ` + + showContentModal({ + title: t('channels.channelDiagTitle', { platform: platformName }), + content: `${summary}
    ${list || empty}
    ${t('channels.notes')}
      ${hints}
    `, + width: 540, + }) +} + async function runChannelTestForBinding(binding, btnEl) { const match = binding?.match || {} const channel = match.channel @@ -1489,30 +1522,18 @@ async function runChannelTestForBinding(binding, btnEl) { const prevHtml = btnEl?.innerHTML if (btnEl) { btnEl.disabled = true - btnEl.textContent = channel === 'qqbot' ? t('channels.diagnosing') : t('channels.testing') + btnEl.textContent = t('channels.diagnosing') } try { + const result = await api.diagnoseChannel(platformId, accountId) if (channel === 'qqbot') { - const result = await api.diagnoseChannel('qqbot', accountId) showQqDiagnoseModal(result, { accountId }) return } - const res = await api.readPlatformConfig(platformId, accountId) - if (!res?.exists) { - toast(t('channels.noCredentialsFound'), 'warning') - return - } - const form = res.values || {} - const out = await api.verifyBotToken(platformId, form) - if (out.valid) { - const details = (out.details || []).join(' · ') - toast(`${t('channels.testPassed')}${details ? ': ' + details : ''}`, 'success') - } else { - const errs = (out.errors || [t('channels.verifyFailed')]).join('; ') - toast(t('channels.testFailed') + ': ' + errs, 'error') - } + const platformName = PLATFORM_REGISTRY[platformId]?.label || CHANNEL_LABELS[platformId] || platformId + showChannelDiagnoseModal(result, { platformName, accountId }) } catch (e) { - toast(humanizeError(e, channel === 'qqbot' ? t('channels.diagFailed') : t('channels.testFailed')), 'error') + toast(humanizeError(e, t('channels.diagFailed')), 'error') } finally { if (btnEl) { btnEl.disabled = false diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index a641f9b..efa90a9 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -2,6 +2,7 @@ import test from 'node:test' import assert from 'node:assert/strict' import { + buildOpenClawChannelDiagnosis, buildMessagingPlatformFormValues, listPlatformAccounts, mergeOpenClawMessagingPlatformConfig, @@ -286,6 +287,84 @@ test('OpenClaw 渠道保存带账号标识时会写入 accounts 而不是覆盖 assert.equal(cfg.channels.slack.accounts['team-a'].appToken, 'team-app') }) +test('通用渠道诊断会指出 Telegram 缺少 Bot Token', () => { + const result = buildOpenClawChannelDiagnosis({ + platform: 'telegram', + configExists: true, + channelEnabled: true, + form: { + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + }, + }) + + assert.equal(result.ok, false) + assert.equal(result.overallReady, false) + assert.equal(result.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(result.checks.find(item => item.id === 'credentials')?.detail || '', /Bot Token/) +}) + +test('通用渠道诊断在缺少渠道配置时不会误报渠道已禁用', () => { + const result = buildOpenClawChannelDiagnosis({ + platform: 'telegram', + configExists: false, + channelEnabled: true, + form: {}, + }) + + assert.equal(result.ok, false) + assert.equal(result.checks.find(item => item.id === 'config_exists')?.ok, false) + assert.equal(result.checks.find(item => item.id === 'channel_enabled')?.ok, true) + assert.match(result.checks.find(item => item.id === 'channel_enabled')?.detail || '', /未被显式禁用/) +}) + +test('通用渠道诊断会按 Slack 模式检查动态必填凭证', () => { + const socketResult = buildOpenClawChannelDiagnosis({ + platform: 'slack', + configExists: true, + channelEnabled: true, + form: { + mode: 'socket', + botToken: 'xoxb-token', + }, + }) + const httpResult = buildOpenClawChannelDiagnosis({ + platform: 'slack', + configExists: true, + channelEnabled: true, + form: { + mode: 'http', + botToken: 'xoxb-token', + signingSecret: 'secret', + }, + }) + + assert.equal(socketResult.ok, false) + assert.match(socketResult.checks.find(item => item.id === 'credentials')?.detail || '', /App Token/) + assert.equal(httpResult.checks.find(item => item.id === 'credentials')?.ok, true) +}) + +test('通用渠道诊断会识别钉钉 Client ID 和 Client Secret', () => { + const result = buildOpenClawChannelDiagnosis({ + platform: 'dingtalk', + configExists: true, + channelEnabled: true, + form: { + clientId: 'ding-app-key', + clientSecret: 'ding-secret', + }, + verifyResult: { + valid: true, + details: ['已通过 accessToken 接口校验'], + }, + }) + + assert.equal(result.ok, true) + assert.equal(result.overallReady, true) + assert.equal(result.checks.find(item => item.id === 'credentials')?.ok, true) + assert.equal(result.checks.find(item => item.id === 'online_verify')?.ok, true) +}) + test('Discord 渠道保存会保留运行时需要的 applicationId', () => { const cfg = { channels: {} }