Files
clawpanel/src-tauri/src/commands/messaging.rs

6420 lines
233 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};
use std::time::Duration;
fn platform_storage_key(platform: &str) -> &str {
match platform {
"dingtalk" | "dingtalk-connector" => "dingtalk-connector",
"weixin" => "openclaw-weixin",
_ => platform,
}
}
fn platform_list_id(platform: &str) -> &str {
match platform {
"dingtalk-connector" => "dingtalk",
"openclaw-weixin" => "weixin",
_ => 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 form_string(form_obj: &Map<String, Value>, key: &str) -> String {
form_obj
.get(key)
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string()
}
fn insert_string_if_present(form: &mut Map<String, Value>, source: &Value, key: &str) {
if let Some(v) = source.get(key).and_then(|v| v.as_str()) {
form.insert(key.into(), Value::String(v.into()));
}
}
fn secret_ref_parts(value: &Value) -> Option<(&str, &str, &str)> {
let obj = value.as_object()?;
let source = obj.get("source").and_then(|v| v.as_str())?.trim();
if !matches!(source, "env" | "file" | "exec") {
return None;
}
let provider = obj
.get("provider")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("default");
let id = obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())?;
Some((source, provider, id))
}
fn secret_ref_placeholder(value: &Value) -> Option<String> {
let (source, provider, id) = secret_ref_parts(value)?;
Some(format!("SecretRef({}:{}:{})", source, provider, id))
}
fn insert_secret_aware_form_value(form: &mut Map<String, Value>, source: &Value, key: &str) {
if let Some(v) = source.get(key).and_then(|v| v.as_str()) {
form.insert(key.into(), Value::String(v.into()));
return;
}
let Some(value) = source.get(key) else {
return;
};
let Some(placeholder) = secret_ref_placeholder(value) else {
return;
};
form.insert(key.into(), Value::String(placeholder));
let refs = form
.entry("__secretRefs")
.or_insert_with(|| Value::Object(Map::new()));
if let Some(obj) = refs.as_object_mut() {
obj.insert(key.into(), value.clone());
}
}
fn resolve_messaging_credential_value_for_save(
form_obj: &Map<String, Value>,
current: &Value,
key: &str,
) -> Option<Value> {
let raw_value = form_obj.get(key)?;
let Value::String(raw) = raw_value else {
return Some(raw_value.clone());
};
let value = raw.trim();
if let Some(current_value) = current.get(key) {
if let Some(placeholder) = secret_ref_placeholder(current_value) {
if value.is_empty() || value == placeholder {
return Some(current_value.clone());
}
}
}
if value.is_empty() {
None
} else {
Some(Value::String(value.to_string()))
}
}
fn preserve_messaging_credential_refs(
entry: &mut Map<String, Value>,
form_obj: &Map<String, Value>,
current: &Value,
) {
entry.remove("__secretRefs");
for key in [
"accessToken",
"appId",
"appPassword",
"appSecret",
"appToken",
"botToken",
"channelAccessToken",
"channelSecret",
"clientId",
"clientSecret",
"gatewayPassword",
"gatewayToken",
"password",
"secretFile",
"serviceAccount",
"serviceAccountFile",
"serviceAccountRef",
"signingSecret",
"token",
"tokenFile",
"webhookSecret",
] {
if !form_obj.contains_key(key) {
continue;
}
match resolve_messaging_credential_value_for_save(form_obj, current, key) {
Some(value) => {
entry.insert(key.into(), value);
}
None => {
entry.remove(key);
}
}
}
}
fn has_configured_messaging_value(value: Option<&Value>) -> bool {
match value {
Some(Value::String(raw)) => !raw.trim().is_empty(),
Some(value) if secret_ref_parts(value).is_some() => true,
Some(Value::Null) | None => false,
Some(_) => true,
}
}
fn is_enabled_form_flag(value: Option<&Value>) -> bool {
match value {
Some(Value::Bool(v)) => *v,
Some(Value::Number(v)) => v.as_i64().map(|n| n != 0).unwrap_or(false),
Some(Value::String(raw)) => matches!(
raw.trim().to_ascii_lowercase().as_str(),
"true" | "1" | "yes" | "on" | "enabled"
),
_ => false,
}
}
fn msteams_credential_missing_labels(form: &Map<String, Value>) -> Vec<&'static str> {
if !has_configured_messaging_value(form.get("appId")) {
return vec!["App ID"];
}
if has_configured_messaging_value(form.get("appPassword")) {
return vec![];
}
if is_enabled_form_flag(form.get("useManagedIdentity")) {
return vec![];
}
let auth_type = form_string(form, "authType").to_ascii_lowercase();
let has_federated_credential = has_configured_messaging_value(form.get("certificatePath"))
|| has_configured_messaging_value(form.get("certificateThumbprint"));
if auth_type == "federated" && has_federated_credential {
return vec![];
}
if auth_type == "federated" {
return vec!["Certificate Path / Certificate Thumbprint / Managed Identity / App Password"];
}
vec!["App Password"]
}
fn channel_root_has_messaging_credential(root: &Map<String, Value>) -> bool {
[
"accessToken",
"appId",
"appPassword",
"appSecret",
"appToken",
"botToken",
"channelAccessToken",
"channelSecret",
"clientId",
"clientSecret",
"gatewayPassword",
"gatewayToken",
"password",
"secretFile",
"serviceAccount",
"serviceAccountFile",
"serviceAccountRef",
"signingSecret",
"token",
"tokenFile",
"webhookSecret",
]
.iter()
.any(|key| has_configured_messaging_value(root.get(*key)))
}
fn value_has_messaging_credential(value: &Value) -> bool {
value
.as_object()
.map(channel_root_has_messaging_credential)
.unwrap_or(false)
}
fn required_channel_credential_fields(
platform: &str,
form: &Map<String, Value>,
) -> Vec<(&'static str, &'static str)> {
match platform_storage_key(platform) {
"telegram" => vec![("botToken", "Bot Token")],
"discord" => vec![("token", "Bot Token")],
"feishu" => vec![("appId", "App ID"), ("appSecret", "App Secret")],
"dingtalk-connector" => vec![("clientId", "Client ID"), ("clientSecret", "Client Secret")],
"mattermost" => vec![("botToken", "Bot Token"), ("baseUrl", "Base URL")],
"synology-chat" => vec![("token", "Token"), ("incomingUrl", "Incoming URL")],
"clickclack" => vec![
("baseUrl", "Base URL"),
("token", "Token"),
("workspace", "Workspace"),
],
"signal" => vec![("account", "Signal 账号")],
"slack" => {
let mode = form_string(form, "mode");
vec![
("botToken", "Bot Token"),
if mode == "http" {
("signingSecret", "Signing Secret")
} else {
("appToken", "App Token")
},
]
}
"matrix" => {
if has_configured_messaging_value(form.get("accessToken")) {
vec![("accessToken", "Access Token")]
} else {
vec![
("homeserver", "Homeserver"),
("userId", "User ID"),
("password", "Password"),
]
}
}
"msteams" => msteams_credential_missing_labels(form)
.into_iter()
.map(|label| {
if label == "App ID" {
("appId", "App ID")
} else {
("__msteamsAuth", label)
}
})
.collect(),
_ => vec![],
}
}
fn channel_any_credential_fields(platform: &str) -> Vec<(&'static str, &'static str)> {
match platform_storage_key(platform) {
"zalo" => vec![("botToken", "Bot Token"), ("tokenFile", "Token File")],
"googlechat" => vec![
("serviceAccountFile", "Service Account File"),
("serviceAccount", "Service Account JSON"),
("serviceAccountRef", "Service Account SecretRef"),
],
_ => vec![],
}
}
fn channel_any_credential_groups(
platform: &str,
) -> Vec<(&'static str, Vec<(&'static str, &'static str)>)> {
match platform_storage_key(platform) {
"line" => vec![
(
"Channel Access Token 或 Token File",
vec![
("channelAccessToken", "Channel Access Token"),
("tokenFile", "Token File"),
],
),
(
"Channel Secret 或 Secret File",
vec![
("channelSecret", "Channel Secret"),
("secretFile", "Secret File"),
],
),
],
_ => vec![],
}
}
fn channel_diagnosis_credentials_ready(platform: &str, form: &Map<String, Value>) -> bool {
if matches!(
platform_storage_key(platform),
"zalouser" | "imessage" | "whatsapp"
) {
return true;
}
if platform_storage_key(platform) == "msteams" {
return msteams_credential_missing_labels(form).is_empty();
}
let required_fields = required_channel_credential_fields(platform, form);
if !required_fields.is_empty() {
return required_fields
.iter()
.all(|(key, _)| has_configured_messaging_value(form.get(*key)));
}
let any_groups = channel_any_credential_groups(platform);
if !any_groups.is_empty() {
return any_groups.iter().all(|(_, fields)| {
fields
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
});
}
let any_fields = channel_any_credential_fields(platform);
if !any_fields.is_empty() {
return any_fields
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)));
}
channel_root_has_messaging_credential(form)
}
fn credential_labels(fields: &[(&'static str, &'static str)]) -> String {
fields
.iter()
.map(|(_, label)| *label)
.collect::<Vec<_>>()
.join(" / ")
}
fn json_string_list(value: Option<&Value>) -> Vec<String> {
value
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|item| item.as_str())
.map(str::trim)
.filter(|item| !item.is_empty())
.map(str::to_string)
.collect()
})
.unwrap_or_default()
}
fn compact_diagnostic_details(values: &[String]) -> String {
values
.iter()
.map(|value| value.trim())
.filter(|value| !value.is_empty())
.collect::<Vec<_>>()
.join("")
}
fn build_openclaw_channel_diagnosis(
platform: &str,
account_id: Option<&str>,
config_exists: bool,
channel_enabled: bool,
form: &Map<String, Value>,
verify_result: Option<Value>,
verify_error: Option<String>,
) -> Value {
let storage_key = platform_storage_key(platform);
let display_platform = platform_list_id(storage_key);
let account_id = account_id.map(str::trim).filter(|id| !id.is_empty());
let mut checks = Vec::new();
checks.push(json!({
"id": "config_exists",
"ok": config_exists,
"title": "渠道配置已保存",
"detail": if config_exists {
format!(
"已读取 channels.{}{} 的配置。",
storage_key,
account_id.map(|id| format!(".accounts.{}", id)).unwrap_or_default()
)
} else {
format!(
"未在 openclaw.json 中找到 {} 渠道配置,请先在「渠道列表」接入并保存。",
display_platform
)
}
}));
checks.push(json!({
"id": "channel_enabled",
"ok": channel_enabled,
"title": "渠道已启用",
"detail": if channel_enabled {
"渠道未被显式禁用Gateway 重启/重载后会尝试加载。".to_string()
} else {
format!("channels.{}.enabled 为 false请先在渠道列表中启用该渠道。", storage_key)
}
}));
let required_fields = required_channel_credential_fields(storage_key, form);
let any_fields = channel_any_credential_fields(storage_key);
let any_groups = channel_any_credential_groups(storage_key);
let missing: Vec<&str> = if storage_key == "msteams" {
msteams_credential_missing_labels(form)
} else {
required_fields
.iter()
.filter(|(key, _)| !has_configured_messaging_value(form.get(*key)))
.map(|(_, label)| *label)
.collect()
};
let missing_groups: Vec<&str> = any_groups
.iter()
.filter(|(_, fields)| {
!fields
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
})
.map(|(label, _)| *label)
.collect();
let any_credential_ok = if any_fields.is_empty() {
false
} else {
any_fields
.iter()
.any(|(key, _)| has_configured_messaging_value(form.get(*key)))
};
let credential_ok = if matches!(storage_key, "zalouser" | "imessage" | "whatsapp") {
config_exists
} else if !required_fields.is_empty() {
missing.is_empty()
} else if !any_groups.is_empty() {
missing_groups.is_empty()
} else if !any_fields.is_empty() {
any_credential_ok
} else {
channel_root_has_messaging_credential(form)
};
let required_labels = credential_labels(&required_fields);
let any_labels = credential_labels(&any_fields);
checks.push(json!({
"id": "credentials",
"ok": credential_ok,
"title": if storage_key == "zalouser" {
"登录/会话配置"
} else if storage_key == "imessage" {
"桥接运行配置"
} else if storage_key == "whatsapp" {
"扫码/会话配置"
} else {
"必要凭证字段"
},
"detail": if storage_key == "zalouser" {
"Zalo Personal 通过二维码登录保存本地会话;配置已保存后,请按手动命令完成或刷新登录。".to_string()
} else if storage_key == "imessage" {
if config_exists {
"iMessage 使用本机或远端桥接运行,不需要 Bot Token已保存基础运行配置。".to_string()
} else {
"尚未保存 iMessage 渠道配置,请先填写并保存。".to_string()
}
} else if storage_key == "whatsapp" {
if config_exists {
"WhatsApp 使用扫码登录保存本地会话,不需要 Bot Token已保存扫码运行配置。".to_string()
} else {
"尚未保存 WhatsApp 渠道配置,请先填写并保存,再启动扫码登录。".to_string()
}
} else if credential_ok {
if !required_fields.is_empty() {
format!("已填写 {}", required_labels)
} else if !any_groups.is_empty() {
format!(
"已填写 {}",
any_groups
.iter()
.map(|(label, _)| *label)
.collect::<Vec<_>>()
.join("")
)
} else if !any_fields.is_empty() {
format!("已填写 {} 其中一项。", any_labels)
} else {
"已检测到可用凭证字段。".to_string()
}
} else if !missing.is_empty() {
format!("缺少 {},请补齐后保存。", missing.join(" / "))
} else if !missing_groups.is_empty() {
format!("缺少 {},请补齐后保存。", missing_groups.join(""))
} else if !any_fields.is_empty() {
format!("缺少 {},至少填写一项后保存。", any_labels)
} else {
"未检测到可用凭证字段,请检查渠道配置。".to_string()
}
}));
if let Some(error) = verify_error.filter(|error| !error.trim().is_empty()) {
checks.push(json!({
"id": "online_verify",
"ok": false,
"title": "平台在线校验",
"detail": error
}));
} else if let Some(result) = verify_result {
let valid = result.get("valid").and_then(|v| v.as_bool()) == Some(true);
let errors = json_string_list(result.get("errors"));
let warnings = json_string_list(result.get("warnings"));
let details = json_string_list(result.get("details"));
let verify_ok = valid || (!warnings.is_empty() && errors.is_empty());
checks.push(json!({
"id": "online_verify",
"ok": verify_ok,
"title": "平台在线校验",
"detail": if valid {
let detail = compact_diagnostic_details(&details);
if detail.is_empty() {
"平台 API 已接受当前凭证。".to_string()
} else {
detail
}
} else {
let detail = compact_diagnostic_details(&errors);
if detail.is_empty() {
let warning_detail = compact_diagnostic_details(&warnings);
if warning_detail.is_empty() {
"该平台暂不支持在线校验。".to_string()
} else {
warning_detail
}
} else {
detail
}
}
}));
} else {
checks.push(json!({
"id": "online_verify",
"ok": true,
"title": "平台在线校验",
"detail": "未执行在线校验,仅完成本地配置检查。"
}));
}
let failed_count = checks
.iter()
.filter(|check| check.get("ok").and_then(|v| v.as_bool()) != Some(true))
.count();
json!({
"ok": failed_count == 0,
"overallReady": failed_count == 0,
"platform": display_platform,
"accountId": account_id,
"checks": checks,
"userHints": if failed_count == 0 {
vec!["配置侧检查已通过。若仍收不到消息,请确认 Gateway 已重启、机器人已加入目标会话,并检查 Gateway 日志。"]
} else {
vec![
"先修复未通过的检查项,保存渠道后重启或重载 Gateway。",
"在线校验只能证明平台凭证可用;群聊白名单、机器人邀请和平台回调仍需在对应平台控制台确认。",
]
}
})
}
fn insert_bool_as_string(form: &mut Map<String, Value>, source: &Value, key: &str) {
if let Some(v) = source.get(key).and_then(|v| v.as_bool()) {
form.insert(
key.into(),
Value::String(if v { "true" } else { "false" }.into()),
);
}
}
fn insert_array_as_csv(form: &mut Map<String, Value>, source: &Value, key: &str) {
if let Some(items) = source.get(key).and_then(|v| v.as_array()) {
let joined = items
.iter()
.filter_map(|v| v.as_str())
.filter(|s| !s.trim().is_empty())
.collect::<Vec<_>>()
.join(", ");
if !joined.is_empty() {
form.insert(key.into(), Value::String(joined));
}
}
}
fn insert_number_as_string(form: &mut Map<String, Value>, source: &Value, key: &str) {
if let Some(v) = source.get(key).and_then(|v| v.as_f64()) {
form.insert(key.into(), Value::String(v.to_string()));
}
}
fn insert_access_policy_form_values(
form: &mut Map<String, Value>,
source: &Value,
telegram_compat: bool,
mention_compat: bool,
) {
insert_string_if_present(form, source, "dmPolicy");
insert_string_if_present(form, source, "groupPolicy");
if mention_compat
&& form.get("groupPolicy").and_then(|v| v.as_str()) == Some("open")
&& source.get("requireMention").and_then(|v| v.as_bool()) == Some(true)
{
form.insert("groupPolicy".into(), Value::String("mentioned".into()));
}
insert_array_as_csv(form, source, "allowFrom");
if telegram_compat {
if let Some(v) = form.get("allowFrom").cloned() {
form.insert("allowedUsers".into(), v);
}
}
}
fn csv_to_json_array(raw: &str) -> Option<Value> {
let items = raw
.split(&[',', '\n', ';'][..])
.map(str::trim)
.filter(|s| !s.is_empty())
.map(|s| Value::String(s.to_string()))
.collect::<Vec<_>>();
if items.is_empty() {
None
} else {
Some(Value::Array(items))
}
}
fn json_array_from_csv_value(value: Option<&Value>) -> Vec<Value> {
match value {
Some(Value::Array(items)) => items
.iter()
.filter_map(|v| {
if let Some(s) = v.as_str() {
let trimmed = s.trim();
if trimmed.is_empty() {
None
} else {
Some(Value::String(trimmed.to_string()))
}
} else if v.is_number() || v.is_boolean() {
Some(Value::String(v.to_string()))
} else {
None
}
})
.collect(),
Some(Value::String(raw)) => csv_to_json_array(raw)
.and_then(|v| v.as_array().cloned())
.unwrap_or_default(),
_ => vec![],
}
}
fn bool_from_form_value(raw: &str) -> Option<bool> {
match raw.trim().to_ascii_lowercase().as_str() {
"true" | "1" | "yes" | "on" => Some(true),
"false" | "0" | "no" | "off" => Some(false),
_ => None,
}
}
fn put_string(entry: &mut Map<String, Value>, key: &str, value: String) {
if !value.is_empty() {
entry.insert(key.into(), Value::String(value));
}
}
fn put_bool_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str) {
if let Some(v) = bool_from_form_value(raw) {
entry.insert(key.into(), Value::Bool(v));
}
}
fn put_number_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str) {
let value = raw.trim();
if value.is_empty() {
return;
}
if let Ok(number) = value.parse::<f64>() {
if let Some(json_number) = serde_json::Number::from_f64(number) {
entry.insert(key.into(), Value::Number(json_number));
}
}
}
fn put_number_value_if_present(entry: &mut Map<String, Value>, key: &str, value: Option<&Value>) {
if let Some(number) = value.and_then(|v| v.as_f64()) {
if let Some(json_number) = serde_json::Number::from_f64(number) {
entry.insert(key.into(), Value::Number(json_number));
}
return;
}
put_number_from_form(entry, key, &value.and_then(|v| v.as_str()).unwrap_or(""));
}
fn normalize_numeric_form_value(map: &mut Map<String, Value>, key: &str) {
let Some(value) = map.get(key).cloned() else {
return;
};
match value {
Value::String(raw) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
map.remove(key);
return;
}
if let Ok(number) = trimmed.parse::<f64>() {
if let Some(json_number) = serde_json::Number::from_f64(number) {
map.insert(key.into(), Value::Number(json_number));
}
}
}
Value::Null => {
map.remove(key);
}
_ => {}
}
}
fn put_bool_value_if_present(entry: &mut Map<String, Value>, key: &str, value: Option<&Value>) {
match value {
Some(Value::Bool(v)) => {
entry.insert(key.into(), Value::Bool(*v));
}
Some(Value::String(raw)) => put_bool_from_form(entry, key, raw),
_ => {}
}
}
fn put_array_from_form_value(entry: &mut Map<String, Value>, key: &str, value: Option<&Value>) {
let items = json_array_from_csv_value(value);
if !items.is_empty() {
entry.insert(key.into(), Value::Array(items));
}
}
fn normalize_dm_policy_value(raw: Option<&Value>, fallback: &str) -> String {
let value = raw.and_then(|v| v.as_str()).unwrap_or("").trim();
match value {
"" => fallback.to_string(),
"allow" | "open" => "open".into(),
"deny" | "disabled" => "disabled".into(),
"pairing" => "pairing".into(),
"allowlist" => "allowlist".into(),
_ => fallback.to_string(),
}
}
fn normalize_group_policy_value(raw: Option<&Value>, fallback: &str) -> String {
let value = raw.and_then(|v| v.as_str()).unwrap_or("").trim();
match value {
"" => fallback.to_string(),
"all" | "mentioned" | "open" => "open".into(),
"deny" | "disabled" => "disabled".into(),
"allowlist" => "allowlist".into(),
_ => fallback.to_string(),
}
}
fn platform_supports_top_level_require_mention(platform: &str) -> bool {
matches!(
platform_storage_key(platform),
"feishu" | "slack" | "msteams" | "mattermost" | "googlechat"
)
}
fn normalize_messaging_platform_form(
platform: &str,
form: &Map<String, Value>,
) -> Map<String, Value> {
let storage_key = platform_storage_key(platform);
let mut normalized = form.clone();
if !normalized.contains_key("allowFrom") {
if let Some(v) = normalized.get("allowedUsers").cloned() {
normalized.insert("allowFrom".into(), v);
}
}
let needs_access_defaults = matches!(
storage_key,
"telegram"
| "discord"
| "feishu"
| "slack"
| "signal"
| "msteams"
| "whatsapp"
| "zalo"
| "zalouser"
| "line"
| "mattermost"
| "googlechat"
| "imessage"
);
let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults;
let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults;
if has_dm_field {
let dm_policy = normalize_dm_policy_value(normalized.get("dmPolicy"), "pairing");
normalized.insert("dmPolicy".into(), Value::String(dm_policy.clone()));
if normalized.contains_key("allowFrom") {
let items = json_array_from_csv_value(normalized.get("allowFrom"));
normalized.insert("allowFrom".into(), Value::Array(items));
}
if dm_policy == "open" {
let mut items = json_array_from_csv_value(normalized.get("allowFrom"));
if !items.iter().any(|v| v.as_str() == Some("*")) {
items.push(Value::String("*".into()));
}
normalized.insert("allowFrom".into(), Value::Array(items));
}
} else if normalized.contains_key("allowFrom") {
let items = json_array_from_csv_value(normalized.get("allowFrom"));
normalized.insert("allowFrom".into(), Value::Array(items));
}
if has_group_field {
let requested_group_policy = normalized
.get("groupPolicy")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let group_policy = normalize_group_policy_value(normalized.get("groupPolicy"), "allowlist");
normalized.insert("groupPolicy".into(), Value::String(group_policy));
if requested_group_policy == "mentioned"
&& platform_supports_top_level_require_mention(storage_key)
{
normalized.insert("requireMention".into(), Value::Bool(true));
} else if requested_group_policy != "mentioned" {
if platform_supports_top_level_require_mention(storage_key) {
normalized.insert("requireMention".into(), Value::Bool(false));
} else if normalized.contains_key("requireMention") {
let value = match normalized.get("requireMention") {
Some(Value::Bool(v)) => *v,
Some(Value::String(s)) => bool_from_form_value(s).unwrap_or(false),
_ => false,
};
normalized.insert("requireMention".into(), Value::Bool(value));
}
}
}
if normalized.contains_key("groupAllowFrom") {
let items = json_array_from_csv_value(normalized.get("groupAllowFrom"));
normalized.insert("groupAllowFrom".into(), Value::Array(items));
}
if normalized.contains_key("allowedUserIds") {
let items = json_array_from_csv_value(normalized.get("allowedUserIds"));
normalized.insert("allowedUserIds".into(), Value::Array(items));
}
normalize_numeric_form_value(&mut normalized, "mediaMaxMb");
normalize_numeric_form_value(&mut normalized, "historyLimit");
normalize_numeric_form_value(&mut normalized, "dmHistoryLimit");
normalize_numeric_form_value(&mut normalized, "textChunkLimit");
normalize_numeric_form_value(&mut normalized, "probeTimeoutMs");
normalize_numeric_form_value(&mut normalized, "debounceMs");
normalize_numeric_form_value(&mut normalized, "rateLimitPerMinute");
normalize_numeric_form_value(&mut normalized, "httpPort");
normalize_numeric_form_value(&mut normalized, "webhookPort");
normalize_numeric_form_value(&mut normalized, "feedbackReflectionCooldownMs");
normalize_numeric_form_value(&mut normalized, "timeoutSeconds");
normalize_numeric_form_value(&mut normalized, "reconnectMs");
for key in [
"promptStarters",
"delegatedAuthScopes",
"attachmentRoots",
"remoteAttachmentRoots",
"toolsAllow",
] {
if normalized.contains_key(key) {
let items = json_array_from_csv_value(normalized.get(key));
normalized.insert(key.into(), Value::Array(items));
}
}
for key in [
"dangerouslyAllowNameMatching",
"dangerouslyAllowPrivateNetwork",
"dangerouslyAllowInheritedWebhookPath",
"allowInsecureSsl",
"enabled",
"allowBots",
"blockStreaming",
"useManagedIdentity",
"typingIndicator",
"welcomeCard",
"groupWelcomeCard",
"feedbackEnabled",
"feedbackReflection",
"delegatedAuthEnabled",
"ssoEnabled",
"configWrites",
"includeAttachments",
"sendReadReceipts",
"coalesceSameSenderDms",
"selfChatMode",
"ackDirect",
"senderIsOwner",
] {
if normalized.contains_key(key) {
let value = match normalized.get(key) {
Some(Value::Bool(v)) => Some(*v),
Some(Value::String(raw)) => {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
Some(bool_from_form_value(trimmed).unwrap_or(false))
}
}
_ => None,
};
if let Some(v) = value {
normalized.insert(key.into(), Value::Bool(v));
} else {
normalized.remove(key);
}
}
}
if storage_key == "feishu" {
let domain = normalized
.get("domain")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
normalized.insert(
"domain".into(),
Value::String(if domain.is_empty() { "feishu" } else { domain }.into()),
);
normalized
.entry("connectionMode")
.or_insert(Value::String("websocket".into()));
normalized
.entry("webhookPath")
.or_insert(Value::String("/feishu/events".into()));
normalized
.entry("reactionNotifications")
.or_insert(Value::String("off".into()));
normalized
.entry("typingIndicator")
.or_insert(Value::Bool(true));
normalized
.entry("resolveSenderNames")
.or_insert(Value::Bool(true));
}
if storage_key == "slack" {
normalized
.entry("mode")
.or_insert(Value::String("socket".into()));
normalized
.entry("webhookPath")
.or_insert(Value::String("/slack/events".into()));
normalized
.entry("userTokenReadOnly")
.or_insert(Value::Bool(false));
}
normalized
}
/// 合并渠道配置:将新的表单字段覆盖到现有配置上,保留用户通过 CLI 或手动编辑的自定义字段。
/// 例如用户手动添加的 streaming / retry / dmPolicy 等不会被丢弃。
fn merge_channel_entry(
channels_map: &mut Map<String, Value>,
key: &str,
new_entry: Map<String, Value>,
) {
let merged = if let Some(Value::Object(existing)) = channels_map.get(key) {
let mut m = existing.clone();
for (k, v) in new_entry {
m.insert(k, v);
}
m
} else {
new_entry
};
channels_map.insert(key.to_string(), Value::Object(merged));
}
/// 合并账号级渠道配置:保留渠道根节点和账号已有自定义字段,只覆盖本次表单字段。
fn merge_account_channel_entry(
channels_map: &mut Map<String, Value>,
key: &str,
account_id: &str,
new_entry: Map<String, Value>,
) -> Result<(), String> {
let channel = channels_map
.entry(key.to_string())
.or_insert_with(|| json!({ "enabled": true }));
let channel_obj = channel
.as_object_mut()
.ok_or(format!("{} 节点格式错误", key))?;
let accounts_before = channel_obj
.get("accounts")
.and_then(|value| value.as_object())
.map(|accounts| accounts.keys().filter(|id| !id.is_empty()).count())
.unwrap_or(0);
let should_set_default_account = channel_obj
.get("defaultAccount")
.and_then(|value| value.as_str())
.map(str::trim)
.filter(|value| !value.is_empty())
.is_none()
&& !channel_root_has_messaging_credential(channel_obj)
&& accounts_before == 0;
channel_obj.insert("enabled".into(), Value::Bool(true));
let accounts = channel_obj.entry("accounts").or_insert_with(|| json!({}));
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
let merged = if let Some(Value::Object(existing)) = accounts_obj.get(account_id) {
let mut m = existing.clone();
for (k, v) in new_entry {
m.insert(k, v);
}
m
} else {
new_entry
};
accounts_obj.insert(account_id.to_string(), Value::Object(merged));
if should_set_default_account {
channel_obj.insert(
"defaultAccount".into(),
Value::String(account_id.to_string()),
);
}
Ok(())
}
fn merge_channel_entry_for_account(
channels_map: &mut Map<String, Value>,
key: &str,
account_id: Option<&str>,
new_entry: Map<String, Value>,
) -> Result<(), String> {
if let Some(acct) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
merge_account_channel_entry(channels_map, key, acct, new_entry)
} else {
merge_channel_entry(channels_map, key, new_entry);
Ok(())
}
}
fn normalize_binding_match_value(value: &Value) -> Option<Value> {
match value {
Value::Null => None,
Value::String(s) => Some(Value::String(s.trim().to_string())),
Value::Array(items) => {
let mut normalized: Vec<Value> = items
.iter()
.filter_map(normalize_binding_match_value)
.collect();
if normalized.iter().all(|item| item.as_str().is_some()) {
normalized.sort_by(|a, b| a.as_str().unwrap().cmp(b.as_str().unwrap()));
}
Some(Value::Array(normalized))
}
Value::Object(map) => {
let mut result = Map::new();
let mut keys: Vec<&String> = map.keys().collect();
keys.sort();
for key in keys {
let Some(item) = map.get(key) else {
continue;
};
if key == "peer" {
if let Some(peer_id) = item.as_str().map(str::trim).filter(|s| !s.is_empty()) {
result.insert("peer".into(), json!({ "kind": "direct", "id": peer_id }));
} else if let Some(peer_obj) = item.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(peer_id) = id {
result.insert("peer".into(), json!({ "kind": kind, "id": peer_id }));
}
}
continue;
}
let Some(normalized) = normalize_binding_match_value(item) else {
continue;
};
if key == "accountId" && normalized.as_str().map(|s| s.is_empty()).unwrap_or(false)
{
continue;
}
if normalized.as_str().map(|s| s.is_empty()).unwrap_or(false) {
continue;
}
result.insert(key.clone(), normalized);
}
Some(Value::Object(result))
}
_ => Some(value.clone()),
}
}
fn build_binding_match(channel: &str, account_id: Option<&str>, binding_config: &Value) -> Value {
let mut match_config = Map::new();
match_config.insert("channel".into(), Value::String(channel.to_string()));
if let Some(acct) = account_id.map(str::trim).filter(|s| !s.is_empty()) {
match_config.insert("accountId".into(), Value::String(acct.to_string()));
}
if let Some(config_obj) = binding_config.as_object() {
for (k, v) in config_obj {
if k == "peer" {
if let Some(peer_str) = v.as_str().map(str::trim).filter(|s| !s.is_empty()) {
match_config.insert("peer".into(), json!({ "kind": "direct", "id": peer_str }));
} else if let Some(peer_obj) = v.as_object() {
let kind = peer_obj
.get("kind")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or("direct");
let id = peer_obj
.get("id")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty());
if let Some(peer_id) = id {
match_config.insert("peer".into(), json!({ "kind": kind, "id": peer_id }));
}
}
} else if k != "accountId" && k != "channel" && !v.is_null() {
match_config.insert(k.clone(), v.clone());
}
}
}
normalize_binding_match_value(&Value::Object(match_config))
.unwrap_or_else(|| Value::Object(Map::new()))
}
fn binding_identity_matches(binding: &Value, agent_id: &str, target_match: &Value) -> bool {
let binding_agent = binding
.get("agentId")
.and_then(|v| v.as_str())
.unwrap_or("main");
if binding_agent != agent_id {
return false;
}
let existing_match =
normalize_binding_match_value(binding.get("match").unwrap_or(&Value::Null))
.unwrap_or_else(|| Value::Object(Map::new()));
let expected_match =
normalize_binding_match_value(target_match).unwrap_or_else(|| Value::Object(Map::new()));
existing_match == expected_match
}
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())
}
fn resolve_platform_config_entry(
channel_root: Option<&Value>,
platform: &str,
account_id: Option<&str>,
) -> Option<Value> {
let root = channel_root?;
let account = account_id.map(str::trim).filter(|s| !s.is_empty());
if let Some(acct) = account {
if let Some(value) = root.get("accounts").and_then(|a| a.get(acct)) {
return Some(value.clone());
}
if platform_storage_key(platform) == "qqbot" && !qqbot_channel_has_credentials(root) {
return None;
}
return Some(root.clone());
}
if platform_storage_key(platform) == "qqbot" && !qqbot_channel_has_credentials(root) {
return root
.get("accounts")
.and_then(|a| a.get(QQBOT_DEFAULT_ACCOUNT_ID))
.cloned()
.or_else(|| Some(root.clone()));
}
Some(root.clone())
}
/// 读取指定平台的当前配置(从 openclaw.json 中提取表单可用的值)
/// account_id: 可选,指定时读取 channels.<platform>.accounts.<account_id>(多账号模式)
#[tauri::command]
pub async fn read_platform_config(
platform: String,
account_id: Option<String>,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
let mut form = Map::new();
// 多账号模式:读凭证位置
// 飞书credentials 可写在 root 或 accounts.<id> 下,优先找非空那个
let channel_root = cfg.get("channels").and_then(|c| c.get(storage_key));
let saved = resolve_platform_config_entry(channel_root, &platform, account_id.as_deref())
.unwrap_or(Value::Null);
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
insert_secret_aware_form_value(&mut form, &saved, "token");
insert_string_if_present(&mut form, &saved, "applicationId");
insert_access_policy_form_values(&mut form, &saved, false, false);
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 数组需要拼回逗号字符串
insert_secret_aware_form_value(&mut form, &saved, "botToken");
insert_access_policy_form_values(&mut form, &saved, true, false);
}
"qqbot" => {
// 多账号:读 accounts.<account_id>;单账号:先读 qqbot 根节点,若无凭证再读 accounts.default与官方 CLI 一致)
let qqbot_val: &Value = match (&account_id, channel_root) {
(Some(acct), Some(ch)) if !acct.is_empty() => ch
.get("accounts")
.and_then(|a| a.get(acct.as_str()))
.filter(|v| !v.is_null())
.unwrap_or(&Value::Null),
(_, Some(ch)) => {
if qqbot_channel_has_credentials(ch) {
ch
} else {
ch.get("accounts")
.and_then(|a| a.get(QQBOT_DEFAULT_ACCOUNT_ID))
.filter(|v| !v.is_null())
.unwrap_or(ch)
}
}
_ => &Value::Null,
};
let mut needs_migrate = false;
let mut app_id_val: Option<&str> = None;
let mut client_secret_val: Option<&str> = None;
// 优先读新格式 appId + clientSecret
if let Some(v) = qqbot_val
.get("appId")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
app_id_val = Some(v);
}
if let Some(v) = qqbot_val
.get("clientSecret")
.and_then(|v| v.as_str())
.filter(|s| !s.is_empty())
{
client_secret_val = Some(v);
}
// 旧格式兼容token = "AppID:ClientSecret"
// 若新格式缺失,尝试从 token 拆分(仅读,不写回)
if app_id_val.is_none() || client_secret_val.is_none() {
if let Some(t) = qqbot_val.get("token").and_then(|v| v.as_str()) {
if let Some((aid, csec)) = t.split_once(':') {
if app_id_val.is_none() {
app_id_val = Some(aid.trim());
}
if client_secret_val.is_none() {
client_secret_val = Some(csec.trim());
}
needs_migrate = app_id_val.is_some() && client_secret_val.is_some();
}
}
}
if app_id_val.is_none() && client_secret_val.is_none() {
return Ok(json!({ "exists": false }));
}
// 写入表单字段(前端 UI 用 clientSecret
if let Some(v) = app_id_val {
form.insert("appId".into(), Value::String(v.into()));
}
if let Some(v) = client_secret_val {
form.insert("clientSecret".into(), Value::String(v.into()));
}
// 旧格式迁移:仅有 token 字符串时,折叠为 accounts.* 下的 appId + clientSecret + token与官方 CLI 结构一致)
let migrate_app_id = app_id_val.map(|s| s.to_string());
let migrate_secret = client_secret_val.map(|s| s.to_string());
if needs_migrate {
let acct_key = account_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(QQBOT_DEFAULT_ACCOUNT_ID);
let channels = cfg.as_object_mut().ok_or("配置格式错误")?;
let qqbot_node = channels
.entry("qqbot")
.or_insert_with(|| json!({ "enabled": true }));
let qqbot_obj = qqbot_node.as_object_mut().ok_or("qqbot 节点格式错误")?;
qqbot_obj.insert("enabled".into(), Value::Bool(true));
qqbot_obj.remove("appId");
qqbot_obj.remove("clientSecret");
qqbot_obj.remove("appSecret");
qqbot_obj.remove("token");
let accounts = qqbot_obj.entry("accounts").or_insert_with(|| json!({}));
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
let target = accounts_obj
.entry(acct_key.to_string())
.or_insert_with(|| json!({}));
if let Some(obj) = target.as_object_mut() {
if let (Some(aid), Some(sec)) = (&migrate_app_id, &migrate_secret) {
obj.insert("appId".into(), Value::String(aid.clone()));
obj.insert("clientSecret".into(), Value::String(sec.clone()));
obj.insert("token".into(), Value::String(format!("{}:{}", aid, sec)));
}
obj.insert("enabled".into(), Value::Bool(true));
}
super::config::save_openclaw_json(&cfg)?;
}
return Ok(json!({ "exists": true, "values": Value::Object(form) }));
}
"feishu" => {
if saved.is_null() {
return Ok(json!({ "exists": false }));
}
// 飞书凭证:优先从 accounts.<id> 读(多账号),否则从 root 读
insert_secret_aware_form_value(&mut form, &saved, "appId");
insert_secret_aware_form_value(&mut form, &saved, "appSecret");
// 读 shared fields优先从 channel root 读(多账号模式下 credentials 在 accounts 下shared fields 在 root
if let Some(ref acct) = account_id {
if !acct.is_empty() {
// 从 channel root 补 shared fields
let mut shared_source = saved.clone();
if let Some(ch_root) = channel_root {
if let (Some(target), Some(root)) =
(shared_source.as_object_mut(), ch_root.as_object())
{
for key in &[
"domain",
"connectionMode",
"webhookPath",
"dmPolicy",
"groupPolicy",
"allowFrom",
"reactionNotifications",
"typingIndicator",
"resolveSenderNames",
"requireMention",
"textChunkLimit",
"mediaMaxMb",
] {
if let Some(v) = root.get(*key) {
target.insert(key.to_string(), v.clone());
}
}
}
}
{
for key in &[
"domain",
"connectionMode",
"webhookPath",
"groupAllowFrom",
"groups",
"reactionNotifications",
"streaming",
"blockStreaming",
"textChunkLimit",
"mediaMaxMb",
] {
if let Some(v) = shared_source.get(*key) {
if !v.is_null() {
form.insert(key.to_string(), v.clone());
}
}
}
insert_access_policy_form_values(&mut form, &shared_source, false, true);
insert_bool_as_string(&mut form, &shared_source, "typingIndicator");
insert_bool_as_string(&mut form, &shared_source, "resolveSenderNames");
insert_bool_as_string(&mut form, &shared_source, "requireMention");
}
}
} else {
// 无账号:直接从 root 读 shared fields
for key in &[
"domain",
"connectionMode",
"webhookPath",
"reactionNotifications",
"textChunkLimit",
"mediaMaxMb",
] {
insert_string_if_present(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_bool_as_string(&mut form, &saved, "typingIndicator");
insert_bool_as_string(&mut form, &saved, "resolveSenderNames");
insert_bool_as_string(&mut form, &saved, "requireMention");
}
}
"dingtalk" | "dingtalk-connector" => {
insert_secret_aware_form_value(&mut form, &saved, "clientId");
insert_secret_aware_form_value(&mut form, &saved, "clientSecret");
insert_secret_aware_form_value(&mut form, &saved, "gatewayToken");
insert_secret_aware_form_value(&mut form, &saved, "gatewayPassword");
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");
}
_ => {}
}
}
"slack" => {
insert_string_if_present(&mut form, &saved, "mode");
insert_secret_aware_form_value(&mut form, &saved, "botToken");
insert_secret_aware_form_value(&mut form, &saved, "appToken");
insert_secret_aware_form_value(&mut form, &saved, "signingSecret");
insert_string_if_present(&mut form, &saved, "webhookPath");
insert_string_if_present(&mut form, &saved, "teamId");
insert_string_if_present(&mut form, &saved, "appId");
insert_string_if_present(&mut form, &saved, "socketMode");
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_bool_as_string(&mut form, &saved, "userTokenReadOnly");
insert_bool_as_string(&mut form, &saved, "requireMention");
}
"whatsapp" => {
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_bool_as_string(&mut form, &saved, "enabled");
for key in [
"configWrites",
"sendReadReceipts",
"selfChatMode",
"blockStreaming",
] {
insert_bool_as_string(&mut form, &saved, key);
}
for key in [
"defaultTo",
"contextVisibility",
"chunkMode",
"reactionLevel",
"replyToMode",
"messagePrefix",
"responsePrefix",
] {
insert_string_if_present(&mut form, &saved, key);
}
for key in [
"historyLimit",
"dmHistoryLimit",
"mediaMaxMb",
"debounceMs",
"textChunkLimit",
] {
insert_number_as_string(&mut form, &saved, key);
}
if let Some(ack_reaction) = saved.get("ackReaction") {
if let Some(v) = ack_reaction.get("emoji").and_then(|v| v.as_str()) {
form.insert("ackEmoji".into(), Value::String(v.into()));
}
if let Some(v) = ack_reaction.get("direct").and_then(|v| v.as_bool()) {
form.insert(
"ackDirect".into(),
Value::String(if v { "true" } else { "false" }.into()),
);
}
if let Some(v) = ack_reaction.get("group").and_then(|v| v.as_str()) {
form.insert("ackGroup".into(), Value::String(v.into()));
}
}
}
"signal" => {
insert_string_if_present(&mut form, &saved, "account");
insert_string_if_present(&mut form, &saved, "cliPath");
insert_string_if_present(&mut form, &saved, "httpUrl");
insert_string_if_present(&mut form, &saved, "httpHost");
insert_number_as_string(&mut form, &saved, "httpPort");
insert_string_if_present(&mut form, &saved, "responsePrefix");
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_bool_as_string(&mut form, &saved, "blockStreaming");
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
] {
insert_number_as_string(&mut form, &saved, key);
}
}
"imessage" => {
for key in [
"cliPath",
"dbPath",
"remoteHost",
"service",
"region",
"defaultTo",
"contextVisibility",
"chunkMode",
"reactionNotifications",
"responsePrefix",
] {
insert_string_if_present(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_array_as_csv(&mut form, &saved, "attachmentRoots");
insert_array_as_csv(&mut form, &saved, "remoteAttachmentRoots");
for key in [
"configWrites",
"includeAttachments",
"blockStreaming",
"sendReadReceipts",
"coalesceSameSenderDms",
] {
insert_bool_as_string(&mut form, &saved, key);
}
for key in [
"historyLimit",
"dmHistoryLimit",
"mediaMaxMb",
"probeTimeoutMs",
"textChunkLimit",
] {
insert_number_as_string(&mut form, &saved, key);
}
}
"matrix" => {
insert_string_if_present(&mut form, &saved, "homeserver");
insert_secret_aware_form_value(&mut form, &saved, "accessToken");
insert_string_if_present(&mut form, &saved, "userId");
insert_secret_aware_form_value(&mut form, &saved, "password");
insert_string_if_present(&mut form, &saved, "deviceId");
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_bool_as_string(&mut form, &saved, "e2ee");
if saved.get("accessToken").and_then(|v| v.as_str()).is_some() {
form.insert("authMode".into(), Value::String("token".into()));
} else if saved.get("userId").and_then(|v| v.as_str()).is_some()
|| saved.get("password").and_then(|v| v.as_str()).is_some()
{
form.insert("authMode".into(), Value::String("password".into()));
}
}
"msteams" => {
insert_secret_aware_form_value(&mut form, &saved, "appId");
insert_secret_aware_form_value(&mut form, &saved, "appPassword");
for key in [
"tenantId",
"authType",
"certificatePath",
"certificateThumbprint",
"managedIdentityClientId",
"botEndpoint",
"replyStyle",
"sharePointSiteId",
"responsePrefix",
] {
insert_string_if_present(&mut form, &saved, key);
}
if let Some(webhook) = saved.get("webhook") {
insert_string_if_present(&mut form, webhook, "path");
if let Some(v) = form.remove("path") {
form.insert("webhookPath".into(), v);
}
insert_number_as_string(&mut form, webhook, "port");
if let Some(v) = form.remove("port") {
form.insert("webhookPort".into(), v);
}
} else {
insert_string_if_present(&mut form, &saved, "webhookPath");
}
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_bool_as_string(&mut form, &saved, "requireMention");
for key in [
"useManagedIdentity",
"blockStreaming",
"typingIndicator",
"welcomeCard",
"groupWelcomeCard",
"feedbackEnabled",
"feedbackReflection",
] {
insert_bool_as_string(&mut form, &saved, key);
}
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
"feedbackReflectionCooldownMs",
] {
insert_number_as_string(&mut form, &saved, key);
}
insert_array_as_csv(&mut form, &saved, "promptStarters");
if let Some(delegated_auth) = saved.get("delegatedAuth") {
insert_bool_as_string(&mut form, delegated_auth, "enabled");
if let Some(v) = form.remove("enabled") {
form.insert("delegatedAuthEnabled".into(), v);
}
insert_array_as_csv(&mut form, delegated_auth, "scopes");
if let Some(v) = form.remove("scopes") {
form.insert("delegatedAuthScopes".into(), v);
}
}
if let Some(sso) = saved.get("sso") {
insert_bool_as_string(&mut form, sso, "enabled");
if let Some(v) = form.remove("enabled") {
form.insert("ssoEnabled".into(), v);
}
insert_string_if_present(&mut form, sso, "connectionName");
if let Some(v) = form.remove("connectionName") {
form.insert("ssoConnectionName".into(), v);
}
}
}
"line" => {
for key in [
"channelAccessToken",
"tokenFile",
"channelSecret",
"secretFile",
"webhookPath",
"responsePrefix",
] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
if let Some(v) = saved.get("mediaMaxMb").and_then(|v| v.as_i64()) {
form.insert("mediaMaxMb".into(), Value::String(v.to_string()));
}
}
"mattermost" => {
for key in [
"botToken",
"baseUrl",
"name",
"replyToMode",
"responsePrefix",
] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_bool_as_string(&mut form, &saved, "dangerouslyAllowNameMatching");
if let Some(network) = saved.get("network") {
insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork");
}
if let Some(commands) = saved.get("commands") {
insert_string_if_present(&mut form, commands, "callbackPath");
insert_string_if_present(&mut form, commands, "callbackUrl");
}
}
"clickclack" => {
for key in [
"name",
"baseUrl",
"token",
"workspace",
"botUserId",
"agentId",
"replyMode",
"model",
"systemPrompt",
"defaultTo",
] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
insert_bool_as_string(&mut form, &saved, "enabled");
insert_bool_as_string(&mut form, &saved, "senderIsOwner");
insert_array_as_csv(&mut form, &saved, "toolsAllow");
insert_array_as_csv(&mut form, &saved, "allowFrom");
insert_number_as_string(&mut form, &saved, "timeoutSeconds");
insert_number_as_string(&mut form, &saved, "reconnectMs");
}
"synology-chat" => {
for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
insert_string_if_present(&mut form, &saved, "dmPolicy");
insert_array_as_csv(&mut form, &saved, "allowedUserIds");
if let Some(v) = saved.get("rateLimitPerMinute").and_then(|v| v.as_i64()) {
form.insert("rateLimitPerMinute".into(), Value::String(v.to_string()));
}
insert_bool_as_string(&mut form, &saved, "dangerouslyAllowNameMatching");
insert_bool_as_string(&mut form, &saved, "dangerouslyAllowInheritedWebhookPath");
insert_bool_as_string(&mut form, &saved, "allowInsecureSsl");
}
"googlechat" => {
for key in [
"serviceAccount",
"serviceAccountFile",
"serviceAccountRef",
"audienceType",
"audience",
"appPrincipal",
"webhookPath",
"webhookUrl",
"botUser",
"chunkMode",
"replyToMode",
"typingIndicator",
"responsePrefix",
] {
insert_secret_aware_form_value(&mut form, &saved, key);
}
if let Some(dm) = saved.get("dm") {
if let Some(policy) = dm.get("policy").and_then(|v| v.as_str()) {
form.insert("dmPolicy".into(), Value::String(policy.into()));
}
insert_array_as_csv(&mut form, dm, "allowFrom");
}
insert_string_if_present(&mut form, &saved, "groupPolicy");
insert_array_as_csv(&mut form, &saved, "groupAllowFrom");
insert_bool_as_string(&mut form, &saved, "requireMention");
insert_bool_as_string(&mut form, &saved, "dangerouslyAllowNameMatching");
insert_bool_as_string(&mut form, &saved, "allowBots");
insert_bool_as_string(&mut form, &saved, "blockStreaming");
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
] {
if let Some(v) = saved.get(key).and_then(|v| v.as_f64()) {
form.insert(key.into(), Value::String(v.to_string()));
}
}
}
_ => {
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 secret_ref_placeholder(v).is_some() {
insert_secret_aware_form_value(&mut form, &saved, k);
} else if let Some(s) = v.as_str() {
form.insert(k.clone(), Value::String(s.into()));
} else if v.is_array() {
insert_array_as_csv(&mut form, &saved, k);
} else if let Some(b) = v.as_bool() {
form.insert(
k.clone(),
Value::String(if b { "true" } else { "false" }.into()),
);
} else if v.is_number() {
form.insert(k.clone(), Value::String(v.to_string()));
}
}
}
}
}
Ok(json!({ "exists": exists, "values": Value::Object(form) }))
}
/// 保存平台配置到 openclaw.json
/// 前端传入的是表单字段,后端负责转换成 OpenClaw 要求的结构
/// account_id: 可选,指定时写入 channels.<platform>.accounts.<account_id>(多账号模式)
/// agent_id: 可选,指定时同时创建 bindings 配置将渠道绑定到 Agent
#[tauri::command]
pub async fn save_messaging_platform(
platform: String,
form: Value,
account_id: Option<String>,
agent_id: Option<String>,
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 raw_form_obj = form.as_object().ok_or("表单数据格式错误")?;
let normalized_form = normalize_messaging_platform_form(&platform, raw_form_obj);
let form_obj = &normalized_form;
let current_saved = resolve_platform_config_entry(
channels_map.get(storage_key.as_str()),
&platform,
account_id.as_deref(),
)
.unwrap_or(Value::Null);
// 用于后续创建 bindings 的平台信息
let saved_account_id = account_id.clone();
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()));
}
put_string(
&mut entry,
"applicationId",
form_string(form_obj, "applicationId"),
);
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
// 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 }
}
}
}),
);
}
// 合并到现有配置,保留用户通过 CLI 设置的 streaming / retry / dmPolicy 等
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(channels_map, "discord", account_id.as_deref(), entry)?;
// 仅在首次创建时设置默认值,不覆盖用户已有的设置
if let Some(Value::Object(d)) = channels_map.get_mut("discord") {
d.entry("groupPolicy")
.or_insert(Value::String("allowlist".into()));
d.entry("dm").or_insert(json!({ "enabled": false }));
d.entry("retry").or_insert(json!({
"attempts": 3,
"minDelayMs": 500,
"maxDelayMs": 30000,
"jitter": 0.1
}));
}
}
"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));
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
"telegram",
account_id.as_deref(),
entry,
)?;
}
"zalo" => {
let bot_token = form_string(form_obj, "botToken");
let token_file = form_string(form_obj, "tokenFile");
if bot_token.is_empty() && token_file.is_empty() {
return Err("Bot Token 或 Token File 至少填写一项".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "botToken", bot_token);
put_string(&mut entry, "tokenFile", token_file);
put_string(
&mut entry,
"webhookUrl",
form_string(form_obj, "webhookUrl"),
);
put_string(
&mut entry,
"webhookSecret",
form_string(form_obj, "webhookSecret"),
);
put_string(
&mut entry,
"webhookPath",
form_string(form_obj, "webhookPath"),
);
put_string(&mut entry, "proxy", form_string(form_obj, "proxy"));
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
if let Some(value) = form_obj.get("mediaMaxMb").and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert("mediaMaxMb".into(), Value::Number(number));
}
} else {
put_number_from_form(
&mut entry,
"mediaMaxMb",
&form_string(form_obj, "mediaMaxMb"),
);
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "zalo")?;
}
"zalouser" => {
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "profile", form_string(form_obj, "profile"));
put_string(
&mut entry,
"messagePrefix",
form_string(form_obj, "messagePrefix"),
);
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
put_bool_value_if_present(
&mut entry,
"dangerouslyAllowNameMatching",
form_obj.get("dangerouslyAllowNameMatching"),
);
if let Some(value) = form_obj.get("historyLimit").and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert("historyLimit".into(), Value::Number(number));
}
} else {
put_number_from_form(
&mut entry,
"historyLimit",
&form_string(form_obj, "historyLimit"),
);
}
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "zalouser")?;
}
"qqbot" => {
let app_id = form_obj
.get("appId")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
// 优先取 clientSecret腾讯官方插件字段名
// 也兼容前端 UI 传 appSecret旧字段名
let client_secret = form_obj
.get("clientSecret")
.or_else(|| form_obj.get("appSecret"))
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if app_id.is_empty() {
return Err("AppID 不能为空".into());
}
if client_secret.is_empty() {
return Err("ClientSecret 不能为空".into());
}
// 与 `openclaw channels add --channel qqbot --token "AppID:Secret"` 一致:凭证写在 accounts.<id> 下,并保留组合 token
let acct_key = account_id
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
.unwrap_or(QQBOT_DEFAULT_ACCOUNT_ID);
let token_combo = format!("{}:{}", app_id, client_secret);
let qqbot_node = channels_map
.entry("qqbot")
.or_insert_with(|| json!({ "enabled": true }));
let qqbot_obj = qqbot_node.as_object_mut().ok_or("qqbot 节点格式错误")?;
qqbot_obj.insert("enabled".into(), Value::Bool(true));
// 清除写在根上的旧字段,避免官方插件只认 accounts.* 时读不到账号
qqbot_obj.remove("appId");
qqbot_obj.remove("clientSecret");
qqbot_obj.remove("appSecret");
qqbot_obj.remove("token");
let accounts = qqbot_obj.entry("accounts").or_insert_with(|| json!({}));
let accounts_obj = accounts.as_object_mut().ok_or("accounts 格式错误")?;
let mut entry = Map::new();
entry.insert("appId".into(), Value::String(app_id));
entry.insert("clientSecret".into(), Value::String(client_secret));
entry.insert("token".into(), Value::String(token_combo));
entry.insert("enabled".into(), Value::Bool(true));
accounts_obj.insert(acct_key.to_string(), Value::Object(entry));
ensure_openclaw_qqbot_plugin(&mut cfg)?;
ensure_chat_completions_enabled(&mut cfg)?;
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));
put_string(
&mut entry,
"connectionMode",
form_string(form_obj, "connectionMode"),
);
put_string(&mut entry, "domain", form_string(form_obj, "domain"));
put_string(
&mut entry,
"webhookPath",
form_string(form_obj, "webhookPath"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_string(
&mut entry,
"reactionNotifications",
form_string(form_obj, "reactionNotifications"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_bool_value_if_present(
&mut entry,
"typingIndicator",
form_obj.get("typingIndicator"),
);
put_bool_value_if_present(
&mut entry,
"resolveSenderNames",
form_obj.get("resolveSenderNames"),
);
put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention"));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "openclaw-lark")?;
// 禁用旧版 feishu 插件,防止新旧插件同时运行冲突
disable_legacy_plugin(&mut cfg, "feishu");
let _ = cleanup_legacy_plugin_backup_dir("feishu");
let _ = cleanup_legacy_plugin_backup_dir("openclaw-lark");
}
"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()),
);
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "dingtalk-connector")?;
ensure_chat_completions_enabled(&mut cfg)?;
let _ = cleanup_legacy_plugin_backup_dir("dingtalk-connector");
}
"slack" => {
let mode = form_string(form_obj, "mode");
let bot_token = form_string(form_obj, "botToken");
let app_token = form_string(form_obj, "appToken");
let signing_secret = form_string(form_obj, "signingSecret");
if bot_token.is_empty() {
return Err("Slack Bot Token 不能为空".into());
}
if mode == "http" && signing_secret.is_empty() {
return Err("HTTP 模式下 Signing Secret 不能为空".into());
}
if mode != "http" && app_token.is_empty() {
return Err("Socket 模式下 App Token 不能为空".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(
&mut entry,
"mode",
if mode.is_empty() {
"socket".into()
} else {
mode
},
);
put_string(&mut entry, "botToken", bot_token);
put_string(&mut entry, "appToken", app_token);
put_string(&mut entry, "signingSecret", signing_secret);
put_string(
&mut entry,
"webhookPath",
form_string(form_obj, "webhookPath"),
);
put_string(&mut entry, "teamId", form_string(form_obj, "teamId"));
put_string(&mut entry, "appId", form_string(form_obj, "appId"));
put_bool_value_if_present(
&mut entry,
"userTokenReadOnly",
form_obj.get("userTokenReadOnly"),
);
put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention"));
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
}
"whatsapp" => {
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_bool_value_if_present(&mut entry, "enabled", form_obj.get("enabled"));
for key in [
"defaultTo",
"contextVisibility",
"chunkMode",
"reactionLevel",
"replyToMode",
"messagePrefix",
"responsePrefix",
] {
put_string(&mut entry, key, form_string(form_obj, key));
}
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
for key in [
"configWrites",
"sendReadReceipts",
"selfChatMode",
"blockStreaming",
] {
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
}
for key in [
"historyLimit",
"dmHistoryLimit",
"mediaMaxMb",
"debounceMs",
"textChunkLimit",
] {
put_number_value_if_present(&mut entry, key, form_obj.get(key));
}
let mut ack_reaction = current_saved
.get("ackReaction")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_string(
&mut ack_reaction,
"emoji",
form_string(form_obj, "ackEmoji"),
);
put_bool_value_if_present(&mut ack_reaction, "direct", form_obj.get("ackDirect"));
put_string(
&mut ack_reaction,
"group",
form_string(form_obj, "ackGroup"),
);
if !ack_reaction.is_empty() {
entry.insert("ackReaction".into(), Value::Object(ack_reaction));
}
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "whatsapp")?;
}
"signal" => {
let account = form_string(form_obj, "account");
if account.is_empty() {
return Err("Signal 号码不能为空".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "account", account);
put_string(&mut entry, "cliPath", form_string(form_obj, "cliPath"));
put_string(&mut entry, "httpUrl", form_string(form_obj, "httpUrl"));
put_string(&mut entry, "httpHost", form_string(form_obj, "httpHost"));
put_number_from_form(&mut entry, "httpPort", &form_string(form_obj, "httpPort"));
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
put_bool_value_if_present(&mut entry, "blockStreaming", form_obj.get("blockStreaming"));
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
] {
put_number_from_form(&mut entry, key, &form_string(form_obj, key));
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
}
"imessage" => {
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
for key in [
"cliPath",
"dbPath",
"remoteHost",
"service",
"region",
"defaultTo",
"contextVisibility",
"chunkMode",
"reactionNotifications",
"responsePrefix",
] {
put_string(&mut entry, key, form_string(form_obj, key));
}
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
put_array_from_form_value(
&mut entry,
"attachmentRoots",
form_obj.get("attachmentRoots"),
);
put_array_from_form_value(
&mut entry,
"remoteAttachmentRoots",
form_obj.get("remoteAttachmentRoots"),
);
for key in [
"configWrites",
"includeAttachments",
"blockStreaming",
"sendReadReceipts",
"coalesceSameSenderDms",
] {
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
}
for key in [
"historyLimit",
"dmHistoryLimit",
"mediaMaxMb",
"probeTimeoutMs",
"textChunkLimit",
] {
put_number_value_if_present(&mut entry, key, form_obj.get(key));
}
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "imessage")?;
}
"matrix" => {
let homeserver = form_string(form_obj, "homeserver");
let access_token = form_string(form_obj, "accessToken");
let user_id = form_string(form_obj, "userId");
let password = form_string(form_obj, "password");
if homeserver.is_empty() {
return Err("Homeserver 不能为空".into());
}
if access_token.is_empty() && (user_id.is_empty() || password.is_empty()) {
return Err("请至少填写 Access Token或填写 User ID + Password".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "homeserver", homeserver);
put_string(&mut entry, "accessToken", access_token);
put_string(&mut entry, "userId", user_id);
put_string(&mut entry, "password", password);
put_string(&mut entry, "deviceId", form_string(form_obj, "deviceId"));
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_bool_from_form(&mut entry, "e2ee", &form_string(form_obj, "e2ee"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "matrix")?;
}
"msteams" => {
let app_id = form_string(form_obj, "appId");
let app_password = form_string(form_obj, "appPassword");
let missing_credentials = msteams_credential_missing_labels(form_obj);
if !missing_credentials.is_empty() {
return Err(format!("缺少 {}", missing_credentials.join(" / ")));
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "appId", app_id);
put_string(&mut entry, "appPassword", app_password);
for key in [
"tenantId",
"authType",
"certificatePath",
"certificateThumbprint",
"managedIdentityClientId",
"replyStyle",
"sharePointSiteId",
"responsePrefix",
] {
put_string(&mut entry, key, form_string(form_obj, key));
}
let mut webhook = current_saved
.get("webhook")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_number_from_form(&mut webhook, "port", &form_string(form_obj, "webhookPort"));
put_string(&mut webhook, "path", form_string(form_obj, "webhookPath"));
if !webhook.is_empty() {
entry.insert("webhook".into(), Value::Object(webhook));
}
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
for key in [
"useManagedIdentity",
"requireMention",
"blockStreaming",
"typingIndicator",
"welcomeCard",
"groupWelcomeCard",
"feedbackEnabled",
"feedbackReflection",
] {
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
}
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
"feedbackReflectionCooldownMs",
] {
put_number_from_form(&mut entry, key, &form_string(form_obj, key));
}
put_array_from_form_value(&mut entry, "promptStarters", form_obj.get("promptStarters"));
let mut delegated_auth = current_saved
.get("delegatedAuth")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_bool_value_if_present(
&mut delegated_auth,
"enabled",
form_obj.get("delegatedAuthEnabled"),
);
put_array_from_form_value(
&mut delegated_auth,
"scopes",
form_obj.get("delegatedAuthScopes"),
);
if !delegated_auth.is_empty() {
entry.insert("delegatedAuth".into(), Value::Object(delegated_auth));
}
let mut sso = current_saved
.get("sso")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_bool_value_if_present(&mut sso, "enabled", form_obj.get("ssoEnabled"));
put_string(
&mut sso,
"connectionName",
form_string(form_obj, "ssoConnectionName"),
);
if !sso.is_empty() {
entry.insert("sso".into(), Value::Object(sso));
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "msteams")?;
}
"line" => {
let channel_access_token = form_string(form_obj, "channelAccessToken");
let token_file = form_string(form_obj, "tokenFile");
let channel_secret = form_string(form_obj, "channelSecret");
let secret_file = form_string(form_obj, "secretFile");
if channel_access_token.is_empty() && token_file.is_empty() {
return Err("Channel Access Token 或 Token File 至少填写一项".into());
}
if channel_secret.is_empty() && secret_file.is_empty() {
return Err("Channel Secret 或 Secret File 至少填写一项".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "channelAccessToken", channel_access_token);
put_string(&mut entry, "tokenFile", token_file);
put_string(&mut entry, "channelSecret", channel_secret);
put_string(&mut entry, "secretFile", secret_file);
put_string(
&mut entry,
"webhookPath",
form_string(form_obj, "webhookPath"),
);
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
if let Some(value) = form_obj.get("mediaMaxMb").and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert("mediaMaxMb".into(), Value::Number(number));
}
} else {
put_number_from_form(
&mut entry,
"mediaMaxMb",
&form_string(form_obj, "mediaMaxMb"),
);
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "line")?;
}
"mattermost" => {
let bot_token = form_string(form_obj, "botToken");
let base_url = form_string(form_obj, "baseUrl");
if bot_token.is_empty() {
return Err("Mattermost Bot Token 不能为空".into());
}
if base_url.is_empty() {
return Err("Mattermost Base URL 不能为空".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "botToken", bot_token);
put_string(&mut entry, "baseUrl", base_url);
put_string(&mut entry, "name", form_string(form_obj, "name"));
put_string(
&mut entry,
"replyToMode",
form_string(form_obj, "replyToMode"),
);
put_string(
&mut entry,
"responsePrefix",
form_string(form_obj, "responsePrefix"),
);
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
put_bool_value_if_present(
&mut entry,
"dangerouslyAllowNameMatching",
form_obj.get("dangerouslyAllowNameMatching"),
);
if form_obj.contains_key("dangerouslyAllowPrivateNetwork") {
let mut network = current_saved
.get("network")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
match form_obj.get("dangerouslyAllowPrivateNetwork") {
Some(Value::Bool(v)) => {
network.insert("dangerouslyAllowPrivateNetwork".into(), Value::Bool(*v));
}
Some(Value::String(raw)) => {
if let Some(v) = bool_from_form_value(raw) {
network.insert("dangerouslyAllowPrivateNetwork".into(), Value::Bool(v));
}
}
_ => {}
}
if !network.is_empty() {
entry.insert("network".into(), Value::Object(network));
}
}
let mut commands = current_saved
.get("commands")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_string(
&mut commands,
"callbackPath",
form_string(form_obj, "callbackPath"),
);
put_string(
&mut commands,
"callbackUrl",
form_string(form_obj, "callbackUrl"),
);
if !commands.is_empty() {
entry.insert("commands".into(), Value::Object(commands));
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "mattermost")?;
}
"clickclack" => {
let base_url = form_string(form_obj, "baseUrl");
let token = form_string(form_obj, "token");
let workspace = form_string(form_obj, "workspace");
if base_url.is_empty() {
return Err("ClickClack Base URL 不能为空".into());
}
if token.is_empty() {
return Err("ClickClack Token 不能为空".into());
}
if workspace.is_empty() {
return Err("ClickClack Workspace 不能为空".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_bool_value_if_present(&mut entry, "enabled", form_obj.get("enabled"));
put_string(&mut entry, "baseUrl", base_url);
put_string(&mut entry, "token", token);
put_string(&mut entry, "workspace", workspace);
for key in [
"name",
"botUserId",
"agentId",
"replyMode",
"model",
"systemPrompt",
"defaultTo",
] {
put_string(&mut entry, key, form_string(form_obj, key));
}
put_array_from_form_value(&mut entry, "toolsAllow", form_obj.get("toolsAllow"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_bool_value_if_present(&mut entry, "senderIsOwner", form_obj.get("senderIsOwner"));
put_number_value_if_present(
&mut entry,
"timeoutSeconds",
form_obj.get("timeoutSeconds"),
);
put_number_value_if_present(&mut entry, "reconnectMs", form_obj.get("reconnectMs"));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "clickclack")?;
}
"synology-chat" => {
let token = form_string(form_obj, "token");
let incoming_url = form_string(form_obj, "incomingUrl");
if token.is_empty() {
return Err("Synology Chat Token 不能为空".into());
}
if incoming_url.is_empty() {
return Err("Synology Chat Incoming URL 不能为空".into());
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
put_string(&mut entry, "token", token);
put_string(&mut entry, "incomingUrl", incoming_url);
put_string(&mut entry, "nasHost", form_string(form_obj, "nasHost"));
put_string(
&mut entry,
"webhookPath",
form_string(form_obj, "webhookPath"),
);
put_string(&mut entry, "botName", form_string(form_obj, "botName"));
put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy"));
put_array_from_form_value(&mut entry, "allowedUserIds", form_obj.get("allowedUserIds"));
if let Some(value) = form_obj.get("rateLimitPerMinute").and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert("rateLimitPerMinute".into(), Value::Number(number));
}
} else {
put_number_from_form(
&mut entry,
"rateLimitPerMinute",
&form_string(form_obj, "rateLimitPerMinute"),
);
}
put_bool_value_if_present(
&mut entry,
"dangerouslyAllowNameMatching",
form_obj.get("dangerouslyAllowNameMatching"),
);
put_bool_value_if_present(
&mut entry,
"dangerouslyAllowInheritedWebhookPath",
form_obj.get("dangerouslyAllowInheritedWebhookPath"),
);
put_bool_value_if_present(
&mut entry,
"allowInsecureSsl",
form_obj.get("allowInsecureSsl"),
);
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "synology-chat")?;
}
"googlechat" => {
let has_service_account =
has_configured_messaging_value(form_obj.get("serviceAccount"))
|| has_configured_messaging_value(form_obj.get("serviceAccountFile"))
|| has_configured_messaging_value(form_obj.get("serviceAccountRef"));
if !has_service_account {
return Err(
"Google Chat 需要填写 Service Account JSON、Service Account File 或 SecretRef"
.into(),
);
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
for key in [
"serviceAccount",
"serviceAccountFile",
"serviceAccountRef",
"audienceType",
"audience",
"appPrincipal",
"webhookPath",
"webhookUrl",
"botUser",
"chunkMode",
"replyToMode",
"typingIndicator",
"responsePrefix",
] {
put_string(&mut entry, key, form_string(form_obj, key));
}
let mut dm = current_saved
.get("dm")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_string(&mut dm, "policy", form_string(form_obj, "dmPolicy"));
let allow_from = json_array_from_csv_value(form_obj.get("allowFrom"));
if !allow_from.is_empty() {
dm.insert("allowFrom".into(), Value::Array(allow_from));
}
if !dm.is_empty() {
entry.insert("dm".into(), Value::Object(dm));
}
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
for key in [
"dangerouslyAllowNameMatching",
"requireMention",
"allowBots",
"blockStreaming",
] {
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
}
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
] {
if let Some(value) = form_obj.get(key).and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert(key.into(), Value::Number(number));
}
} else {
put_number_from_form(&mut entry, key, &form_string(form_obj, key));
}
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "googlechat")?;
}
_ => {
// 通用平台:直接保存表单字段
let mut entry = Map::new();
for (k, v) in form_obj {
entry.insert(k.clone(), v.clone());
}
entry.insert("enabled".into(), Value::Bool(true));
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
}
}
// 如果指定了 agent_id同时创建 bindings 配置
if let Some(ref agent) = agent_id {
if !agent.is_empty() {
create_agent_binding(&mut cfg, agent, &platform, saved_account_id)?;
}
}
// 写回配置并重载 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 }))
}
/// 删除指定平台配置
/// account_id: 可选,指定时仅删除 channels.<platform>.accounts.<account_id>(多账号模式)
/// 未指定时删除整个平台配置
#[tauri::command]
pub async fn remove_messaging_platform(
platform: String,
account_id: Option<String>,
app: tauri::AppHandle,
) -> Result<Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let storage_key = platform_storage_key(&platform);
match &account_id {
Some(acct) if !acct.is_empty() => {
// 多账号模式:仅删除指定账号
if let Some(channel) = cfg.get_mut("channels").and_then(|c| c.get_mut(storage_key)) {
if let Some(accounts) = channel.get_mut("accounts").and_then(|a| a.as_object_mut())
{
accounts.remove(acct.as_str());
}
}
}
_ => {
// 整平台删除
if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) {
channels.remove(storage_key);
}
}
}
// 清理对应的 bindings 条目
let binding_channel = platform_list_id(&platform);
if let Some(bindings) = cfg.get_mut("bindings").and_then(|b| b.as_array_mut()) {
bindings.retain(|b| {
let m = match b.get("match") {
Some(m) => m,
None => return true,
};
if m.get("channel").and_then(|v| v.as_str()) != Some(binding_channel) {
return true; // 不同渠道,保留
}
match &account_id {
Some(acct) if !acct.is_empty() => {
m.get("accountId").and_then(|v| v.as_str()) != Some(acct.as_str())
}
_ => false, // 整平台删除,移除该渠道所有 binding
}
});
}
super::config::save_openclaw_json(&cfg)?;
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 = super::build_http_client(std::time::Duration::from_secs(15), None)
.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,
"slack" => verify_slack(&client, form_obj).await,
"zalo" => verify_zalo(&client, form_obj).await,
"zalouser" => Ok(json!({
"valid": true,
"warnings": ["Zalo Personal 通过二维码登录维护本地会话;请使用 openclaw channels status --probe 检查登录状态"]
})),
"matrix" => verify_matrix(&client, form_obj).await,
"signal" => verify_signal(&client, form_obj).await,
"msteams" => verify_msteams(&client, form_obj).await,
"imessage" => Ok(json!({
"valid": true,
"warnings": ["iMessage 使用本机或远端桥接运行,无需在线校验 Bot Token请通过 Gateway 日志确认桥接进程状态"]
})),
"whatsapp" => Ok(json!({
"valid": true,
"warnings": ["WhatsApp 使用扫码登录,无需在线校验凭证;请通过「启动扫码登录」完成配对"]
})),
"clickclack" => Ok(json!({
"valid": true,
"warnings": ["ClickClack 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
_ => Ok(json!({
"valid": true,
"warnings": ["该平台暂不支持在线校验"]
})),
}
}
/// 检测微信插件安装状态与版本
#[tauri::command]
pub async fn check_weixin_plugin_status() -> Result<Value, String> {
let ext_dir = super::openclaw_dir()
.join("extensions")
.join("openclaw-weixin");
let mut installed = false;
let mut installed_version: Option<String> = None;
// 检查本地安装
let pkg_json = ext_dir.join("package.json");
if pkg_json.is_file() {
installed = true;
if let Ok(content) = std::fs::read_to_string(&pkg_json) {
if let Ok(pkg) = serde_json::from_str::<Value>(&content) {
installed_version = pkg
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
}
// 从 npm registry 获取最新版本
let mut latest_version: Option<String> = None;
let client = super::build_http_client(std::time::Duration::from_secs(8), None)
.unwrap_or_else(|_| reqwest::Client::new());
if let Ok(resp) = client
.get("https://registry.npmjs.org/@tencent-weixin/openclaw-weixin/latest")
.header("Accept", "application/json")
.send()
.await
{
if let Ok(body) = resp.json::<Value>().await {
latest_version = body
.get("version")
.and_then(|v| v.as_str())
.map(|s| s.to_string());
}
}
let update_available = match (&installed_version, &latest_version) {
(Some(cur), Some(lat)) if cur != lat => {
// 简单 semver 比较:按 . 分割为数字段逐段比较
let parse =
|s: &str| -> Vec<u32> { s.split('.').filter_map(|p| p.parse().ok()).collect() };
let cv = parse(cur);
let lv = parse(lat);
lv > cv
}
_ => false,
};
// 兼容性检查:微信插件要求 OpenClaw >= 2026.3.22,通过版本号判断
let mut compatible = true;
let mut compat_error = String::new();
if installed {
let oc_ver = crate::utils::resolve_openclaw_cli_path()
.and_then(|_| {
let out = crate::utils::openclaw_command()
.arg("--version")
.output()
.ok()?;
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
raw.split_whitespace()
.find(|w| w.chars().next().is_some_and(|c| c.is_ascii_digit()))
.map(String::from)
})
.unwrap_or_default();
let oc_nums: Vec<u32> = oc_ver
.split(|c: char| !c.is_ascii_digit())
.filter_map(|s| s.parse().ok())
.collect();
if oc_nums < vec![2026, 3, 22] {
compatible = false;
compat_error = format!(
"插件版本与当前 OpenClaw {} 不兼容(要求 >= 2026.3.22),请先升级 OpenClaw 或在终端执行: npx -y @tencent-weixin/openclaw-weixin-cli@latest install",
oc_ver
);
}
}
Ok(json!({
"installed": installed,
"installedVersion": installed_version,
"latestVersion": latest_version,
"updateAvailable": update_available,
"extensionDir": ext_dir.to_string_lossy(),
"compatible": compatible,
"compatError": compat_error,
}))
}
#[tauri::command]
pub async fn run_channel_action(
app: tauri::AppHandle,
platform: String,
action: String,
version: Option<String>,
) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use std::sync::{Arc, Mutex};
use tauri::Emitter;
let platform = platform.trim().to_string();
let action = action.trim().to_string();
if platform.is_empty() || action.is_empty() {
return Err("platform 和 action 不能为空".into());
}
// weixin install 走 npx 而非 openclaw CLI
if platform == "weixin" && action == "install" {
// 微信 CLI 版本号独立于 OpenClaw1.0.x / 2.0.x不能用 OpenClaw 版本号 pin
// v2.0.1 需要 OpenClaw >= 2026.3.22 的 SDK旧版用 v1.0.3(最后兼容版)
let weixin_spec = if version.as_deref().is_some_and(|v| !v.is_empty()) {
format!(
"@tencent-weixin/openclaw-weixin-cli@{}",
version.as_deref().unwrap()
)
} else {
// 检测 OpenClaw 版本,决定装哪个
let oc_ver = crate::utils::resolve_openclaw_cli_path()
.and_then(|_| {
let out = crate::utils::openclaw_command()
.arg("--version")
.output()
.ok()?;
let raw = String::from_utf8_lossy(&out.stdout).trim().to_string();
// 输出格式: "OpenClaw 2026.3.24 (hash)" → 取第二个词(版本号)
raw.split_whitespace()
.find(|w| w.chars().next().is_some_and(|c| c.is_ascii_digit()))
.map(String::from)
})
.unwrap_or_default();
let oc_nums: Vec<u32> = oc_ver
.split(|c: char| !c.is_ascii_digit())
.filter_map(|s| s.parse().ok())
.collect();
let needs_legacy = oc_nums < vec![2026, 3, 22];
if needs_legacy {
// 微信插件所有版本都依赖 OpenClaw >= 2026.3.22 的 SDK
// 给用户两个选择:升级 OpenClaw 或手动尝试安装
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "error",
"message": format!("⚠ 微信插件要求 OpenClaw >= 2026.3.22,当前版本 {}。", oc_ver) }),
);
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info",
"message": "建议方案 1推荐先升级 OpenClaw再安装微信插件" }),
);
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info",
"message": " → 前往「服务管理」页面点击升级" }),
);
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info",
"message": "建议方案 2在终端手动尝试安装可能存在兼容问题" }),
);
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info",
"message": " → npx -y @tencent-weixin/openclaw-weixin-cli@latest install" }),
);
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info",
"message": "后续版本将升级推荐内核到最新版以完整支持微信插件。" }),
);
let _ = app.emit(
"channel-action-progress",
json!({ "platform": &platform, "action": &action, "progress": 100 }),
);
return Err(format!(
"微信插件要求 OpenClaw >= 2026.3.22(当前 {}),请先升级 OpenClaw 或在终端手动安装",
oc_ver
));
}
"@tencent-weixin/openclaw-weixin-cli@latest".to_string()
};
// 先清理旧的不兼容插件目录 + openclaw.json 中的残留配置
// (否则 OpenClaw 配置校验会报 unknown channel / plugin not found
let weixin_ext_dir = super::openclaw_dir()
.join("extensions")
.join("openclaw-weixin");
if weixin_ext_dir.exists() {
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info", "message": "清理旧版微信插件目录..." }),
);
let _ = std::fs::remove_dir_all(&weixin_ext_dir);
}
// 清理 openclaw.json 中的微信残留配置
if let Ok(mut cfg) = super::config::load_openclaw_json() {
let mut changed = false;
if let Some(channels) = cfg.get_mut("channels").and_then(|c| c.as_object_mut()) {
if channels.remove("openclaw-weixin").is_some() {
changed = true;
}
}
if let Some(plugins) = cfg.get_mut("plugins").and_then(|p| p.as_object_mut()) {
if let Some(allow) = plugins.get_mut("allow").and_then(|a| a.as_array_mut()) {
let before = allow.len();
allow.retain(|v| v.as_str() != Some("openclaw-weixin"));
if allow.len() != before {
changed = true;
}
}
if let Some(entries) = plugins.get_mut("entries").and_then(|e| e.as_object_mut()) {
if entries.remove("openclaw-weixin").is_some() {
changed = true;
}
}
}
if changed {
let _ = super::config::save_openclaw_json(&cfg);
let _ = app.emit(
"channel-action-log",
json!({ "platform": &platform, "action": &action, "kind": "info", "message": "已清理 openclaw.json 中的微信插件残留配置" }),
);
}
}
let _ = app.emit(
"channel-action-log",
json!({
"platform": &platform, "action": &action, "kind": "info",
"message": format!("开始安装微信插件: npx -y {} install", weixin_spec),
}),
);
let _ = app.emit(
"channel-action-progress",
json!({ "platform": &platform, "action": &action, "progress": 5 }),
);
let path_env = super::enhanced_path();
#[cfg(target_os = "windows")]
let mut cmd = {
use std::os::windows::process::CommandExt;
const CREATE_NO_WINDOW: u32 = 0x08000000;
let mut c = std::process::Command::new("cmd");
c.args(["/c", "npx", "-y", &weixin_spec, "install"]);
c.creation_flags(CREATE_NO_WINDOW);
c
};
#[cfg(not(target_os = "windows"))]
let mut cmd = {
let mut c = std::process::Command::new("npx");
c.args(["-y", &weixin_spec, "install"]);
c
};
cmd.env("PATH", &path_env);
cmd.stdout(Stdio::piped());
cmd.stderr(Stdio::piped());
crate::commands::apply_proxy_env(&mut cmd);
let mut child = cmd.spawn().map_err(|e| format!("启动 npx 失败: {}", e))?;
let stderr = child.stderr.take();
let app2 = app.clone();
let platform2 = platform.clone();
let action2 = action.clone();
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let err_lines = lines.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if let Ok(mut guard) = err_lines.lock() {
guard.push(line.clone());
}
let _ = app2.emit("channel-action-log", json!({ "platform": platform2, "action": action2, "message": line, "kind": "stderr" }));
}
}
});
let mut progress: u32 = 15;
if let Some(pipe) = child.stdout.take() {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if let Ok(mut guard) = lines.lock() {
guard.push(line.clone());
}
let _ = app.emit("channel-action-log", json!({ "platform": &platform, "action": &action, "message": line, "kind": "stdout" }));
if progress < 90 {
progress += 5;
let _ = app.emit(
"channel-action-progress",
json!({ "platform": &platform, "action": &action, "progress": progress }),
);
}
}
}
let _ = handle.join();
let status = child
.wait()
.map_err(|e| format!("等待命令结束失败: {}", e))?;
let text = lines.lock().ok().map(|g| g.join("\n")).unwrap_or_default();
let _ = app.emit(
"channel-action-progress",
json!({ "platform": &platform, "action": &action, "progress": 100 }),
);
if status.success() {
let _ = app.emit(
"channel-action-done",
json!({ "platform": &platform, "action": &action }),
);
return Ok(text);
} else {
let _ = app.emit(
"channel-action-error",
json!({ "platform": &platform, "action": &action, "message": "安装失败" }),
);
return Err(format!(
"微信插件安装失败 (exit {})\n{}",
status.code().unwrap_or(-1),
text
));
}
}
// weixin login 映射到 openclaw-weixin channel id
let channel_id = if platform == "weixin" {
"openclaw-weixin".to_string()
} else {
platform.clone()
};
let args: Vec<String> = match action.as_str() {
"login" => {
vec![
"channels".into(),
"login".into(),
"--channel".into(),
channel_id,
]
}
_ => return Err(format!("不支持的渠道动作: {}", action)),
};
let emit_payload = |kind: &str, message: String| {
let payload = json!({
"platform": platform,
"action": action,
"message": message,
"kind": kind,
});
let _ = app.emit("channel-action-log", payload);
};
let progress_payload = |progress: u32| {
let payload = json!({
"platform": platform,
"action": action,
"progress": progress,
});
let _ = app.emit("channel-action-progress", payload);
};
emit_payload("info", format!("开始执行 openclaw {}", args.join(" ")));
progress_payload(5);
let lines: Arc<Mutex<Vec<String>>> = Arc::new(Mutex::new(Vec::new()));
let spawn_result = crate::utils::openclaw_command()
.args(args.iter().map(|s| s.as_str()))
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawn_result {
Ok(child) => child,
Err(e) => {
let payload = json!({
"platform": platform,
"action": action,
"message": format!("启动 openclaw 失败: {}", e),
});
let _ = app.emit("channel-action-error", payload);
return Err(format!("启动 openclaw 失败: {}", e));
}
};
let stderr = child.stderr.take();
let app2 = app.clone();
let platform2 = platform.clone();
let action2 = action.clone();
let err_lines = lines.clone();
let handle = std::thread::spawn(move || {
if let Some(pipe) = stderr {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if let Ok(mut guard) = err_lines.lock() {
guard.push(line.clone());
}
let payload = json!({
"platform": platform2,
"action": action2,
"message": line,
"kind": "stderr",
});
let _ = app2.emit("channel-action-log", payload);
}
}
});
let mut progress = 15;
if let Some(pipe) = child.stdout.take() {
for line in BufReader::new(pipe).lines().map_while(Result::ok) {
if let Ok(mut guard) = lines.lock() {
guard.push(line.clone());
}
let payload = json!({
"platform": platform,
"action": action,
"message": line,
"kind": "stdout",
});
let _ = app.emit("channel-action-log", payload);
if progress < 90 {
progress += 5;
progress_payload(progress);
}
}
}
let _ = handle.join();
let status = child
.wait()
.map_err(|e| format!("等待命令结束失败: {}", e))?;
let message = lines
.lock()
.ok()
.map(|guard| {
let text = guard.join("\n");
if text.trim().is_empty() {
"操作完成".to_string()
} else {
text
}
})
.unwrap_or_else(|| "操作完成".into());
if status.success() {
// 微信登录成功后写入 channels.openclaw-weixin.enabled 以便 list_configured_platforms 检测
if platform == "weixin" && action == "login" {
if let Ok(mut cfg) = super::config::load_openclaw_json() {
let channels = cfg
.as_object_mut()
.map(|r| r.entry("channels").or_insert_with(|| json!({})))
.and_then(|c| c.as_object_mut());
if let Some(ch) = channels {
let entry = ch.entry("openclaw-weixin").or_insert_with(|| json!({}));
if let Some(obj) = entry.as_object_mut() {
obj.insert("enabled".into(), json!(true));
}
let _ = super::config::save_openclaw_json(&cfg);
}
}
}
progress_payload(100);
let payload = json!({
"platform": platform,
"action": action,
"message": message,
});
let _ = app.emit("channel-action-done", payload);
Ok(message)
} else {
let payload = json!({
"platform": platform,
"action": action,
"message": message,
});
let _ = app.emit("channel-action-error", payload);
Err(message)
}
}
const QQ_OPENCLAW_FAQ_URL: &str = "https://q.qq.com/qqbot/openclaw/faq.html";
/// OpenClaw 配置 schema 中 `plugins.entries` / `plugins.allow` 的合法 QQ 插件键。
/// 插件自身 package 声明 id 为 "qqbot"openclaw.plugin.json
const OPENCLAW_QQBOT_PLUGIN_ID: &str = "qqbot";
/// 腾讯文档推荐的包CLI 通常安装到 `~/.openclaw/extensions/openclaw-qqbot`(插件运行时 id 仍为 `qqbot`)。
const TENCENT_OPENCLAW_QQBOT_PACKAGE: &str = "@tencent-connect/openclaw-qqbot@latest";
const OPENCLAW_QQBOT_EXTENSION_FOLDER: &str = "openclaw-qqbot";
/// 与 `openclaw channels add --channel qqbot` 默认账号 id 一致。
const QQBOT_DEFAULT_ACCOUNT_ID: &str = "default";
fn qqbot_channel_has_credentials(val: &Value) -> bool {
val.get("appId").is_some_and(secret_like_value_present)
|| val
.get("clientSecret")
.or_else(|| val.get("appSecret"))
.is_some_and(secret_like_value_present)
|| val.get("token").is_some_and(secret_like_value_present)
}
fn secret_like_value_present(value: &Value) -> bool {
value.as_str().is_some_and(|s| !s.trim().is_empty()) || secret_ref_placeholder(value).is_some()
}
fn account_display_value(value: &Value, key: &str) -> Option<String> {
value.get(key).and_then(|v| {
v.as_str()
.map(|s| s.trim().to_string())
.filter(|s| !s.is_empty())
.or_else(|| secret_ref_placeholder(v))
})
}
// ── QQ 插件:扩展目录可能是 ~/.openclaw/extensions/openclaw-qqbot官方包或旧版 qqbot 目录 ──
fn qqbot_extension_installed() -> (bool, Option<&'static str>) {
let d1 = qqbot_plugin_dir();
if d1.is_dir() && plugin_install_marker_exists(&d1) {
return (true, Some("qqbot"));
}
let d2 = generic_plugin_dir("openclaw-qqbot");
if d2.is_dir() && plugin_install_marker_exists(&d2) {
return (true, Some("openclaw-qqbot"));
}
(false, None)
}
fn qqbot_plugins_allow_flags(cfg: &Value) -> (bool, bool) {
let Some(arr) = cfg
.get("plugins")
.and_then(|p| p.get("allow"))
.and_then(|v| v.as_array())
else {
return (false, false);
};
let aq = arr
.iter()
.any(|v| v.as_str() == Some(OPENCLAW_QQBOT_PLUGIN_ID));
let ao = arr.iter().any(|v| v.as_str() == Some("openclaw-qqbot"));
(aq, ao)
}
/// 移除可能导致 OpenClaw 校验失败的旧/误配置。
/// 注意plugins.entries.qqbot 是合法的(插件 id = "qqbot"),不要删。
fn strip_legacy_qqbot_plugin_config_keys(cfg: &mut Value) {
let Some(plugins) = cfg.get_mut("plugins").and_then(|p| p.as_object_mut()) else {
return;
};
// 仅删 plugins.allow 里的误识别字符串 "openclaw-qqbot"(插件实际 id 是 qqbot
if let Some(allow) = plugins.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|v| v.as_str() != Some("openclaw-qqbot"));
}
// plugins.entries.qqbot 本身是合法的,不删除;根级 qqbot 由 strip_ui_fields 处理
}
fn ensure_openclaw_qqbot_plugin(cfg: &mut Value) -> Result<(), String> {
strip_legacy_qqbot_plugin_config_keys(cfg);
ensure_plugin_allowed(cfg, OPENCLAW_QQBOT_PLUGIN_ID)
}
fn qqbot_entry_enabled_ok(cfg: &Value, plugin_id: &str) -> bool {
let has_entry = cfg
.get("plugins")
.and_then(|p| p.get("entries"))
.and_then(|e| e.get(plugin_id))
.is_some();
if !has_entry {
return true;
}
cfg.get("plugins")
.and_then(|p| p.get("entries"))
.and_then(|e| e.get(plugin_id))
.and_then(|ent| ent.get("enabled"))
.and_then(|v| v.as_bool())
!= Some(false)
}
/// (plugin_ok, detail_line)
fn qqbot_plugin_diagnose(cfg: &Value) -> (bool, String) {
let (installed, loc) = qqbot_extension_installed();
let (allow_q, allow_o) = qqbot_plugins_allow_flags(cfg);
let entry_id_ok = qqbot_entry_enabled_ok(cfg, OPENCLAW_QQBOT_PLUGIN_ID);
// 与 ensure_plugin_allowed 一致:插件 id 为 qqbotplugins.entries.qqbot + enabled 为合法配置;
// 仅当存在该条目且 enabled=false 时判失败(不存在条目视为可接受,由一键修复补齐)。
let plugin_ok = installed && allow_q && entry_id_ok;
let mut detail = format!(
"本地扩展:{}(目录:{}plugins.allowqqbot={}、误识别 openclaw-qqbot={}plugins.entries.qqbot 未禁用={}",
if installed {
"已检测到插件文件"
} else {
"未检测到(~/.openclaw/extensions/openclaw-qqbot 或旧版 …/qqbot"
},
loc.unwrap_or(""),
allow_q,
allow_o,
entry_id_ok
);
if allow_o && !allow_q {
detail.push_str(
" **plugins.allow 仅有 openclaw-qqbot 不够,需包含 qqbot保存 QQ 渠道或一键修复)。**",
);
} else if installed && allow_q && !entry_id_ok {
detail.push_str(" **plugins.entries.qqbot 已存在但被禁用enabled=false请改为启用或删除该条目后一键修复。**");
}
(plugin_ok, detail)
}
/// QQ 渠道深度诊断:凭证 + 本机 Gateway + HTTP 健康检查 + 配置与插件。
/// 用于解释 QQ 客户端「灵魂不在线」等(多为 Gateway / 长连接侧,而非 AppID 填错)。
#[tauri::command]
pub async fn diagnose_channel(
platform: String,
account_id: Option<String>,
) -> Result<Value, String> {
let platform = platform.trim().to_string();
if platform.is_empty() {
return Err("platform 不能为空".into());
}
if platform == "qqbot" {
return diagnose_qqbot_channel(account_id).await;
}
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
let storage_key = platform_storage_key(&platform);
let normalized_account_id = account_id
.as_deref()
.map(str::trim)
.filter(|id| !id.is_empty());
let channel_root = cfg.get("channels").and_then(|c| c.get(storage_key));
let channel_enabled = channel_root
.and_then(|node| node.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
let saved =
read_platform_config(platform.clone(), normalized_account_id.map(str::to_string)).await?;
let config_exists = saved
.get("exists")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let form = saved
.get("values")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let credentials_ready = channel_diagnosis_credentials_ready(&platform, &form);
let (verify_result, verify_error) = if config_exists && credentials_ready {
match verify_bot_token(platform.clone(), Value::Object(form.clone())).await {
Ok(result) => (Some(result), None),
Err(error) => (None, Some(error)),
}
} else {
(None, None)
};
Ok(build_openclaw_channel_diagnosis(
&platform,
normalized_account_id,
config_exists,
channel_enabled,
&form,
verify_result,
verify_error,
))
}
/// 一键修复 QQ 插件:未安装则安装官方包并重启 Gateway已安装则补齐 plugins.allow / entries 并重载 Gateway。
#[tauri::command]
pub async fn repair_qqbot_channel_setup(app: tauri::AppHandle) -> Result<Value, String> {
let (installed, _loc) = qqbot_extension_installed();
if !installed {
install_qqbot_plugin(app.clone(), None).await?;
return Ok(json!({
"ok": true,
"action": "installed",
"message": "已安装腾讯 openclaw-qqbot 插件、写入 plugins 并已触发 Gateway 重启"
}));
}
let mut cfg = super::config::load_openclaw_json()?;
ensure_openclaw_qqbot_plugin(&mut cfg)?;
super::config::save_openclaw_json(&cfg)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(json!({
"ok": true,
"action": "config_repaired",
"message": "已写入 plugins.allow / entries 并重载 Gateway"
}))
}
async fn diagnose_qqbot_channel(account_id: Option<String>) -> Result<Value, String> {
let port = crate::commands::gateway_listen_port();
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
let mut checks: Vec<Value> = vec![];
// ── 1) 已保存的凭证 ──
let saved = read_platform_config("qqbot".to_string(), account_id.clone()).await?;
let exists = saved
.get("exists")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let values = saved
.get("values")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let cred_ok = if !exists {
checks.push(json!({
"id": "credentials",
"ok": false,
"title": "QQ 凭证已写入配置",
"detail": "未在 openclaw.json 中找到 qqbot 渠道配置,请先在「渠道列表」完成接入并保存。"
}));
false
} else {
match verify_qqbot(
&super::build_http_client(Duration::from_secs(15), None)
.map_err(|e| format!("HTTP 客户端初始化失败: {}", e))?,
&values,
)
.await
{
Ok(r) if r.get("valid").and_then(|v| v.as_bool()) == Some(true) => {
let details: Vec<String> = r
.get("details")
.and_then(|d| d.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_default();
checks.push(json!({
"id": "credentials",
"ok": true,
"title": "QQ 开放平台凭证getAppAccessToken",
"detail": if details.is_empty() {
"AppID / ClientSecret 可通过腾讯接口换取 access_token。".to_string()
} else {
details.join(" · ")
}
}));
true
}
Ok(r) => {
let errs: Vec<String> = r
.get("errors")
.and_then(|e| e.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect()
})
.unwrap_or_else(|| vec!["凭证校验失败".into()]);
checks.push(json!({
"id": "credentials",
"ok": false,
"title": "QQ 开放平台凭证getAppAccessToken",
"detail": errs.join("")
}));
false
}
Err(e) => {
checks.push(json!({
"id": "credentials",
"ok": false,
"title": "QQ 开放平台凭证getAppAccessToken",
"detail": e
}));
false
}
}
};
// ── 2) channels.qqbot.enabled ──
let qq_node = cfg.get("channels").and_then(|c| c.get("qqbot"));
let qq_enabled = qq_node
.and_then(|n| n.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(true);
checks.push(json!({
"id": "qq_channel_enabled",
"ok": qq_enabled,
"title": "配置中 QQ 渠道已启用",
"detail": if qq_enabled {
"channels.qqbot.enabled 为 true或未写默认启用"
} else {
"channels.qqbot.enabled 为 falseGateway 不会连接 QQ请在渠道列表中启用。"
}
}));
// ── 3) chatCompletionsQQ 常见问题里 405 等) ──
let chat_on = cfg
.get("gateway")
.and_then(|g| g.get("http"))
.and_then(|h| h.get("endpoints"))
.and_then(|e| e.get("chatCompletions"))
.and_then(|c| c.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
checks.push(json!({
"id": "chat_completions",
"ok": chat_on,
"title": "Gateway HTTP · chatCompletions 端点",
"detail": if chat_on {
"gateway.http.endpoints.chatCompletions.enabled 已开启。"
} else {
"未启用 chatCompletions 时,机器人往往无法正常对话(如 405。保存 QQ 渠道时面板通常会打开此项;若手动改过配置请检查。"
}
}));
// ── 4) QQ 插件extensions/qqbot 或 extensions/openclaw-qqbot + plugins.allow ──
let (plugin_ok, plugin_detail) = qqbot_plugin_diagnose(&cfg);
checks.push(json!({
"id": "qq_plugin",
"ok": plugin_ok,
"title": "QQ 机器人插件qqbot / openclaw-qqbot",
"detail": plugin_detail
}));
// ── 5) Gateway TCP ──
let port_copy = port;
let tcp_ok = tokio::task::spawn_blocking(move || {
let addr = format!("127.0.0.1:{}", port_copy);
match addr.parse::<std::net::SocketAddr>() {
Ok(a) => std::net::TcpStream::connect_timeout(&a, Duration::from_secs(2)).is_ok(),
Err(_) => false,
}
})
.await
.unwrap_or(false);
checks.push(json!({
"id": "gateway_tcp",
"ok": tcp_ok,
"title": format!("本机 Gateway 端口 {}TCP", port),
"detail": if tcp_ok {
format!("可在 {}s 内连接到 127.0.0.1:{}", 2, port)
} else {
format!(
"无法连接 127.0.0.1:{}。QQ 提示「灵魂不在线」时最常见原因是 OpenClaw Gateway 未在本机运行或未监听该端口。请在面板「Gateway」页或托盘菜单启动 Gateway。",
port
)
}
}));
// ── 6) Gateway HTTP /__api/health ──
let (http_ok, http_detail) = if tcp_ok {
let url = format!("http://127.0.0.1:{}/__api/health", port);
match super::build_http_client(Duration::from_secs(3), None) {
Ok(client) => match client.get(&url).send().await {
Ok(resp) => {
let status = resp.status();
let ok = status.is_success() || status.is_redirection();
(ok, format!("GET {} → HTTP {}", url, status))
}
Err(e) => (false, format!("请求 {} 失败: {}", url, e)),
},
Err(e) => (false, format!("HTTP 客户端错误: {}", e)),
}
} else {
(false, "已跳过TCP 未连通)。".to_string())
};
checks.push(json!({
"id": "gateway_http",
"ok": http_ok,
"title": "Gateway HTTP 探测(/__api/health",
"detail": http_detail
}));
let overall_ready = cred_ok && qq_enabled && chat_on && plugin_ok && tcp_ok && http_ok;
let hints: Vec<String> = vec![
"QQ 客户端提示「灵魂不在线」表示消息到了腾讯侧,但本机 OpenClaw Gateway 未就绪或未建立 QQ 长连接;仅通过「换 token」校验不能发现该问题。".to_string(),
format!(
"请确认本机 Gateway 已启动、端口与 openclaw.json 中 gateway.port当前 {})一致,并查看日志目录(如 ~/.openclaw/logs/)中 gateway 与 qqbot 相关报错。",
port
),
format!("官方排查说明见:{}", QQ_OPENCLAW_FAQ_URL),
];
Ok(json!({
"platform": "qqbot",
"gatewayPort": port,
"faqUrl": QQ_OPENCLAW_FAQ_URL,
"checks": checks,
"overallReady": overall_ready,
"userHints": hints,
}))
}
/// 列出当前已配置的平台清单
/// 若平台包含 accounts 子对象(多账号模式),返回各账号的安全显示字段
#[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);
let mut accounts: Vec<Value> = vec![];
// 提取多账号信息(仅安全字段,不含 appSecret 等敏感数据)
if let Some(accts) = val.get("accounts").and_then(|a| a.as_object()) {
for (acct_id, acct_val) in accts {
let mut entry = json!({ "accountId": acct_id });
if let Some(display_id) = account_display_value(acct_val, "appId")
.or_else(|| account_display_value(acct_val, "clientId"))
.or_else(|| account_display_value(acct_val, "account"))
{
entry["appId"] = Value::String(display_id);
}
accounts.push(entry);
}
}
result.push(json!({
"id": platform_list_id(name),
"enabled": enabled,
"accounts": accounts
}));
}
}
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 (qq_ext_ok, qq_ext_loc) = if plugin_id == OPENCLAW_QQBOT_PLUGIN_ID {
qqbot_extension_installed()
} else {
(false, None)
};
// QQ 官方包落在 extensions/openclaw-qqbot运行时插件 id 仍为 qqbot
let installed = if plugin_id == OPENCLAW_QQBOT_PLUGIN_ID {
qq_ext_ok
} else {
plugin_dir.is_dir() && plugin_install_marker_exists(&plugin_dir)
};
let path_display: PathBuf = if plugin_id == OPENCLAW_QQBOT_PLUGIN_ID {
match qq_ext_loc {
Some("openclaw-qqbot") => generic_plugin_dir(OPENCLAW_QQBOT_EXTENSION_FOLDER),
Some("qqbot") => qqbot_plugin_dir(),
_ => generic_plugin_dir(OPENCLAW_QQBOT_EXTENSION_FOLDER),
}
} else {
plugin_dir.clone()
};
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": path_display.to_string_lossy(),
"allowed": allowed,
"enabled": enabled,
"legacyBackupDetected": legacy_backup_detected
}))
}
#[tauri::command]
pub async fn list_all_plugins() -> Result<Value, String> {
let cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
let entries = cfg
.pointer("/plugins/entries")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
let allow_arr = cfg
.pointer("/plugins/allow")
.and_then(|v| v.as_array())
.cloned()
.unwrap_or_default();
let ext_dir = super::openclaw_dir().join("extensions");
let mut plugins: Vec<Value> = Vec::new();
let mut seen = std::collections::HashSet::new();
// Scan extensions directory
if ext_dir.is_dir() {
if let Ok(rd) = std::fs::read_dir(&ext_dir) {
for entry in rd.flatten() {
let name = entry.file_name().to_string_lossy().to_string();
if name.starts_with('.') {
continue;
}
let p = entry.path();
if !p.is_dir() {
continue;
}
let has_marker = p.join("package.json").is_file()
|| p.join("plugin.ts").is_file()
|| p.join("index.js").is_file();
if !has_marker {
continue;
}
let plugin_id = name.clone();
seen.insert(plugin_id.clone());
let entry_cfg = entries.get(&plugin_id);
let enabled = entry_cfg
.and_then(|e| e.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
let allowed = allow_arr.iter().any(|v| v.as_str() == Some(&plugin_id));
let builtin = is_plugin_builtin(&plugin_id);
// Try to read version from package.json
let version = std::fs::read_to_string(p.join("package.json"))
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| v.get("version").and_then(|v| v.as_str().map(String::from)));
let description = std::fs::read_to_string(p.join("package.json"))
.ok()
.and_then(|s| serde_json::from_str::<Value>(&s).ok())
.and_then(|v| {
v.get("description")
.and_then(|v| v.as_str().map(String::from))
});
plugins.push(json!({
"id": plugin_id,
"installed": true,
"builtin": builtin,
"enabled": enabled,
"allowed": allowed,
"version": version,
"description": description,
"config": entry_cfg.and_then(|e| e.get("config")),
}));
}
}
}
// Also include entries from config that might not be in extensions dir (built-in)
for (pid, entry_val) in &entries {
if seen.contains(pid.as_str()) {
continue;
}
seen.insert(pid.clone());
let enabled = entry_val
.get("enabled")
.and_then(|v| v.as_bool())
.unwrap_or(false);
let allowed = allow_arr.iter().any(|v| v.as_str() == Some(pid.as_str()));
let builtin = is_plugin_builtin(pid);
plugins.push(json!({
"id": pid,
"installed": builtin,
"builtin": builtin,
"enabled": enabled,
"allowed": allowed,
"version": null,
"description": null,
"config": entry_val.get("config"),
}));
}
plugins.sort_by(|a, b| {
let ae = a.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
let be = b.get("enabled").and_then(|v| v.as_bool()).unwrap_or(false);
be.cmp(&ae).then_with(|| {
let an = a.get("id").and_then(|v| v.as_str()).unwrap_or("");
let bn = b.get("id").and_then(|v| v.as_str()).unwrap_or("");
an.cmp(bn)
})
});
Ok(json!({ "plugins": plugins }))
}
#[tauri::command]
pub async fn toggle_plugin(plugin_id: String, enabled: bool) -> Result<Value, String> {
let plugin_id = plugin_id.trim();
if plugin_id.is_empty() {
return Err("plugin_id 不能为空".into());
}
let mut cfg = super::config::load_openclaw_json().unwrap_or_else(|_| json!({}));
if enabled {
ensure_plugin_allowed(&mut cfg, plugin_id)?;
} else {
disable_legacy_plugin(&mut cfg, plugin_id);
}
// 使用 save_openclaw_json 写入(含备份和 UI 字段清理),而非直接 fs::write
super::config::save_openclaw_json(&cfg)?;
Ok(json!({ "ok": true, "enabled": enabled, "pluginId": plugin_id }))
}
#[tauri::command]
pub async fn install_plugin(package_name: String) -> Result<Value, String> {
let package_name = package_name.trim().to_string();
if package_name.is_empty() {
return Err("包名不能为空".into());
}
let cli = crate::utils::resolve_openclaw_cli_path()
.ok_or_else(|| "找不到 OpenClaw CLI请先安装".to_string())?;
let output = std::process::Command::new(&cli)
.args(["plugins", "install", &package_name])
.current_dir(dirs::home_dir().unwrap_or_default())
.output()
.map_err(|e| format!("执行 openclaw plugins install 失败: {e}"))?;
let stdout = String::from_utf8_lossy(&output.stdout).to_string();
let stderr = String::from_utf8_lossy(&output.stderr).to_string();
if !output.status.success() {
return Err(format!("安装失败: {}{}", stdout, stderr));
}
Ok(json!({ "ok": true, "output": format!("{}{}", stdout, stderr).trim().to_string() }))
}
// ── Slack / Matrix / Discord 凭证校验 ─────────────────────
async fn verify_slack(
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 resp = client
.post("https://slack.com/api/auth.test")
.bearer_auth(bot_token)
.send()
.await
.map_err(|e| format!("Slack API 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析 Slack 响应失败: {}", e))?;
if body.get("ok").and_then(|v| v.as_bool()) != Some(true) {
let err = body
.get("error")
.and_then(|v| v.as_str())
.unwrap_or("unknown_error");
return Ok(json!({ "valid": false, "errors": [format!("Slack 鉴权失败: {}", err)] }));
}
let team = body
.get("team")
.and_then(|v| v.as_str())
.unwrap_or("未知工作区");
let user = body
.get("user")
.and_then(|v| v.as_str())
.unwrap_or("未知用户");
Ok(json!({
"valid": true,
"details": [format!("工作区: {}", team), format!("Bot 用户: {}", user)]
}))
}
async fn verify_matrix(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let homeserver = form
.get("homeserver")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let access_token = form
.get("accessToken")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if homeserver.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Homeserver 不能为空"] }));
}
if access_token.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Access Token 不能为空"] }));
}
let base = homeserver.trim_end_matches('/');
let resp = client
.get(format!("{}/_matrix/client/v3/account/whoami", base))
.bearer_auth(access_token)
.send()
.await
.map_err(|e| format!("Matrix API 连接失败: {}", e))?;
if resp.status() == 401 {
return Ok(json!({ "valid": false, "errors": ["Access Token 无效或已失效"] }));
}
if !resp.status().is_success() {
return Ok(json!({
"valid": false,
"errors": [format!("Matrix API 返回异常: {}", resp.status())]
}));
}
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析 Matrix 响应失败: {}", e))?;
let user_id = body
.get("user_id")
.and_then(|v| v.as_str())
.unwrap_or("未知用户");
let device_id = body
.get("device_id")
.and_then(|v| v.as_str())
.unwrap_or("未返回");
Ok(json!({
"valid": true,
"details": [format!("用户: {}", user_id), format!("设备: {}", device_id)]
}))
}
// ── Signal 连通性校验 ─────────────────────────────────────
async fn verify_signal(
client: &reqwest::Client,
form: &Map<String, Value>,
) -> Result<Value, String> {
let account = form
.get("account")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if account.is_empty() {
return Ok(json!({ "valid": false, "errors": ["Signal 号码不能为空"] }));
}
let http_url = form
.get("httpUrl")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
let http_host = form
.get("httpHost")
.and_then(|v| v.as_str())
.unwrap_or("127.0.0.1")
.trim()
.to_string();
let http_port = form
.get("httpPort")
.and_then(|v| v.as_str())
.unwrap_or("8080")
.trim()
.to_string();
let base = if !http_url.is_empty() {
http_url
} else {
format!("http://{}:{}", http_host, http_port)
};
let url = format!("{}/v1/about", base.trim_end_matches('/'));
match client.get(&url).send().await {
Ok(resp) => {
if resp.status().is_success() {
let body: Value = resp.json().await.unwrap_or(json!({}));
let versions = body
.get("versions")
.and_then(|v| v.as_array())
.map(|arr| {
arr.iter()
.filter_map(|v| v.as_str())
.collect::<Vec<_>>()
.join(", ")
})
.unwrap_or_default();
let mut details = vec![
format!("号码: {}", account),
format!("signal-cli 端点: {}", base),
];
if !versions.is_empty() {
details.push(format!("API 版本: {}", versions));
}
Ok(json!({ "valid": true, "details": details }))
} else {
Ok(json!({
"valid": false,
"errors": [format!("signal-cli HTTP 返回异常: {} — 请确认 signal-cli daemon 正在运行", resp.status())]
}))
}
}
Err(e) => Ok(json!({
"valid": false,
"errors": [format!("无法连接 signal-cli HTTP 端点 {}{}", url, e)]
})),
}
}
// ── MS Teams 凭证校验 ─────────────────────────────────────
async fn verify_msteams(
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_password = form
.get("appPassword")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
let tenant_id = form
.get("tenantId")
.and_then(|v| v.as_str())
.unwrap_or("botframework.com")
.trim();
if app_id.is_empty() {
return Ok(json!({ "valid": false, "errors": ["App ID 不能为空"] }));
}
let missing_credentials = msteams_credential_missing_labels(form);
if !missing_credentials.is_empty() {
return Ok(
json!({ "valid": false, "errors": [format!("缺少 {}", missing_credentials.join(" / "))] }),
);
}
if app_password.is_empty() {
return Ok(json!({
"valid": true,
"warnings": ["当前 Teams 认证模式不使用 Client Secret面板已完成结构校验实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。"],
"details": [format!("App ID: {}", app_id)]
}));
}
let token_url = format!(
"https://login.microsoftonline.com/{}/oauth2/v2.0/token",
if tenant_id.is_empty() {
"botframework.com"
} else {
tenant_id
}
);
let resp = client
.post(&token_url)
.form(&[
("grant_type", "client_credentials"),
("client_id", app_id),
("client_secret", app_password),
("scope", "https://api.botframework.com/.default"),
])
.send()
.await
.map_err(|e| format!("Azure AD 连接失败: {}", e))?;
let body: Value = resp
.json()
.await
.map_err(|e| format!("解析 Azure AD 响应失败: {}", e))?;
if body
.get("access_token")
.and_then(|v| v.as_str())
.filter(|v| !v.is_empty())
.is_some()
{
let expires_in = body.get("expires_in").and_then(|v| v.as_u64()).unwrap_or(0);
Ok(json!({
"valid": true,
"details": [
format!("App ID: {}", app_id),
format!("Tenant: {}", tenant_id),
format!("Token 有效期: {}s", expires_in)
]
}))
} else {
let err = body
.get("error_description")
.or_else(|| body.get("error"))
.and_then(|v| v.as_str())
.unwrap_or("凭证无效,请检查 App ID 和 App Password");
Ok(json!({
"valid": false,
"errors": [err]
}))
}
}
// ── 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();
// 腾讯官方插件用 clientSecret也兼容旧版 appSecret
let app_secret = form
.get("clientSecret")
.or_else(|| 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": ["ClientSecret 不能为空"] }));
}
// 通过 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(())
}
/// 禁用旧版插件:在 plugins.entries 中设置 enabled=false并从 plugins.allow 中移除
fn disable_legacy_plugin(cfg: &mut Value, plugin_id: &str) {
if let Some(root) = cfg.as_object_mut() {
if let Some(plugins) = root.get_mut("plugins").and_then(|p| p.as_object_mut()) {
// 从 allow 列表中移除
if let Some(allow) = plugins.get_mut("allow").and_then(|a| a.as_array_mut()) {
allow.retain(|v| v.as_str() != Some(plugin_id));
}
// 在 entries 中设置 enabled=false
if let Some(entries) = plugins.get_mut("entries").and_then(|e| e.as_object_mut()) {
if let Some(entry) = entries.get_mut(plugin_id).and_then(|e| e.as_object_mut()) {
entry.insert("enabled".into(), Value::Bool(false));
}
}
}
}
}
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 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 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_extension_install(
plugin_dir: &Path,
plugin_backup: &Path,
config_backup: &Path,
had_plugin_backup: bool,
had_config_backup: bool,
) -> Result<(), String> {
let config_path = super::openclaw_dir().join("openclaw.json");
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",
"openclaw-lark" => "@larksuite/openclaw-lark",
"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,
version: Option<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());
}
// 拼接版本号package@version兼容用户 OpenClaw 版本的插件)
let install_spec = match &version {
Some(v) if !v.is_empty() => format!("{}@{}", package_name, v),
_ => package_name.to_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);
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 _ = app.emit("plugin-log", format!("安装规格: {}", install_spec));
let spawn_result = crate::utils::openclaw_command()
.args(["plugins", "install", &install_spec])
.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 stderr_lines = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
let stderr_clone = 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);
stderr_clone.lock().unwrap().push(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 all_stderr = stderr_lines.lock().unwrap().join("\n");
let is_host_version_issue = all_stderr.contains("minHostVersion")
|| all_stderr.contains("minimum host version")
|| all_stderr.contains("requires OpenClaw")
|| all_stderr.contains("host version");
if is_host_version_issue {
let _ = app.emit(
"plugin-log",
"⚠ 插件要求更高版本的 OpenClawminHostVersion 不满足)",
);
let _ = app.emit("plugin-log", "请先升级 OpenClaw 到最新版,再安装此插件:");
let _ = app.emit(
"plugin-log",
" 前往「服务管理」页面点击升级,或在终端执行:",
);
let _ = app.emit("plugin-log", " npm i -g @qingchencloud/openclaw-zh@latest --registry https://registry.npmmirror.com");
}
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),
);
if is_host_version_issue {
return Err("插件安装失败:当前 OpenClaw 版本过低,请先升级后重试".into());
}
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,
version: Option<String>,
) -> Result<String, String> {
use std::io::{BufRead, BufReader};
use std::process::Stdio;
use tauri::Emitter;
let install_spec = match &version {
Some(v) if !v.is_empty() => format!("{}@{}", TENCENT_OPENCLAW_QQBOT_PACKAGE, v),
_ => TENCENT_OPENCLAW_QQBOT_PACKAGE.to_string(),
};
let plugin_dir = generic_plugin_dir(OPENCLAW_QQBOT_EXTENSION_FOLDER);
let plugin_backup = generic_plugin_backup_dir(OPENCLAW_QQBOT_EXTENSION_FOLDER);
let config_path = super::openclaw_dir().join("openclaw.json");
let config_backup = generic_plugin_config_backup_path(OPENCLAW_QQBOT_EXTENSION_FOLDER);
let had_existing_plugin = plugin_dir.exists();
let had_existing_config = config_path.exists();
let _ = app.emit(
"plugin-log",
format!(
"正在安装腾讯 OpenClaw QQ 插件 {} ...",
TENCENT_OPENCLAW_QQBOT_PACKAGE
),
);
let _ = app.emit("plugin-progress", 10);
fs::create_dir_all(plugin_backup_root()).map_err(|e| format!("创建插件备份目录失败: {e}"))?;
if cleanup_legacy_plugin_backup_dir(OPENCLAW_QQBOT_EXTENSION_FOLDER)? {
let _ = app.emit("plugin-log", "已清理旧版 QQ 插件备份目录");
}
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 _ = app.emit("plugin-log", format!("安装规格: {}", install_spec));
let spawn_result = crate::utils::openclaw_command()
.args(["plugins", "install", &install_spec])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn();
let mut child = match spawn_result {
Ok(child) => child,
Err(e) => {
let _ = cleanup_failed_extension_install(
&plugin_dir,
&plugin_backup,
&config_backup,
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_extension_install(
&plugin_dir,
&plugin_backup,
&config_backup,
had_existing_plugin,
had_existing_config,
);
let _ = app.emit("plugin-progress", 100);
return Err("OpenClaw CLI 原生依赖缺失,请先在终端重装 OpenClaw详见上方日志".into());
}
if !status.success() {
let all_stderr = qqbot_stderr_lines.lock().unwrap().join("\n");
let is_host_version_issue = all_stderr.contains("minHostVersion")
|| all_stderr.contains("minimum host version")
|| all_stderr.contains("requires OpenClaw")
|| all_stderr.contains("host version");
if is_host_version_issue {
let _ = app.emit(
"plugin-log",
"⚠ 插件要求更高版本的 OpenClawminHostVersion 不满足)",
);
let _ = app.emit("plugin-log", "请先升级 OpenClaw 到最新版,再安装此插件:");
let _ = app.emit(
"plugin-log",
" 前往「服务管理」页面点击升级,或在终端执行:",
);
let _ = app.emit("plugin-log", " npm i -g @qingchencloud/openclaw-zh@latest --registry https://registry.npmmirror.com");
} else {
let _ = app.emit(
"plugin-log",
"openclaw plugins install 未成功结束,正在回退",
);
}
let _ = cleanup_failed_extension_install(
&plugin_dir,
&plugin_backup,
&config_backup,
had_existing_plugin,
had_existing_config,
);
let _ = app.emit("plugin-progress", 100);
if is_host_version_issue {
return Err("插件安装失败:当前 OpenClaw 版本过低,请先升级后重试".into());
}
return Err("QQ 插件安装失败openclaw plugins install 进程退出码非零".into());
}
if !plugin_install_marker_exists(&plugin_dir) {
let _ = app.emit(
"plugin-log",
format!("未在 {} 检测到插件文件,正在回退", plugin_dir.display()),
);
let _ = cleanup_failed_extension_install(
&plugin_dir,
&plugin_backup,
&config_backup,
had_existing_plugin,
had_existing_config,
);
let _ = app.emit("plugin-progress", 100);
return Err(format!(
"安装后未在 extensions/{} 检测到插件,请检查 OpenClaw 版本与网络",
OPENCLAW_QQBOT_EXTENSION_FOLDER
));
}
let finalize = (|| -> Result<(), String> {
let mut cfg = super::config::load_openclaw_json()?;
ensure_openclaw_qqbot_plugin(&mut cfg)?;
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);
}
if qqbot_plugin_dir().is_dir() {
let _ = app.emit(
"plugin-log",
"提示:检测到旧的 extensions/qqbot 目录,可能与官方包并存并触发「无 provenance」日志不需要时可手动删除或改名备份。",
);
}
let _ = app.emit(
"plugin-log",
"QQ 插件安装完成;正在重启 Gateway 以加载插件(与官方文档一致)",
);
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ =
crate::commands::service::restart_service(app2, "ai.openclaw.gateway".into())
.await;
});
Ok("安装成功".into())
}
Err(err) => {
let _ = app.emit(
"plugin-log",
format!("写入 plugins 配置失败,正在回退: {err}"),
);
let rollback_err = cleanup_failed_extension_install(
&plugin_dir,
&plugin_backup,
&config_backup,
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}"))
}
}
}
}
// ── Agent 渠道绑定管理 ──────────────────────────────────
/// 创建 Agent 到渠道的绑定配置OpenClaw bindings schema
fn create_agent_binding(
cfg: &mut serde_json::Value,
agent_id: &str,
channel: &str,
account_id: Option<String>,
) -> Result<(), String> {
let bindings = cfg
.as_object_mut()
.ok_or("配置格式错误")?
.entry("bindings")
.or_insert_with(|| serde_json::json!([]));
let bindings_arr = bindings.as_array_mut().ok_or("bindings 节点格式错误")?;
// 构建新绑定条目(遵循 OpenClaw bindings schema
let mut new_binding = serde_json::Map::new();
new_binding.insert(
"type".to_string(),
serde_json::Value::String("route".to_string()),
);
new_binding.insert(
"agentId".to_string(),
serde_json::Value::String(agent_id.to_string()),
);
// 构建 match 配置
let mut match_config = serde_json::Map::new();
match_config.insert(
"channel".to_string(),
serde_json::Value::String(channel.to_string()),
);
if let Some(ref acct) = account_id {
match_config.insert(
"accountId".to_string(),
serde_json::Value::String(acct.clone()),
);
}
new_binding.insert("match".to_string(), serde_json::Value::Object(match_config));
// 先转换为 Value避免在循环中移动
let binding_value = serde_json::Value::Object(new_binding);
// 检查是否已存在相同 agentId + channel + accountId 的绑定,如有则更新
let mut found = false;
for binding in bindings_arr.iter_mut() {
if let (Some(existing_agent), Some(existing_channel), Some(existing_match)) = (
binding.get("agentId").and_then(|v| v.as_str()),
binding
.get("match")
.and_then(|m| m.get("channel"))
.and_then(|v| v.as_str()),
binding.get("match"),
) {
if existing_agent == agent_id && existing_channel == channel {
let existing_account = existing_match.get("accountId").and_then(|v| v.as_str());
if existing_account == account_id.as_deref() {
*binding = binding_value.clone();
found = true;
break;
}
}
}
}
// 如果没有找到现有绑定,则添加新绑定
if !found {
bindings_arr.push(binding_value);
}
Ok(())
}
/// 获取指定 Agent 的所有渠道绑定
/// 返回格式: { agentId, bindings: [{ channel, accountId, peer, ... }] }
#[tauri::command]
pub async fn get_agent_bindings(agent_id: String) -> Result<serde_json::Value, String> {
let cfg = super::config::load_openclaw_json()?;
let bindings: Vec<serde_json::Value> = cfg
.get("bindings")
.and_then(|b| b.as_array())
.map(|arr| {
arr.iter()
.filter(|b| {
b.get("agentId")
.and_then(|v| v.as_str())
.map(|id| id == agent_id)
.unwrap_or(false)
})
.cloned()
.collect()
})
.unwrap_or_default();
Ok(serde_json::json!({
"agentId": agent_id,
"bindings": bindings
}))
}
/// 获取所有 Agent 的绑定列表(用于管理界面)
#[tauri::command]
pub async fn list_all_bindings() -> Result<serde_json::Value, String> {
let cfg = super::config::load_openclaw_json()?;
let bindings: Vec<serde_json::Value> = cfg
.get("bindings")
.and_then(|b| b.as_array())
.cloned()
.unwrap_or_default();
Ok(serde_json::json!({
"bindings": bindings
}))
}
/// 保存/更新 Agent 的渠道绑定
/// - agent_id: Agent ID
/// - channel: 渠道类型 (feishu/telegram/discord/qqbot/dingtalk)
/// - account_id: 可选,指定账号(多账号模式)
/// - binding_config: 绑定配置 { peer, match, ... }
#[tauri::command]
pub async fn save_agent_binding(
agent_id: String,
channel: String,
account_id: Option<String>,
binding_config: serde_json::Value,
app: tauri::AppHandle,
) -> Result<serde_json::Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
// 账号配置存在性校验(读操作,提前执行以避免与后续可变借用冲突)
let mut warnings: Vec<String> = vec![];
if let Some(ref acct) = account_id {
if !acct.is_empty() {
if let Some(ch) = cfg.get("channels").and_then(|c| c.get(channel.as_str())) {
let has_account = ch
.get("accounts")
.and_then(|a| a.get(acct.as_str()))
.map(value_has_messaging_credential)
.unwrap_or(false);
if !has_account {
let has_root = ch
.as_object()
.map(channel_root_has_messaging_credential)
.unwrap_or(false);
if has_root {
warnings.push(format!(
"账号「{}」在 channels.{}.accounts 下未找到对应配置,\
当前凭证写在根级别(单账号旧格式)。\
建议将账号凭证移入 channels.{}.accounts.\"{}\" 下以支持多账号。",
acct, channel, channel, acct
));
} else {
warnings.push(format!(
"账号「{}」在 channels.{}.accounts 下未找到对应配置,\
该绑定可能无法正常路由消息。\
请先在渠道列表中为账号「{}」接入对应渠道账号。",
acct, channel, acct
));
}
}
} else {
warnings.push(format!(
"渠道「{}」尚未接入channels.{} 不存在),该绑定可能无法正常工作。",
channel, channel
));
}
}
}
// 确保 bindings 节点存在(从这里开始需要可变借用)
let bindings = cfg
.as_object_mut()
.ok_or("配置格式错误")?
.entry("bindings")
.or_insert_with(|| serde_json::json!([]));
let bindings_arr = bindings.as_array_mut().ok_or("bindings 节点格式错误")?;
// 构建新绑定条目(遵循 OpenClaw bindings schema
let mut new_binding = serde_json::Map::new();
new_binding.insert(
"type".to_string(),
serde_json::Value::String("route".to_string()),
);
new_binding.insert(
"agentId".to_string(),
serde_json::Value::String(agent_id.clone()),
);
let target_match = build_binding_match(&channel, account_id.as_deref(), &binding_config);
new_binding.insert("match".to_string(), target_match.clone());
// 先转换为 Value避免在循环中移动
let binding_value = serde_json::Value::Object(new_binding);
let mut found = false;
for binding in bindings_arr.iter_mut() {
if binding_identity_matches(binding, &agent_id, &target_match) {
*binding = binding_value.clone();
found = true;
break;
}
}
// 如果没有找到现有绑定,则添加新绑定
if !found {
bindings_arr.push(binding_value);
}
// 写回配置并重载 Gateway
super::config::save_openclaw_json(&cfg)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(serde_json::json!({
"ok": true,
"warnings": warnings
}))
}
/// 删除 Agent 的渠道绑定
/// - agent_id: Agent ID
/// - channel: 渠道类型
/// - account_id: 指定子账号时仅删该条;为 None 时仅删除「无 accountId」的默认绑定不会一次删掉同渠道下其它子账号
#[tauri::command]
pub async fn delete_agent_binding(
agent_id: String,
channel: String,
account_id: Option<String>,
binding_config: Option<serde_json::Value>,
app: tauri::AppHandle,
) -> Result<serde_json::Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let target_match = build_binding_match(
&channel,
account_id.as_deref(),
binding_config.as_ref().unwrap_or(&Value::Null),
);
let Some(bindings) = cfg.get_mut("bindings").and_then(|b| b.as_array_mut()) else {
return Ok(serde_json::json!({ "ok": true }));
};
let original_len = bindings.len();
bindings.retain(|b| !binding_identity_matches(b, &agent_id, &target_match));
let removed = original_len - bindings.len();
if removed == 0 {
return Err("未找到对应的绑定".to_string());
}
// 写回配置并重载 Gateway
super::config::save_openclaw_json(&cfg)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(serde_json::json!({
"ok": true,
"removed": removed
}))
}
/// 删除指定 Agent 的所有绑定
#[tauri::command]
pub async fn delete_agent_all_bindings(
agent_id: String,
app: tauri::AppHandle,
) -> Result<serde_json::Value, String> {
let mut cfg = super::config::load_openclaw_json()?;
let Some(bindings) = cfg.get_mut("bindings").and_then(|b| b.as_array_mut()) else {
return Ok(serde_json::json!({ "ok": true, "removed": 0 }));
};
let original_len = bindings.len();
bindings.retain(|b| {
b.get("agentId")
.and_then(|v| v.as_str())
.map(|id| id != agent_id)
.unwrap_or(true)
});
let removed = original_len - bindings.len();
// 写回配置并重载 Gateway
super::config::save_openclaw_json(&cfg)?;
let app2 = app.clone();
tauri::async_runtime::spawn(async move {
let _ = super::config::do_reload_gateway(&app2).await;
});
Ok(serde_json::json!({
"ok": true,
"removed": removed
}))
}
// ── 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]
}))
}
}
// ── Zalo Bot 凭证校验 ─────────────────────────────────────
async fn verify_zalo(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();
let token_file = form
.get("tokenFile")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim();
if bot_token.is_empty() {
if token_file.is_empty() {
return Ok(json!({ "valid": false, "errors": ["请填写 Bot Token 或 Token File"] }));
}
return Ok(json!({
"valid": true,
"warnings": ["已配置 Token File桌面端不会读取外部文件做在线校验"]
}));
}
let resp = client
.post(format!(
"https://bot-api.zaloplatforms.com/bot{}/getMe",
bot_token
))
.header("Content-Type", "application/json")
.send()
.await
.map_err(|e| format!("Zalo 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) {
Ok(json!({
"valid": true,
"errors": [],
"details": ["Zalo Bot Token 已通过 getMe 校验"]
}))
} else {
let msg = body
.get("description")
.or_else(|| body.get("message"))
.and_then(|v| v.as_str())
.unwrap_or("Zalo Bot Token 无效");
Ok(json!({
"valid": false,
"errors": [msg]
}))
}
}
// ── 飞书凭证校验 ──────────────────────────────────────
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]
}))
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn normalize_channel_form_adds_telegram_access_defaults() {
let form = json!({
"botToken": "123:token"
});
let normalized =
normalize_messaging_platform_form("telegram", form.as_object().expect("object"));
assert_eq!(
normalized.get("botToken").and_then(|v| v.as_str()),
Some("123:token")
);
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("pairing")
);
assert_eq!(
normalized.get("groupPolicy").and_then(|v| v.as_str()),
Some("allowlist")
);
}
#[test]
fn normalize_channel_form_converts_legacy_ui_policy_values() {
let form = json!({
"mode": "socket",
"botToken": "xoxb-token",
"appToken": "xapp-token",
"dmPolicy": "allow",
"groupPolicy": "mentioned"
});
let normalized =
normalize_messaging_platform_form("slack", form.as_object().expect("object"));
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("open")
);
assert_eq!(
normalized
.get("allowFrom")
.and_then(|v| v.as_array())
.cloned(),
Some(vec![Value::String("*".into())])
);
assert_eq!(
normalized.get("groupPolicy").and_then(|v| v.as_str()),
Some("open")
);
assert_eq!(
normalized.get("requireMention").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("webhookPath").and_then(|v| v.as_str()),
Some("/slack/events")
);
assert_eq!(
normalized
.get("userTokenReadOnly")
.and_then(|v| v.as_bool()),
Some(false)
);
}
#[test]
fn normalize_channel_form_avoids_unsupported_top_level_require_mention() {
let form = json!({
"account": "+15551234567",
"dmPolicy": "deny",
"groupPolicy": "mentioned"
});
let normalized =
normalize_messaging_platform_form("signal", form.as_object().expect("object"));
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("disabled")
);
assert_eq!(
normalized.get("groupPolicy").and_then(|v| v.as_str()),
Some("open")
);
assert!(!normalized.contains_key("requireMention"));
}
#[test]
fn normalize_channel_form_adds_feishu_required_defaults() {
let form = json!({
"appId": "cli_a",
"appSecret": "secret",
"domain": ""
});
let normalized =
normalize_messaging_platform_form("feishu", form.as_object().expect("object"));
assert_eq!(
normalized.get("domain").and_then(|v| v.as_str()),
Some("feishu")
);
assert_eq!(
normalized.get("connectionMode").and_then(|v| v.as_str()),
Some("websocket")
);
assert_eq!(
normalized.get("webhookPath").and_then(|v| v.as_str()),
Some("/feishu/events")
);
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("pairing")
);
assert_eq!(
normalized.get("groupPolicy").and_then(|v| v.as_str()),
Some("allowlist")
);
assert_eq!(
normalized
.get("reactionNotifications")
.and_then(|v| v.as_str()),
Some("off")
);
assert_eq!(
normalized.get("typingIndicator").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized
.get("resolveSenderNames")
.and_then(|v| v.as_bool()),
Some(true)
);
}
#[test]
fn normalize_imessage_form_preserves_bridge_runtime_fields() {
let form = json!({
"dmPolicy": "allowlist",
"allowFrom": "+15551234567, +15557654321",
"groupPolicy": "allowlist",
"groupAllowFrom": "chat-guid-1, chat-guid-2",
"probeTimeoutMs": "5000",
"attachmentRoots": "/Users/me/Downloads, /tmp/imessage",
"includeAttachments": "true",
"sendReadReceipts": "false"
});
let normalized =
normalize_messaging_platform_form("imessage", form.as_object().expect("object"));
assert_eq!(
normalized.get("dmPolicy").and_then(|v| v.as_str()),
Some("allowlist")
);
assert_eq!(
normalized.get("probeTimeoutMs").and_then(|v| v.as_f64()),
Some(5000.0)
);
assert_eq!(
normalized
.get("includeAttachments")
.and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("sendReadReceipts").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
normalized
.get("attachmentRoots")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert!(channel_diagnosis_credentials_ready("imessage", &normalized));
let diagnosis =
build_openclaw_channel_diagnosis("imessage", None, true, true, &normalized, None, None);
assert_eq!(
diagnosis
.get("checks")
.and_then(|v| v.as_array())
.and_then(|items| items
.iter()
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some("credentials")))
.and_then(|item| item.get("title"))
.and_then(|v| v.as_str()),
Some("桥接运行配置")
);
}
#[test]
fn normalize_whatsapp_form_preserves_scan_runtime_fields() {
let form = json!({
"enabled": "true",
"configWrites": "true",
"sendReadReceipts": "false",
"selfChatMode": "true",
"dmPolicy": "allowlist",
"allowFrom": "+15551234567, +15557654321",
"groupPolicy": "allowlist",
"groupAllowFrom": "120363@g.us, 120364@g.us",
"debounceMs": "800",
"mediaMaxMb": "50",
"ackDirect": "true",
"ackGroup": "mentions"
});
let normalized =
normalize_messaging_platform_form("whatsapp", form.as_object().expect("object"));
assert_eq!(
normalized.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("configWrites").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("sendReadReceipts").and_then(|v| v.as_bool()),
Some(false)
);
assert_eq!(
normalized.get("selfChatMode").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("debounceMs").and_then(|v| v.as_f64()),
Some(800.0)
);
assert_eq!(
normalized.get("mediaMaxMb").and_then(|v| v.as_f64()),
Some(50.0)
);
assert_eq!(
normalized.get("ackDirect").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized
.get("allowFrom")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert_eq!(
normalized
.get("groupAllowFrom")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert!(channel_diagnosis_credentials_ready("whatsapp", &normalized));
let diagnosis =
build_openclaw_channel_diagnosis("whatsapp", None, true, true, &normalized, None, None);
assert_eq!(
diagnosis
.get("checks")
.and_then(|v| v.as_array())
.and_then(|items| items
.iter()
.find(|item| item.get("id").and_then(|v| v.as_str()) == Some("credentials")))
.and_then(|item| item.get("title"))
.and_then(|v| v.as_str()),
Some("扫码/会话配置")
);
}
#[test]
fn normalize_clickclack_form_preserves_workspace_runtime_fields() {
let form = json!({
"enabled": "true",
"baseUrl": "https://clickclack.example.com",
"token": "clickclack-token",
"workspace": "ops",
"replyMode": "model",
"timeoutSeconds": "120",
"toolsAllow": "shell, browser.search",
"senderIsOwner": "true",
"defaultTo": "channel:ops",
"allowFrom": "channel:ops, dm:alice",
"reconnectMs": "2500"
});
let normalized =
normalize_messaging_platform_form("clickclack", form.as_object().expect("object"));
assert_eq!(
normalized.get("enabled").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized.get("timeoutSeconds").and_then(|v| v.as_f64()),
Some(120.0)
);
assert_eq!(
normalized.get("reconnectMs").and_then(|v| v.as_f64()),
Some(2500.0)
);
assert_eq!(
normalized.get("senderIsOwner").and_then(|v| v.as_bool()),
Some(true)
);
assert_eq!(
normalized
.get("toolsAllow")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert_eq!(
normalized
.get("allowFrom")
.and_then(|v| v.as_array())
.map(|items| items.len()),
Some(2)
);
assert!(channel_diagnosis_credentials_ready(
"clickclack",
&normalized
));
let missing_workspace = json!({
"baseUrl": "https://clickclack.example.com",
"token": "clickclack-token"
});
let missing = normalize_messaging_platform_form(
"clickclack",
missing_workspace.as_object().expect("object"),
);
assert!(!channel_diagnosis_credentials_ready("clickclack", &missing));
let diagnosis =
build_openclaw_channel_diagnosis("clickclack", None, true, true, &missing, None, None);
assert!(diagnosis
.get("checks")
.and_then(|v| v.as_array())
.and_then(|items| items
.iter()
.find(|item| { item.get("id").and_then(|v| v.as_str()) == Some("credentials") }))
.and_then(|item| item.get("detail"))
.and_then(|v| v.as_str())
.unwrap_or("")
.contains("Workspace"));
}
#[test]
fn channel_form_readback_preserves_mention_policy_choice() {
let saved = json!({
"groupPolicy": "open",
"requireMention": true,
"allowFrom": ["U123"]
});
let mut form = Map::new();
insert_access_policy_form_values(&mut form, &saved, false, true);
assert_eq!(
form.get("groupPolicy").and_then(|v| v.as_str()),
Some("mentioned")
);
assert_eq!(form.get("allowFrom").and_then(|v| v.as_str()), Some("U123"));
}
#[test]
fn channel_form_readback_masks_secret_refs() {
let saved = json!({
"botToken": {
"source": "env",
"provider": "default",
"id": "TELEGRAM_BOT_TOKEN"
}
});
let mut form = Map::new();
insert_secret_aware_form_value(&mut form, &saved, "botToken");
assert_eq!(
form.get("botToken").and_then(|v| v.as_str()),
Some("SecretRef(env:default:TELEGRAM_BOT_TOKEN)")
);
assert_eq!(
form.get("__secretRefs")
.and_then(|v| v.get("botToken"))
.cloned(),
saved.get("botToken").cloned()
);
}
#[test]
fn channel_save_preserves_unchanged_secret_ref_placeholder() {
let current = json!({
"botToken": {
"source": "env",
"provider": "default",
"id": "SLACK_BOT_TOKEN"
}
});
let form = json!({
"botToken": "SecretRef(env:default:SLACK_BOT_TOKEN)"
});
let value = resolve_messaging_credential_value_for_save(
form.as_object().expect("object"),
&current,
"botToken",
);
assert_eq!(value, current.get("botToken").cloned());
}
#[test]
fn channel_save_replaces_secret_ref_when_user_enters_new_secret() {
let current = json!({
"token": {
"source": "env",
"provider": "default",
"id": "DISCORD_BOT_TOKEN"
}
});
let form = json!({
"token": "new-discord-token"
});
let value = resolve_messaging_credential_value_for_save(
form.as_object().expect("object"),
&current,
"token",
);
assert_eq!(value, Some(Value::String("new-discord-token".into())));
}
#[test]
fn messaging_credential_detection_accepts_non_app_id_channels() {
for account in [
json!({ "botToken": "telegram-token" }),
json!({ "token": "discord-token" }),
json!({ "botToken": "xoxb-token", "appToken": "xapp-token" }),
json!({ "clientId": "teams-client-id" }),
] {
assert!(value_has_messaging_credential(&account));
}
assert!(!value_has_messaging_credential(&json!({
"enabled": true,
"dmPolicy": "pairing"
})));
}
#[test]
fn messaging_credential_detection_accepts_secret_refs() {
let account = json!({
"token": {
"source": "env",
"provider": "default",
"id": "DISCORD_BOT_TOKEN"
}
});
assert!(value_has_messaging_credential(&account));
}
}