fix(channels): normalize OpenClaw channel config policies

This commit is contained in:
晴天
2026-05-23 01:14:42 +08:00
parent f4d644ea06
commit 27b35b6298
5 changed files with 904 additions and 84 deletions

View File

@@ -80,6 +80,28 @@ fn insert_array_as_csv(form: &mut Map<String, Value>, source: &Value, key: &str)
}
}
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', ';'][..])
@@ -94,6 +116,32 @@ fn csv_to_json_array(raw: &str) -> Option<Value> {
}
}
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),
@@ -114,12 +162,161 @@ fn put_bool_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str) {
}
}
fn put_csv_array_from_form(entry: &mut Map<String, Value>, key: &str, raw: &str) {
if let Some(v) = csv_to_json_array(raw) {
entry.insert(key.into(), v);
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"
)
}
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"
);
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 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(
@@ -327,6 +524,7 @@ pub async fn read_platform_config(
if let Some(t) = saved.get("token").and_then(|v| v.as_str()) {
form.insert("token".into(), Value::String(t.into()));
}
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()));
@@ -349,10 +547,7 @@ pub async fn read_platform_config(
if let Some(t) = saved.get("botToken").and_then(|v| v.as_str()) {
form.insert("botToken".into(), Value::String(t.into()));
}
if let Some(arr) = saved.get("allowFrom").and_then(|v| v.as_array()) {
let users: Vec<&str> = arr.iter().filter_map(|v| v.as_str()).collect();
form.insert("allowedUsers".into(), Value::String(users.join(", ")));
}
insert_access_policy_form_values(&mut form, &saved, true, false);
}
"qqbot" => {
// 多账号:读 accounts.<account_id>;单账号:先读 qqbot 根节点,若无凭证再读 accounts.default与官方 CLI 一致)
@@ -475,34 +670,72 @@ pub async fn read_platform_config(
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",
"dmPolicy",
"groupPolicy",
"webhookPath",
"groupAllowFrom",
"groups",
"reactionNotifications",
"streaming",
"blockStreaming",
"typingIndicator",
"resolveSenderNames",
"textChunkLimit",
"mediaMaxMb",
] {
if let Some(v) = ch_root.get(*key) {
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
if let Some(v) = saved.get("domain").and_then(|v| v.as_str()) {
form.insert("domain".into(), Value::String(v.into()));
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" => {
@@ -543,14 +776,12 @@ pub async fn read_platform_config(
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_string_if_present(&mut form, &saved, "dmPolicy");
insert_string_if_present(&mut form, &saved, "groupPolicy");
insert_array_as_csv(&mut form, &saved, "allowFrom");
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_string_if_present(&mut form, &saved, "dmPolicy");
insert_string_if_present(&mut form, &saved, "groupPolicy");
insert_array_as_csv(&mut form, &saved, "allowFrom");
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_bool_as_string(&mut form, &saved, "enabled");
}
"signal" => {
@@ -559,9 +790,7 @@ pub async fn read_platform_config(
insert_string_if_present(&mut form, &saved, "httpUrl");
insert_string_if_present(&mut form, &saved, "httpHost");
insert_string_if_present(&mut form, &saved, "httpPort");
insert_string_if_present(&mut form, &saved, "dmPolicy");
insert_string_if_present(&mut form, &saved, "groupPolicy");
insert_array_as_csv(&mut form, &saved, "allowFrom");
insert_access_policy_form_values(&mut form, &saved, false, false);
}
"matrix" => {
insert_string_if_present(&mut form, &saved, "homeserver");
@@ -569,10 +798,8 @@ pub async fn read_platform_config(
insert_string_if_present(&mut form, &saved, "userId");
insert_string_if_present(&mut form, &saved, "password");
insert_string_if_present(&mut form, &saved, "deviceId");
insert_string_if_present(&mut form, &saved, "dmPolicy");
insert_string_if_present(&mut form, &saved, "groupPolicy");
insert_access_policy_form_values(&mut form, &saved, false, false);
insert_bool_as_string(&mut form, &saved, "e2ee");
insert_array_as_csv(&mut form, &saved, "allowFrom");
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()
@@ -587,9 +814,8 @@ pub async fn read_platform_config(
insert_string_if_present(&mut form, &saved, "tenantId");
insert_string_if_present(&mut form, &saved, "botEndpoint");
insert_string_if_present(&mut form, &saved, "webhookPath");
insert_string_if_present(&mut form, &saved, "dmPolicy");
insert_string_if_present(&mut form, &saved, "groupPolicy");
insert_array_as_csv(&mut form, &saved, "allowFrom");
insert_access_policy_form_values(&mut form, &saved, false, true);
insert_bool_as_string(&mut form, &saved, "requireMention");
}
_ => {
if saved.is_null() {
@@ -641,7 +867,9 @@ pub async fn save_messaging_platform(
.or_insert_with(|| json!({}));
let channels_map = channels.as_object_mut().ok_or("channels 节点格式错误")?;
let form_obj = form.as_object().ok_or("表单数据格式错误")?;
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;
// 用于后续创建 bindings 的平台信息
let saved_account_id = account_id.clone();
@@ -655,6 +883,13 @@ pub async fn save_messaging_platform(
entry.insert("token".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"));
// guildId + channelId 展开为 guilds 嵌套结构
let guild_id = form_obj
@@ -711,19 +946,13 @@ pub async fn save_messaging_platform(
entry.insert("botToken".into(), Value::String(t.trim().into()));
}
entry.insert("enabled".into(), Value::Bool(true));
// allowedUsers 逗号字符串 → allowFrom 数组
if let Some(users_str) = form_obj.get("allowedUsers").and_then(|v| v.as_str()) {
let users: Vec<Value> = users_str
.split(',')
.map(|s| s.trim())
.filter(|s| !s.is_empty())
.map(|s| Value::String(s.into()))
.collect();
if !users.is_empty() {
entry.insert("allowFrom".into(), Value::Array(users));
}
}
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"));
merge_channel_entry(channels_map, "telegram", entry);
}
@@ -805,17 +1034,40 @@ pub async fn save_messaging_platform(
entry.insert("appId".into(), Value::String(app_id));
entry.insert("appSecret".into(), Value::String(app_secret));
entry.insert("enabled".into(), Value::Bool(true));
entry.insert("connectionMode".into(), Value::String("websocket".into()));
let domain = form_obj
.get("domain")
.and_then(|v| v.as_str())
.unwrap_or("")
.trim()
.to_string();
if !domain.is_empty() {
entry.insert("domain".into(), Value::String(domain));
}
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"));
// 多账号模式:写入 channels.<storage_key>.accounts.<account_id>
if let Some(ref acct) = account_id {
@@ -926,13 +1178,19 @@ pub async fn save_messaging_platform(
);
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_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
merge_channel_entry(channels_map, &storage_key, entry);
}
"whatsapp" => {
@@ -944,7 +1202,7 @@ pub async fn save_messaging_platform(
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
put_bool_from_form(&mut entry, "enabled", &form_string(form_obj, "enabled"));
merge_channel_entry(channels_map, &storage_key, entry);
}
@@ -967,7 +1225,7 @@ pub async fn save_messaging_platform(
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
merge_channel_entry(channels_map, &storage_key, entry);
}
"matrix" => {
@@ -997,7 +1255,7 @@ pub async fn save_messaging_platform(
form_string(form_obj, "groupPolicy"),
);
put_bool_from_form(&mut entry, "e2ee", &form_string(form_obj, "e2ee"));
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
merge_channel_entry(channels_map, &storage_key, entry);
ensure_plugin_allowed(&mut cfg, "matrix")?;
}
@@ -1029,7 +1287,8 @@ pub async fn save_messaging_platform(
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_csv_array_from_form(&mut entry, "allowFrom", &form_string(form_obj, "allowFrom"));
put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention"));
put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom"));
merge_channel_entry(channels_map, &storage_key, entry);
ensure_plugin_allowed(&mut cfg, "msteams")?;
}
@@ -3860,3 +4119,159 @@ async fn verify_dingtalk(
}))
}
}
#[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 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"));
}
}