diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 237c7e5..f24a963 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -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() { diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 83a1812..ae06fd6 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -62,6 +62,18 @@ pub async fn read_platform_config(platform: String) -> Result { } } } + "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, +) -> Result { + 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] + })) + } +} diff --git a/src/pages/channels.js b/src/pages/channels.js index 6db0ff8..81ec50f 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -43,6 +43,26 @@ const PLATFORM_REGISTRY = { { key: 'allowedUsers', label: '允许的用户 ID', placeholder: '多个用逗号分隔,如 12345, 67890', required: true }, ], }, + feishu: { + label: '飞书', + iconName: 'message-square', + desc: '飞书/Lark 企业消息集成,支持文档、多维表格、日历等飞书生态能力', + guide: [ + '前往 飞书开放平台,创建企业自建应用,在「应用能力」中添加机器人能力', + '在凭证与基础信息页面获取 App IDApp Secret', + '进入权限管理,参照 权限列表 开通所需权限(im:message 等)', + '进入事件订阅,选择使用长连接(WebSocket)模式,订阅接收消息卡片回调事件。如有 user access token 开关请打开', + '将 App ID 和 App Secret 填入下方表单,校验后保存。ClawPanel 会自动安装飞书插件并写入配置', + '保存后在飞书中向机器人发消息,获取配对码,然后在终端执行 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 }, + ], + pluginRequired: '@openclaw/feishu@latest', + }, discord: { label: 'Discord', iconName: 'message-circle',