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:
晴天
2026-03-10 23:37:39 +08:00
parent 6d43b1adf2
commit e25f49d531
3 changed files with 305 additions and 0 deletions

View File

@@ -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() {

View File

@@ -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]
}))
}
}

View File

@@ -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 &lt;配对码&gt; --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',