/** * 消息渠道管理 * 配置 Telegram / Discord 等外部消息接入,凭证校验后写入 openclaw.json */ import { api } from '../lib/tauri-api.js' import { toast } from '../components/toast.js' import { showContentModal, showConfirm } from '../components/modal.js' import { icon } from '../lib/icons.js' // ── 渠道注册表:定义每个支持的消息渠道的元数据和表单规格 ── const PLATFORM_REGISTRY = { qqbot: { label: 'QQ 机器人', iconName: 'message-square', desc: '内置 QQ 机器人接入能力,通过 QQ 开放平台快速启用', guide: [ '使用手机 QQ 扫描二维码,打开 QQ 机器人开放平台 完成注册登录', '点击「创建机器人」,设置机器人名称和头像', '创建完成后,在机器人详情页复制 AppIDAppSecret(AppSecret 仅显示一次,请妥善保存)', '将 AppID 和 AppSecret 填入下方表单,点击「校验凭证」验证后保存', 'ClawPanel 会自动安装 QQBot 社区插件并写入配置,保存后 Gateway 自动重载生效', ], guideFooter: '
详细教程:腾讯云 - 快速搭建 AI 私人 QQ 助理
', fields: [ { key: 'appId', label: 'AppID', placeholder: '如 1903224859', required: true }, { key: 'appSecret', label: 'AppSecret', placeholder: '如 cisldqspngYlyPdc', secret: true, required: true }, ], pluginRequired: '@sliverp/qqbot@latest', }, telegram: { label: 'Telegram', iconName: 'send', desc: '通过 BotFather 创建机器人,用 Bot Token 接入', guide: [ '在 Telegram 中搜索 @BotFather,发送 /newbot 创建机器人', '按提示设置机器人名称和用户名,成功后 BotFather 会返回 Bot Token', '获取你的 Telegram 用户 ID:发送消息给 @userinfobot 即可查看', '将 Bot Token 和用户 ID 填入下方表单,点击「校验凭证」验证后保存', ], fields: [ { key: 'botToken', label: 'Bot Token', placeholder: '123456:ABC-DEF...', secret: true, required: true }, { key: 'allowedUsers', label: '允许的用户 ID', placeholder: '多个用逗号分隔,如 12345, 67890', required: true }, ], }, feishu: { label: '飞书', iconName: 'message-square', desc: '飞书/Lark 企业消息集成,支持文档、多维表格、日历等飞书生态能力', guide: [ '选择插件版本
内置插件(默认)— OpenClaw 自带,主要做聊天入口,安装简单
飞书官方插件 — 飞书团队开发,能以你的身份操作飞书(写文档、建表、约日程)
两者互斥,只能启用一个', '前往 飞书开放平台,创建企业自建应用,在「应用能力」中添加机器人能力', '在凭证与基础信息页面获取 App IDApp Secret', '进入权限管理,参照 权限列表 开通所需权限(im:message 等)', '进入事件订阅,选择使用长连接(WebSocket)模式,订阅接收消息卡片回调事件。如有 user access token 开关请打开', '将 App ID 和 App Secret 填入下方表单,校验后保存', '保存后在飞书中向机器人发消息,获取配对码;你可以直接在下方"配对审批"区域粘贴配对码完成绑定,也可以在终端执行 openclaw pairing approve feishu <配对码> --notify', ], guideFooter: '
国际版 Lark 用户请将域名切换为 lark。详细教程:OpenClaw 飞书官方插件使用指南 · 两个插件怎么选
', fields: [ { key: 'appId', label: 'App ID', placeholder: 'cli_xxxxxxxxxx', required: true }, { key: 'appSecret', label: 'App Secret', placeholder: '应用密钥', secret: true, required: true }, { key: 'domain', label: '域名', placeholder: 'feishu(国际版选 lark)', required: false }, { key: 'pluginVersion', label: '插件版本', type: 'select', required: false, options: [ { value: 'builtin', label: '内置插件(默认,聊天入口)' }, { value: 'official', label: '飞书官方插件(操作文档/日历/任务)' }, ]}, ], pluginRequired: '@openclaw/feishu@latest', pluginId: 'feishu', pairingChannel: 'feishu', pairingNotify: true, }, dingtalk: { label: '钉钉', iconName: 'message-square', desc: '钉钉企业内部应用 + 机器人 Stream 模式接入', guide: [ '前往 钉钉开放平台 创建企业内部应用,并添加机器人能力', '消息接收模式必须选择 Stream 模式,不要选 Webhook', '在凭证与基础信息页面复制 Client IDClient Secret;如 Gateway 开启了鉴权,请按 gateway.auth.mode 填写 Gateway TokenGateway Password', '在权限管理中至少确认已开通 Card.Streaming.WriteCard.Instance.Writeqyapi_robot_sendmsg,如需文档能力再补文档相关权限', '先在钉钉侧发布应用版本,并确认应用可见范围包含你自己和测试成员;否则私聊或加群时可能搜不到机器人', '回到 ClawPanel 保存。首次保存会自动安装插件,后续保存只更新配置;如果本机已配置 Gateway 鉴权,系统会自动带出对应的 Token 或 Password', '私聊测试时,可在钉钉客户端搜索应用/机器人名称,或从工作台进入应用后发起对话;若找不到,优先检查“已发布”和“可见范围”', '如果机器人首次私聊返回的是配对码,你可以直接在下方“配对审批”区域粘贴配对码完成授权,也可以在终端执行 openclaw pairing approve dingtalk-connector <配对码>', '群聊测试时,先进入目标群 → 群设置智能群助手 / 机器人添加机器人,搜索并添加该机器人;回群后建议用 @机器人 再发消息,如仍不响应再检查连接器的 groupPolicy 是否被设为 disabled', ], guideFooter: '
参考资料:本地安装 OpenClaw添加机器人到钉钉群。排障重点:405 通常是 chatCompletions 未启用,401 通常是 Gateway 鉴权字段不匹配。
', fields: [ { key: 'clientId', label: 'Client ID', placeholder: 'dingxxxxxxxxxx', required: true }, { key: 'clientSecret', label: 'Client Secret', placeholder: '应用密钥', secret: true, required: true }, { key: 'gatewayToken', label: 'Gateway Token', placeholder: '如已开启 Gateway token 鉴权则填写', required: false }, { key: 'gatewayPassword', label: 'Gateway Password', placeholder: '与 token 二选一,可选', secret: true, required: false }, ], pluginRequired: '@dingtalk-real-ai/dingtalk-connector', pluginId: 'dingtalk-connector', pairingChannel: 'dingtalk-connector', }, discord: { label: 'Discord', iconName: 'message-circle', desc: '通过 Discord Developer Portal 创建 Bot 应用接入', guide: [ '前往 Discord Developer Portal,点击 New Application 创建应用', '进入应用 → 左侧 Bot 页面 → 点击 Reset Token 生成 Bot Token,并开启 Message Content Intent', '左侧 OAuth2 → URL Generator,勾选 bot 权限,复制链接将 Bot 邀请到你的服务器', '将 Bot Token 和服务器 ID 填入下方表单,点击「校验凭证」验证后保存', ], fields: [ { key: 'token', label: 'Bot Token', placeholder: 'MTIz...', secret: true, required: true }, { key: 'guildId', label: '服务器 ID', placeholder: '右键服务器 → 复制服务器 ID', required: false }, { key: 'channelId', label: '频道 ID(可选)', placeholder: '不填则监听所有频道', required: false }, ], }, } // ── 页面生命周期 ── export async function render() { const page = document.createElement('div') page.className = 'page' page.innerHTML = `
可接入平台
` const state = { configured: [] } await loadPlatforms(page, state) return page } export function cleanup() {} // ── 数据加载 ── async function loadPlatforms(page, state) { try { const list = await api.listConfiguredPlatforms() state.configured = Array.isArray(list) ? list : [] } catch (e) { toast('加载平台列表失败: ' + e, 'error') state.configured = [] } // 加载 bindings 信息 try { const config = await api.readOpenclawConfig() state.bindings = Array.isArray(config?.bindings) ? config.bindings : [] } catch { state.bindings = [] } renderConfigured(page, state) renderAvailable(page, state) } // ── 已配置平台渲染 ── function renderConfigured(page, state) { const el = page.querySelector('#platforms-configured') if (!state.configured.length) { el.innerHTML = '' return } el.innerHTML = `
已接入
${state.configured.map(p => { const reg = PLATFORM_REGISTRY[p.id] const label = reg?.label || p.id const ic = icon(reg?.iconName || 'radio', 22) const channelKey = getChannelBindingKey(p.id) const allBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey) const boundAgents = allBindings.map(b => b.agentId || 'main') // 只有一个 main 绑定时不显示标签(默认行为),多绑定时全部显示 const showAll = boundAgents.length > 1 || (boundAgents.length === 1 && boundAgents[0] !== 'main') const agentBadges = showAll ? boundAgents.map(a => `→ ${escapeAttr(a)}` ).join(' ') : '' return `
${ic} ${label} ${agentBadges}
` }).join('')}
` // 绑定事件 el.querySelectorAll('.platform-card').forEach(card => { const pid = card.dataset.pid card.querySelector('[data-action="edit"]').onclick = () => openConfigDialog(pid, page, state) card.querySelector('[data-action="toggle"]').onclick = async () => { const cur = state.configured.find(p => p.id === pid) if (!cur) return try { await api.toggleMessagingPlatform(pid, !cur.enabled) toast(`${PLATFORM_REGISTRY[pid]?.label || pid} 已${cur.enabled ? '禁用' : '启用'}`, 'success') await loadPlatforms(page, state) } catch (e) { toast('操作失败: ' + e, 'error') } } card.querySelector('[data-action="remove"]').onclick = async () => { const yes = await showConfirm(`确定移除 ${PLATFORM_REGISTRY[pid]?.label || pid}?配置将被删除。`) if (!yes) return try { await api.removeMessagingPlatform(pid) toast('已移除', 'info') await loadPlatforms(page, state) } catch (e) { toast('移除失败: ' + e, '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 const done = configuredIds.has(pid) btn.onclick = () => done ? openBindAgentDialog(pid, page, state) : openConfigDialog(pid, page, state) }) } // ── 快速绑定 Agent 弹窗(已接入平台专用) ── async function openBindAgentDialog(pid, page, state) { const reg = PLATFORM_REGISTRY[pid] if (!reg) return let agents = [] try { agents = await api.listAgents() } catch {} if (!Array.isArray(agents)) agents = [] const channelKey = getChannelBindingKey(pid) const existingBindings = (state.bindings || []).filter(b => b.match?.channel === channelKey) const boundIds = new Set(existingBindings.map(b => b.agentId || 'main')) const availableAgents = agents.filter(a => !boundIds.has(a.id)) if (!availableAgents.length) { toast('所有 Agent 都已绑定到该渠道', 'info') return } const agentOptions = availableAgents.map(a => { const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id return `` }).join('') const modal = showContentModal({ title: `为 ${reg.label} 绑定新 Agent`, content: `
已绑定: ${[...boundIds].join(', ') || '无'}
该渠道的消息将路由到选中的 Agent 处理
`, buttons: [ { label: '绑定', className: 'btn btn-primary', id: 'btn-bind-agent' }, ], width: 400, }) modal.querySelector('#btn-bind-agent').onclick = async () => { const agentId = modal.querySelector('#bind-agent-select')?.value if (!agentId) { toast('请选择 Agent', 'warning'); return } try { await saveChannelBinding(pid, agentId) toast(`已将 ${reg.label} 绑定到 Agent「${agentId}」`, 'success') modal.close?.() || modal.remove?.() await loadPlatforms(page, state) } catch (e) { toast('绑定失败: ' + e, 'error') } } } // ── 配置弹窗(新增 / 编辑共用) ── async function openConfigDialog(pid, page, state) { const reg = PLATFORM_REGISTRY[pid] if (!reg) { toast('未知平台', 'error'); return } // 尝试加载已有配置 let existing = {} let isEdit = false let agents = [] let currentBinding = '' try { const res = await api.readPlatformConfig(pid) if (res?.values) { existing = res.values } if (res?.exists) { isEdit = true } } catch {} // 加载 Agent 列表和当前 binding try { agents = await api.listAgents() } catch {} try { const config = await api.readOpenclawConfig() const bindings = config?.bindings || [] const channelKey = getChannelBindingKey(pid) const found = bindings.find(b => b.match?.channel === channelKey) if (found) currentBinding = found.agentId || '' } catch {} const formId = 'platform-form-' + Date.now() // Agent 绑定选择器 const agentOptions = agents.map(a => { const label = a.identityName ? a.identityName.split(',')[0].trim() : a.id return `` }).join('') const supportsMultiAccount = ['feishu', 'dingtalk', 'dingtalk-connector'].includes(pid) const accountIdHtml = supportsMultiAccount ? `
为同一平台接入多个应用时,每个应用需要一个唯一的账号标识。不同账号可绑定不同 Agent
` : '' const agentBindingHtml = ` ${accountIdHtml}
选择该渠道消息路由到哪个 Agent 处理。留空则使用默认 Agent(main)
` // 飞书插件版本检测:根据已安装的插件自动选择 if (pid === 'feishu' && !existing.pluginVersion) { try { const officialStatus = await api.getChannelPluginStatus('feishu-openclaw-plugin') if (officialStatus?.installed) existing.pluginVersion = 'official' else existing.pluginVersion = localStorage.getItem('clawpanel-feishu-plugin-version') || 'builtin' } catch { existing.pluginVersion = 'builtin' } } const fieldsHtml = reg.fields.map((f, i) => { const val = existing[f.key] || '' if (f.type === 'select' && f.options) { return `
` } return `
${f.secret ? `` : ''}
` }).join('') const guideHtml = reg.guide?.length ? `
接入步骤 (点击展开)
    ${reg.guide.map(s => `
  1. ${s}
  2. `).join('')}
${reg.guideFooter || ''}
` : '' const pairingHtml = reg.pairingChannel ? `
配对审批
当机器人提示 access not configuredPairing code 或要求执行 openclaw pairing approve 时,可直接在这里完成批准。
` : '' const content = ` ${guideHtml} ${!isEdit && (existing.gatewayToken || existing.gatewayPassword) ? `
已从当前 Gateway 鉴权配置中自动带出 ${existing.gatewayToken ? 'Token' : 'Password'},通常无需手填
` : ''} ${isEdit ? `
当前已有配置,修改后点击保存即可覆盖
` : ''}
${fieldsHtml} ${agentBindingHtml}
${pairingHtml}
` const modal = showContentModal({ title: `${isEdit ? '编辑' : '接入'} ${reg.label}`, content, buttons: [ { label: '校验凭证', className: 'btn btn-secondary', id: 'btn-verify' }, { label: isEdit ? '保存' : '接入并保存', className: 'btn btn-primary', id: 'btn-save' }, ], width: 520, }) // 外部链接用系统浏览器打开 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() import('@tauri-apps/plugin-shell').then(({ open }) => open(href)).catch(() => window.open(href, '_blank')) } }) // 密码显隐 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 ? '隐藏' : '显示' } }) // 收集表单值 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 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') if (btnPairingList && pairingResultEl) { btnPairingList.onclick = async () => { btnPairingList.disabled = true btnPairingList.textContent = '读取中...' pairingResultEl.innerHTML = '' try { const output = await api.pairingListChannel(reg.pairingChannel) pairingResultEl.innerHTML = `
待审批请求
${escapeAttr(output || '暂无待审批请求')}
` } catch (e) { pairingResultEl.innerHTML = `
读取失败: ${escapeAttr(String(e))}
` } finally { btnPairingList.disabled = false btnPairingList.textContent = '查看待审批' } } } if (btnPairingApprove && pairingInput && pairingResultEl) { btnPairingApprove.onclick = async () => { const code = pairingInput.value.trim().toUpperCase() if (!code) { toast('请输入配对码', 'warning') pairingInput.focus() return } btnPairingApprove.disabled = true btnPairingApprove.textContent = '批准中...' pairingResultEl.innerHTML = '' try { const output = await api.pairingApproveChannel(reg.pairingChannel, code, !!reg.pairingNotify) pairingResultEl.innerHTML = `
${icon('check', 14)} 配对已批准
${escapeAttr(output || '操作完成')}
` pairingInput.value = '' toast('配对已批准', 'success') } catch (e) { pairingResultEl.innerHTML = `
批准失败: ${escapeAttr(String(e))}
` } finally { btnPairingApprove.disabled = false btnPairingApprove.textContent = '批准配对码' } } } btnVerify.onclick = async () => { const form = collectForm() // 前端基础检查 for (const f of reg.fields) { if (f.required && !form[f.key]) { toast(`请填写「${f.label}」`, 'warning') return } } btnVerify.disabled = true btnVerify.textContent = '校验中...' resultEl.innerHTML = '' try { const res = await api.verifyBotToken(pid, form) if (res.valid) { const details = (res.details || []).join(' · ') resultEl.innerHTML = `
${icon('check', 14)} 凭证有效${details ? ' — ' + details : ''}
` } else { const errs = (res.errors || ['校验失败']).join('
') resultEl.innerHTML = `
${icon('x', 14)} ${errs}
` } } catch (e) { resultEl.innerHTML = `
校验请求失败: ${e}
` } finally { btnVerify.disabled = false btnVerify.textContent = '校验凭证' } } // 保存按钮 btnSave.onclick = async () => { const form = collectForm() for (const f of reg.fields) { if (f.required && !form[f.key]) { toast(`请填写「${f.label}」`, 'warning') return } } btnSave.disabled = true btnVerify.disabled = true btnSave.textContent = '保存中...' try { // 如果需要安装插件,先安装并显示日志 if (reg.pluginRequired) { // 飞书特殊处理:根据用户选择的插件版本决定安装包 let pluginPackage = reg.pluginRequired let pluginId = reg.pluginId || pid if (pid === 'feishu') { const pluginVersionField = modal.querySelector('[data-name="pluginVersion"]') const pluginVersion = pluginVersionField?.value || 'builtin' localStorage.setItem('clawpanel-feishu-plugin-version', pluginVersion) if (pluginVersion === 'official') { pluginPackage = '@larksuiteoapi/feishu-openclaw-plugin' pluginId = 'feishu-openclaw-plugin' } } const pluginStatus = await api.getChannelPluginStatus(pluginId) // 跳过安装:插件已安装 或 已内置(新版 OpenClaw 内置了 feishu 等插件) if (!pluginStatus?.installed && !pluginStatus?.builtin) { btnSave.textContent = '安装插件中...' resultEl.innerHTML = `
${icon('download', 14)} 安装插件 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 } = await import('@tauri-apps/api/event') 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 { if (pid === 'qqbot') { await api.installQqbotPlugin() } else { await api.installChannelPlugin(pluginPackage, pluginId) } } catch (e) { toast('插件安装失败: ' + e, 'error') btnSave.disabled = false btnVerify.disabled = false btnSave.textContent = isEdit ? '保存' : '接入并保存' if (unlistenLog) unlistenLog() if (unlistenProgress) unlistenProgress() return } if (unlistenLog) unlistenLog() if (unlistenProgress) unlistenProgress() } else { resultEl.innerHTML = `
${icon('check', 14)} 已检测到插件,无需重复安装,本次仅更新配置
` } } // 写入配置(多账号模式传 accountId) btnSave.textContent = '写入配置...' const accountId = modal.querySelector('input[name="__accountId"]')?.value?.trim() || null await api.saveMessagingPlatform(pid, form, accountId) // 写入 Agent 绑定到 openclaw.json bindings(多账号时 binding.match 包含 accountId) const selectedAgent = modal.querySelector('select[name="__agentBinding"]')?.value || '' try { await saveChannelBinding(pid, selectedAgent, null, accountId) } catch (e) { console.warn('[channels] 保存 Agent 绑定失败:', e) } toast(`${reg.label} 配置已保存,Gateway 正在重载`, 'success') modal.close?.() || modal.remove?.() await loadPlatforms(page, state) } catch (e) { toast('保存失败: ' + e, 'error') } finally { btnSave.disabled = false btnVerify.disabled = false btnSave.textContent = isEdit ? '保存' : '接入并保存' } } } /** 将平台 ID 映射为 openclaw bindings 中的 channel key */ function getChannelBindingKey(pid) { const map = { qqbot: 'qqbot', telegram: 'telegram', discord: 'discord', feishu: 'feishu', dingtalk: 'dingtalk-connector', } return map[pid] || pid } /** 保存渠道→Agent 绑定到 openclaw.json 的 bindings 数组 * 支持同一渠道多个 Agent 绑定(不同 agentId) * oldAgentId: 编辑时替换老绑定 */ async function saveChannelBinding(pid, agentId, oldAgentId, accountId) { const config = await api.readOpenclawConfig() if (!config) return const channelKey = getChannelBindingKey(pid) let bindings = Array.isArray(config.bindings) ? [...config.bindings] : [] // 构建匹配条件 const matchesBinding = (b) => { if (b.match?.channel !== channelKey) return false if (accountId) return (b.match?.accountId || '') === accountId return !b.match?.accountId } // 编辑模式:移除旧绑定 if (oldAgentId) { bindings = bindings.filter(b => !(matchesBinding(b) && (b.agentId || 'main') === oldAgentId)) } // 避免重复 const effectiveAgent = agentId || 'main' bindings = bindings.filter(b => !(matchesBinding(b) && (b.agentId || 'main') === effectiveAgent)) // 添加新绑定(包含 accountId 用于多账号路由) if (agentId) { const match = { channel: channelKey } if (accountId) match.accountId = accountId bindings.push({ agentId, match }) } config.bindings = bindings await api.writeOpenclawConfig(config) } function escapeAttr(str) { return (str || '').replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>') }