/**
* 消息渠道管理
* 配置 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 机器人开放平台 完成注册登录',
'点击「创建机器人」,设置机器人名称和头像',
'创建完成后,在机器人详情页复制 AppID 和 AppSecret(AppSecret 仅显示一次,请妥善保存)',
'将 AppID 和 AppSecret 填入下方表单,点击「校验凭证」验证后保存',
'ClawPanel 会自动安装 QQBot 社区插件并写入配置,保存后 Gateway 自动重载生效',
],
guideFooter: '
',
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 ID 和 App Secret',
'进入权限管理,参照 权限列表 开通所需权限(im:message 等)',
'进入事件订阅,选择使用长连接(WebSocket)模式,订阅接收消息和卡片回调事件。如有 user access token 开关请打开',
'将 App ID 和 App Secret 填入下方表单,校验后保存',
'保存后在飞书中向机器人发消息,获取配对码;你可以直接在下方"配对审批"区域粘贴配对码完成绑定,也可以在终端执行 openclaw pairing approve feishu <配对码> --notify',
],
guideFooter: '',
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 ID 和 Client Secret;如 Gateway 开启了鉴权,请按 gateway.auth.mode 填写 Gateway Token 或 Gateway Password',
'在权限管理中至少确认已开通 Card.Streaming.Write、Card.Instance.Write、qyapi_robot_sendmsg,如需文档能力再补文档相关权限',
'先在钉钉侧发布应用版本,并确认应用可见范围包含你自己和测试成员;否则私聊或加群时可能搜不到机器人',
'回到 ClawPanel 保存。首次保存会自动安装插件,后续保存只更新配置;如果本机已配置 Gateway 鉴权,系统会自动带出对应的 Token 或 Password',
'私聊测试时,可在钉钉客户端搜索应用/机器人名称,或从工作台进入应用后发起对话;若找不到,优先检查“已发布”和“可见范围”',
'如果机器人首次私聊返回的是配对码,你可以直接在下方“配对审批”区域粘贴配对码完成授权,也可以在终端执行 openclaw pairing approve dingtalk-connector <配对码>',
'群聊测试时,先进入目标群 → 群设置 → 智能群助手 / 机器人 → 添加机器人,搜索并添加该机器人;回群后建议用 @机器人 再发消息,如仍不响应再检查连接器的 groupPolicy 是否被设为 disabled',
],
guideFooter: '',
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 = `
`
// 绑定事件
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 ? `
` : ''
const agentBindingHtml = `
${accountIdHtml}
`
// 飞书插件版本检测:根据已安装的插件自动选择
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 `
`
}).join('')
const guideHtml = reg.guide?.length ? `
接入步骤 (点击展开)
${reg.guide.map(s => `- ${s}
`).join('')}
${reg.guideFooter || ''}
` : ''
const pairingHtml = reg.pairingChannel ? `
配对审批
当机器人提示 access not configured、Pairing code 或要求执行 openclaw pairing approve 时,可直接在这里完成批准。
` : ''
const content = `
${guideHtml}
${!isEdit && (existing.gatewayToken || existing.gatewayPassword) ? `已从当前 Gateway 鉴权配置中自动带出 ${existing.gatewayToken ? 'Token' : 'Password'},通常无需手填
` : ''}
${isEdit ? `当前已有配置,修改后点击保存即可覆盖
` : ''}
${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, '>')
}