From 27b35b62986e657ebf2f87b24f579e892d8c8143 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 01:14:42 +0800 Subject: [PATCH] fix(channels): normalize OpenClaw channel config policies --- scripts/dev-api.js | 246 +++++++++- src-tauri/src/commands/messaging.rs | 533 ++++++++++++++++++--- src/locales/modules/channels.js | 5 + src/pages/channels.js | 43 +- tests/channel-config-normalization.test.js | 161 +++++++ 5 files changed, 904 insertions(+), 84 deletions(-) create mode 100644 tests/channel-config-normalization.test.js diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 923ff97..4fdce27 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2386,6 +2386,219 @@ function platformBindingChannel(platform) { return platformListId(storageKey) } +function csvToStringArray(raw) { + if (Array.isArray(raw)) return raw.map(item => String(item).trim()).filter(Boolean) + if (typeof raw !== 'string') return [] + return raw.split(/[,;\n]/).map(item => item.trim()).filter(Boolean) +} + +function normalizeDmPolicy(raw, fallback = 'pairing') { + const value = String(raw || '').trim() + if (!value) return fallback + if (value === 'allow') return 'open' + if (value === 'deny') return 'disabled' + if (['pairing', 'allowlist', 'open', 'disabled'].includes(value)) return value + return fallback +} + +function normalizeGroupPolicy(raw, fallback = 'allowlist') { + const value = String(raw || '').trim() + if (!value) return fallback + if (value === 'all') return 'open' + if (value === 'mentioned') return 'open' + if (value === 'deny') return 'disabled' + if (['open', 'allowlist', 'disabled'].includes(value)) return value + return fallback +} + +function putWildcardAllowFromWhenOpen(entry, previousAllowFrom) { + if (entry.dmPolicy !== 'open') return + const allowFrom = csvToStringArray(previousAllowFrom) + if (!allowFrom.includes('*')) allowFrom.push('*') + entry.allowFrom = allowFrom +} + +function platformSupportsTopLevelRequireMention(platform) { + return ['feishu', 'slack', 'msteams'].includes(platformStorageKey(platform)) +} + +export function normalizeMessagingPlatformForm(platform, form = {}) { + const storageKey = platformStorageKey(platform) + const normalized = { ...(form || {}) } + if (!Object.hasOwn(normalized, 'allowFrom') && Object.hasOwn(normalized, 'allowedUsers')) { + normalized.allowFrom = normalized.allowedUsers + } + const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp'].includes(storageKey) + const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults + const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults + + if (hasDmField) { + normalized.dmPolicy = normalizeDmPolicy(normalized.dmPolicy) + if (Object.hasOwn(normalized, 'allowFrom')) normalized.allowFrom = csvToStringArray(normalized.allowFrom) + putWildcardAllowFromWhenOpen(normalized, normalized.allowFrom) + } else if (Object.hasOwn(normalized, 'allowFrom')) { + normalized.allowFrom = csvToStringArray(normalized.allowFrom) + } + + if (hasGroupField) { + const requestedGroupPolicy = String(normalized.groupPolicy || '').trim() + normalized.groupPolicy = normalizeGroupPolicy(requestedGroupPolicy) + if (requestedGroupPolicy === 'mentioned' && platformSupportsTopLevelRequireMention(storageKey)) { + normalized.requireMention = true + } else if (requestedGroupPolicy !== 'mentioned') { + if (platformSupportsTopLevelRequireMention(storageKey)) { + normalized.requireMention = false + } else if (Object.hasOwn(normalized, 'requireMention')) { + normalized.requireMention = normalized.requireMention === true || normalized.requireMention === 'true' + } + } + } + + if (storageKey === 'feishu') { + normalized.domain = String(normalized.domain || '').trim() || 'feishu' + normalized.connectionMode = normalized.connectionMode || 'websocket' + normalized.webhookPath = normalized.webhookPath || '/feishu/events' + normalized.reactionNotifications = normalized.reactionNotifications || 'off' + if (!Object.hasOwn(normalized, 'typingIndicator')) normalized.typingIndicator = true + if (!Object.hasOwn(normalized, 'resolveSenderNames')) normalized.resolveSenderNames = true + } + + if (storageKey === 'slack') { + normalized.mode = normalized.mode || 'socket' + normalized.webhookPath = normalized.webhookPath || '/slack/events' + if (!Object.hasOwn(normalized, 'userTokenReadOnly')) normalized.userTokenReadOnly = false + } + + return normalized +} + +function csvForForm(raw) { + return csvToStringArray(raw).join(', ') +} + +function putStringFormValue(form, source, key) { + if (typeof source?.[key] === 'string') form[key] = source[key] +} + +function putBoolFormValue(form, source, key) { + if (typeof source?.[key] === 'boolean') form[key] = source[key] ? 'true' : 'false' +} + +function putCsvFormValue(form, source, key) { + const value = csvForForm(source?.[key]) + if (value) form[key] = value +} + +function putAccessPolicyFormValues(form, source, { telegramCompat = false, mentionCompat = false } = {}) { + putStringFormValue(form, source, 'dmPolicy') + putStringFormValue(form, source, 'groupPolicy') + if (mentionCompat && form.groupPolicy === 'open' && source?.requireMention === true) { + form.groupPolicy = 'mentioned' + } + putCsvFormValue(form, source, 'allowFrom') + if (telegramCompat && form.allowFrom) form.allowedUsers = form.allowFrom +} + +export function buildMessagingPlatformFormValues(platform, saved = {}, options = {}) { + if (!saved || typeof saved !== 'object') return {} + const form = {} + const storageKey = platformStorageKey(platform) + + if (storageKey === 'telegram') { + putStringFormValue(form, saved, 'botToken') + putAccessPolicyFormValues(form, saved, { telegramCompat: true }) + return form + } + + if (storageKey === 'discord') { + putStringFormValue(form, saved, 'token') + putAccessPolicyFormValues(form, saved) + const guilds = saved.guilds && typeof saved.guilds === 'object' ? saved.guilds : null + const guildId = guilds ? Object.keys(guilds)[0] : '' + if (guildId) { + form.guildId = guildId + const channels = guilds[guildId]?.channels && typeof guilds[guildId].channels === 'object' + ? guilds[guildId].channels + : null + const channelId = channels ? Object.keys(channels).find(id => id !== '*') : '' + if (channelId) form.channelId = channelId + } + return form + } + + if (storageKey === 'feishu') { + putStringFormValue(form, saved, 'appId') + putStringFormValue(form, saved, 'appSecret') + const shared = options.channelRoot && typeof options.channelRoot === 'object' + ? { ...saved, ...options.channelRoot } + : saved + for (const key of ['domain', 'connectionMode', 'webhookPath', 'reactionNotifications', 'textChunkLimit', 'mediaMaxMb']) { + putStringFormValue(form, shared, key) + } + putAccessPolicyFormValues(form, shared, { mentionCompat: true }) + putBoolFormValue(form, shared, 'typingIndicator') + putBoolFormValue(form, shared, 'resolveSenderNames') + putBoolFormValue(form, shared, 'requireMention') + return form + } + + if (storageKey === 'slack') { + for (const key of ['mode', 'botToken', 'appToken', 'signingSecret', 'webhookPath', 'teamId', 'appId', 'socketMode']) { + putStringFormValue(form, saved, key) + } + putAccessPolicyFormValues(form, saved, { mentionCompat: true }) + putBoolFormValue(form, saved, 'userTokenReadOnly') + putBoolFormValue(form, saved, 'requireMention') + return form + } + + if (storageKey === 'whatsapp') { + putAccessPolicyFormValues(form, saved, { mentionCompat: true }) + putBoolFormValue(form, saved, 'enabled') + return form + } + + if (storageKey === 'signal') { + for (const key of ['account', 'cliPath', 'httpUrl', 'httpHost', 'httpPort']) { + putStringFormValue(form, saved, key) + } + putAccessPolicyFormValues(form, saved) + return form + } + + if (storageKey === 'matrix') { + for (const key of ['homeserver', 'accessToken', 'userId', 'password', 'deviceId']) { + putStringFormValue(form, saved, key) + } + putAccessPolicyFormValues(form, saved) + putBoolFormValue(form, saved, 'e2ee') + if (form.accessToken) form.authMode = 'token' + else if (form.userId || form.password) form.authMode = 'password' + return form + } + + if (storageKey === 'msteams') { + for (const key of ['appId', 'appPassword', 'tenantId', 'botEndpoint', 'webhookPath']) { + putStringFormValue(form, saved, key) + } + putAccessPolicyFormValues(form, saved) + putBoolFormValue(form, saved, 'requireMention') + return form + } + + for (const [key, value] of Object.entries(saved)) { + if (key === 'enabled' || key === 'accounts') continue + if (typeof value === 'string') form[key] = value + else if (Array.isArray(value)) { + const csv = csvForForm(value) + if (csv) form[key] = csv + } else if (typeof value === 'boolean') { + form[key] = value ? 'true' : 'false' + } + } + return form +} + function channelHasQqbotCredentials(entry) { return !!(entry && typeof entry === 'object' && (entry.appId || entry.clientSecret || entry.appSecret || entry.token)) } @@ -3872,21 +4085,8 @@ const handlers = { if (!appId && !clientSecret) return { exists: false } if (appId) form.appId = appId if (clientSecret) form.clientSecret = clientSecret - } else if (platform === 'telegram') { - if (saved.botToken) form.botToken = saved.botToken - if (saved.allowFrom) form.allowedUsers = saved.allowFrom.join(', ') - } else if (platform === 'discord') { - if (saved.token) form.token = saved.token - const gid = saved.guilds && Object.keys(saved.guilds)[0] - if (gid) form.guildId = gid - } else if (platform === 'feishu') { - if (saved.appId) form.appId = saved.appId - if (saved.appSecret) form.appSecret = saved.appSecret - if (saved.domain) form.domain = saved.domain } else { - for (const [k, v] of Object.entries(saved)) { - if (k !== 'enabled' && k !== 'accounts' && typeof v === 'string') form[k] = v - } + Object.assign(form, buildMessagingPlatformFormValues(platform, saved, { channelRoot })) } return { exists: true, values: form } }, @@ -3894,6 +4094,7 @@ const handlers = { save_messaging_platform({ platform, form, accountId }) { if (!fs.existsSync(CONFIG_PATH)) throw new Error('openclaw.json 不存在') const cfg = readOpenclawConfigRequired() + form = normalizeMessagingPlatformForm(platform, form || {}) if (!cfg.channels) cfg.channels = {} const storageKey = platformStorageKey(platform) const normalizedAccountId = typeof accountId === 'string' ? accountId.trim() : '' @@ -3936,9 +4137,14 @@ const handlers = { cfg.channels.qqbot = current } else if (platform === 'telegram') { entry.botToken = form.botToken - if (form.allowedUsers) entry.allowFrom = form.allowedUsers.split(',').map(s => s.trim()).filter(Boolean) + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom } else if (platform === 'discord') { entry.token = form.token + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom if (form.guildId) { const ck = form.channelId || '*' entry.guilds = { [form.guildId]: { users: ['*'], requireMention: true, channels: { [ck]: { allow: true, requireMention: true } } } } @@ -3947,7 +4153,15 @@ const handlers = { entry.appId = form.appId entry.appSecret = form.appSecret entry.connectionMode = 'websocket' - if (form.domain) entry.domain = form.domain + entry.domain = form.domain + entry.webhookPath = form.webhookPath + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Object.hasOwn(form, 'requireMention')) entry.requireMention = !!form.requireMention + entry.reactionNotifications = form.reactionNotifications + entry.typingIndicator = form.typingIndicator + entry.resolveSenderNames = form.resolveSenderNames if (normalizedAccountId) { setAccountChannelEntry(entry) } else { diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index fc0e815..d7a0965 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -80,6 +80,28 @@ fn insert_array_as_csv(form: &mut Map, source: &Value, key: &str) } } +fn insert_access_policy_form_values( + form: &mut Map, + 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 { let items = raw .split(&[',', '\n', ';'][..]) @@ -94,6 +116,32 @@ fn csv_to_json_array(raw: &str) -> Option { } } +fn json_array_from_csv_value(value: Option<&Value>) -> Vec { + 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 { 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, key: &str, raw: &str) { } } -fn put_csv_array_from_form(entry: &mut Map, 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, 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, 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, +) -> Map { + 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.;单账号:先读 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 = 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..accounts. 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")); + } +} diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 08257b9..faf0d45 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -85,10 +85,15 @@ export default { policyDefault: _('默认', 'Default', '預設'), dmAllow: _('允许私信', 'Allow DMs', '允許私信'), dmDeny: _('拒绝私信', 'Deny DMs', '拒絕私信'), + dmPairing: _('配对 / 白名单', 'Pairing / allowlist', '配對 / 白名單'), + dmOpen: _('允许所有私信', 'Allow all DMs', '允許所有私信'), + dmAllowlist: _('仅白名单私信', 'Allowlist only', '僅白名單私信'), + dmDisabled: _('禁用私信', 'Disable DMs', '停用私信'), groupPolicy: _('群组策略', 'Group Policy', '群組策略'), groupAllChannels: _('所有频道', 'All channels', '所有頻道'), groupMentionOnly: _('仅 @提及时', 'Only when @mentioned', '僅 @提及時'), groupAllowlist: _('白名单', 'Allowlist', '白名單'), + groupDisabled: _('禁用群组', 'Disable groups', '停用群組'), allowFromPh: _('可选,逗号分隔用户/频道 ID', 'Optional, comma-separated user/channel IDs', '可選,逗號分隔使用者/頻道 ID'), allowFromHint: _('限制允许的用户或频道 ID,留空不限制', 'Restrict to specific user or channel IDs; leave empty for no restriction', '限制允許的使用者或頻道 ID,留空不限制'), weixinLabel: _('微信', 'WeChat'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 24da556..90823e6 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -20,6 +20,22 @@ import { // ── 渠道注册表:面板内置向导,覆盖 OpenClaw 官方渠道 + 国内扩展渠道 ── +const DM_POLICY_OPTIONS = [ + { value: '', label: t('channels.policyDefault') }, + { value: 'pairing', label: t('channels.dmPairing') }, + { value: 'open', label: t('channels.dmOpen') }, + { value: 'allowlist', label: t('channels.dmAllowlist') }, + { value: 'disabled', label: t('channels.dmDisabled') }, +] + +const GROUP_POLICY_OPTIONS = (allLabel, { mention = false } = {}) => [ + { value: '', label: t('channels.policyDefault') }, + { value: 'open', label: allLabel }, + ...(mention ? [{ value: 'mentioned', label: t('channels.groupMentionOnly') }] : []), + { value: 'allowlist', label: t('channels.groupAllowlist') }, + { value: 'disabled', label: t('channels.groupDisabled') }, +] + const PLATFORM_REGISTRY = { qqbot: { label: t('channels.qqbotLabel'), @@ -81,11 +97,14 @@ const PLATFORM_REGISTRY = { { key: 'domain', label: t('channels.feishuDomainLabel'), type: 'select', options: [ - { value: '', label: t('channels.feishuDomainFeishu') }, + { value: 'feishu', label: t('channels.feishuDomainFeishu') }, { value: 'lark', label: t('channels.feishuDomainLark') }, ], required: false, }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups'), { mention: true }), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') }, ], pluginRequired: '@larksuite/openclaw-lark@latest', pluginId: 'openclaw-lark', @@ -104,6 +123,9 @@ const PLATFORM_REGISTRY = { guideFooter: t('channels.telegramGuideFooter'), fields: [ { key: 'botToken', label: 'Bot Token', placeholder: '123456:ABC-DEF1234ghIkl-zyx57W2v1u123ew11', secret: true, required: true }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') }, ], configKey: 'telegram', pairingChannel: 'telegram', @@ -121,6 +143,9 @@ const PLATFORM_REGISTRY = { guideFooter: t('channels.discordGuideFooter'), fields: [ { key: 'token', label: 'Bot Token', placeholder: 'MTExxxxxxxxx.Gxxxxxx.xxxxxxxx', secret: true, required: true }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChannels')), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') }, ], configKey: 'discord', pairingChannel: 'discord', @@ -150,8 +175,8 @@ const PLATFORM_REGISTRY = { { key: 'signingSecret', label: 'Signing Secret', placeholder: t('channels.slackSigningSecretPh'), secret: true, requiredWhen: { mode: 'http' }, hint: t('channels.slackSigningSecretHint') }, { key: 'teamId', label: 'Team ID', placeholder: t('channels.slackTeamIdPh'), required: false }, { key: 'webhookPath', label: 'Webhook Path', placeholder: t('channels.slackWebhookPathPh'), required: false }, - { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'allow', label: t('channels.dmAllow') }, { value: 'deny', label: t('channels.dmDeny') }], required: false }, - { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'all', label: t('channels.groupAllChannels') }, { value: 'mentioned', label: t('channels.groupMentionOnly') }, { value: 'allowlist', label: t('channels.groupAllowlist') }], required: false }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChannels'), { mention: true }), required: false }, { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.allowFromPh'), required: false, hint: t('channels.allowFromHint') }, ], configKey: 'slack', @@ -196,8 +221,8 @@ const PLATFORM_REGISTRY = { { key: 'tenantId', label: 'Tenant ID', placeholder: t('channels.msteamsTenantIdPh'), required: false }, { key: 'botEndpoint', label: 'Bot Endpoint', placeholder: 'https://example.com/api/teams/messages', required: false }, { key: 'webhookPath', label: 'Webhook Path', placeholder: '/msteams/messages', required: false }, - { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'allow', label: t('channels.dmAllow') }, { value: 'deny', label: t('channels.dmDeny') }], required: false }, - { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'all', label: t('channels.groupAllTeams') }, { value: 'mentioned', label: t('channels.groupMentionOnly') }, { value: 'allowlist', label: t('channels.groupAllowlist') }], required: false }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllTeams'), { mention: true }), required: false }, { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.msteamsAllowFromPh'), required: false }, ], configKey: 'msteams', @@ -220,8 +245,8 @@ const PLATFORM_REGISTRY = { { key: 'httpUrl', label: 'HTTP URL', placeholder: t('channels.optionalEg', { example: 'http://127.0.0.1:8080' }), required: false }, { key: 'httpHost', label: 'HTTP Host', placeholder: t('channels.optionalEg', { example: '127.0.0.1' }), required: false }, { key: 'httpPort', label: 'HTTP Port', placeholder: t('channels.optionalEg', { example: '8080' }), required: false }, - { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'allow', label: t('channels.dmAllow') }, { value: 'deny', label: t('channels.dmDeny') }], required: false }, - { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'all', label: t('channels.groupAllGroups') }, { value: 'mentioned', label: t('channels.groupMentionBot') }, { value: 'allowlist', label: t('channels.groupAllowlist') }], required: false }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllGroups')), required: false }, { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.signalAllowFromPh'), required: false }, ], configKey: 'signal', @@ -243,8 +268,8 @@ const PLATFORM_REGISTRY = { { key: 'password', label: 'Password', placeholder: t('channels.matrixPasswordPh'), secret: true, required: false }, { key: 'deviceId', label: 'Device ID', placeholder: t('channels.optionalEg', { example: 'CLAWPANEL' }), required: false }, { key: 'e2ee', label: 'E2EE', type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'true', label: t('channels.enable') }, { value: 'false', label: t('channels.disable') }], required: false }, - { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'allow', label: t('channels.dmAllow') }, { value: 'deny', label: t('channels.dmDeny') }], required: false }, - { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: [{ value: '', label: t('channels.policyDefault') }, { value: 'all', label: t('channels.groupAllRooms') }, { value: 'mentioned', label: t('channels.groupMentionBot') }, { value: 'allowlist', label: t('channels.groupAllowlist') }], required: false }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllRooms')), required: false }, { key: 'allowFrom', label: 'Allow From', placeholder: t('channels.matrixAllowFromPh'), required: false }, ], configKey: 'matrix', diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js new file mode 100644 index 0000000..1de2b2b --- /dev/null +++ b/tests/channel-config-normalization.test.js @@ -0,0 +1,161 @@ +import test from 'node:test' +import assert from 'node:assert/strict' + +import { + buildMessagingPlatformFormValues, + normalizeMessagingPlatformForm, +} from '../scripts/dev-api.js' + +test('渠道保存会为 Telegram 补齐新版 OpenClaw 必填访问策略', () => { + const form = normalizeMessagingPlatformForm('telegram', { + botToken: '123:token', + }) + + assert.equal(form.botToken, '123:token') + assert.equal(form.dmPolicy, 'pairing') + assert.equal(form.groupPolicy, 'allowlist') +}) + +test('渠道保存会把旧 UI 策略值转换为 OpenClaw 支持的枚举', () => { + const form = normalizeMessagingPlatformForm('slack', { + mode: 'socket', + botToken: 'xoxb-token', + appToken: 'xapp-token', + dmPolicy: 'allow', + groupPolicy: 'mentioned', + }) + + assert.equal(form.dmPolicy, 'open') + assert.deepEqual(form.allowFrom, ['*']) + assert.equal(form.groupPolicy, 'open') + assert.equal(form.requireMention, true) + assert.equal(form.webhookPath, '/slack/events') + assert.equal(form.userTokenReadOnly, false) +}) + +test('渠道保存不会向不支持顶层 requireMention 的平台写入非法字段', () => { + const form = normalizeMessagingPlatformForm('signal', { + account: '+15551234567', + dmPolicy: 'deny', + groupPolicy: 'mentioned', + }) + + assert.equal(form.dmPolicy, 'disabled') + assert.equal(form.groupPolicy, 'open') + assert.equal(Object.hasOwn(form, 'requireMention'), false) +}) + +test('渠道保存会为飞书补齐新版内核要求的默认字段', () => { + const form = normalizeMessagingPlatformForm('feishu', { + appId: 'cli_a', + appSecret: 'secret', + domain: '', + }) + + assert.equal(form.domain, 'feishu') + assert.equal(form.connectionMode, 'websocket') + assert.equal(form.webhookPath, '/feishu/events') + assert.equal(form.dmPolicy, 'pairing') + assert.equal(form.groupPolicy, 'allowlist') + assert.equal(form.reactionNotifications, 'off') + assert.equal(form.typingIndicator, true) + assert.equal(form.resolveSenderNames, true) +}) + +test('渠道读取会把新版访问策略字段回显为表单可编辑值', () => { + const values = buildMessagingPlatformFormValues('telegram', { + botToken: '123:token', + dmPolicy: 'allowlist', + groupPolicy: 'disabled', + allowFrom: ['u-1', 'u-2'], + }) + + assert.equal(values.botToken, '123:token') + assert.equal(values.dmPolicy, 'allowlist') + assert.equal(values.groupPolicy, 'disabled') + assert.equal(values.allowFrom, 'u-1, u-2') + assert.equal(values.allowedUsers, 'u-1, u-2') +}) + +test('渠道读取会合并飞书账号凭证和根节点共享策略字段', () => { + const values = buildMessagingPlatformFormValues( + 'feishu', + { + appId: 'cli_a', + appSecret: 'secret', + }, + { + channelRoot: { + domain: 'lark', + connectionMode: 'websocket', + webhookPath: '/feishu/events', + dmPolicy: 'pairing', + groupPolicy: 'allowlist', + reactionNotifications: 'off', + typingIndicator: true, + resolveSenderNames: false, + }, + }, + ) + + assert.equal(values.appId, 'cli_a') + assert.equal(values.appSecret, 'secret') + assert.equal(values.domain, 'lark') + assert.equal(values.connectionMode, 'websocket') + assert.equal(values.webhookPath, '/feishu/events') + assert.equal(values.dmPolicy, 'pairing') + assert.equal(values.groupPolicy, 'allowlist') + assert.equal(values.reactionNotifications, 'off') + assert.equal(values.typingIndicator, 'true') + assert.equal(values.resolveSenderNames, 'false') +}) + +test('渠道读取飞书多账号时不会用根节点旧凭证覆盖账号凭证', () => { + const values = buildMessagingPlatformFormValues( + 'feishu', + { + appId: 'account_app', + appSecret: 'account_secret', + dmPolicy: 'pairing', + }, + { + channelRoot: { + appId: 'root_app', + appSecret: 'root_secret', + domain: 'lark', + groupPolicy: 'allowlist', + }, + }, + ) + + assert.equal(values.appId, 'account_app') + assert.equal(values.appSecret, 'account_secret') + assert.equal(values.domain, 'lark') + assert.equal(values.dmPolicy, 'pairing') + assert.equal(values.groupPolicy, 'allowlist') +}) + +test('渠道读取会把 open + requireMention 反向回显为仅提及时策略', () => { + const values = buildMessagingPlatformFormValues('slack', { + mode: 'socket', + botToken: 'xoxb-token', + appToken: 'xapp-token', + groupPolicy: 'open', + requireMention: true, + }) + + assert.equal(values.groupPolicy, 'mentioned') + assert.equal(values.requireMention, 'true') +}) + +test('渠道保存会在用户改回所有群组时显式清除仅提及开关', () => { + const form = normalizeMessagingPlatformForm('slack', { + mode: 'socket', + botToken: 'xoxb-token', + appToken: 'xapp-token', + groupPolicy: 'open', + }) + + assert.equal(form.groupPolicy, 'open') + assert.equal(form.requireMention, false) +})