fix(channels): add generic channel diagnostics

This commit is contained in:
晴天
2026-05-23 04:14:45 +08:00
parent d3d527ca34
commit 067389d65f
5 changed files with 560 additions and 25 deletions

View File

@@ -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 模式暂未实现渠道操作,请使用桌面客户端')

View File

@@ -200,6 +200,220 @@ fn value_has_messaging_credential(value: &Value) -> bool {
.unwrap_or(false)
}
fn required_channel_credential_fields(
platform: &str,
form: &Map<String, Value>,
) -> 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<String, Value>) -> 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<String> {
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::<Vec<_>>()
.join("")
}
fn build_openclaw_channel_diagnosis(
platform: &str,
account_id: Option<&str>,
config_exists: bool,
channel_enabled: bool,
form: &Map<String, Value>,
verify_result: Option<Value>,
verify_error: Option<String>,
) -> 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::<Vec<_>>()
.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<String, Value>, 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<String>,
) -> Result<Value, String> {
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。

View File

@@ -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'),

View File

@@ -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
? `<div style="background:var(--success-muted);color:var(--success);padding:10px 14px;border-radius:var(--radius-md);margin-bottom:12px;font-size:var(--font-size-sm);line-height:1.5">${icon('check', 14)} ${t('channels.channelDiagAllPassed')}</div>`
: `<div style="background:var(--warning-muted);color:var(--warning);padding:10px 14px;border-radius:var(--radius-md);margin-bottom:12px;font-size:var(--font-size-sm);line-height:1.5">${icon('alert-triangle', 14)} ${t('channels.channelDiagHasFailed')}</div>`
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 `<div style="border:1px solid ${bg};border-left:3px solid ${tone};padding:10px 12px;margin-bottom:8px;background:var(--bg-tertiary);border-radius:var(--radius-md)">
<div style="display:flex;align-items:center;gap:8px;font-weight:600;color:var(--text-primary)">
<span style="color:${tone};min-width:18px">${ok ? icon('check', 14) : icon('x', 14)}</span>
<span>${escapeAttr(c.title || '')}</span>
<span style="margin-left:auto;font-size:var(--font-size-xs);font-weight:600;color:${tone};background:${bg};padding:2px 8px;border-radius:999px;white-space:nowrap">${label}</span>
</div>
<div style="font-size:var(--font-size-sm);color:var(--text-secondary);margin-top:6px;line-height:1.55;white-space:pre-wrap">${escapeAttr(c.detail || '')}</div>
</div>`
}).join('')
const hints = (result?.userHints || []).map(h =>
`<li style="margin-bottom:8px;line-height:1.5">${escapeAttr(h)}</li>`
).join('')
const empty = `<div class="form-hint" style="padding:12px 0">${t('channels.channelDiagNoChecks')}</div>`
showContentModal({
title: t('channels.channelDiagTitle', { platform: platformName }),
content: `${summary}<div style="max-height:min(52vh,420px);overflow-y:auto;margin-bottom:12px;margin-top:12px">${list || empty}</div><div style="font-weight:600;margin-bottom:8px;font-size:var(--font-size-sm)">${t('channels.notes')}</div><ul style="padding-left:18px;font-size:var(--font-size-sm);color:var(--text-secondary);margin:0">${hints}</ul>`,
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

View File

@@ -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: {} }