Files
clawpanel/src-tauri/src/commands/messaging.rs
晴天 db30f29abf feat: v0.8.2 — 15 fixes + 4 features + 3 improvements
Fixes:
- Stop force-appending /v1 to API URLs (breaks Volcengine /v3 etc)
- SSH upgrade: --unset-all + --add for 4 git insteadOf rules
- Feishu: builtin detection, overlay→modal fix, select field, plugin version persistence
- Docker: HTML response detection, Web mode guidance
- Chat: runId dedup prevents duplicate messages
- Cron: RPC params name→id
- Channels: Gateway reload async (instant UI response), toggle cache invalidation
- Linux: auto sudo for non-root npm installs (libc geteuid)
- Control UI: dynamic hostname + auth token for remote access
- npm: mirror fallback (npmmirror→npmjs.org)
- QQBot: native binding friendly error message
- Error diagnosis: SSH vs Git-not-installed, native binding detection

Features:
- About page: company info (武汉晴辰天下网络科技有限公司)
- model-presets.js: shared module for models.js + assistant.js
- Feishu: dual plugin support (builtin vs official @larksuiteoapi)
- Assistant: provider preset quick-fill buttons

Improvements:
- Website: dynamic download links from latest.json + claw.qt.cool proxy
- Linux deploy docs: upgrade guide, Gitee mirror, sudo notes
- linux-deploy.sh: Gitee fallback + sudo npm + mirror retry
2026-03-13 00:03:09 +08:00

1485 lines
52 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/// 消息渠道管理
/// 负责 Telegram / Discord / QQ Bot 等消息渠道的配置持久化与凭证校验
/// 配置写入 openclaw.json 的 channels / plugins 节点
use serde_json::{json, Map, Value};
use std::fs;
use std::path::{Path, PathBuf};
fn platform_storage_key(platform: &str) -> &str {
match platform {
"dingtalk" | "dingtalk-connector" => "dingtalk-connector",
_ => platform,
}
}
fn platform_list_id(platform: &str) -> &str {
match platform {
"dingtalk-connector" => "dingtalk",
_ => platform,
}
}
fn ensure_chat_completions_enabled(cfg: &mut Value) -> Result<(), String> {
let root = cfg.as_object_mut().ok_or("配置格式错误")?;
let gateway = root.entry("gateway").or_insert_with(|| json!({}));
let gateway_obj = gateway.as_object_mut().ok_or("gateway 节点格式错误")?;
let http = gateway_obj.entry("http").or_insert_with(|| json!({}));
let http_obj = http.as_object_mut().ok_or("gateway.http 节点格式错误")?;
let endpoints = http_obj.entry("endpoints").or_insert_with(|| json!({}));
let endpoints_obj = endpoints
.as_object_mut()
.ok_or("gateway.http.endpoints 节点格式错误")?;
let chat = endpoints_obj
.entry("chatCompletions")
.or_insert_with(|| json!({}));
let chat_obj = chat
.as_object_mut()
.ok_or("gateway.http.endpoints.chatCompletions 节点格式错误")?;
chat_obj.insert("enabled".into(), Value::Bool(true));
Ok(())
}
fn gateway_auth_mode(cfg: &Value) -> Option<&str> {
cfg.get("gateway")
.and_then(|g| g.get("auth"))
.and_then(|a| a.get("mode"))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
}
fn gateway_auth_value(cfg: &Value, key: &str) -> Option<String> {
cfg.get("gateway")
.and_then(|g| g.get("auth"))
.and_then(|a| a.get(key))
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|v| !v.is_empty())
.map(|v| v.to_string())
}
/// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值)
#[tauri::command]
pub async fn read_platform_config(platform: String) -> Result<Value, String> {
let cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
// 从已有配置中提取用户可编辑字段
let saved = cfg
.get("channels")
.and_then(|c| c.get(storage_key))
.cloned()
.unwrap_or(Value::Null);
let mut form = Map::new();
let exists = !saved.is_null();
match platform.as_str() {
"discord" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// Discord 配置在 openclaw.json 中是展开的 guilds 结构
// 需要反向提取成表单字段token, guildId, channelId
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
form.insert("token".into(), Value::String(t.into()));
}
if let Some(guilds) = saved.get("guilds").and_then(|v| v.as_object()) {
if let Some(gid) = guilds.keys().next() {
form.insert("guildId".into(), Value::String(gid.clone()));
if let Some(channels) = guilds[gid].get("channels").and_then(|v| v.as_object())
{
let cids: Vec<&String> =
channels.keys().filter(|k| k.as_str() != "*").collect();
if let Some(cid) = cids.first() {
form.insert("channelId".into(), Value::String((*cid).clone()));
}
}
}
}
}
"telegram" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// Telegram: botToken 直接保存, allowFrom 数组需要拼回逗号字符串
if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) {
form.insert("botToken".into(), Value::String(t.into()));
}
if let Some(arr) = saved.get("allowFrom").and_then(|v| v.as_array()) {
let users: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
form.insert("allowedUsers".into(), Value::String(users.join(", ")));
}
}
"qqbot" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// QQ Bot: token 格式为 "AppID:AppSecret",拆分回表单字段
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
if let Some((app_id, app_secret)) = t.split_once(':') {
form.insert("appId".into(), Value::String(app_id.into()));
form.insert("appSecret".into(), Value::String(app_secret.into()));
}
}
}
"feishu" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// 飞书: 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()));
}
}
"dingtalk" | "dingtalk-connector" => {
if let Some(v) = saved.get("clientId").and_then(|v| v.as_str()) {
form.insert("clientId".into(), Value::String(v.into()));
}
if let Some(v) = saved.get("clientSecret").and_then(|v| v.as_str()) {
form.insert("clientSecret".into(), Value::String(v.into()));
}
if let Some(v) = saved.get("gatewayToken").and_then(|v| v.as_str()) {
form.insert("gatewayToken".into(), Value::String(v.into()));
}
if let Some(v) = saved.get("gatewayPassword").and_then(|v| v.as_str()) {
form.insert("gatewayPassword".into(), Value::String(v.into()));
}
match gateway_auth_mode(&cfg) {
Some("token") => {
if let Some(v) = gateway_auth_value(&cfg, "token") {
form.insert("gatewayToken".into(), Value::String(v));
}
form.remove("gatewayPassword");
}
Some("password") => {
if let Some(v) = gateway_auth_value(&cfg, "password") {
form.insert("gatewayPassword".into(), Value::String(v));
}
form.remove("gatewayToken");
}
_ => {}
}
}
_ => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// 通用:原样返回字符串类型字段
if let Some(obj) = saved.as_object() {
for (k, v) in obj {
if k == "enabled" {
continue;
}
if let Some(s) = v.as_str() {
form.insert(k.clone(), Value::String(s.into()));
}
}
}
}
}
Ok(json!({ "exists": exists, "values": Value::Object(form) }))
}
/// 保存平台配置到 openclaw.json
/// 前端传入的是表单字段,后端负责转换成 OpenClaw 要求的结构
#[tauri::command]
pub async fn save_messaging_platform(
platform: String,
form: Value,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform).to_string();
let channels = cfg
.as_object_mut()
.ok_or("配置格式错误")?
.entry("channels")
.or_insert_with(|| json!({}));
let channels_map = channels.as_object_mut().ok_or("channels 节点格式错误")?;
let form_obj = form.as_object().ok_or("表单数据格式错误")?;
match platform.as_str() {
"discord" => {
let mut entry = Map::new();
// Bot Token
if let Some(t) = form_obj.get("token").and_then(|v| v.as_str()) {
entry.insert("token".into(), Value::String(t.trim().into()));
}
entry.insert("enabled".into(), Value::Bool(true));
entry.insert("groupPolicy".into(), Value::String("allowlist".into()));
entry.insert("dm".into(), json!({ "enabled": false }));
entry.insert(
"retry".into(),
json!({
"attempts": 3,
"minDelayMs": 500,
"maxDelayMs": 30000,
"jitter": 0.1
}),
);
// guildId + channelId 展开为 guilds 嵌套结构
let guild_id = form_obj
.get("guildId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if !guild_id.is_empty() {
let channel_id = form_obj
.get("channelId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let channel_key = if channel_id.is_empty() {
"*".to_string()
} else {
channel_id
};
entry.insert(
"guilds".into(),
json!({
guild_id: {
"users": ["*"],
"requireMention": true,
"channels": {
channel_key: { "allow": true, "requireMention": true }
}
}
}),
);
}
channels_map.insert("discord".into(), Value::Object(entry));
}
"telegram" => {
let mut entry = Map::new();
if let Some(t) = form_obj.get("botToken").and_then(|v| v.as_str()) {
entry.insert("botToken".into(), Value::String(t.trim().into()));
}
entry.insert("enabled".into(), Value::Bool(true));
// allowedUsers 逗号字符串 → allowFrom 数组
if let Some(users_str) = form_obj.get("allowedUsers").and_then(|v| v.as_str()) {
let users: Vec<Value> = users_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| Value::String(s.into()))
.collect();
if !users.is_empty() {
entry.insert("allowFrom".into(), Value::Array(users));
}
}
channels_map.insert("telegram".into(), Value::Object(entry));
}
"qqbot" => {
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("AppID 和 AppSecret 不能为空".into());
}
let token = format!("{}:{}", app_id, app_secret);
let mut entry = Map::new();
entry.insert("token".into(), Value::String(token));
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert("qqbot".into(), Value::Object(entry));
ensure_plugin_allowed(&mut cfg, "qqbot")?;
let _ = cleanup_legacy_plugin_backup_dir("qqbot");
}
"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));
ensure_plugin_allowed(&mut cfg, "feishu")?;
let _ = cleanup_legacy_plugin_backup_dir("feishu");
}
"dingtalk" | "dingtalk-connector" => {
let client_id = form_obj
.get("clientId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let client_secret = form_obj
.get("clientSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if client_id.is_empty() || client_secret.is_empty() {
return Err("Client ID 和 Client Secret 不能为空".into());
}
let mut entry = Map::new();
entry.insert("clientId".into(), Value::String(client_id));
entry.insert("clientSecret".into(), Value::String(client_secret));
entry.insert("enabled".into(), Value::Bool(true));
let gateway_token = form_obj
.get("gatewayToken")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !gateway_token.is_empty() {
entry.insert("gatewayToken".into(), Value::String(gateway_token.into()));
}
let gateway_password = form_obj
.get("gatewayPassword")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !gateway_password.is_empty() {
entry.insert(
"gatewayPassword".into(),
Value::String(gateway_password.into()),
);
}
channels_map.insert(storage_key, Value::Object(entry));
ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?;
ensure_chat_completions_enabled(&mut cfg)?;
let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector");
}
_ => {
// 通用平台:直接保存表单字段
let mut entry = Map::new();
for (k, v) in form_obj {
entry.insert(k.clone(), v.clone());
}
entry.insert("enabled".into(), Value::Bool(true));
channels_map.insert(storage_key, Value::Object(entry));
}
}
// 写回配置并重载 Gateway
super::config::save_openclaw_json(&cfg)?;
// Gateway 重载在后台进行,不阻塞 UI 响应
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(json!({ "ok": true }))
}
/// 删除指定平台配置
#[tauri::command]
pub async fn remove_messaging_platform(
platform: String,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) {
channels.remove(storage_key);
}
super::config::save_openclaw_json(&cfg)?;
// Gateway 重载在后台进行,不阻塞 UI 响应
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(json!({ "ok": true }))
}
/// 切换平台启用/禁用
#[tauri::command]
pub async fn toggle_messaging_platform(
platform: String,
enabled: bool,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
if let Some(entry) = cfg
.get_mut("channels")
.and_then(|c| c.get_mut(storage_key))
.and_then(|v| v.as_object_mut())
{
entry.insert("enabled".into(), Value::Bool(enabled));
} else {
return Err(format!("平台 {} 未配置", platform));
}
super::config::save_openclaw_json(&cfg)?;
// Gateway 重载在后台进行,不阻塞 UI 响应
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(json!({ "ok": true }))
}
/// 在线校验 Bot 凭证(调用平台 API 验证 Token 是否有效)
#[tauri::command]
pub async fn verify_bot_token(platform: String, form: Value) -> Result<Value, String> {
let form_obj = form.as_object().ok_or("表单数据格式错误")?;
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("HTTP 客户端初始化失败: {}", e))?;
match platform.as_str() {
"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,
"dingtalk" | "dingtalk-connector" => verify_dingtalk(&client, form_obj).await,
_ => Ok(json!({
"valid": true,
"warnings": ["该平台暂不支持在线校验"]
})),
}
}
/// 列出当前已配置的平台清单
#[tauri::command]
pub async fn list_configured_platforms() -> Result<Value, String> {
let cfg = super::config::load_openclaw_json()?;
let mut result: Vec<Value> = vec![];
if let Some(channels) = cfg.get("channels").and_then(|c| c.as_object()) {
for (name, val) in channels {
let enabled = val.get("enabled").and_then(|v| v.as_bool()).unwrap_or(true);
result.push(json!({
"id": platform_list_id(name),
"enabled": enabled
}));
}
}
Ok(json!(result))
}
#[tauri::command]
pub async fn get_channel_plugin_status(plugin_id: String) -> Result<Value, String> {
let plugin_id = plugin_id.trim();
if plugin_id.is_empty() {
return Err("plugin_id 不能为空".into());
}
let plugin_dir = generic_plugin_dir(plugin_id);
let installed = plugin_dir.is_dir() && plugin_install_marker_exists(&plugin_dir);
let legacy_backup_detected = legacy_plugin_backup_dir(plugin_id).exists();
// 检测插件是否为 OpenClaw 内置(新版 openclaw/openclaw-zh 打包了 feishu 等插件)
let builtin = is_plugin_builtin(plugin_id);
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
let allowed = cfg
.get("plugins")
.and_then(|p| p.get("allow"))
.and_then(|v| v.as_array())
.map(|arr| arr.iter().any(|v| v.as_str() == Some(plugin_id)))
.unwrap_or(false);
let enabled = cfg
.get("plugins")
.and_then(|p| p.get("entries"))
.and_then(|e| e.get(plugin_id))
.and_then(|entry| entry.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
Ok(json!({
"installed": installed,
"builtin": builtin,
"path": plugin_dir.to_string_lossy(),
"allowed": allowed,
"enabled": enabled,
"legacyBackupDetected": legacy_backup_detected
}))
}
// ── Discord 凭证校验 ──────────────────────────────────────
async fn verify_discord(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let token = form
.get("token")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if token.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Bot Token 不能为空"] }));
}
// 验证 Bot Token
let me_resp = client
.get("https://discord.com/api/v10/users/@me")
.header("Authorization", format!("Bot {}", token))
.send()
.await
.map_err(|e| format!("Discord API 连接失败: {}", e))?;
if me_resp.status() == 401 {
return Ok(json!({ "valid": false, "errors": ["Bot Token 无效,请检查后重试"] }));
}
if !me_resp.status().is_success() {
return Ok(json!({
"valid": false,
"errors": [format!("Discord API 返回异常: {}", me_resp.status())]
}));
}
let me: Value = me_resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if me.get("bot").and_then(|v| v.as_bool()) != Some(true) {
return Ok(json!({
"valid": false,
"errors": ["提供的 Token 不属于 Bot 账号,请使用 Bot Token"]
}));
}
let bot_name = me
.get("username")
.and_then(|v| v.as_str())
.unwrap_or("未知");
let mut details = vec![format!("Bot: @{}", bot_name)];
// 验证 Guild可选
let guild_id = form
.get("guildId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if !guild_id.is_empty() {
match client
.get(format!("https://discord.com/api/v10/guilds/{}", guild_id))
.header("Authorization", format!("Bot {}", token))
.send()
.await
{
Ok(resp) if resp.status().is_success() => {
let guild: Value = resp.json().await.unwrap_or_default();
let name = guild.get("name").and_then(|v| v.as_str()).unwrap_or("?");
details.push(format!("服务器: {}", name));
}
Ok(resp) if resp.status().as_u16() == 403 || resp.status().as_u16() == 404 => {
return Ok(json!({
"valid": false,
"errors": [format!("无法访问服务器 {},请确认 Bot 已加入该服务器", guild_id)]
}));
}
_ => {
details.push("服务器 ID 未能验证(网络问题)".into());
}
}
}
Ok(json!({
"valid": true,
"errors": [],
"details": details
}))
}
// ── QQ Bot 凭证校验 ──────────────────────────────────────
async fn verify_qqbot(
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": ["AppID 不能为空"] }));
}
if app_secret.is_empty() {
return Ok(json!({ "valid": false, "errors": ["AppSecret 不能为空"] }));
}
// 通过 QQ Bot API 获取 access_token 验证凭证
let resp = client
.post("https://bots.qq.com/app/getAppAccessToken")
.json(&json!({
"appId": app_id,
"clientSecret": app_secret
}))
.send()
.await
.map_err(|e| format!("QQ Bot API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if body.get("access_token").and_then(|v| v.as_str()).is_some() {
Ok(json!({
"valid": true,
"errors": [],
"details": [format!("AppID: {}", app_id)]
}))
} else {
let msg = body
.get("message")
.or_else(|| body.get("msg"))
.and_then(|v| v.as_str())
.unwrap_or("凭证无效,请检查 AppID 和 AppSecret");
Ok(json!({
"valid": false,
"errors": [msg]
}))
}
}
fn ensure_plugin_allowed(cfg: &mut Value, plugin_id: &str) -> Result<(), String> {
let root = cfg.as_object_mut().ok_or("配置格式错误")?;
let plugins = root.entry("plugins").or_insert_with(|| json!({}));
let plugins_map = plugins.as_object_mut().ok_or("plugins 节点格式错误")?;
let allow = plugins_map.entry("allow").or_insert_with(|| json!([]));
let allow_arr = allow.as_array_mut().ok_or("plugins.allow 节点格式错误")?;
if !allow_arr.iter().any(|v| v.as_str() == Some(plugin_id)) {
allow_arr.push(Value::String(plugin_id.to_string()));
}
let entries = plugins_map.entry("entries").or_insert_with(|| json!({}));
let entries_map = entries
.as_object_mut()
.ok_or("plugins.entries 节点格式错误")?;
let entry = entries_map
.entry(plugin_id.to_string())
.or_insert_with(|| json!({}));
let entry_obj = entry
.as_object_mut()
.ok_or("plugins.entries 条目格式错误")?;
entry_obj.insert("enabled".into(), Value::Bool(true));
Ok(())
}
fn plugin_backup_root() -> PathBuf {
super::openclaw_dir()
.join("backups")
.join("plugin-installs")
}
fn qqbot_plugin_dir() -> PathBuf {
super::openclaw_dir().join("extensions").join("qqbot")
}
fn qqbot_backup_dir() -> PathBuf {
plugin_backup_root().join("qqbot.__clawpanel_backup")
}
fn qqbot_config_backup_path() -> PathBuf {
plugin_backup_root().join("openclaw.qqbot-install.bak")
}
fn legacy_plugin_backup_dir(plugin_id: &str) -> PathBuf {
super::openclaw_dir()
.join("extensions")
.join(format!("{plugin_id}.__clawpanel_backup"))
}
fn cleanup_legacy_plugin_backup_dir(plugin_id: &str) -> Result<bool, String> {
let legacy_backup = legacy_plugin_backup_dir(plugin_id);
if !legacy_backup.exists() {
return Ok(false);
}
if legacy_backup.is_dir() {
fs::remove_dir_all(&legacy_backup).map_err(|e| format!("清理旧版插件备份失败: {e}"))?;
} else {
fs::remove_file(&legacy_backup).map_err(|e| format!("清理旧版插件备份失败: {e}"))?;
}
Ok(true)
}
fn plugin_install_marker_exists(plugin_dir: &Path) -> bool {
plugin_dir.join("package.json").is_file()
|| plugin_dir.join("plugin.ts").is_file()
|| plugin_dir.join("index.js").is_file()
|| plugin_dir.join("dist").join("index.js").is_file()
}
fn path_to_plugin_entry(path: &Path) -> String {
let mut normalized = path.to_string_lossy().replace('\\', "/");
while normalized.starts_with("./") {
normalized = normalized[2..].to_string();
}
format!("./{}", normalized.trim_start_matches('/'))
}
fn plugin_entry_exists(plugin_dir: &Path, entry: &str) -> bool {
plugin_dir.join(entry.trim_start_matches("./")).is_file()
}
fn synthesize_qqbot_runtime_entry(plugin_dir: &Path) -> Result<String, String> {
let channel = plugin_dir.join("src").join("channel.js");
let runtime = plugin_dir.join("src").join("runtime.js");
if !channel.is_file() || !runtime.is_file() {
return Err("QQBot 插件缺少运行时文件,无法自动修复".into());
}
let dist_dir = plugin_dir.join("dist");
fs::create_dir_all(&dist_dir).map_err(|e| format!("创建 dist 目录失败: {e}"))?;
let dist_entry = dist_dir.join("index.js");
let code = r#"import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { qqbotPlugin } from "../src/channel.js";
import { setQQBotRuntime } from "../src/runtime.js";
const plugin = {
id: "qqbot",
name: "QQ Bot",
description: "QQ Bot channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api) {
setQQBotRuntime(api.runtime);
api.registerChannel({ plugin: qqbotPlugin });
},
};
export default plugin;
"#;
fs::write(&dist_entry, code).map_err(|e| format!("写入 dist/index.js 失败: {e}"))?;
Ok("./dist/index.js".into())
}
fn repair_qqbot_package_manifest(plugin_dir: &Path) -> Result<String, String> {
let package_path = plugin_dir.join("package.json");
if !package_path.is_file() {
return Err("QQBot 插件缺少 package.json".into());
}
let raw =
fs::read_to_string(&package_path).map_err(|e| format!("读取 package.json 失败: {e}"))?;
let mut pkg: Value =
serde_json::from_str(&raw).map_err(|e| format!("解析 package.json 失败: {e}"))?;
let desired_entry = if let Some(main) = pkg.get("main").and_then(|v| v.as_str()) {
let candidate = path_to_plugin_entry(Path::new(main));
if plugin_entry_exists(plugin_dir, &candidate) {
candidate
} else if main.replace('\\', "/") == "dist/index.js" {
synthesize_qqbot_runtime_entry(plugin_dir)?
} else {
return Err(format!("插件入口文件不存在: {main}"));
}
} else if plugin_entry_exists(plugin_dir, "./index.js") {
"./index.js".into()
} else if plugin_dir.join("index.ts").is_file() {
synthesize_qqbot_runtime_entry(plugin_dir)?
} else {
return Err("未找到可用的 QQBot 插件入口".into());
};
for field in ["openclaw", "clawdbot", "moltbot"] {
if let Some(obj) = pkg.get_mut(field).and_then(|v| v.as_object_mut()) {
obj.insert("extensions".into(), json!([desired_entry.clone()]));
}
}
let serialized =
serde_json::to_string_pretty(&pkg).map_err(|e| format!("序列化 package.json 失败: {e}"))?;
fs::write(&package_path, serialized).map_err(|e| format!("写入 package.json 失败: {e}"))?;
Ok(desired_entry)
}
fn restore_path(backup: &Path, target: &Path) -> Result<(), String> {
if target.exists() {
if target.is_dir() {
fs::remove_dir_all(target).map_err(|e| format!("清理目录失败: {e}"))?;
} else {
fs::remove_file(target).map_err(|e| format!("清理文件失败: {e}"))?;
}
}
if backup.exists() {
fs::rename(backup, target).map_err(|e| format!("恢复备份失败: {e}"))?;
}
Ok(())
}
fn cleanup_failed_qqbot_install(
had_plugin_backup: bool,
had_config_backup: bool,
) -> Result<(), String> {
let plugin_dir = qqbot_plugin_dir();
let plugin_backup = qqbot_backup_dir();
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = qqbot_config_backup_path();
if plugin_dir.exists() {
fs::remove_dir_all(&plugin_dir).map_err(|e| format!("清理坏插件目录失败: {e}"))?;
}
if had_plugin_backup {
restore_path(&plugin_backup, &plugin_dir)?;
} else if plugin_backup.exists() {
fs::remove_dir_all(&plugin_backup).map_err(|e| format!("清理插件备份失败: {e}"))?;
}
if had_config_backup {
restore_path(&config_backup, &config_path)?;
} else if config_backup.exists() {
fs::remove_file(&config_backup).map_err(|e| format!("清理配置备份失败: {e}"))?;
}
Ok(())
}
/// 检测插件是否为 OpenClaw 内置(作为 npm 依赖打包在 openclaw/openclaw-zh 中)
fn is_plugin_builtin(plugin_id: &str) -> bool {
// 插件 ID → npm 包名映射
let pkg_name = match plugin_id {
"feishu" => "@openclaw/feishu",
"dingtalk-connector" => "@dingtalk-real-ai/dingtalk-connector",
_ => return false,
};
// 在全局 npm node_modules 中查找 openclaw 安装目录
let npm_dirs: Vec<PathBuf> = {
let mut dirs = Vec::new();
#[cfg(target_os = "windows")]
if let Some(appdata) = std::env::var_os("APPDATA") {
let base = PathBuf::from(appdata).join("npm").join("node_modules");
dirs.push(base.join("@qingchencloud").join("openclaw-zh"));
dirs.push(base.join("openclaw"));
}
#[cfg(target_os = "macos")]
{
dirs.push(PathBuf::from("/opt/homebrew/lib/node_modules/@qingchencloud/openclaw-zh"));
dirs.push(PathBuf::from("/opt/homebrew/lib/node_modules/openclaw"));
dirs.push(PathBuf::from("/usr/local/lib/node_modules/@qingchencloud/openclaw-zh"));
dirs.push(PathBuf::from("/usr/local/lib/node_modules/openclaw"));
}
#[cfg(target_os = "linux")]
{
dirs.push(PathBuf::from("/usr/local/lib/node_modules/@qingchencloud/openclaw-zh"));
dirs.push(PathBuf::from("/usr/local/lib/node_modules/openclaw"));
dirs.push(PathBuf::from("/usr/lib/node_modules/@qingchencloud/openclaw-zh"));
dirs.push(PathBuf::from("/usr/lib/node_modules/openclaw"));
}
dirs
};
// 插件包名拆分成路径片段,如 @openclaw/feishu → @openclaw/feishu
let pkg_path: PathBuf = pkg_name.split('/').collect();
for base in &npm_dirs {
let candidate = base.join("node_modules").join(&pkg_path);
if candidate.join("package.json").is_file() {
return true;
}
}
false
}
fn generic_plugin_dir(plugin_id: &str) -> PathBuf {
super::openclaw_dir().join("extensions").join(plugin_id)
}
fn generic_plugin_backup_dir(plugin_id: &str) -> PathBuf {
plugin_backup_root().join(format!("{plugin_id}.__clawpanel_backup"))
}
fn generic_plugin_config_backup_path(plugin_id: &str) -> PathBuf {
plugin_backup_root().join(format!("openclaw.{plugin_id}-install.bak"))
}
fn cleanup_failed_plugin_install(
plugin_id: &str,
had_plugin_backup: bool,
had_config_backup: bool,
) -> Result<(), String> {
let plugin_dir = generic_plugin_dir(plugin_id);
let plugin_backup = generic_plugin_backup_dir(plugin_id);
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = generic_plugin_config_backup_path(plugin_id);
if plugin_dir.exists() {
fs::remove_dir_all(&plugin_dir).map_err(|e| format!("清理坏插件目录失败: {e}"))?;
}
if had_plugin_backup {
restore_path(&plugin_backup, &plugin_dir)?;
} else if plugin_backup.exists() {
fs::remove_dir_all(&plugin_backup).map_err(|e| format!("清理插件备份失败: {e}"))?;
}
if had_config_backup {
restore_path(&config_backup, &config_path)?;
} else if config_backup.exists() {
fs::remove_file(&config_backup).map_err(|e| format!("清理配置备份失败: {e}"))?;
}
Ok(())
}
// ── QQ Bot 插件安装(带日志流) ──────────────────────────
#[tauri::command]
pub async fn install_channel_plugin(
app: tauri::AppHandle,
package_name: String,
plugin_id: String,
) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let package_name = package_name.trim();
let plugin_id = plugin_id.trim();
if package_name.is_empty() || plugin_id.is_empty() {
return Err("package_name 和 plugin_id 不能为空".into());
}
let plugin_dir = generic_plugin_dir(plugin_id);
let plugin_backup = generic_plugin_backup_dir(plugin_id);
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = generic_plugin_config_backup_path(plugin_id);
let had_existing_plugin = plugin_dir.exists();
let had_existing_config = config_path.exists();
let _ = app.emit("plugin-log", format!("正在安装插件 {} ...", package_name));
let _ = app.emit("plugin-progress", 10);
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
if cleanup_legacy_plugin_backup_dir(plugin_id)? {
let _ = app.emit("plugin-log", "已清理旧版插件备份目录");
}
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if had_existing_plugin {
fs::rename(&plugin_dir, &plugin_backup).map_err(|e| format!("备份旧插件失败: {e}"))?;
let _ = app.emit(
"plugin-log",
format!("检测到旧插件目录,已备份 {}", plugin_dir.display()),
);
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
if had_existing_config {
fs::copy(&config_path, &config_backup).map_err(|e| format!("备份配置失败: {e}"))?;
}
let spawn_result = crate::utils::openclaw_command()
.args(["plugins", "install", package_name])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawn_result {
Ok(child) => child,
Err(e) => {
let _ =
cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config);
return Err(format!("启动 openclaw 失败: {}", e));
}
};
let stderr = child.stderr.take();
let app2 = app.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app2.emit("plugin-log", &line);
}
}
});
let _ = app.emit("plugin-progress", 30);
let mut progress = 30;
if let Some(pipe) = child.stdout.take() {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app.emit("plugin-log", &line);
if progress < 90 {
progress += 10;
let _ = app.emit("plugin-progress", progress);
}
}
}
let _ = handle.join();
let _ = app.emit("plugin-progress", 95);
let status = child
.wait()
.map_err(|e| format!("等待安装进程失败: {}", e))?;
if !status.success() {
let rollback_err =
cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config)
.err()
.unwrap_or_default();
let _ = app.emit(
"plugin-log",
format!("插件 {} 安装失败,已回退", package_name),
);
return if rollback_err.is_empty() {
Err(format!("插件安装失败:{}", package_name))
} else {
Err(format!(
"插件安装失败:{};回退失败:{}",
package_name, rollback_err
))
};
}
let finalize = (|| -> Result<(), String> {
let mut cfg = super::config::load_openclaw_json()?;
ensure_plugin_allowed(&mut cfg, plugin_id)?;
super::config::save_openclaw_json(&cfg)?;
Ok(())
})();
if let Err(err) = finalize {
let rollback_err =
cleanup_failed_plugin_install(plugin_id, had_existing_plugin, had_existing_config)
.err()
.unwrap_or_default();
let _ = app.emit(
"plugin-log",
format!("插件 {} 安装后收尾失败,已回退: {}", package_name, err),
);
return if rollback_err.is_empty() {
Err(format!("插件安装失败:{err}"))
} else {
Err(format!("插件安装失败:{err};回退失败:{rollback_err}"))
};
}
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
let _ = app.emit("plugin-progress", 100);
let _ = app.emit("plugin-log", format!("插件 {} 安装完成", package_name));
Ok("安装成功".into())
}
#[tauri::command]
pub async fn install_qqbot_plugin(app: tauri::AppHandle) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let plugin_dir = qqbot_plugin_dir();
let plugin_backup = qqbot_backup_dir();
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = qqbot_config_backup_path();
let had_existing_plugin = plugin_dir.exists();
let had_existing_config = config_path.exists();
let _ = app.emit("plugin-log", "正在安装 QQBot 社区插件 @sliverp/qqbot ...");
let _ = app.emit("plugin-progress", 10);
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
if cleanup_legacy_plugin_backup_dir("qqbot")? {
let _ = app.emit("plugin-log", "已清理旧版 QQBot 插件备份目录");
}
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if had_existing_plugin {
fs::rename(&plugin_dir, &plugin_backup)
.map_err(|e| format!("备份旧 QQBot 插件失败: {e}"))?;
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
if had_existing_config {
fs::copy(&config_path, &config_backup).map_err(|e| format!("备份配置失败: {e}"))?;
}
let spawn_result = crate::utils::openclaw_command()
.args(["plugins", "install", "@sliverp/qqbot@latest"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawn_result {
Ok(child) => child,
Err(e) => {
let _ = cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config);
return Err(format!("启动 openclaw 失败: {}", e));
}
};
let stderr = child.stderr.take();
let app2 = app.clone();
let qqbot_stderr_lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let qqbot_stderr_clone = qqbot_stderr_lines.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app2.emit("plugin-log", &line);
qqbot_stderr_clone.lock().unwrap().push(line);
}
}
});
let _ = app.emit("plugin-progress", 30);
let mut progress = 30;
let mut qqbot_stdout_lines = Vec::new();
if let Some(pipe) = child.stdout.take() {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
let _ = app.emit("plugin-log", &line);
qqbot_stdout_lines.push(line);
if progress < 90 {
progress += 10;
let _ = app.emit("plugin-progress", progress);
}
}
}
let _ = handle.join();
let _ = app.emit("plugin-progress", 95);
let status = child
.wait()
.map_err(|e| format!("等待安装进程失败: {}", e))?;
// 检测 native binding 缺失macOS/Linux 上 OpenClaw CLI 自身启动失败)
let all_output = {
let stderr_guard = qqbot_stderr_lines.lock().unwrap();
let mut combined = qqbot_stdout_lines.join("\n");
combined.push('\n');
combined.push_str(&stderr_guard.join("\n"));
combined
};
if all_output.contains("native binding") || all_output.contains("Failed to start CLI") {
let _ = app.emit("plugin-log", "");
let _ = app.emit("plugin-log", "⚠️ 检测到 OpenClaw CLI 原生依赖问题native binding 缺失)");
let _ = app.emit("plugin-log", "这是 OpenClaw 的上游依赖问题,非 QQBot 插件本身的问题。");
let _ = app.emit("plugin-log", "请在终端手动执行以下命令重装 OpenClaw");
let _ = app.emit("plugin-log", " npm i -g @qingchencloud/openclaw-zh@latest --registry https://registry.npmmirror.com");
let _ = app.emit("plugin-log", "重装完成后再回来安装 QQBot 插件。");
let _ = cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config);
let _ = app.emit("plugin-progress", 100);
return Err("OpenClaw CLI 原生依赖缺失,请先在终端重装 OpenClaw详见上方日志".into());
}
let finalize = (|| -> Result<(), String> {
if !status.success() {
let _ = app.emit(
"plugin-log",
"安装器返回失败,正在尝试自动修复 QQBot 插件...",
);
}
let entry = repair_qqbot_package_manifest(&plugin_dir)?;
let _ = app.emit("plugin-log", format!("已修正 QQBot 插件入口: {entry}"));
let mut cfg = super::config::load_openclaw_json()?;
ensure_plugin_allowed(&mut cfg, "qqbot")?;
super::config::save_openclaw_json(&cfg)?;
let _ = app.emit(
"plugin-log",
"已补齐 plugins.allow 与 entries.qqbot.enabled",
);
Ok(())
})();
match finalize {
Ok(()) => {
let _ = app.emit("plugin-progress", 100);
if plugin_backup.exists() {
let _ = fs::remove_dir_all(&plugin_backup);
}
if config_backup.exists() {
let _ = fs::remove_file(&config_backup);
}
let _ = app.emit("plugin-log", "QQBot 插件安装完成");
Ok("安装成功".into())
}
Err(err) => {
let _ = app.emit("plugin-log", format!("自动修复失败,正在回退: {err}"));
let rollback_err =
cleanup_failed_qqbot_install(had_existing_plugin, had_existing_config)
.err()
.unwrap_or_default();
let _ = app.emit("plugin-progress", 100);
let _ = app.emit("plugin-log", "QQBot 插件安装失败,已自动回退到安装前状态");
if rollback_err.is_empty() {
Err(format!("插件安装失败:{err}"))
} else {
Err(format!("插件安装失败:{err};回退失败:{rollback_err}"))
}
}
}
}
// ── Telegram 凭证校验 ─────────────────────────────────────
async fn verify_telegram(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let bot_token = form
.get("botToken")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if bot_token.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Bot Token 不能为空"] }));
}
let allowed = form
.get("allowedUsers")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if allowed.is_empty() {
return Ok(json!({ "valid": false, "errors": ["至少需要填写一个允许的用户 ID"] }));
}
let url = format!("https://api.telegram.org/bot{}/getMe", bot_token);
let resp = client
.get(&url)
.send()
.await
.map_err(|e| format!("Telegram API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if body.get("ok").and_then(|v| v.as_bool()) == Some(true) {
let username = body
.get("result")
.and_then(|r| r.get("username"))
.and_then(|v| v.as_str())
.unwrap_or("未知");
Ok(json!({
"valid": true,
"errors": [],
"details": [format!("Bot: @{}", username)]
}))
} else {
let desc = body
.get("description")
.and_then(|v| v.as_str())
.unwrap_or("Token 无效");
Ok(json!({
"valid": false,
"errors": [desc]
}))
}
}
// ── 飞书凭证校验 ──────────────────────────────────────
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]
}))
}
}
// ── 钉钉凭证校验 ──────────────────────────────────────
async fn verify_dingtalk(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let client_id = form
.get("clientId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let client_secret = form
.get("clientSecret")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if client_id.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Client ID 不能为空"] }));
}
if client_secret.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Client Secret 不能为空"] }));
}
let resp = client
.post("https://api.dingtalk.com/v1.0/oauth2/accessToken")
.json(&json!({
"appKey": client_id,
"appSecret": client_secret
}))
.send()
.await
.map_err(|e| format!("钉钉 API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析响应失败: {}", e))?;
if body
.get("accessToken")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.is_some()
|| body
.get("access_token")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.is_some()
{
Ok(json!({
"valid": true,
"errors": [],
"details": [
format!("AppKey: {}", client_id),
"已通过 accessToken 接口校验".to_string()
]
}))
} else {
let msg = body
.get("message")
.or_else(|| body.get("msg"))
.or_else(|| body.get("errmsg"))
.and_then(|v| v.as_str())
.unwrap_or("凭证无效,请检查 Client ID 和 Client Secret");
Ok(json!({
"valid": false,
"errors": [msg]
}))
}
}