mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-07-20 20:22:26 +08:00
feat(channels): add Feishu/Lark messaging channel integration
- Frontend: add feishu to PLATFORM_REGISTRY with official tutorial guide - Tauri backend: add feishu read/save/verify in messaging.rs - Web backend: add all messaging platform handlers to dev-api.js - Feishu verification uses tenant_access_token API (feishu/lark dual domain) - Plugin: @openclaw/feishu@latest auto-installed on save - Guide links to official OpenClaw Feishu plugin article
This commit is contained in:
@@ -1111,6 +1111,165 @@ const handlers = {
|
||||
}
|
||||
},
|
||||
|
||||
// === 消息渠道管理 ===
|
||||
|
||||
list_configured_platforms() {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return []
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
||||
const channels = cfg.channels || {}
|
||||
return Object.entries(channels).map(([id, val]) => ({
|
||||
id,
|
||||
enabled: val?.enabled !== false,
|
||||
}))
|
||||
},
|
||||
|
||||
read_platform_config({ platform }) {
|
||||
if (!fs.existsSync(CONFIG_PATH)) return { exists: false }
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
||||
const saved = cfg.channels?.[platform]
|
||||
if (!saved) return { exists: false }
|
||||
const form = {}
|
||||
if (platform === 'qqbot') {
|
||||
const t = saved.token || ''
|
||||
const [appId, ...rest] = t.split(':')
|
||||
if (appId) form.appId = appId
|
||||
if (rest.length) form.appSecret = rest.join(':')
|
||||
} else if (platform === 'telegram') {
|
||||
if (saved.botToken) form.botToken = saved.botToken
|
||||
if (saved.allowFrom) form.allowedUsers = saved.allowFrom.join(', ')
|
||||
} else if (platform === 'discord') {
|
||||
if (saved.token) form.token = saved.token
|
||||
const gid = saved.guilds && Object.keys(saved.guilds)[0]
|
||||
if (gid) form.guildId = gid
|
||||
} else if (platform === 'feishu') {
|
||||
if (saved.appId) form.appId = saved.appId
|
||||
if (saved.appSecret) form.appSecret = saved.appSecret
|
||||
if (saved.domain) form.domain = saved.domain
|
||||
} else {
|
||||
for (const [k, v] of Object.entries(saved)) {
|
||||
if (k !== 'enabled' && typeof v === 'string') form[k] = v
|
||||
}
|
||||
}
|
||||
return { exists: true, values: form }
|
||||
},
|
||||
|
||||
save_messaging_platform({ platform, form }) {
|
||||
if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在')
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
||||
if (!cfg.channels) cfg.channels = {}
|
||||
const entry = { enabled: true }
|
||||
if (platform === 'qqbot') {
|
||||
entry.token = `${form.appId}:${form.appSecret}`
|
||||
} else if (platform === 'telegram') {
|
||||
entry.botToken = form.botToken
|
||||
if (form.allowedUsers) entry.allowFrom = form.allowedUsers.split(',').map(s => s.trim()).filter(Boolean)
|
||||
} else if (platform === 'discord') {
|
||||
entry.token = form.token
|
||||
entry.groupPolicy = 'allowlist'
|
||||
if (form.guildId) {
|
||||
const ck = form.channelId || '*'
|
||||
entry.guilds = { [form.guildId]: { users: ['*'], requireMention: true, channels: { [ck]: { allow: true, requireMention: true } } } }
|
||||
}
|
||||
} else if (platform === 'feishu') {
|
||||
entry.appId = form.appId
|
||||
entry.appSecret = form.appSecret
|
||||
entry.connectionMode = 'websocket'
|
||||
if (form.domain) entry.domain = form.domain
|
||||
} else {
|
||||
Object.assign(entry, form)
|
||||
}
|
||||
cfg.channels[platform] = entry
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2))
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
remove_messaging_platform({ platform }) {
|
||||
if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在')
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
||||
if (cfg.channels) delete cfg.channels[platform]
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2))
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
toggle_messaging_platform({ platform, enabled }) {
|
||||
if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在')
|
||||
const cfg = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
|
||||
if (!cfg.channels?.[platform]) throw new Error(`平台 ${platform} 未配置`)
|
||||
cfg.channels[platform].enabled = enabled
|
||||
fs.writeFileSync(CONFIG_PATH, JSON.stringify(cfg, null, 2))
|
||||
return { ok: true }
|
||||
},
|
||||
|
||||
async verify_bot_token({ platform, form }) {
|
||||
if (platform === 'feishu') {
|
||||
const domain = (form.domain || '').trim()
|
||||
const base = domain === 'lark' ? 'https://open.larksuite.com' : 'https://open.feishu.cn'
|
||||
try {
|
||||
const resp = await fetch(`${base}/open-apis/auth/v3/tenant_access_token/internal`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ app_id: form.appId, app_secret: form.appSecret }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
const body = await resp.json()
|
||||
if (body.code === 0) return { valid: true, errors: [], details: [`App ID: ${form.appId}`] }
|
||||
return { valid: false, errors: [body.msg || '凭证无效'] }
|
||||
} catch (e) {
|
||||
return { valid: false, errors: [`飞书 API 连接失败: ${e.message}`] }
|
||||
}
|
||||
}
|
||||
if (platform === 'qqbot') {
|
||||
try {
|
||||
const resp = await fetch('https://bots.qq.com/app/getAppAccessToken', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ appId: form.appId, clientSecret: form.appSecret }),
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
const body = await resp.json()
|
||||
if (body.access_token) return { valid: true, errors: [], details: [`AppID: ${form.appId}`] }
|
||||
return { valid: false, errors: [body.message || body.msg || '凭证无效'] }
|
||||
} catch (e) {
|
||||
return { valid: false, errors: [`QQ Bot API 连接失败: ${e.message}`] }
|
||||
}
|
||||
}
|
||||
if (platform === 'telegram') {
|
||||
try {
|
||||
const resp = await fetch(`https://api.telegram.org/bot${form.botToken}/getMe`, { signal: AbortSignal.timeout(15000) })
|
||||
const body = await resp.json()
|
||||
if (body.ok) return { valid: true, errors: [], details: [`Bot: @${body.result?.username}`] }
|
||||
return { valid: false, errors: [body.description || 'Token 无效'] }
|
||||
} catch (e) {
|
||||
return { valid: false, errors: [`Telegram API 连接失败: ${e.message}`] }
|
||||
}
|
||||
}
|
||||
if (platform === 'discord') {
|
||||
try {
|
||||
const resp = await fetch('https://discord.com/api/v10/users/@me', {
|
||||
headers: { Authorization: `Bot ${form.token}` },
|
||||
signal: AbortSignal.timeout(15000),
|
||||
})
|
||||
if (resp.status === 401) return { valid: false, errors: ['Bot Token 无效'] }
|
||||
const body = await resp.json()
|
||||
if (body.bot) return { valid: true, errors: [], details: [`Bot: @${body.username}`] }
|
||||
return { valid: false, errors: ['提供的 Token 不属于 Bot 账号'] }
|
||||
} catch (e) {
|
||||
return { valid: false, errors: [`Discord API 连接失败: ${e.message}`] }
|
||||
}
|
||||
}
|
||||
return { valid: true, warnings: ['该平台暂不支持在线校验'] }
|
||||
},
|
||||
|
||||
install_qqbot_plugin() {
|
||||
const bin = findOpenclawBin() || 'openclaw'
|
||||
try {
|
||||
execSync(`${bin} plugins install @sliverp/qqbot@latest`, { timeout: 60000, cwd: homedir() })
|
||||
return '安装成功'
|
||||
} catch (e) {
|
||||
throw new Error('QQBot 插件安装失败: ' + (e.message || e))
|
||||
}
|
||||
},
|
||||
|
||||
// === 实例管理 ===
|
||||
|
||||
instance_list() {
|
||||
|
||||
@@ -62,6 +62,18 @@ pub async fn read_platform_config(platform: String) -> Result<Value, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
"feishu" => {
|
||||
// 飞书: appId, appSecret, domain 直接保存
|
||||
if let Some(v) = saved.get("appId").and_then(|v| v.as_str()) {
|
||||
form.insert("appId".into(), Value::String(v.into()));
|
||||
}
|
||||
if let Some(v) = saved.get("appSecret").and_then(|v| v.as_str()) {
|
||||
form.insert("appSecret".into(), Value::String(v.into()));
|
||||
}
|
||||
if let Some(v) = saved.get("domain").and_then(|v| v.as_str()) {
|
||||
form.insert("domain".into(), Value::String(v.into()));
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
// 通用:原样返回字符串类型字段
|
||||
if let Some(obj) = saved.as_object() {
|
||||
@@ -203,6 +215,46 @@ pub async fn save_messaging_platform(
|
||||
|
||||
channels_map.insert("qqbot".into(), Value::Object(entry));
|
||||
}
|
||||
"feishu" => {
|
||||
let app_id = form_obj
|
||||
.get("appId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
let app_secret = form_obj
|
||||
.get("appSecret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
|
||||
if app_id.is_empty() || app_secret.is_empty() {
|
||||
return Err("App ID 和 App Secret 不能为空".into());
|
||||
}
|
||||
|
||||
let mut entry = Map::new();
|
||||
entry.insert("appId".into(), Value::String(app_id));
|
||||
entry.insert("appSecret".into(), Value::String(app_secret));
|
||||
entry.insert("enabled".into(), Value::Bool(true));
|
||||
entry.insert(
|
||||
"connectionMode".into(),
|
||||
Value::String("websocket".into()),
|
||||
);
|
||||
|
||||
// 域名(默认 feishu,国际版选 lark)
|
||||
let domain = form_obj
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim()
|
||||
.to_string();
|
||||
if !domain.is_empty() {
|
||||
entry.insert("domain".into(), Value::String(domain));
|
||||
}
|
||||
|
||||
channels_map.insert("feishu".into(), Value::Object(entry));
|
||||
}
|
||||
_ => {
|
||||
// 通用平台:直接保存表单字段
|
||||
let mut entry = Map::new();
|
||||
@@ -282,6 +334,7 @@ pub async fn verify_bot_token(
|
||||
"discord" => verify_discord(&client, form_obj).await,
|
||||
"telegram" => verify_telegram(&client, form_obj).await,
|
||||
"qqbot" => verify_qqbot(&client, form_obj).await,
|
||||
"feishu" => verify_feishu(&client, form_obj).await,
|
||||
_ => Ok(json!({
|
||||
"valid": true,
|
||||
"warnings": ["该平台暂不支持在线校验"]
|
||||
@@ -568,3 +621,76 @@ async fn verify_telegram(
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
// ── 飞书凭证校验 ──────────────────────────────────────
|
||||
|
||||
async fn verify_feishu(
|
||||
client: &reqwest::Client,
|
||||
form: &Map<String, Value>,
|
||||
) -> Result<Value, String> {
|
||||
let app_id = form
|
||||
.get("appId")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let app_secret = form
|
||||
.get("appSecret")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
|
||||
if app_id.is_empty() {
|
||||
return Ok(json!({ "valid": false, "errors": ["App ID 不能为空"] }));
|
||||
}
|
||||
if app_secret.is_empty() {
|
||||
return Ok(json!({ "valid": false, "errors": ["App Secret 不能为空"] }));
|
||||
}
|
||||
|
||||
// 通过飞书 API 获取 tenant_access_token 验证凭证
|
||||
let domain = form
|
||||
.get("domain")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("")
|
||||
.trim();
|
||||
let base_url = if domain == "lark" {
|
||||
"https://open.larksuite.com"
|
||||
} else {
|
||||
"https://open.feishu.cn"
|
||||
};
|
||||
|
||||
let resp = client
|
||||
.post(format!(
|
||||
"{}/open-apis/auth/v3/tenant_access_token/internal",
|
||||
base_url
|
||||
))
|
||||
.json(&json!({
|
||||
"app_id": app_id,
|
||||
"app_secret": app_secret
|
||||
}))
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| format!("飞书 API 连接失败: {}", e))?;
|
||||
|
||||
let body: Value = resp
|
||||
.json()
|
||||
.await
|
||||
.map_err(|e| format!("解析响应失败: {}", e))?;
|
||||
|
||||
let code = body.get("code").and_then(|v| v.as_i64()).unwrap_or(-1);
|
||||
if code == 0 {
|
||||
Ok(json!({
|
||||
"valid": true,
|
||||
"errors": [],
|
||||
"details": [format!("App ID: {}", app_id)]
|
||||
}))
|
||||
} else {
|
||||
let msg = body
|
||||
.get("msg")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("凭证无效,请检查 App ID 和 App Secret");
|
||||
Ok(json!({
|
||||
"valid": false,
|
||||
"errors": [msg]
|
||||
}))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -43,6 +43,26 @@ const PLATFORM_REGISTRY = {
|
||||
{ key: 'allowedUsers', label: '允许的用户 ID', placeholder: '多个用逗号分隔,如 12345, 67890', required: true },
|
||||
],
|
||||
},
|
||||
feishu: {
|
||||
label: '飞书',
|
||||
iconName: 'message-square',
|
||||
desc: '飞书/Lark 企业消息集成,支持文档、多维表格、日历等飞书生态能力',
|
||||
guide: [
|
||||
'前往 <a href="https://open.feishu.cn/app" target="_blank" style="color:var(--accent);text-decoration:underline">飞书开放平台</a>,创建企业自建应用,在「应用能力」中添加<b>机器人</b>能力',
|
||||
'在<b>凭证与基础信息</b>页面获取 <b>App ID</b> 和 <b>App Secret</b>',
|
||||
'进入<b>权限管理</b>,参照 <a href="https://open.larkoffice.com/document/server-docs/application-scope/scope-list" target="_blank" style="color:var(--accent);text-decoration:underline">权限列表</a> 开通所需权限(<code>im:message</code> 等)',
|
||||
'进入<b>事件订阅</b>,选择<b>使用长连接(WebSocket)</b>模式,订阅<b>接收消息</b>和<b>卡片回调</b>事件。如有 user access token 开关请打开',
|
||||
'将 App ID 和 App Secret 填入下方表单,校验后保存。ClawPanel 会自动安装飞书插件并写入配置',
|
||||
'保存后在飞书中向机器人发消息,获取配对码,然后在终端执行 <code>openclaw pairing approve feishu <配对码> --notify</code> 完成绑定',
|
||||
],
|
||||
guideFooter: '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">国际版 Lark 用户请将域名切换为 <b>lark</b>。详细教程:<a href="https://www.feishu.cn/content/article/7613711414611463386" target="_blank" style="color:var(--accent);text-decoration:underline">OpenClaw 飞书官方插件使用指南</a></div>',
|
||||
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 },
|
||||
],
|
||||
pluginRequired: '@openclaw/feishu@latest',
|
||||
},
|
||||
discord: {
|
||||
label: 'Discord',
|
||||
iconName: 'message-circle',
|
||||
|
||||
Reference in New Issue
Block a user