/** * 消息渠道管理 * 渠道列表 + 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')}
` 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 `` } const accountAttr = escapeAttr(accountId || '') const stopDisabled = summary.state === 'missing' || summary.state === 'configured' const logoutDisabled = summary.state === 'missing' return ` ` } 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)}
${state.configured.map(p => { const reg = PLATFORM_REGISTRY[p.id] const label = platformLabel(p.id) const ic = icon(reg?.iconName || 'radio', 22) const channelKey = getChannelBindingKey(p.id) const runtimeSummary = getChannelRuntimeSummary(state.runtimeStatus, channelKey, label) const accounts = Array.isArray(p.accounts) ? p.accounts : [] const hasAccounts = accounts.length > 0 const supportsMulti = supportsMessagingMultiAccount(p.id) if (hasAccounts) { const accountsHtml = accounts.map(acc => { const accId = acc.accountId || 'default' const accBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey && (b.match?.accountId || '') === (acc.accountId || '') ) const accAgents = accBindings.map(b => b.agentId || 'main') const showBadge = accAgents.length > 0 && !(accAgents.length === 1 && accAgents[0] === 'main') const badgesHtml = showBadge ? accAgents.map(a => `\u2192 ${escapeAttr(a)}` ).join(' ') : '' return ` ` }).join('') return `
${ic} ${label} ${renderRuntimeBadge(runtimeSummary)}
${renderRuntimeSummary(runtimeSummary)}
${accountsHtml}
${renderRuntimeActions(runtimeSummary)} ${supportsMulti ? `` : ''} ${reg ? `` : `${t('channels.noGuide')}`}
` } const allBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey) const boundAgents = allBindings.map(b => b.agentId || 'main') const showAll = boundAgents.length > 1 || (boundAgents.length === 1 && boundAgents[0] !== 'main') const agentBadges = showAll ? boundAgents.map(a => `\u2192 ${escapeAttr(a)}` ).join(' ') : '' return `
${ic} ${label} ${agentBadges} ${renderRuntimeBadge(runtimeSummary)}
${renderRuntimeSummary(runtimeSummary)}
${renderRuntimeActions(runtimeSummary)} ${supportsMulti ? `` : ''} ${reg ? `` : `${t('channels.noGuide')}`}
` }).join('')}
` // 已接入平台的操作选项弹窗 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 => ` `).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 `` }).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: `
${t('channels.targetAgentHint')}
${t('channels.peerAllHint')}
`, 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 ` ` }).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) : ''}
` }).join('') : `
💬
${t('channels.noBindings')}
` const addDisabled = !canBind.length ? 'disabled' : '' return `
${icon('package', 18)} ${escapeAttr(aid)}
${subtitle ? `
${subtitle}
` : ''}
${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 `` }).join('') const modal = showContentModal({ title: t('channels.addBindingForAgent', { agent: agentId }), content: `
${t('channels.bindingIndependentHint')}
${t('channels.peerAllHint')}
`, 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 => ``).join('') } else { // 无多账号时,也显示一行提示,方便用户去渠道列表添加 wrapAcct.style.display = '' selAcct.innerHTML = `` 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}
${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')}
    WhatsApp QR
    ${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 => `
    1. ${s}
    2. `).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 => `
    1. ${s}
    2. `).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 => ``).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')}
    WeChat QR
    ${t('channels.weixinOpenInBrowser')}
    ` 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 ? `
    ${t('channels.accountIdHint')}
    ` : '' // 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 `` }).join('') const agentBindingHtml = `
    ${t('channels.bindAgentHint')}
    ` 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 `
    ${f.hint ? `
    ${f.hint}
    ` : ''}
    ` } return `
    ${f.secret ? `` : ''}
    ${fieldHint ? `
    ${fieldHint}
    ` : ''}
    ` }).join('') const guideHtml = reg.guide?.length ? `
    ${t('channels.setupSteps')} (${t('channels.clickToExpand')})
      ${reg.guide.map(s => `
    1. ${s}
    2. `).join('')}
    ${reg.guideFooter || ''}
    ` : '' const manualCommandSpecs = getManualCommandSpecs(pid, reg) const manualCommandHtml = buildManualCommandPanel(manualCommandSpecs) const pairingHtml = reg.pairingChannel ? `
    ${t('channels.pairingApproval')}
    ${t('channels.pairingApprovalHint')}
    ` : '' const actionPanelHtml = reg.actions?.length ? `
    ${t('channels.preActions')}
    ${t('channels.preActionsHint')}
    ${reg.actions.map(action => ``).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')}
    ` : ''}
    ${fieldsHtml} ${accountIdHtml} ${agentBindingHtml}
    ${manualCommandHtml} ${actionPanelHtml} ${pairingHtml}
    ${pid === 'qqbot' ? `

    ${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, '>') }