/**
* 消息渠道管理
* 渠道列表 + Agent 对接(多绑定、独立配置、渠道测试)
*/
import { api, invalidate, safeTauriListen } from '../lib/tauri-api.js'
import { toast } from '../components/toast.js'
import { humanizeError } from '../lib/humanize-error.js'
import { showContentModal, showConfirm } from '../components/modal.js'
import { icon } from '../lib/icons.js'
import { CHANNEL_LABELS } from '../lib/channel-labels.js'
import { t } from '../lib/i18n.js'
import { termHelpHtml, attachTermTooltips } from '../lib/term-tooltip.js'
import { wsClient } from '../lib/ws-client.js'
import {
formatRuntimeAge,
getChannelRuntimeSummary,
getRuntimeStateMeta,
normalizeChannelRuntimeStatus,
} from '../lib/channel-runtime.js'
// ── 渠道注册表:面板内置向导,覆盖 OpenClaw 官方渠道 + 国内扩展渠道 ──
const DM_POLICY_OPTIONS = [
{ value: '', label: t('channels.policyDefault') },
{ value: 'pairing', label: t('channels.dmPairing') },
{ value: 'open', label: t('channels.dmOpen') },
{ value: 'allowlist', label: t('channels.dmAllowlist') },
{ value: 'disabled', label: t('channels.dmDisabled') },
]
const GROUP_POLICY_OPTIONS = (allLabel, { mention = false } = {}) => [
{ value: '', label: t('channels.policyDefault') },
{ value: 'open', label: allLabel },
...(mention ? [{ value: 'mentioned', label: t('channels.groupMentionOnly') }] : []),
{ value: 'allowlist', label: t('channels.groupAllowlist') },
{ value: 'disabled', label: t('channels.groupDisabled') },
]
const BOOLEAN_OPTIONS = [
{ value: '', label: t('channels.policyDefault') },
{ value: 'true', label: t('channels.enable') },
{ value: 'false', label: t('channels.disable') },
]
const PLATFORM_REGISTRY = {
qqbot: {
label: t('channels.qqbotLabel'),
iconName: 'message-square',
desc: t('channels.qqbotDesc'),
guide: [
t('channels.qqbotGuide1'),
t('channels.qqbotGuide2'),
t('channels.qqbotGuide3'),
t('channels.qqbotGuide4'),
t('channels.qqbotGuide5'),
t('channels.qqbotGuide6'),
],
guideFooter: t('channels.qqbotGuideFooter'),
fields: [
{ key: 'appId', label: 'AppID', placeholder: t('channels.qqbotAppIdPh'), required: true },
{ key: 'clientSecret', label: 'ClientSecret', placeholder: t('channels.qqbotSecretPh'), secret: true, required: true },
],
pluginRequired: '@tencent-connect/openclaw-qqbot@latest',
pluginId: 'qqbot',
},
dingtalk: {
label: t('channels.dingtalkLabel'),
iconName: 'message-square',
desc: t('channels.dingtalkDesc'),
guide: [
t('channels.dingtalkGuide1'),
t('channels.dingtalkGuide2'),
t('channels.dingtalkGuide3'),
t('channels.dingtalkGuide4'),
t('channels.dingtalkGuide5'),
t('channels.dingtalkGuide6'),
t('channels.dingtalkGuide7'),
],
guideFooter: t('channels.dingtalkGuideFooter'),
fields: [
{ key: 'clientId', label: 'Client ID', placeholder: t('channels.dingtalkClientIdPh'), required: true },
{ key: 'clientSecret', label: 'Client Secret', placeholder: t('channels.dingtalkClientSecretPh'), secret: true, required: true },
],
pluginRequired: '@dingtalk-real-ai/dingtalk-connector@latest',
pluginId: 'dingtalk-connector',
},
feishu: {
label: t('channels.feishuLabel'),
iconName: 'message-square',
desc: t('channels.feishuDesc'),
guide: [
t('channels.feishuGuide1'),
t('channels.feishuGuide2'),
t('channels.feishuGuide3'),
t('channels.feishuGuide4'),
t('channels.feishuGuide5'),
t('channels.feishuGuide6'),
],
guideFooter: t('channels.feishuGuideFooter'),
fields: [
{ key: 'appId', label: 'App ID', placeholder: t('channels.feishuAppIdPh'), required: true },
{ key: 'appSecret', label: 'App Secret', placeholder: t('channels.feishuAppSecretPh'), secret: true, required: true },
{
key: 'domain', label: t('channels.feishuDomainLabel'), type: 'select',
options: [
{ value: 'feishu', label: t('channels.feishuDomainFeishu') },
{ value: 'lark', label: t('channels.feishuDomainLark') },
],
required: false,
},
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups'), { mention: true }), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') },
],
pluginRequired: '@larksuite/openclaw-lark@latest',
pluginId: 'openclaw-lark',
pairingChannel: 'feishu',
},
telegram: {
label: 'Telegram',
iconName: 'send',
desc: t('channels.telegramDesc'),
guide: [
t('channels.telegramGuide1'),
t('channels.telegramGuide2'),
t('channels.telegramGuide3'),
t('channels.telegramGuide4'),
],
guideFooter: t('channels.telegramGuideFooter'),
fields: [
{ key: 'botToken', label: 'Bot Token', placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', secret: true, required: true },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') },
],
configKey: 'telegram',
pairingChannel: 'telegram',
},
zalo: {
label: 'Zalo',
iconName: 'send',
desc: t('channels.zaloDesc'),
guide: [
t('channels.zaloGuide1'),
t('channels.zaloGuide2'),
t('channels.zaloGuide3'),
t('channels.zaloGuide4'),
t('channels.zaloGuide5'),
],
guideFooter: t('channels.zaloGuideFooter'),
fields: [
{ key: 'botToken', label: 'Bot Token', placeholder: t('channels.zaloBotTokenPh'), secret: true, required: false, hint: t('channels.zaloBotTokenHint') },
{ key: 'tokenFile', label: 'Token File', placeholder: t('channels.zaloTokenFilePh'), required: false, hint: t('channels.zaloTokenFileHint') },
{ key: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://example.com/zalo-webhook', required: false },
{ key: 'webhookSecret', label: 'Webhook Secret', placeholder: t('channels.zaloWebhookSecretPh'), secret: true, required: false },
{ key: 'webhookPath', label: 'Webhook Path', placeholder: '/zalo-webhook', required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.zaloAllowFromPh'), required: false, hint: t('channels.zaloAllowFromHint') },
{ key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.zaloGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') },
{ key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false },
{ key: 'proxy', label: 'Proxy', placeholder: 'http://127.0.0.1:7890', required: false },
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false },
],
requiredAny: [{ keys: ['botToken', 'tokenFile'], label: t('channels.zaloTokenOrFile') }],
configKey: 'zalo',
pairingChannel: 'zalo',
pluginRequired: '@openclaw/zalo@latest',
pluginId: 'zalo',
},
zalouser: {
label: 'Zalo Personal',
iconName: 'message-circle',
desc: t('channels.zalouserDesc'),
guide: [
t('channels.zalouserGuide1'),
t('channels.zalouserGuide2'),
t('channels.zalouserGuide3'),
t('channels.zalouserGuide4'),
t('channels.zalouserGuide5'),
],
guideFooter: t('channels.zalouserGuideFooter'),
fields: [
{ key: 'profile', label: 'Profile', placeholder: 'default', required: false, hint: t('channels.zalouserProfileHint') },
{ key: 'dangerouslyAllowNameMatching', label: t('channels.zalouserNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.zalouserNameMatchingHint') },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.zalouserAllowFromPh'), required: false, hint: t('channels.zalouserAllowFromHint') },
{ key: 'groupAllowFrom', label: 'Group Allow From', placeholder: t('channels.zalouserGroupAllowFromPh'), required: false, hint: t('channels.groupAllowFromHint') },
{ key: 'historyLimit', label: 'History Limit', placeholder: '20', required: false },
{ key: 'messagePrefix', label: 'Message Prefix', placeholder: t('channels.optionalEg', { example: '[Zalo]' }), required: false },
{ key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false },
],
configKey: 'zalouser',
pluginRequired: '@openclaw/zalouser@latest',
pluginId: 'zalouser',
},
discord: {
label: 'Discord',
iconName: 'hash',
desc: t('channels.discordDesc'),
guide: [
t('channels.discordGuide1'),
t('channels.discordGuide2'),
t('channels.discordGuide3'),
t('channels.discordGuide4'),
],
guideFooter: t('channels.discordGuideFooter'),
fields: [
{ key: 'token', label: 'Bot Token', placeholder: 'MTExxxxxxxxx.Gxxxxxx.xxxxxxxx', secret: true, required: true },
{ key: 'applicationId', label: 'Application ID', placeholder: '123456789012345678', required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChannels')), required: false },
{ key: 'guildId', label: 'Guild ID', placeholder: 'Discord Server ID', required: false },
{ key: 'channelId', label: 'Channel ID', placeholder: 'Discord Channel ID;留空匹配整个服务器', required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') },
],
configKey: 'discord',
pairingChannel: 'discord',
},
slack: {
label: 'Slack',
iconName: 'hash',
desc: t('channels.slackDesc'),
guide: [
t('channels.slackGuide1'),
t('channels.slackGuide2'),
t('channels.slackGuide3'),
t('channels.slackGuide4'),
t('channels.slackGuide5'),
],
guideFooter: t('channels.slackGuideFooter'),
fields: [
{
key: 'mode', label: t('channels.modeLabel'), type: 'select', required: true,
options: [
{ value: 'socket', label: t('channels.slackSocketMode') },
{ value: 'http', label: t('channels.slackHttpMode') },
],
},
{ key: 'botToken', label: 'Bot Token', placeholder: 'xoxb-xxxxxxxxxxxx', secret: true, required: true },
{ key: 'appToken', label: 'App Token', placeholder: 'xapp-xxxxxxxxxxxx', secret: true, requiredWhen: { mode: 'socket' }, hint: t('channels.slackAppTokenHint') },
{ key: 'signingSecret', label: 'Signing Secret', placeholder: t('channels.slackSigningSecretPh'), secret: true, requiredWhen: { mode: 'http' }, hint: t('channels.slackSigningSecretHint') },
{ key: 'teamId', label: 'Team ID', placeholder: t('channels.slackTeamIdPh'), required: false },
{ key: 'webhookPath', label: 'Webhook Path', placeholder: t('channels.slackWebhookPathPh'), required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChannels'), { mention: true }), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') },
],
configKey: 'slack',
pairingChannel: 'slack',
},
// WhatsApp 已移除:上游插件运行时未加载,web.login.start 返回 "not available"
// 等上游修复后可重新启用
weixin: {
label: t('channels.weixinLabel'),
iconName: 'message-circle',
desc: t('channels.weixinDesc'),
guide: [
t('channels.weixinGuide1'),
t('channels.weixinGuide2'),
t('channels.weixinGuide3'),
t('channels.weixinGuide4'),
t('channels.weixinGuide5'),
],
guideFooter: t('channels.weixinGuideFooter'),
actions: [
{ id: 'install', label: t('channels.weixinInstall'), hint: t('channels.weixinInstallHint') },
{ id: 'login', label: t('channels.weixinLogin'), hint: t('channels.weixinLoginHint') },
],
fields: [],
configKey: 'openclaw-weixin',
panelSupport: 'action-only',
},
msteams: {
label: 'Microsoft Teams',
iconName: 'users',
desc: t('channels.msteamsDesc'),
guide: [
t('channels.msteamsGuide1'),
t('channels.msteamsGuide2'),
t('channels.msteamsGuide3'),
t('channels.msteamsGuide4'),
],
guideFooter: t('channels.msteamsGuideFooter'),
fields: [
{ key: 'appId', label: 'App ID', placeholder: 'Azure AD Application ID', required: true },
{ key: 'appPassword', label: 'App Password', placeholder: 'Azure AD Client Secret', secret: true, required: true },
{ key: 'tenantId', label: 'Tenant ID', placeholder: t('channels.msteamsTenantIdPh'), required: false },
{ key: 'botEndpoint', label: 'Bot Endpoint', placeholder: 'https://example.com/api/teams/messages', required: false },
{ key: 'webhookPath', label: 'Webhook Path', placeholder: '/msteams/messages', required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllTeams'), { mention: true }), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.msteamsAllowFromPh'), required: false },
],
configKey: 'msteams',
pluginRequired: '@openclaw/msteams@latest',
pluginId: 'msteams',
},
signal: {
label: 'Signal',
iconName: 'shield',
desc: t('channels.signalDesc'),
guide: [
t('channels.signalGuide1'),
t('channels.signalGuide2'),
t('channels.signalGuide3'),
],
guideFooter: t('channels.signalGuideFooter'),
fields: [
{ key: 'account', label: t('channels.signalAccountLabel'), placeholder: t('channels.signalAccountPh'), required: true },
{ key: 'cliPath', label: t('channels.signalCliPathLabel'), placeholder: t('channels.signalCliPathPh'), required: false },
{ key: 'httpUrl', label: 'HTTP URL', placeholder: t('channels.optionalEg', { example: 'http://127.0.0.1:8080' }), required: false },
{ key: 'httpHost', label: 'HTTP Host', placeholder: t('channels.optionalEg', { example: '127.0.0.1' }), required: false },
{ key: 'httpPort', label: 'HTTP Port', placeholder: t('channels.optionalEg', { example: '8080' }), required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.signalAllowFromPh'), required: false },
],
configKey: 'signal',
},
matrix: {
label: 'Matrix',
iconName: 'globe',
desc: t('channels.matrixDesc'),
guide: [
t('channels.matrixGuide1'),
t('channels.matrixGuide2'),
t('channels.matrixGuide3'),
],
guideFooter: t('channels.matrixGuideFooter'),
fields: [
{ key: 'homeserver', label: 'Homeserver', placeholder: 'https://matrix.org', required: true },
{ key: 'accessToken', label: 'Access Token', placeholder: 'syt_xxxxx', secret: true, required: false, hint: t('channels.matrixAccessTokenHint') },
{ key: 'userId', label: 'User ID', placeholder: '@bot:matrix.org', required: false },
{ key: 'password', label: 'Password', placeholder: t('channels.matrixPasswordPh'), secret: true, required: false },
{ key: 'deviceId', label: 'Device ID', placeholder: t('channels.optionalEg', { example: 'CLAWPANEL' }), required: false },
{ key: 'e2ee', label: 'E2EE', type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'true', label: t('channels.enable') }, { value: 'false', label: t('channels.disable') }], required: false },
{ key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false },
{ key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllRooms')), required: false },
{ key: 'allowFrom', label: 'Allow From', placeholder: t('channels.matrixAllowFromPh'), required: false },
],
configKey: 'matrix',
pluginRequired: '@openclaw/matrix@latest',
pluginId: 'matrix',
},
}
function parseChannelsRouteIntent() {
const params = new URLSearchParams(location.hash.split('?')[1] || '')
return {
tab: params.get('tab') || '',
action: params.get('action') || '',
agentId: params.get('agent') || '',
}
}
function activateChannelsPageTab(page, key = 'channels') {
const activeKey = key === 'agents' ? 'agents' : 'channels'
page.querySelectorAll('#channels-page-tabs .tab').forEach(tab => {
tab.classList.toggle('active', tab.dataset.chTab === activeKey)
})
const listEl = page.querySelector('#channels-panel-list')
const agentsEl = page.querySelector('#channels-panel-agents')
if (listEl) listEl.style.display = activeKey === 'channels' ? '' : 'none'
if (agentsEl) agentsEl.style.display = activeKey === 'agents' ? '' : 'none'
}
// ── 页面生命周期 ──
export async function render() {
const page = document.createElement('div')
page.className = 'page'
page.innerHTML = `
${t('channels.tabChannels')}
${t('channels.tabAgents')}
${t('channels.available')}
${t('channels.agentBindHint')}
`
bindChannelTabs(page)
const state = {
configured: [],
bindings: [],
agents: [],
runtimeStatus: normalizeChannelRuntimeStatus(null),
runtimeStatusError: '',
routeIntent: parseChannelsRouteIntent(),
routeIntentConsumed: false,
routeIntentHintShown: false,
}
await loadPlatforms(page, state)
return page
}
function bindChannelTabs(page) {
page.querySelectorAll('#channels-page-tabs .tab').forEach(tab => {
tab.addEventListener('click', () => {
activateChannelsPageTab(page, tab.dataset.chTab)
})
})
}
export function cleanup() {}
// ── 数据加载 ──
async function loadPlatforms(page, state) {
try {
const list = await api.listConfiguredPlatforms()
state.configured = Array.isArray(list) ? list : []
} catch (e) {
toast(humanizeError(e, t('channels.loadFailed')), 'error')
state.configured = []
}
try {
const res = await api.listAllBindings()
state.bindings = Array.isArray(res?.bindings) ? res.bindings : []
} catch {
state.bindings = []
}
try {
state.agents = await api.listAgents()
if (!Array.isArray(state.agents)) state.agents = []
} catch {
state.agents = []
}
renderConfigured(page, state)
renderAvailable(page, state)
renderAgentBindings(page, state)
applyRouteIntent(page, state)
refreshChannelRuntimeStatus(page, state, { probe: true, timeoutMs: 5000 })
}
async function loadChannelRuntimeStatus(state, options = {}) {
state.runtimeStatusError = ''
try {
const raw = await wsClient.requestCompat('channels.status', {
probe: options.probe !== false,
timeoutMs: options.timeoutMs || 5000,
}, null)
state.runtimeStatus = normalizeChannelRuntimeStatus(raw)
} catch (e) {
state.runtimeStatus = normalizeChannelRuntimeStatus(null)
state.runtimeStatusError = e?.message || String(e)
}
}
async function refreshChannelRuntimeStatus(page, state, options = {}) {
await loadChannelRuntimeStatus(state, options)
renderConfigured(page, state)
}
function applyRouteIntent(page, state) {
const intent = state.routeIntent
if (!intent) return
const requestedTab = intent.action === 'bind'
? 'agents'
: (intent.tab === 'agents' ? 'agents' : intent.tab === 'channels' ? 'channels' : '')
if (requestedTab) activateChannelsPageTab(page, requestedTab)
if (state.routeIntentConsumed) return
if (intent.action === 'bind' && intent.agentId) {
const enabledConfigured = (state.configured || []).filter(p => p.enabled !== false)
if (!enabledConfigured.length) {
activateChannelsPageTab(page, 'channels')
if (!state.routeIntentHintShown) {
state.routeIntentHintShown = true
toast(t('channels.enableChannelFirst'), 'info')
}
return
}
state.routeIntentConsumed = true
const targetCard = Array.from(page.querySelectorAll('.agent-binding-card')).find(card => card.dataset.agentId === intent.agentId)
targetCard?.scrollIntoView?.({ block: 'center', behavior: 'smooth' })
setTimeout(() => openAddAgentBindingModal(intent.agentId, page, state), 0)
return
}
if (requestedTab) state.routeIntentConsumed = true
}
// ── 已配置平台渲染 ──
// ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ──
const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser']
function supportsMessagingMultiAccount(pid) {
return MULTI_INSTANCE_PLATFORMS.includes(pid)
}
function platformLabel(pid) {
return PLATFORM_REGISTRY[pid]?.label || CHANNEL_LABELS[pid] || pid
}
function renderRuntimeNotice(state) {
if (state.runtimeStatusError) {
return `
${icon('alert-triangle', 14)}
无法读取 Gateway 渠道运行态:${escapeAttr(state.runtimeStatusError)}
`
}
if (!state.runtimeStatus?.supported) {
return `
${icon('alert-triangle', 14)}
当前 OpenClaw 内核不支持通用渠道运行态,请升级到新版内核;配置编辑仍可继续使用。
`
}
const warnings = Array.isArray(state.runtimeStatus.warnings) ? state.runtimeStatus.warnings : []
if (state.runtimeStatus.partial || warnings.length) {
return `
${icon('alert-triangle', 14)}
${state.runtimeStatus.partial ? '运行态结果不完整' : '运行态探测有提示'}${warnings.length ? `:${escapeAttr(warnings.join(';'))}` : ''}
`
}
return ''
}
function renderRuntimeBadge(summary) {
if (!summary.supported) return ''
const meta = getRuntimeStateMeta(summary.state)
return `${icon(meta.icon, 12)} ${meta.label} `
}
function renderRuntimeSummary(summary) {
if (!summary.supported) {
return `通用运行态不可用
`
}
const parts = []
if (summary.counts.total) parts.push(`${summary.counts.total} 个账号`)
if (summary.counts.connected) parts.push(`${summary.counts.connected} 已连接`)
if (summary.counts.running) parts.push(`${summary.counts.running} 运行中`)
if (summary.counts.error) parts.push(`${summary.counts.error} 异常`)
if (summary.lastInboundAt) parts.push(`最近接收 ${formatRuntimeAge(summary.lastInboundAt)}`)
if (summary.lastOutboundAt) parts.push(`最近发送 ${formatRuntimeAge(summary.lastOutboundAt)}`)
if (summary.lastError) parts.push(`错误:${summary.lastError}`)
return `${escapeAttr(parts.join(' · ') || 'Gateway 暂未返回账号状态')}
`
}
function renderRuntimeAccountInfo(summary, accountId) {
if (!summary.supported) return ''
const normalizedAccountId = accountId || 'default'
const account = summary.accounts.find(a => (a.accountId || 'default') === normalizedAccountId)
|| (!accountId ? summary.accounts.find(a => a.accountId === summary.defaultAccountId) : null)
if (!account) return ''
const meta = getRuntimeStateMeta(account.state)
const details = []
if (account.lastError) details.push(account.lastError)
if (account.healthState) details.push(`health=${account.healthState}`)
if (account.lastInboundAt) details.push(`收 ${formatRuntimeAge(account.lastInboundAt)}`)
if (account.lastOutboundAt) details.push(`发 ${formatRuntimeAge(account.lastOutboundAt)}`)
if (account.probe && typeof account.probe === 'object' && account.probe.ok === false) details.push('探测失败')
return `
${icon(meta.icon, 12)} ${meta.label}
`
}
function renderRuntimeActions(summary, accountId = '') {
if (!summary.supported) {
return `${icon('refresh-cw', 14)} 刷新状态 `
}
const accountAttr = escapeAttr(accountId || '')
const stopDisabled = summary.state === 'missing' || summary.state === 'configured'
const logoutDisabled = summary.state === 'missing'
return `
${icon('refresh-cw', 14)} 刷新
${icon('play', 14)} 启动
${icon('stop', 14)} 停止
${icon('x-circle', 14)} 注销
`
}
async function handleRuntimeAction(pid, action, accountId, btn, page, state) {
const channel = getChannelBindingKey(pid)
const prevHtml = btn?.innerHTML
if (btn) {
btn.disabled = true
btn.textContent = action === 'refresh' ? '刷新中' : '处理中'
}
try {
if (action === 'refresh') {
await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 })
renderConfigured(page, state)
toast('渠道运行态已刷新', 'success')
return
}
if (!wsClient.connected || !wsClient.gatewayReady) {
throw new Error('Gateway WebSocket 未连接,请先启动 OpenClaw Gateway')
}
const params = { channel }
if (accountId) params.accountId = accountId
await wsClient.request(`channels.${action}`, params)
await loadChannelRuntimeStatus(state, { probe: true, timeoutMs: 7000 })
renderConfigured(page, state)
const actionText = action === 'start' ? '启动' : action === 'stop' ? '停止' : '注销'
toast(`${platformLabel(pid)} ${actionText}完成`, 'success')
} catch (e) {
state.runtimeStatusError = e?.message || String(e)
renderConfigured(page, state)
toast(humanizeError(e, '渠道运行时操作失败'), 'error')
} finally {
if (btn) {
btn.disabled = false
if (prevHtml != null) btn.innerHTML = prevHtml
}
}
}
function renderConfigured(page, state) {
const el = page.querySelector('#platforms-configured')
if (!state.configured.length) {
el.innerHTML = ''
return
}
el.innerHTML = `
${t('channels.configured')}
${renderRuntimeNotice(state)}
`
// 已接入平台的操作选项弹窗
function showPlatformActionMenu(pid, page, state) {
const configured = state.configured.find(p => p.id === pid)
if (!configured) return
const accounts = Array.isArray(configured.accounts) ? configured.accounts : []
const hasAccounts = accounts.length > 0
const supportsMulti = supportsMessagingMultiAccount(pid)
// 统计当前 channel+accountId 组合已有的 agent 绑定
const channelKey = getChannelBindingKey(pid)
const getBindingInfo = (accountId) => {
const bindings = (state.bindings || []).filter(b =>
b.match?.channel === channelKey &&
(b.match?.accountId || '') === (accountId || '')
)
return bindings.map(b => b.agentId || 'main')
}
const actions = []
if (hasAccounts) {
accounts.forEach(acc => {
const accId = acc.accountId || 'default'
const agents = getBindingInfo(acc.accountId || '')
actions.push({
label: `${icon('edit', 14)} ${t('channels.editAccountLabel', { id: accId })}${acc.appId ? ' · ' + acc.appId : ''}`,
sub: agents.length ? `${t('channels.bound')}: ${agents.join(', ')}` : t('channels.notBoundAgent'),
onClick: () => openConfigDialog(pid, page, state, acc.accountId || '')
})
actions.push({
label: `${icon('link', 14)} ${t('channels.addAgentBindingForAccount')}`,
sub: t('channels.addAgentBindingSub'),
onClick: () => openAddAgentBindingModalForAccount(pid, acc.accountId || '', page, state)
})
})
} else {
const agents = getBindingInfo('')
actions.push({
label: `${icon('edit', 14)} ${t('channels.editConfig')}`,
sub: agents.length ? `${t('channels.bound')}: ${agents.join(', ')}` : t('channels.notBoundAgent'),
onClick: () => openConfigDialog(pid, page, state, null)
})
actions.push({
label: `${icon('link', 14)} ${t('channels.addAgentBinding')}`,
sub: t('channels.routeToAgent'),
onClick: () => openAddAgentBindingModalForAccount(pid, null, page, state)
})
}
if (supportsMulti) {
actions.push({
label: `${icon('plus', 14)} ${t('channels.addNewAccount')}`,
sub: t('channels.addNewAccountSub'),
onClick: () => openConfigDialog(pid, page, state, '')
})
}
const actionHtml = actions.map(a => `
${a.label}
${a.sub}
`).join('')
const modal = showContentModal({
title: `${platformLabel(pid)} ${t('channels.actions')}`,
content: `${actionHtml}
`,
width: 400,
})
modal.querySelectorAll('[data-action="run"]').forEach((btn, i) => {
btn.addEventListener('click', () => {
modal.close?.() || modal.remove?.()
actions[i].onClick()
})
})
}
// 快速为指定 channel+accountId 添加 Agent 绑定(不打开完整配置弹窗)
async function openAddAgentBindingModalForAccount(pid, accountId, page, state) {
const agents = Array.isArray(state.agents) ? state.agents : []
if (!agents.length) {
toast(t('channels.createAgentFirst'), 'warning')
return
}
const configured = state.configured.find(p => p.id === pid)
const channelKey = getChannelBindingKey(pid)
const agentOptions = agents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
return `${a.id}${a.id !== label ? ' — ' + escapeAttr(label) : ''} `
}).join('')
const accountLabel = accountId ? t('channels.accountLabel', { id: accountId }) : t('channels.defaultAccount')
const modal = showContentModal({
title: t('channels.bindAgentTitle', { platform: platformLabel(pid), account: accountLabel }),
content: `
`,
buttons: [{ label: t('channels.saveBinding'), className: 'btn btn-primary', id: 'btn-quick-bind-save' }],
width: 440,
})
const PEER_KIND_HINTS = {
'': t('channels.peerAllHint'),
direct: t('channels.peerDirectHint'),
group: t('channels.peerGroupHint'),
}
const PEER_HINT_LABELS = {
direct: t('channels.peerDirectLabel'),
group: t('channels.peerGroupLabel'),
}
const selPeerKind = modal.querySelector('#quick-bind-peer-kind')
const peerHint = modal.querySelector('#quick-bind-peer-hint')
const wrapPeerId = modal.querySelector('#quick-bind-peer-id-wrap')
const inpPeerId = modal.querySelector('#quick-bind-peer-id')
const lblPeerId = modal.querySelector('#quick-bind-peer-id-label')
const hintPeerId = modal.querySelector('#quick-bind-peer-id-hint')
selPeerKind?.addEventListener('change', () => {
const kind = selPeerKind.value
if (peerHint) peerHint.textContent = PEER_KIND_HINTS[kind] || ''
if (kind) {
wrapPeerId.style.display = ''
if (lblPeerId) lblPeerId.textContent = PEER_HINT_LABELS[kind] || t('channels.targetId')
if (inpPeerId) inpPeerId.placeholder = kind === 'direct' ? 'ou_xxxxxxxxxxxxxxxx' : 'oc_xxxxxxxxxxxxxxxx'
if (hintPeerId) hintPeerId.innerHTML = t('channels.peerIdHint')
} else {
wrapPeerId.style.display = 'none'
if (inpPeerId) inpPeerId.value = ''
}
})
modal.querySelector('#btn-quick-bind-save').onclick = async () => {
const agentId = modal.querySelector('#quick-bind-agent')?.value
if (!agentId) return
const peerKind = selPeerKind?.value || ''
const peerId = inpPeerId?.value?.trim() || ''
// 检查重复
const dup = (state.bindings || []).some(b => {
const bm = b.match || {}
const bp = bm.peer
return (b.agentId || 'main') === agentId &&
bm.channel === channelKey &&
(bm.accountId || '') === (accountId || '') &&
((bp?.kind || bp) ? (bp?.kind || bp) === peerKind : !peerKind) &&
((bp?.id) ? bp.id === peerId : !peerId)
})
if (dup) {
toast(t('channels.duplicateBinding'), 'warning')
return
}
let bindingConfig = {}
if (peerKind === 'direct' && peerId) {
bindingConfig.peer = { kind: 'direct', id: peerId }
} else if (peerKind === 'group' && peerId) {
bindingConfig.peer = { kind: 'group', id: peerId }
}
modal.querySelector('#btn-quick-bind-save').disabled = true
modal.querySelector('#btn-quick-bind-save').textContent = t('channels.saving')
try {
await api.saveAgentBinding(agentId, channelKey, accountId, bindingConfig)
toast(t('channels.bindingSaved'), 'success')
modal.close?.() || modal.remove?.()
await loadPlatforms(page, state)
} catch (e) {
toast(humanizeError(e, t('channels.saveFailed')), 'error')
} finally {
modal.querySelector('#btn-quick-bind-save').disabled = false
modal.querySelector('#btn-quick-bind-save').textContent = t('channels.saveBinding')
}
}
}
el.querySelectorAll('.platform-card').forEach(card => {
const pid = card.dataset.pid
// 点击卡片区域弹出操作菜单(不再直接进入编辑)
card.querySelector('.platform-card-header')?.addEventListener('click', (e) => {
// 忽略按钮的点击(按钮有自己的事件)
if (e.target.closest('button')) return
showPlatformActionMenu(pid, page, state)
})
card.querySelector('[data-action="add-account"]')?.addEventListener('click', () => openConfigDialog(pid, page, state, ''))
card.querySelector('[data-action="edit"]')?.addEventListener('click', () => openConfigDialog(pid, page, state))
card.querySelectorAll('[data-runtime-action]').forEach(btn => {
btn.addEventListener('click', () => {
handleRuntimeAction(pid, btn.dataset.runtimeAction, btn.dataset.accountId || '', btn, page, state)
})
})
card.querySelectorAll('[data-action="edit-account"]').forEach(btn => {
btn.addEventListener('click', () => {
const accountId = btn.dataset.accountId
openConfigDialog(pid, page, state, accountId)
})
})
card.querySelectorAll('[data-action="remove-account"]').forEach(btn => {
btn.addEventListener('click', async () => {
const accountId = btn.dataset.accountId
const displayName = accountId ? `${platformLabel(pid)} ${t('channels.accountLabel', { id: accountId })}` : `${platformLabel(pid)} ${t('channels.defaultAccount')}`
const yes = await showConfirm(t('channels.confirmRemoveAccount', { name: displayName }))
if (!yes) return
try {
await api.removeMessagingPlatform(pid, accountId || null)
toast(t('channels.removed'), 'info')
await loadPlatforms(page, state)
} catch (e) { toast(humanizeError(e, t('channels.removeFailed')), 'error') }
})
})
card.querySelector('[data-action="toggle"]')?.addEventListener('click', async () => {
const cur = state.configured.find(p => p.id === pid)
if (!cur) return
try {
await api.toggleMessagingPlatform(pid, !cur.enabled)
toast(`${platformLabel(pid)} ${cur.enabled ? t('channels.disabled') : t('channels.enabled')}`, 'success')
await loadPlatforms(page, state)
} catch (e) { toast(humanizeError(e, t('channels.operationFailed')), 'error') }
})
card.querySelector('[data-action="remove"]')?.addEventListener('click', async () => {
const channelKey = getChannelBindingKey(pid)
const linkedBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey).length
const impact = [
t('channels.removePlatformImpactConfig'),
t('channels.removePlatformImpactStop'),
]
if (linkedBindings > 0) {
impact.unshift(t('channels.removePlatformImpactBindings', { n: linkedBindings }))
}
const yes = await showConfirm({
title: t('channels.removePlatformTitle', { name: platformLabel(pid) }),
message: t('channels.confirmRemovePlatform', { name: platformLabel(pid) }),
impact,
confirmText: t('channels.removePlatformBtn'),
cancelText: t('channels.removePlatformCancel'),
})
if (!yes) return
try {
await api.removeMessagingPlatform(pid)
toast(t('channels.removed'), 'info')
await loadPlatforms(page, state)
} catch (e) { toast(humanizeError(e, t('channels.removeFailed')), 'error') }
})
})
}
// ── 可接入平台渲染 ──
function renderAvailable(page, state) {
const el = page.querySelector('#platforms-available')
const configuredIds = new Set(state.configured.map(p => p.id))
el.innerHTML = Object.entries(PLATFORM_REGISTRY).map(([pid, reg]) => {
const done = configuredIds.has(pid)
return `
${icon(reg.iconName, 28)}
${reg.label}
${reg.desc}
${reg.actions?.length ? `${t('channels.supportsActions')} ` : ''}
${done ? `${t('channels.connectedClickEdit')} ` : ''}
`
}).join('')
el.querySelectorAll('.platform-pick').forEach(btn => {
const pid = btn.dataset.pid
btn.onclick = () => openConfigDialog(pid, page, state)
})
}
// ── Agent 对接:按 Agent 管理多条渠道绑定 ──
/** openclaw binding.match.channel → listConfiguredPlatforms 的 id(read_platform_config 的 platform) */
function bindingChannelToPlatformId(channel) {
if (!channel) return ''
if (channel === 'dingtalk-connector') return 'dingtalk'
if (channel === 'openclaw-weixin') return 'weixin'
return channel
}
function channelKeyLabel(ch) {
const pid = bindingChannelToPlatformId(ch)
return CHANNEL_LABELS[pid] || CHANNEL_LABELS[ch] || ch || '—'
}
function formatBindingMatchSummary(binding) {
const match = binding?.match || {}
const peer = match?.peer
const parts = [channelKeyLabel(match.channel)]
if (match.accountId) parts.push(`${t('channels.accountShort')} ${match.accountId}`)
if (peer) {
if (typeof peer === 'string') {
parts.push(`${t('channels.peerDm')} ${peer}`)
} else if (typeof peer === 'object' && peer) {
const kindLabel = peer.kind === 'group' ? t('channels.peerGroupShort') : peer.kind === 'channel' ? t('channels.peerChannelShort') : t('channels.peerDm')
parts.push(`${kindLabel} ${peer.id || ''}`)
}
}
return parts.join(' · ')
}
function collectAgentBindingRows(state) {
const agents = Array.isArray(state.agents) ? state.agents : []
const byId = new Map(agents.map(a => [a.id, a]))
const bindingAgentIds = new Set()
for (const b of state.bindings || []) {
bindingAgentIds.add(b.agentId || 'main')
}
const rows = agents.map(a => ({ ...a, orphan: false }))
for (const id of bindingAgentIds) {
if (!byId.has(id)) {
rows.push({ id, identityName: '', orphan: true })
}
}
return rows
}
function renderAgentBindings(page, state) {
const root = page.querySelector('#agents-bindings-root')
if (!root) return
const rows = collectAgentBindingRows(state)
if (!rows.length) {
root.innerHTML = `${t('channels.noAgents')}
`
return
}
const configured = state.configured || []
const canBind = configured.filter(p => p.enabled !== false)
root.innerHTML = rows.map(agent => {
const aid = agent.id
const display = agent.identityName ? agent.identityName.split(',')[0].trim() : ''
const subtitle = agent.orphan
? `${t('channels.orphanAgent')} `
: (display && display !== aid ? escapeAttr(display) : '')
const list = (state.bindings || []).filter(b => (b.agentId || 'main') === aid)
const rowsHtml = list.length
? list.map((b, idx) => {
const match = b.match || {}
const ch = match.channel || ''
const acct = match.accountId || ''
const summary = formatBindingMatchSummary(b)
return `
${escapeAttr(summary)}
${escapeAttr(ch)}${acct ? ' · ' + escapeAttr(acct) : ''}
${icon('zap', 12)} ${t('channels.diagnose')}
${icon('trash', 12)} ${t('channels.remove')}
`
}).join('')
: `💬
${t('channels.noBindings')}
`
const addDisabled = !canBind.length ? 'disabled' : ''
return `
${icon('package', 18)} ${escapeAttr(aid)}
${subtitle ? `
${subtitle}
` : ''}
${icon('plus', 14)} ${t('channels.addChannelBinding')}
${rowsHtml}
`
}).join('')
root.querySelectorAll('[data-action="add-binding"]').forEach(btn => {
if (btn.disabled) {
btn.title = t('channels.enableChannelFirst')
return
}
btn.addEventListener('click', () => {
const card = btn.closest('.agent-binding-card')
openAddAgentBindingModal(card?.dataset.agentId, page, state)
})
})
root.querySelectorAll('[data-action="test-binding"]').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.agent-binding-row')
const aid = row?.dataset.agent
const idx = Number(row?.dataset.idx)
const list = (state.bindings || []).filter(b => (b.agentId || 'main') === aid)
const binding = list[idx]
if (!binding) return
await runChannelTestForBinding(binding, btn)
})
})
root.querySelectorAll('[data-action="del-binding"]').forEach(btn => {
btn.addEventListener('click', async () => {
const row = btn.closest('.agent-binding-row')
const aid = row?.dataset.agent
const idx = Number(row?.dataset.idx)
const list = (state.bindings || []).filter(b => (b.agentId || 'main') === aid)
const binding = list[idx]
if (!binding) return
const match = binding.match || {}
const ch = match.channel
const acct = match.accountId || null
const yes = await showConfirm({
title: t('channels.removeBindingTitle'),
message: t('channels.confirmRemoveBinding', { agent: aid, summary: formatBindingMatchSummary(binding) }),
impact: [
t('channels.removeBindingImpactAgent'),
t('channels.removeBindingImpactConfig'),
],
confirmText: t('channels.removeBindingBtn'),
cancelText: t('channels.removeBindingCancel'),
})
if (!yes) return
try {
await api.deleteAgentBinding(aid, ch, acct, match)
toast(t('channels.bindingRemoved'), 'success')
await loadPlatforms(page, state)
} catch (e) {
toast(humanizeError(e, t('channels.removeFailed')), 'error')
}
})
})
}
async function openAddAgentBindingModal(agentId, page, state) {
const configured = (state.configured || []).filter(p => p.enabled !== false)
if (!configured.length) {
toast(t('channels.enableChannelFirst'), 'warning')
return
}
const platformOptions = configured.map(p => {
const label = platformLabel(p.id)
return `${escapeAttr(label)} (${escapeAttr(p.id)}) `
}).join('')
const modal = showContentModal({
title: t('channels.addBindingForAgent', { agent: agentId }),
content: `
${t('channels.subAccount')}
`,
buttons: [{ label: t('channels.saveBinding'), className: 'btn btn-primary', id: 'btn-add-bind-save' }],
width: 480,
})
const selPlat = modal.querySelector('#add-bind-platform')
const wrapAcct = modal.querySelector('#add-bind-account-wrap')
const selAcct = modal.querySelector('#add-bind-account')
const selPeerKind = modal.querySelector('#add-bind-peer-kind')
const peerHint = modal.querySelector('#add-bind-peer-kind-hint')
const wrapPeerId = modal.querySelector('#add-bind-peer-id-wrap')
const inpPeerId = modal.querySelector('#add-bind-peer-id')
const lblPeerId = modal.querySelector('#add-bind-peer-id-label')
const hintPeerId = modal.querySelector('#add-bind-peer-id-hint')
const warnEl = modal.querySelector('#add-bind-warning')
const PEER_KIND_HINTS = {
'': t('channels.peerAllHint'),
direct: t('channels.peerDirectHint'),
group: t('channels.peerGroupHint'),
}
const PEER_HINT_LABELS = {
direct: t('channels.peerDirectLabel'),
group: t('channels.peerGroupLabel'),
}
const showWarning = (msg, level = 'warning') => {
warnEl.style.display = ''
warnEl.innerHTML = `${escapeAttr(msg)}
`
}
const hideWarning = () => {
warnEl.style.display = 'none'
warnEl.innerHTML = ''
}
const syncAccounts = () => {
const pid = selPlat?.value
const p = configured.find(x => x.id === pid)
const accounts = Array.isArray(p?.accounts) ? p.accounts : []
if (accounts.length) {
wrapAcct.style.display = ''
selAcct.innerHTML = accounts.map(a => `${escapeAttr(a.accountId || 'default')}${a.appId ? ` · ${escapeAttr(a.appId)}` : ''} `).join('')
} else {
// 无多账号时,也显示一行提示,方便用户去渠道列表添加
wrapAcct.style.display = ''
selAcct.innerHTML = `— ${t('channels.noMultiAccount')} — `
selAcct.disabled = true
}
}
// 当账号为空时,在 peer hint 里给出提示
const syncPeerHint = () => {
const kind = selPeerKind?.value || ''
const noAccount = selAcct?.value === '' || selAcct?.disabled
if (peerHint) {
if (noAccount && !kind) {
peerHint.textContent = t('channels.noMultiAccountHint')
} else {
peerHint.textContent = PEER_KIND_HINTS[kind] || ''
}
}
if (kind) {
wrapPeerId.style.display = ''
if (lblPeerId) lblPeerId.textContent = PEER_HINT_LABELS[kind] || t('channels.targetId')
if (inpPeerId) inpPeerId.placeholder = kind === 'direct' ? 'ou_xxxxxxxxxxxxxxxx' : 'oc_xxxxxxxxxxxxxxxx'
if (hintPeerId) hintPeerId.innerHTML = t('channels.peerIdHintDetailed')
} else {
wrapPeerId.style.display = 'none'
if (inpPeerId) inpPeerId.value = ''
}
hideWarning()
}
selPlat?.addEventListener('change', () => { syncAccounts(); hideWarning() })
selPeerKind?.addEventListener('change', syncPeerHint)
syncAccounts()
syncPeerHint()
modal.querySelector('#btn-add-bind-save').onclick = async () => {
const pid = selPlat?.value
if (!pid) return
const channelKey = getChannelBindingKey(pid)
const accountId = (selAcct?.disabled || selAcct?.value === '' || selAcct?.value === `— ${t('channels.noMultiAccount')} —`)
? null
: (selAcct?.value?.trim() || null)
const peerKind = selPeerKind?.value || ''
const peerId = inpPeerId?.value?.trim() || ''
// 检查重复绑定
const dup = (state.bindings || []).some(b => {
const bm = b.match || {}
const bp = bm.peer
return (b.agentId || 'main') === agentId &&
bm.channel === channelKey &&
(bm.accountId || '') === (accountId || '') &&
((bp?.kind || bp) ? (bp?.kind || bp) === peerKind : !peerKind) &&
((bp?.id) ? bp.id === peerId : !peerId)
})
if (dup) {
toast(t('channels.duplicateBinding'), 'warning')
return
}
// 构建 peer 配置
let bindingConfig = {}
if (peerKind === 'direct' && peerId) {
bindingConfig.peer = { kind: 'direct', id: peerId }
} else if (peerKind === 'group' && peerId) {
bindingConfig.peer = { kind: 'group', id: peerId }
}
btnSave.disabled = true
btnSave.textContent = t('channels.saving')
try {
const res = await api.saveAgentBinding(agentId, channelKey, accountId, bindingConfig)
// 处理警告
const warnings = res?.warnings || []
if (warnings.length) {
warnings.forEach(w => showWarning(w, 'warning'))
}
toast(t('channels.bindingSaved'), 'success')
if (!warnings.length) {
modal.close?.() || modal.remove?.()
}
await loadPlatforms(page, state)
} catch (e) {
toast(humanizeError(e, t('channels.saveFailed')), 'error')
} finally {
btnSave.disabled = false
btnSave.textContent = t('channels.saveBinding')
}
}
const btnSave = modal.querySelector('#btn-add-bind-save')
}
function openExternalUrl(href) {
if (!href) return
import('@tauri-apps/plugin-shell').then(({ open }) => open(href)).catch(() => window.open(href, '_blank'))
}
function getManualCommandSpecs(pid, reg) {
if (pid === 'weixin') {
return [
{
id: 'install',
title: t('channels.manualInstallCommand'),
hint: t('channels.manualInstallHint', { platform: reg.label }),
command: 'npx -y @tencent-weixin/openclaw-weixin-cli@latest install',
},
{
id: 'login',
title: t('channels.manualLoginCommand'),
hint: t('channels.manualLoginHint'),
command: 'openclaw channels login --channel openclaw-weixin',
},
]
}
if (!reg.pluginRequired) {
return []
}
const commands = [{
id: 'install',
title: t('channels.manualInstallCommand'),
hint: t('channels.manualInstallHint', { platform: reg.label }),
command: `openclaw plugins install ${reg.pluginRequired}`,
}]
if (pid === 'zalouser') {
commands.push({
id: 'login',
title: t('channels.manualLoginCommand'),
hint: t('channels.zalouserManualLoginHint'),
command: 'openclaw channels login --channel zalouser',
})
}
return commands
}
function buildManualCommandPanel(commandSpecs) {
if (!commandSpecs.length) return ''
return `
${t('channels.manualCommands')}
${t('channels.manualCommandsHint')}
${commandSpecs.map(spec => `
${spec.title}
${spec.hint}
${t('common.copy')}
${escapeAttr(spec.command)}
`).join('')}
`
}
async function copyTextToClipboard(text) {
if (navigator.clipboard?.writeText) {
await navigator.clipboard.writeText(text)
return
}
const textarea = document.createElement('textarea')
textarea.value = text
textarea.setAttribute('readonly', '')
textarea.style.position = 'fixed'
textarea.style.opacity = '0'
document.body.appendChild(textarea)
textarea.select()
const ok = document.execCommand('copy')
textarea.remove()
if (!ok) throw new Error('copy failed')
}
function bindManualCommandCopy(root, commandSpecs) {
if (!root || !commandSpecs.length) return
const commandMap = new Map(commandSpecs.map(spec => [spec.id, spec.command]))
root.querySelectorAll('[data-manual-copy]').forEach(btn => {
btn.addEventListener('click', async () => {
const command = commandMap.get(btn.dataset.manualCopy)
if (!command) return
const prev = btn.textContent
try {
await copyTextToClipboard(command)
btn.textContent = t('common.copied')
toast(t('common.copied'), 'success')
setTimeout(() => {
btn.textContent = prev
}, 1200)
} catch (e) {
toast(humanizeError(e, t('channels.copyCommandFailed')), 'error')
}
})
})
}
/** QQ:展示后端完整诊断(凭证 + Gateway + 插件 + chatCompletions);可选一键修复插件 */
function showQqDiagnoseModal(result, options = {}) {
const accountId = options.accountId != null ? options.accountId : null
const faqUrl = result?.faqUrl || 'https://q.qq.com/qqbot/openclaw/faq.html'
const checks = Array.isArray(result?.checks) ? result.checks : []
const pluginFailed = checks.some(c => c.id === 'qq_plugin' && !c.ok)
const list = checks.map(c => {
const ok = !!c.ok
const color = ok ? 'var(--success)' : 'var(--error)'
const mark = ok ? '✓' : '✗'
return `
${mark} ${escapeAttr(c.title || '')}
${escapeAttr(c.detail || '')}
`
}).join('')
const hints = (result?.userHints || []).map(h =>
`${escapeAttr(h)} `
).join('')
const summary = result?.overallReady
? `${t('channels.qqDiagAllPassed')}
`
: `${t('channels.qqDiagHasFailed')}
`
const repairHint = pluginFailed
? `${t('channels.qqRepairHint')}
`
: ''
const buttons = []
if (pluginFailed) {
buttons.push({ label: t('channels.qqRepairBtn'), className: 'btn btn-primary', id: 'btn-diag-repair' })
}
buttons.push({
label: t('channels.qqFaqBtn'),
className: pluginFailed ? 'btn btn-secondary' : 'btn btn-primary',
id: 'btn-diag-faq',
})
const diagModal = showContentModal({
title: t('channels.qqDiagTitle'),
content: `${summary}${repairHint}${list}
${t('channels.notes')}
`,
buttons,
width: 540,
})
diagModal.querySelector('#btn-diag-faq')?.addEventListener('click', () => openExternalUrl(faqUrl))
const repairBtn = diagModal.querySelector('#btn-diag-repair')
repairBtn?.addEventListener('click', async () => {
const prev = repairBtn.innerHTML
try {
repairBtn.disabled = true
repairBtn.textContent = t('channels.processing')
const out = await api.repairQqbotChannelSetup()
toast(out?.message || t('channels.repairDone'), 'success')
const fresh = await api.diagnoseChannel('qqbot', accountId)
diagModal.remove()
showQqDiagnoseModal(fresh, { accountId })
} catch (e) {
toast(humanizeError(e, t('channels.repairFailed')), 'error')
} finally {
repairBtn.disabled = false
repairBtn.innerHTML = prev
}
})
}
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')}
`,
width: 540,
})
}
async function runChannelTestForBinding(binding, btnEl) {
const match = binding?.match || {}
const channel = match.channel
const accountId = match.accountId || null
const platformId = bindingChannelToPlatformId(channel)
if (!platformId) {
toast(t('channels.unknownChannelType'), 'warning')
return
}
const prevHtml = btnEl?.innerHTML
if (btnEl) {
btnEl.disabled = true
btnEl.textContent = t('channels.diagnosing')
}
try {
const result = await api.diagnoseChannel(platformId, accountId)
if (channel === 'qqbot') {
showQqDiagnoseModal(result, { accountId })
return
}
const platformName = PLATFORM_REGISTRY[platformId]?.label || CHANNEL_LABELS[platformId] || platformId
showChannelDiagnoseModal(result, { platformName, accountId })
} catch (e) {
toast(humanizeError(e, t('channels.diagFailed')), 'error')
} finally {
if (btnEl) {
btnEl.disabled = false
if (prevHtml != null) btnEl.innerHTML = prevHtml
}
}
}
// ── WhatsApp Gateway QR 登录 ──
async function handleGatewayWhatsAppLogin(btn, resultEl, actionDef) {
const origLabel = btn.textContent
btn.disabled = true
btn.textContent = t('channels.connectingGateway')
// 检查 Gateway WebSocket 是否已连接
if (!wsClient.connected || !wsClient.gatewayReady) {
resultEl.innerHTML = `
${icon('alert-triangle', 14)} ${t('channels.gatewayNotConnected')}
`
btn.disabled = false
btn.textContent = origLabel
return
}
resultEl.innerHTML = `
${t('channels.generatingQr')}
`
try {
btn.textContent = t('channels.generatingQrShort')
const startResult = await wsClient.request('web.login.start', { force: false })
if (!startResult?.qrDataUrl) {
// 已链接或无 QR 数据
resultEl.innerHTML = `
${icon('check', 14)} ${escapeAttr(startResult?.message || t('channels.whatsappAlreadyLinked'))}
`
btn.disabled = false
btn.textContent = origLabel
return
}
// 显示 QR 码
resultEl.innerHTML = `
${t('channels.whatsappScanQr')}
${t('channels.whatsappScanPath')}
${t('channels.waitingScan')}
`
// 等待扫码完成
btn.textContent = t('channels.waitingScan')
const statusEl = resultEl.querySelector('#whatsapp-login-status')
const waitResult = await wsClient.request('web.login.wait', { timeoutMs: 120000 })
if (waitResult?.connected) {
if (statusEl) statusEl.innerHTML = `${icon('check', 14)} ${t('channels.linkedSuccess')} `
resultEl.innerHTML = `
${icon('check', 14)} ${t('channels.whatsappLinked')} ${escapeAttr(waitResult.message || '')}
`
toast(t('channels.whatsappLinked'), 'success')
} else {
if (statusEl) statusEl.innerHTML = `${escapeAttr(waitResult?.message || t('channels.scanTimeout'))} `
resultEl.innerHTML = `
${icon('alert-triangle', 14)} ${escapeAttr(waitResult?.message || t('channels.scanTimeoutRetry'))}
`
}
} catch (e) {
const msg = String(e?.message || e)
// web login provider is not available = WhatsApp 插件未加载
const hint = /not available|not supported/i.test(msg)
? '. ' + t('channels.whatsappNotAvailableHint')
: ''
resultEl.innerHTML = `
${icon('x', 14)} ${t('channels.scanLoginFailed')}: ${escapeAttr(msg)}${hint}
`
} finally {
btn.disabled = false
btn.textContent = origLabel
}
}
// ── 配置弹窗(新增 / 编辑共用) ──
async function openConfigDialog(pid, page, state, accountId) {
const reg = PLATFORM_REGISTRY[pid]
if (!reg) { toast(t('channels.unknownPlatform'), 'error'); return }
if (reg.panelSupport === 'docs-only') {
const docsOnlyContent = `
${reg.guide?.length ? `
${t('channels.setupSteps')}
${reg.guide.map(s => `${s} `).join('')}
${reg.guideFooter || ''}
` : ''}
${t('channels.docsOnlyTitle')}
${reg.supportNote || t('channels.docsOnlyDefault')}
`
const modal = showContentModal({
title: `${reg.label} ${t('channels.setupGuide')}`,
content: docsOnlyContent,
buttons: [
{ label: t('channels.gotIt'), className: 'btn btn-primary', id: 'btn-close' },
],
width: 560,
})
modal.querySelector('#btn-close')?.addEventListener('click', () => modal.close?.() || modal.remove?.())
modal.addEventListener('click', (e) => {
const a = e.target.closest('a[href]')
if (!a) return
const href = a.getAttribute('href')
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
e.preventDefault()
openExternalUrl(href)
}
})
return
}
if (reg.panelSupport === 'action-only') {
const actionOnlyGuide = reg.guide?.length ? `
${t('channels.setupSteps')}
${reg.guide.map(s => `${s} `).join('')}
${reg.guideFooter || ''}
` : ''
const pluginStatusHtml = pid === 'weixin' ? `
${t('channels.detectingPlugin')}
` : ''
const manualCommandSpecs = getManualCommandSpecs(pid, reg)
const manualCommandHtml = buildManualCommandPanel(manualCommandSpecs)
const actionOnlyBtns = reg.actions?.length ? `
${t('channels.operations')}
${reg.actions.map(action => `${action.label} `).join('')}
${reg.actions.map(action => action.hint ? `
${action.label}:${action.hint}
` : '').join('')}
` : ''
const modal = showContentModal({
title: `${reg.label} ${t('channels.setup')}`,
content: actionOnlyGuide + pluginStatusHtml + manualCommandHtml + actionOnlyBtns,
buttons: [
{ label: t('channels.close'), className: 'btn btn-secondary', id: 'btn-close' },
],
width: 560,
})
bindManualCommandCopy(modal, manualCommandSpecs)
modal.querySelector('#btn-close')?.addEventListener('click', () => modal.close?.() || modal.remove?.())
modal.addEventListener('click', (e) => {
const a = e.target.closest('a[href]')
if (!a) return
const href = a.getAttribute('href')
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
e.preventDefault()
openExternalUrl(href)
}
})
// 微信插件状态检测
if (pid === 'weixin') {
const statusEl = modal.querySelector('#weixin-plugin-status')
if (statusEl) {
api.checkWeixinPluginStatus().then(s => {
if (!s) { statusEl.textContent = t('channels.pluginStatusFailed'); return }
const parts = []
const installBtn = modal.querySelector('[data-channel-action="install"]')
if (s.installed && s.compatible === false) {
parts.push(`⚠ ${t('channels.pluginIncompatible')} `)
parts.push(`${t('channels.version')} ${s.installedVersion || '?'} `)
parts.push(`${s.compatError || t('channels.pluginCompatErrorHint')} `)
if (installBtn) {
installBtn.textContent = t('channels.reinstallCompatible')
installBtn.style.background = 'var(--error)'
}
} else if (s.installed) {
parts.push(`● ${t('channels.pluginInstalled')} `)
parts.push(`${t('channels.version')} ${s.installedVersion || t('channels.unknown')} `)
if (s.updateAvailable && s.latestVersion) {
parts.push(`→ ${t('channels.newVersionAvailable', { version: s.latestVersion })} `)
if (installBtn) installBtn.textContent = t('channels.upgradePlugin')
} else if (s.latestVersion) {
parts.push(`(${t('channels.upToDate')}) `)
}
} else {
parts.push(`○ ${t('channels.pluginNotInstalled')} `)
if (s.latestVersion) parts.push(`${t('channels.latestVersion')} ${s.latestVersion}`)
parts.push(t('channels.clickInstallBelow'))
}
statusEl.innerHTML = parts.join(' ')
}).catch(() => { statusEl.textContent = t('channels.pluginStatusFailed') })
}
}
const actionResultEl = modal.querySelector('#channel-action-result')
modal.querySelectorAll('[data-channel-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const actionId = btn.dataset.channelAction
if (!actionId || !actionResultEl) return
actionResultEl.innerHTML = `
${icon('zap', 14)}
${t('channels.executing')}
0%
`
const logBox = actionResultEl.querySelector('#channel-action-log-box')
const progressBar = actionResultEl.querySelector('#channel-action-progress-bar')
const progressText = actionResultEl.querySelector('#channel-action-progress-text')
const listen = safeTauriListen
let unlistenLog = null, unlistenProgress = null
let _qrTimer = null
const cleanup = () => { unlistenLog?.(); unlistenProgress?.(); clearTimeout(_qrTimer) }
try {
btn.disabled = true
btn.textContent = t('channels.executingShort')
if (logBox) {
const hint = document.createElement('div')
hint.style.cssText = 'color:var(--text-tertiary);font-style:italic'
hint.id = 'action-loading-hint'
hint.textContent = t('channels.downloadingPlugin')
logBox.appendChild(hint)
}
const _qrBuf = []
let _qrDone = false
const _flushQr = () => {
if (!_qrBuf.length || _qrDone) return
_qrDone = true
// 解析 Unicode 半块字符为二值矩阵
const hasHalf = _qrBuf.some(l => /[\u2580\u2584]/.test(l))
const matrix = []
for (const line of _qrBuf) {
if (hasHalf) {
const top = [], bot = []
for (const ch of line) {
if (ch === '\u2588') { top.push(1); bot.push(1) }
else if (ch === '\u2580') { top.push(1); bot.push(0) }
else if (ch === '\u2584') { top.push(0); bot.push(1) }
else { top.push(0); bot.push(0) }
}
matrix.push(top, bot)
} else {
matrix.push([...line].map(ch => ch === '\u2588' ? 1 : 0))
}
}
if (!matrix.length) return
const mod = 4, w = Math.max(...matrix.map(r => r.length)), h = matrix.length
const cvs = document.createElement('canvas')
cvs.width = w * mod; cvs.height = h * mod
const ctx = cvs.getContext('2d')
ctx.fillStyle = '#fff'; ctx.fillRect(0, 0, cvs.width, cvs.height)
ctx.fillStyle = '#000'
for (let y = 0; y < h; y++) for (let x = 0; x < (matrix[y]?.length || 0); x++) {
if (matrix[y][x]) ctx.fillRect(x * mod, y * mod, mod, mod)
}
const wrap = document.createElement('div')
wrap.style.cssText = 'text-align:center;margin:12px 0;padding:16px;background:#fff;border-radius:var(--radius-md);border:1px solid var(--border-primary)'
wrap.innerHTML = `${t('channels.weixinScanQr')}
`
const img = document.createElement('img')
img.src = cvs.toDataURL()
img.style.cssText = 'display:block;margin:0 auto;image-rendering:pixelated;max-width:280px'
wrap.appendChild(img)
logBox.appendChild(wrap)
}
unlistenLog = await listen('channel-action-log', (e) => {
if (e.payload?.platform !== pid || e.payload?.action !== actionId) return
if (!logBox) return
const msg = e.payload?.message || ''
const isQrLine = /[\u2580\u2584\u2588]/.test(msg)
if (isQrLine && (actionId === 'login' || actionId === 'install')) {
_qrBuf.push(msg)
clearTimeout(_qrTimer)
_qrTimer = setTimeout(_flushQr, 500)
} else if (!isQrLine) {
if (_qrBuf.length && !_qrDone) _flushQr()
// 检测微信扫码 URL 并渲染为可扫描的二维码
const weixinUrlMatch = msg.match(/(https:\/\/liteapp\.weixin\.qq\.com\/q\/[^\s]+)/)
if (weixinUrlMatch && !_qrDone) {
_qrDone = true
const qrUrl = weixinUrlMatch[1]
const wrap = document.createElement('div')
wrap.style.cssText = 'text-align:center;margin:12px 0;padding:16px;background:#fff;border-radius:var(--radius-md);border:1px solid var(--border-primary)'
wrap.innerHTML = `
${t('channels.weixinScanQr')}
`
logBox.appendChild(wrap)
} else if (msg.trim()) {
const loadingHint = logBox.querySelector('#action-loading-hint')
if (loadingHint) loadingHint.remove()
const div = document.createElement('div')
div.textContent = msg
logBox.appendChild(div)
}
}
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('channel-action-progress', (e) => {
if (e.payload?.platform !== pid || e.payload?.action !== actionId) return
const pct = Number(e.payload?.progress || 0)
if (progressBar) progressBar.style.width = `${pct}%`
if (progressText) progressText.textContent = `${pct}%`
})
// runChannelAction 的版本由后端自动检测(微信/QQ 版本号独立于 OpenClaw)
const output = await api.runChannelAction(pid, actionId, null)
_flushQr() // 命令结束后刷新残留 QR 缓冲
if (progressBar) progressBar.style.width = '100%'
if (progressText) progressText.textContent = '100%'
toast(t('channels.executionDone'), 'success')
// 安装完成后刷新插件状态
if (pid === 'weixin' && actionId === 'install') {
const statusEl = modal.querySelector('#weixin-plugin-status')
if (statusEl) {
statusEl.textContent = t('channels.reDetecting')
api.checkWeixinPluginStatus().then(s => {
if (!s) return
const p = []
if (s.installed) {
p.push(`● ${t('channels.pluginInstalled')} `)
p.push(`${t('channels.version')} ${s.installedVersion || t('channels.unknown')} `)
if (s.latestVersion) p.push(`(${t('channels.upToDate')}) `)
}
statusEl.innerHTML = p.join(' ') || t('channels.pluginInstalled')
}).catch(() => {})
}
}
// 登录成功后:显示成功提示 + 刷新渠道列表 + 自动关闭弹窗
if (actionId === 'login') {
if (logBox) {
const banner = document.createElement('div')
banner.style.cssText = 'margin-top:12px;padding:12px 16px;background:var(--success-bg, #e8f5e9);border:1px solid var(--success, #4caf50);border-radius:var(--radius-md);color:var(--success, #2e7d32);font-weight:600;text-align:center'
banner.textContent = t('channels.channelConnected')
logBox.appendChild(banner)
logBox.scrollTop = logBox.scrollHeight
}
// 刷新渠道列表(先清缓存)
invalidate('list_configured_platforms')
loadPlatforms(page, state).then(() => renderConfigured(page, state)).catch(() => {})
// 2 秒后自动关闭弹窗
setTimeout(() => { modal.close?.() || modal.remove?.() }, 2000)
}
} catch (e) {
_flushQr()
toast(humanizeError(e, t('channels.executionFailed')), 'error')
if (logBox) {
const div = document.createElement('div')
div.style.color = 'var(--error)'
div.textContent = t('channels.executionFailed') + ': ' + String(e)
logBox.appendChild(div)
}
} finally {
cleanup()
btn.disabled = false
btn.textContent = reg.actions.find(a => a.id === actionId)?.label || t('channels.execute')
}
})
})
return
}
// 尝试加载已有配置(accountId 用于多账号读取)
let existing = {}
let isEdit = false
try {
const res = await api.readPlatformConfig(pid, accountId)
if (res?.values) {
existing = res.values
}
if (res?.exists) {
isEdit = true
}
} catch {}
// 加载 Agent 列表(不预选,因为一个 channel+accountId 可以被多个 agent 绑定)
let agents = []
try {
agents = await api.listAgents()
} catch {}
const formId = 'platform-form-' + Date.now()
const supportsMultiAccount = supportsMessagingMultiAccount(pid)
// 账号标识(多账号);编辑时 accountId 非空会在 input value 中显示
const accountIdHtml = supportsMultiAccount ? `
` : ''
// Agent 绑定选择(一个 channel+accountId 可以绑定到多个不同 agent)
const agentOptions = agents.map(a => {
const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id
// 默认预选第一个 agent,不依赖当前 binding
const isFirst = a === agents[0]
return `${a.id}${a.id !== label ? ' — ' + escapeAttr(label) : ''} `
}).join('')
const agentBindingHtml = `
`
const isFieldRequired = (field, form) => {
if (field.required) return true
if (!field.requiredWhen) return false
return Object.entries(field.requiredWhen).every(([k, expected]) => (form[k] || '') === expected)
}
// 字段 label 智能匹配术语 → 自动追加 ⓘ 按钮
const labelWithHelp = (label) => {
const l = String(label || '').toLowerCase()
if (l.includes('bot token')) return label + termHelpHtml('bottoken')
if (l.includes('webhook')) return label + termHelpHtml('webhook')
if (l.includes('signing secret') || l.includes('client secret') || l.includes('app secret') || l.includes('api key')) return label + termHelpHtml('apikey')
return label
}
const fieldsHtml = reg.fields.map((f, i) => {
const val = existing[f.key] || ''
const secretRefLocked = existing.__secretRefs?.[f.key]
const fieldHint = [
f.hint,
secretRefLocked ? t('channels.secretRefPreserveHint') : '',
].filter(Boolean).join(' ')
if (f.type === 'select' && f.options) {
return `
`
}
return `
`
}).join('')
const guideHtml = reg.guide?.length ? `
${t('channels.setupSteps')} (${t('channels.clickToExpand')})
${reg.guide.map(s => `${s} `).join('')}
${reg.guideFooter || ''}
` : ''
const manualCommandSpecs = getManualCommandSpecs(pid, reg)
const manualCommandHtml = buildManualCommandPanel(manualCommandSpecs)
const pairingHtml = reg.pairingChannel ? `
${t('channels.pairingApproval')}
${t('channels.pairingApprovalHint')}
${t('channels.viewPending')}
${t('channels.approvePairingCode')}
` : ''
const actionPanelHtml = reg.actions?.length ? `
${t('channels.preActions')}
${t('channels.preActionsHint')}
${reg.actions.map(action => `${action.label} `).join('')}
${reg.actions.map(action => action.hint ? `
${action.label}:${action.hint}
` : '').join('')}
` : ''
const content = `
${guideHtml}
${!isEdit && (existing.gatewayToken || existing.gatewayPassword) ? `${t('channels.gatewayAuthAutoFilled', { type: existing.gatewayToken ? 'Token' : 'Password' })}
` : ''}
${isEdit ? `${t('channels.existingConfigHint')}
` : ''}
${manualCommandHtml}
${actionPanelHtml}
${pairingHtml}
${pid === 'qqbot' ? `
${icon('zap', 14)} ${t('channels.fullDiagnose')}
${t('channels.qqDiagHint')}
` : ''}
`
const modal = showContentModal({
title: `${isEdit ? t('channels.edit') : t('channels.connect')} ${reg.label}`,
content,
buttons: [
{ label: t('channels.verifyCredentials'), className: 'btn btn-secondary', id: 'btn-verify' },
{ label: isEdit ? t('channels.save') : t('channels.connectAndSave'), className: 'btn btn-primary', id: 'btn-save' },
],
width: 520,
})
bindManualCommandCopy(modal, manualCommandSpecs)
attachTermTooltips(modal)
// 外部链接用系统浏览器打开
modal.addEventListener('click', (e) => {
const a = e.target.closest('a[href]')
if (!a) return
const href = a.getAttribute('href')
if (href && (href.startsWith('http://') || href.startsWith('https://'))) {
e.preventDefault()
openExternalUrl(href)
}
})
if (pid === 'qqbot') {
const diagBtn = modal.querySelector('#btn-qq-full-diagnose')
diagBtn?.addEventListener('click', async () => {
const prev = diagBtn.innerHTML
try {
diagBtn.disabled = true
diagBtn.textContent = t('channels.diagnosing')
const result = await api.diagnoseChannel('qqbot', accountId || null)
showQqDiagnoseModal(result, { accountId: accountId || null })
} catch (e) {
toast(humanizeError(e, t('channels.diagFailed')), 'error')
} finally {
diagBtn.disabled = false
diagBtn.innerHTML = prev
}
})
}
// 密码显隐
modal.querySelectorAll('.toggle-vis').forEach(btn => {
btn.onclick = () => {
const input = modal.querySelector(`input[name="${btn.dataset.field}"]`)
if (!input) return
const show = input.type === 'password'
input.type = show ? 'text' : 'password'
btn.textContent = show ? t('channels.hide') : t('channels.show')
}
})
// 收集表单值
const collectForm = () => {
const obj = {}
reg.fields.forEach(f => {
const el = modal.querySelector(`input[name="${f.key}"]`) || modal.querySelector(`select[name="${f.key}"]`)
if (el) obj[f.key] = el.value.trim()
})
return obj
}
// 校验按钮
const btnVerify = modal.querySelector('#btn-verify')
const btnSave = modal.querySelector('#btn-save')
const resultEl = modal.querySelector('#verify-result')
const actionResultEl = modal.querySelector('#channel-action-result')
const pairingInput = modal.querySelector('input[name="pairingCode"]')
const pairingResultEl = modal.querySelector('#pairing-result')
const btnPairingList = modal.querySelector('#btn-pairing-list')
const btnPairingApprove = modal.querySelector('#btn-pairing-approve')
modal.querySelectorAll('[data-channel-action]').forEach(btn => {
btn.addEventListener('click', async () => {
const actionId = btn.dataset.channelAction
if (!actionId || !actionResultEl) return
// WhatsApp 扫码登录:通过 Gateway WebSocket RPC 直接调用 web.login.start / web.login.wait
const actionDef = reg.actions?.find(a => a.id === actionId)
if (actionDef?.useGatewayLogin) {
await handleGatewayWhatsAppLogin(btn, actionResultEl, actionDef)
return
}
actionResultEl.innerHTML = `
${icon('zap', 14)}
${t('channels.executingAction')}
0%
`
const logBox = actionResultEl.querySelector('#channel-action-log-box')
const progressBar = actionResultEl.querySelector('#channel-action-progress-bar')
const progressText = actionResultEl.querySelector('#channel-action-progress-text')
const listen = safeTauriListen
let unlistenLog = null
let unlistenProgress = null
let unlistenDone = null
let unlistenError = null
const cleanup = () => {
unlistenLog?.()
unlistenProgress?.()
unlistenDone?.()
unlistenError?.()
}
try {
btn.disabled = true
btn.textContent = t('channels.executingShort')
unlistenLog = await listen('channel-action-log', (e) => {
if (e.payload?.platform !== pid || e.payload?.action !== actionId) return
if (logBox) {
logBox.textContent += (logBox.textContent ? '\n' : '') + (e.payload?.message || '')
logBox.scrollTop = logBox.scrollHeight
}
})
unlistenProgress = await listen('channel-action-progress', (e) => {
if (e.payload?.platform !== pid || e.payload?.action !== actionId) return
const pct = Number(e.payload?.progress || 0)
if (progressBar) progressBar.style.width = `${pct}%`
if (progressText) progressText.textContent = `${pct}%`
})
unlistenDone = await listen('channel-action-done', (e) => {
if (e.payload?.platform !== pid || e.payload?.action !== actionId) return
if (progressBar) progressBar.style.width = '100%'
if (progressText) progressText.textContent = '100%'
})
unlistenError = await listen('channel-action-error', (e) => {
if (e.payload?.platform !== pid || e.payload?.action !== actionId) return
if (logBox) {
logBox.textContent += (logBox.textContent ? '\n' : '') + t('channels.executionFailed') + ': ' + (e.payload?.message || t('channels.unknownError'))
logBox.scrollTop = logBox.scrollHeight
}
})
// 微信/QQ 等第三方插件版本号独立,不 pin;run_channel_action 的 version 参数仅用于 npx 包名
const output = await api.runChannelAction(pid, actionId, null)
toast(t('channels.actionDone'), 'success')
if (logBox && output && !String(output).includes(logBox.textContent)) {
logBox.textContent += (logBox.textContent ? '\n' : '') + String(output)
}
} catch (e) {
toast(humanizeError(e, t('channels.actionFailed')), 'error')
} finally {
cleanup()
btn.disabled = false
btn.textContent = reg.actions.find(a => a.id === actionId)?.label || t('channels.execute')
}
})
})
if (btnPairingList && pairingResultEl) {
btnPairingList.onclick = async () => {
btnPairingList.disabled = true
btnPairingList.textContent = t('channels.reading')
pairingResultEl.innerHTML = ''
try {
const output = await api.pairingListChannel(reg.pairingChannel)
pairingResultEl.innerHTML = `
${t('channels.pendingRequests')}
${escapeAttr(output || t('channels.noPendingRequests'))}
`
} catch (e) {
pairingResultEl.innerHTML = `${t('channels.readFailed')}: ${escapeAttr(String(e))}
`
} finally {
btnPairingList.disabled = false
btnPairingList.textContent = t('channels.viewPending')
}
}
}
if (btnPairingApprove && pairingInput && pairingResultEl) {
btnPairingApprove.onclick = async () => {
const code = pairingInput.value.trim().toUpperCase()
if (!code) {
toast(t('channels.enterPairingCode'), 'warning')
pairingInput.focus()
return
}
btnPairingApprove.disabled = true
btnPairingApprove.textContent = t('channels.approving')
pairingResultEl.innerHTML = ''
try {
const output = await api.pairingApproveChannel(reg.pairingChannel, code, !!reg.pairingNotify)
pairingResultEl.innerHTML = `
${icon('check', 14)} ${t('channels.pairingApproved')}
${escapeAttr(output || t('channels.operationComplete'))}
`
pairingInput.value = ''
toast(t('channels.pairingApproved'), 'success')
} catch (e) {
pairingResultEl.innerHTML = `${t('channels.approveFailed')}: ${escapeAttr(String(e))}
`
} finally {
btnPairingApprove.disabled = false
btnPairingApprove.textContent = t('channels.approvePairingCode')
}
}
}
btnVerify.onclick = async () => {
const form = collectForm()
// 前端基础检查
for (const f of reg.fields) {
if (isFieldRequired(f, form) && !form[f.key]) {
toast(t('channels.pleaseFill', { field: f.label }), 'warning')
return
}
}
for (const group of reg.requiredAny || []) {
if (!group.keys.some(key => form[key])) {
toast(t('channels.pleaseFill', { field: group.label }), 'warning')
return
}
}
btnVerify.disabled = true
btnVerify.textContent = t('channels.verifying')
resultEl.innerHTML = ''
try {
const res = await api.verifyBotToken(pid, form)
if (res.valid) {
const details = (res.details || []).join(' · ')
resultEl.innerHTML = `
${icon('check', 14)} ${t('channels.credentialsValid')}${details ? ' — ' + details : ''}
${pid === 'qqbot' ? `${t('channels.qqVerifyNote')}
` : ''}`
} else {
const errs = (res.errors || [t('channels.verifyFailed')]).join(' ')
resultEl.innerHTML = `
${icon('x', 14)} ${errs}
`
}
} catch (e) {
resultEl.innerHTML = `${t('channels.verifyRequestFailed')}: ${e}
`
} finally {
btnVerify.disabled = false
btnVerify.textContent = t('channels.verifyCredentials')
}
}
// 保存按钮
btnSave.onclick = async () => {
const form = collectForm()
for (const f of reg.fields) {
if (isFieldRequired(f, form) && !form[f.key]) {
toast(t('channels.pleaseFill', { field: f.label }), 'warning')
return
}
}
for (const group of reg.requiredAny || []) {
if (!group.keys.some(key => form[key])) {
toast(t('channels.pleaseFill', { field: group.label }), 'warning')
return
}
}
if (pid === 'matrix' && !form.accessToken && !(form.userId && form.password)) {
toast(t('channels.matrixAuthRequired'), 'warning')
return
}
btnSave.disabled = true
btnVerify.disabled = true
btnSave.textContent = t('channels.saving')
try {
// 如果需要安装插件,先安装并显示日志
if (reg.pluginRequired) {
const pluginPackage = reg.pluginRequired
const pluginId = reg.pluginId || pid
const pluginStatus = await api.getChannelPluginStatus(pluginId)
// 跳过安装:插件已安装或已内置
if (!pluginStatus?.installed && !pluginStatus?.builtin) {
btnSave.textContent = t('channels.installingPlugin')
resultEl.innerHTML = `
${icon('download', 14)}
${t('channels.installPlugin')}
0%
`
const logBox = resultEl.querySelector('#plugin-log-box')
const progressBar = resultEl.querySelector('#plugin-progress-bar')
const progressText = resultEl.querySelector('#plugin-progress-text')
let unlistenLog, unlistenProgress
try {
const listen = safeTauriListen
unlistenLog = await listen('plugin-log', (e) => {
logBox.textContent += e.payload + '\n'
logBox.scrollTop = logBox.scrollHeight
})
unlistenProgress = await listen('plugin-progress', (e) => {
const pct = e.payload
progressBar.style.width = pct + '%'
progressText.textContent = pct + '%'
})
} catch {}
try {
// 自动 pin 插件版本:仅 @openclaw/ 前缀的包与 OpenClaw 版本号同步,其他包(微信 CLI、QQ Bot)版本号独立
let pluginVersion = null
if (pluginPackage && pluginPackage.startsWith('@openclaw/')) {
try {
const vInfo = await api.getVersionInfo()
if (vInfo?.current) pluginVersion = vInfo.current.split('-')[0]
} catch {}
}
// QQ 必须用专用安装命令:官方包目录为 openclaw-qqbot,与 install_channel_plugin(…, "qqbot") 的备份路径不一致
if (pid === 'qqbot') {
await api.installQqbotPlugin(null)
} else {
await api.installChannelPlugin(pluginPackage, pluginId, pluginVersion)
}
} catch (e) {
toast(humanizeError(e, t('channels.pluginInstallFailed')), 'error')
btnSave.disabled = false
btnVerify.disabled = false
btnSave.textContent = isEdit ? t('channels.save') : t('channels.connectAndSave')
if (unlistenLog) unlistenLog()
if (unlistenProgress) unlistenProgress()
return
}
if (unlistenLog) unlistenLog()
if (unlistenProgress) unlistenProgress()
} else {
resultEl.innerHTML = `
${icon('check', 14)} ${t('channels.pluginDetected')}
`
}
}
// 写入配置
btnSave.textContent = t('channels.writingConfig')
const saveAccountId = modal.querySelector('input[name="__accountId"]')?.value?.trim() || null
const saveAgentId = modal.querySelector('select[name="__agentId"]')?.value?.trim() || 'main'
await api.saveMessagingPlatform(pid, form, saveAccountId, null)
// 为该 channel + accountId 创建/更新 agent 绑定
const channelKey = getChannelBindingKey(pid)
await api.saveAgentBinding(saveAgentId, channelKey, saveAccountId, {})
toast(t('channels.configSaved', { platform: reg.label }), 'success')
modal.close?.() || modal.remove?.()
await loadPlatforms(page, state)
} catch (e) {
toast(humanizeError(e, t('channels.saveFailed')), 'error')
} finally {
btnSave.disabled = false
btnVerify.disabled = false
btnSave.textContent = isEdit ? t('channels.save') : t('channels.connectAndSave')
}
}
}
/** 将平台 ID 映射为 openclaw bindings 中的 channel key */
function getChannelBindingKey(pid) {
const map = {
qqbot: 'qqbot',
telegram: 'telegram',
discord: 'discord',
feishu: 'feishu',
dingtalk: 'dingtalk-connector',
weixin: 'openclaw-weixin',
}
return map[pid] || pid
}
function escapeAttr(str) {
return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>')
}