From 09bc45ae4c444ab1829dd1b7155275f26a6232e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E6=99=B4=E5=A4=A9?= Date: Sat, 23 May 2026 05:37:06 +0800 Subject: [PATCH] feat(channels): add Synology and Google Chat config --- scripts/dev-api.js | 94 ++++++++- src-tauri/src/commands/messaging.rs | 223 ++++++++++++++++++++- src/locales/en.json | 39 +++- src/locales/modules/channels.js | 32 +++ src/locales/zh-CN.json | 39 +++- src/pages/channels.js | 88 +++++++- tests/channel-config-normalization.test.js | 143 +++++++++++++ 7 files changed, 645 insertions(+), 13 deletions(-) diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 0e123d3..8f8d4ba 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2429,7 +2429,7 @@ function putWildcardAllowFromWhenOpen(entry, previousAllowFrom) { } function platformSupportsTopLevelRequireMention(platform) { - return ['feishu', 'slack', 'msteams', 'mattermost'].includes(platformStorageKey(platform)) + return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat'].includes(platformStorageKey(platform)) } export function normalizeMessagingPlatformForm(platform, form = {}) { @@ -2438,7 +2438,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { if (!Object.hasOwn(normalized, 'allowFrom') && Object.hasOwn(normalized, 'allowedUsers')) { normalized.allowFrom = normalized.allowedUsers } - const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost'].includes(storageKey) + const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat'].includes(storageKey) const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults @@ -2468,7 +2468,11 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { normalized.groupAllowFrom = csvToStringArray(normalized.groupAllowFrom) } - for (const key of ['mediaMaxMb', 'historyLimit']) { + if (Object.hasOwn(normalized, 'allowedUserIds')) { + normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds) + } + + for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'rateLimitPerMinute']) { if (!Object.hasOwn(normalized, key)) continue const value = String(normalized[key] || '').trim() if (!value) { @@ -2481,7 +2485,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } - for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork']) { + for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming']) { if (Object.hasOwn(normalized, key)) { const value = String(normalized[key] || '').trim() if (!value) { @@ -2593,6 +2597,9 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'gatewayToken', 'password', 'secretFile', + 'serviceAccount', + 'serviceAccountFile', + 'serviceAccountRef', 'signingSecret', 'token', 'tokenFile', @@ -2615,6 +2622,13 @@ function channelAnyCredentialFields(platform) { if (storageKey === 'zalo') { return [['botToken', 'Bot Token'], ['tokenFile', 'Token File']] } + if (storageKey === 'googlechat') { + return [ + ['serviceAccountFile', 'Service Account File'], + ['serviceAccount', 'Service Account JSON'], + ['serviceAccountRef', 'Service Account SecretRef'], + ] + } return [] } @@ -2637,6 +2651,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { 'dingtalk-connector': [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']], msteams: [['appId', 'App ID'], ['appPassword', 'App Password']], mattermost: [['botToken', 'Bot Token'], ['baseUrl', 'Base URL']], + 'synology-chat': [['token', 'Token'], ['incomingUrl', 'Incoming URL']], signal: [['account', 'Signal 账号']], } @@ -2918,6 +2933,42 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'synology-chat') { + for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { + putSecretAwareFormValue(form, saved, key) + } + putStringFormValue(form, saved, 'dmPolicy') + putCsvFormValue(form, saved, 'allowedUserIds') + if (typeof saved.rateLimitPerMinute === 'number') form.rateLimitPerMinute = String(saved.rateLimitPerMinute) + putBoolFormValue(form, saved, 'dangerouslyAllowNameMatching') + putBoolFormValue(form, saved, 'dangerouslyAllowInheritedWebhookPath') + putBoolFormValue(form, saved, 'allowInsecureSsl') + return form + } + + if (storageKey === 'googlechat') { + for (const key of ['serviceAccount', 'serviceAccountFile', 'serviceAccountRef', 'audienceType', 'audience', 'appPrincipal', 'webhookPath', 'webhookUrl', 'botUser', 'chunkMode', 'replyToMode', 'typingIndicator', 'responsePrefix']) { + putSecretAwareFormValue(form, saved, key) + } + const dm = saved.dm && typeof saved.dm === 'object' ? saved.dm : {} + putStringFormValue(form, dm, 'policy') + if (form.policy && !form.dmPolicy) { + form.dmPolicy = form.policy + delete form.policy + } + putCsvFormValue(form, dm, 'allowFrom') + putStringFormValue(form, saved, 'groupPolicy') + putCsvFormValue(form, saved, 'groupAllowFrom') + putBoolFormValue(form, saved, 'requireMention') + putBoolFormValue(form, saved, 'dangerouslyAllowNameMatching') + putBoolFormValue(form, saved, 'allowBots') + putBoolFormValue(form, saved, 'blockStreaming') + for (const key of ['historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'mediaMaxMb']) { + if (typeof saved[key] === 'number') form[key] = String(saved[key]) + } + return form + } + for (const [key, value] of Object.entries(saved)) { if (key === 'enabled' || key === 'accounts') continue if (typeof value === 'string') form[key] = value @@ -3479,6 +3530,32 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) if (form.callbackPath) commands.callbackPath = form.callbackPath if (form.callbackUrl) commands.callbackUrl = form.callbackUrl if (Object.keys(commands).length) entry.commands = { ...(currentSaved?.commands || {}), ...commands } + } else if (storageKey === 'synology-chat') { + for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { + if (form[key]) entry[key] = form[key] + } + entry.dmPolicy = form.dmPolicy || 'allowlist' + if (Array.isArray(form.allowedUserIds) && form.allowedUserIds.length) entry.allowedUserIds = form.allowedUserIds + if (typeof form.rateLimitPerMinute === 'number') entry.rateLimitPerMinute = form.rateLimitPerMinute + for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl']) { + if (typeof form[key] === 'boolean') entry[key] = form[key] + } + } else if (storageKey === 'googlechat') { + for (const key of ['serviceAccount', 'serviceAccountFile', 'serviceAccountRef', 'audienceType', 'audience', 'appPrincipal', 'webhookPath', 'webhookUrl', 'botUser', 'chunkMode', 'replyToMode', 'typingIndicator', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + const dm = { ...(currentSaved?.dm && typeof currentSaved.dm === 'object' ? currentSaved.dm : {}) } + if (form.dmPolicy) dm.policy = form.dmPolicy + if (Array.isArray(form.allowFrom)) dm.allowFrom = form.allowFrom + if (Object.keys(dm).length) entry.dm = dm + entry.groupPolicy = form.groupPolicy + if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom + for (const key of ['dangerouslyAllowNameMatching', 'requireMention', 'allowBots', 'blockStreaming']) { + if (typeof form[key] === 'boolean') entry[key] = form[key] + } + for (const key of ['historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'mediaMaxMb']) { + if (typeof form[key] === 'number') entry[key] = form[key] + } } else { Object.assign(entry, form) } @@ -3494,6 +3571,9 @@ export function mergeOpenClawMessagingPlatformConfig(cfg, { platform, form, acco const currentSaved = resolvePlatformConfigEntry(cfg.channels?.[storageKey], platform, normalizedAccountId) || {} const entry = buildOpenClawMessagingPlatformEntry(platform, normalizedForm, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) + if (['zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat'].includes(storageKey)) { + ensureMessagingPluginAllowed(cfg, storageKey) + } return { entry, accountId: normalizedAccountId, storageKey } } @@ -4961,16 +5041,16 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (platform === 'line' || platform === 'mattermost') { + } else if (['line', 'mattermost', 'synology-chat', 'googlechat'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built) - ensureMessagingPluginAllowed(cfg, platform) + ensureMessagingPluginAllowed(cfg, storageKey) } else { Object.assign(entry, form) preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && platform !== 'line' && platform !== 'mattermost') { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'synology-chat', 'googlechat'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 4f72631..ae17b32 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -148,6 +148,9 @@ fn preserve_messaging_credential_refs( "gatewayToken", "password", "secretFile", + "serviceAccount", + "serviceAccountFile", + "serviceAccountRef", "signingSecret", "token", "tokenFile", @@ -192,6 +195,9 @@ fn channel_root_has_messaging_credential(root: &Map) -> bool { "gatewayToken", "password", "secretFile", + "serviceAccount", + "serviceAccountFile", + "serviceAccountRef", "signingSecret", "token", "tokenFile", @@ -219,6 +225,7 @@ fn required_channel_credential_fields( "dingtalk-connector" => vec![("clientId", "Client ID"), ("clientSecret", "Client Secret")], "msteams" => vec![("appId", "App ID"), ("appPassword", "App Password")], "mattermost" => vec![("botToken", "Bot Token"), ("baseUrl", "Base URL")], + "synology-chat" => vec![("token", "Token"), ("incomingUrl", "Incoming URL")], "signal" => vec![("account", "Signal 账号")], "slack" => { let mode = form_string(form, "mode"); @@ -249,6 +256,11 @@ fn required_channel_credential_fields( 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![], } } @@ -701,7 +713,7 @@ fn normalize_group_policy_value(raw: Option<&Value>, fallback: &str) -> String { fn platform_supports_top_level_require_mention(platform: &str) -> bool { matches!( platform_storage_key(platform), - "feishu" | "slack" | "msteams" | "mattermost" + "feishu" | "slack" | "msteams" | "mattermost" | "googlechat" ) } @@ -731,6 +743,7 @@ fn normalize_messaging_platform_form( | "zalouser" | "line" | "mattermost" + | "googlechat" ); let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults; let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults; @@ -786,12 +799,24 @@ fn normalize_messaging_platform_form( 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, "rateLimitPerMinute"); for key in [ "dangerouslyAllowNameMatching", "dangerouslyAllowPrivateNetwork", + "dangerouslyAllowInheritedWebhookPath", + "allowInsecureSsl", + "allowBots", + "blockStreaming", ] { if normalized.contains_key(key) { let value = match normalized.get(key) { @@ -1452,6 +1477,60 @@ pub async fn read_platform_config( insert_string_if_present(&mut form, commands, "callbackUrl"); } } + "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 })); @@ -2226,6 +2305,148 @@ pub async fn save_messaging_platform( )?; ensure_plugin_allowed(&mut cfg, "mattermost")?; } + "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, ¤t_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, ¤t_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(); diff --git a/src/locales/en.json b/src/locales/en.json index a2e05d1..0985378 100644 --- a/src/locales/en.json +++ b/src/locales/en.json @@ -935,11 +935,48 @@ "matrixPasswordPh": "Leave empty when using Access Token", "matrixAllowFromPh": "Optional, comma-separated user IDs", "matrixAuthRequired": "Matrix requires an Access Token, or User ID + Password", + "synologyChatDesc": "Connect Synology Chat for NAS-hosted team messaging", + "synologyChatGuide1": "Create a bot in Synology Chat administration and copy its Token", + "synologyChatGuide2": "Configure an Incoming Webhook or bot post URL, then paste it as Incoming URL", + "synologyChatGuide3": "Configure the Outgoing Webhook path in Synology Chat; /webhook/synology is recommended", + "synologyChatGuide4": "Save, reload Gateway, then test from the target channel", + "synologyChatGuideFooter": "
For private NAS networks, make sure Gateway can reach the NAS host.
", + "synologyChatTokenPh": "Synology Chat Bot Token", + "synologyChatIncomingUrlHint": "Synology Chat Incoming Webhook URL used to send bot replies", + "synologyChatAllowedUserIdsHint": "Comma-separated DM allowlist user IDs; used when dmPolicy=allowlist", + "synologyChatNameMatching": "Allow name matching", + "synologyChatNameMatchingHint": "Use only when stable user IDs are unavailable; IDs are safer", + "synologyChatInheritedWebhookPath": "Allow inherited Webhook Path", + "synologyChatInheritedWebhookPathHint": "Enable when multiple accounts share the root webhookPath", + "synologyChatAllowInsecureSsl": "Allow insecure SSL", + "synologyChatAllowInsecureSslHint": "Use only for self-signed certificates or trusted internal testing", + "googleChatDesc": "Connect a Google Chat App for spaces and direct messages", + "googleChatGuide1": "Create a Chat App in Google Cloud and enable the Google Chat API", + "googleChatGuide2": "Create a Service Account, then download its JSON file or prepare inline JSON", + "googleChatGuide3": "Configure the Google Chat callback URL and set audienceType plus audience", + "googleChatGuide4": "Save, reload Gateway, then invite the app to a target Space for testing", + "googleChatGuideFooter": "
Prefer Service Account File in production to avoid storing long JSON inline.
", + "googleChatServiceAccountFileHint": "Path to the Service Account JSON file; recommended for production", + "googleChatServiceAccountHint": "Paste Service Account JSON inline; it will be written to openclaw.json", + "googleChatServiceAccountRefHint": "Preserve or enter an existing SecretRef, e.g. SecretRef(env:default:GOOGLE_CHAT_SERVICE_ACCOUNT)", + "googleChatAudienceHint": "Use callback URL for app-url mode; use Google Cloud project number for project-number mode", + "googleChatAllowFromHint": "DM allowlist, using users/ or email entries separated by commas", + "googleChatGroupAllowFromHint": "Space allowlist, using spaces/ entries separated by commas", + "googleChatNameMatching": "Allow name matching", + "googleChatNameMatchingHint": "Stable IDs are preferred; enable only when email/name matching is required", + "googleChatRequireMention": "Require mention in groups", + "googleChatServiceAccountRequired": "Service Account File / JSON / SecretRef", "groupAllGroups": "All groups", "groupAllRooms": "All rooms", "groupAllTeams": "All teams", + "groupAllSpaces": "All Spaces", "groupMentionBot": "Only when @bot", - "optionalEg": "Optional, e.g.", + "groupDisabled": "Disable groups", + "dmPairing": "Pairing approval", + "dmOpen": "Allow all DMs", + "dmAllowlist": "Allowlist", + "dmDisabled": "Disable DMs", + "optionalEg": "Optional, e.g. {example}", "editAccountLabel": "Edit {id}", "bound": "Bound", "notBoundAgent": "No Agent bound", diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 5b95714..87bf4fb 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -128,6 +128,37 @@ export default { mattermostNameMatchingHint: _('关闭时优先使用稳定 ID,避免同名用户或频道误匹配。', 'When disabled, prefer stable IDs to avoid wrong matches with duplicate users or channels.'), mattermostPrivateNetwork: _('允许内网地址', 'Allow Private Network'), mattermostPrivateNetworkHint: _('仅在 Mattermost 部署于可信内网时开启。', 'Enable only when Mattermost is deployed on a trusted private network.'), + synologyChatDesc: _('接入群晖 Synology Chat,适合 NAS 内网团队协作', 'Connect Synology Chat for NAS-hosted team messaging'), + synologyChatGuide1: _('在 Synology Chat 管理后台创建 Bot,并复制 Token', 'Create a bot in Synology Chat administration and copy its Token'), + synologyChatGuide2: _('配置 Incoming Webhook 或机器人发消息 URL,填入 Incoming URL', 'Configure an Incoming Webhook or bot post URL, then paste it as Incoming URL'), + synologyChatGuide3: _('在 Synology Chat 中配置 Outgoing Webhook,路径建议使用 /webhook/synology', 'Configure the Outgoing Webhook path in Synology Chat; /webhook/synology is recommended'), + synologyChatGuide4: _('保存后重启或重载 Gateway,再在目标频道测试消息', 'Save, reload Gateway, then test from the target channel'), + synologyChatGuideFooter: _('
内网 NAS 场景如需访问私有地址,请确认 Gateway 网络能访问 NAS。
', '
For private NAS networks, make sure Gateway can reach the NAS host.
'), + synologyChatTokenPh: _('Synology Chat Bot Token', 'Synology Chat Bot Token'), + synologyChatIncomingUrlHint: _('Synology Chat Incoming Webhook URL,用于机器人发送回复', 'Synology Chat Incoming Webhook URL used to send bot replies'), + synologyChatAllowedUserIdsHint: _('DM 白名单用户 ID,逗号分隔;dmPolicy=allowlist 时生效', 'Comma-separated DM allowlist user IDs; used when dmPolicy=allowlist'), + synologyChatNameMatching: _('允许名称匹配', 'Allow name matching'), + synologyChatNameMatchingHint: _('仅在无法稳定获取用户 ID 时使用;更推荐填写用户 ID', 'Use only when stable user IDs are unavailable; IDs are safer'), + synologyChatInheritedWebhookPath: _('允许继承 Webhook Path', 'Allow inherited Webhook Path'), + synologyChatInheritedWebhookPathHint: _('多账号共享根配置 webhookPath 时开启', 'Enable when multiple accounts share the root webhookPath'), + synologyChatAllowInsecureSsl: _('允许不安全 SSL', 'Allow insecure SSL'), + synologyChatAllowInsecureSslHint: _('仅用于自签名证书或内网测试环境', 'Use only for self-signed certificates or trusted internal testing'), + googleChatDesc: _('接入 Google Chat App,支持空间消息和私聊', 'Connect a Google Chat App for spaces and direct messages'), + googleChatGuide1: _('在 Google Cloud 中创建 Chat App,并启用 Google Chat API', 'Create a Chat App in Google Cloud and enable the Google Chat API'), + googleChatGuide2: _('创建 Service Account,下载 JSON 文件或准备内联 JSON', 'Create a Service Account, then download its JSON file or prepare inline JSON'), + googleChatGuide3: _('配置 Google Chat 回调 URL,并设置 audienceType 与 audience', 'Configure the Google Chat callback URL and set audienceType plus audience'), + googleChatGuide4: _('保存后重启或重载 Gateway,再邀请应用到目标 Space 测试', 'Save, reload Gateway, then invite the app to a target Space for testing'), + googleChatGuideFooter: _('
建议优先使用 Service Account File,避免在配置文件中直接粘贴长 JSON。
', '
Prefer Service Account File in production to avoid storing long JSON inline.
'), + googleChatServiceAccountFileHint: _('Service Account JSON 文件路径,推荐用于生产环境', 'Path to the Service Account JSON file; recommended for production'), + googleChatServiceAccountHint: _('可直接粘贴 Service Account JSON;保存后会写入 openclaw.json', 'Paste Service Account JSON inline; it will be written to openclaw.json'), + googleChatServiceAccountRefHint: _('已有 SecretRef 时可保留或手动填写,如 SecretRef(env:default:GOOGLE_CHAT_SERVICE_ACCOUNT)', 'Preserve or enter an existing SecretRef, e.g. SecretRef(env:default:GOOGLE_CHAT_SERVICE_ACCOUNT)'), + googleChatAudienceHint: _('app-url 模式填回调 URL;project-number 模式填 Google Cloud 项目编号', 'Use callback URL for app-url mode; use Google Cloud project number for project-number mode'), + googleChatAllowFromHint: _('DM 白名单,使用 users/ 或邮箱,逗号分隔', 'DM allowlist, using users/ or email entries separated by commas'), + googleChatGroupAllowFromHint: _('Space 白名单,使用 spaces/,逗号分隔', 'Space allowlist, using spaces/ entries separated by commas'), + googleChatNameMatching: _('允许名称匹配', 'Allow name matching'), + googleChatNameMatchingHint: _('默认建议使用稳定 ID;仅在邮箱/名称匹配必需时开启', 'Stable IDs are preferred; enable only when email/name matching is required'), + googleChatRequireMention: _('群组要求提及', 'Require mention in groups'), + googleChatServiceAccountRequired: _('Service Account File / JSON / SecretRef', 'Service Account File / JSON / SecretRef'), discordDesc: _('接入 Discord Bot,支持服务器频道和私信', 'Connect a Discord Bot, supports server channels and DMs', '接入 Discord Bot,支援伺服器頻道和私信', 'Discord Bot に接続'), discordGuide1: _('前往 Discord Developer Portal 创建 Application', '前往 Discord Developer Portal 创建 Application', '前往 Discord Developer Portal 建立 Application'), discordGuide2: _('在 Bot 页面点击「Reset Token」获取 Bot Token', 'Click "Reset Token" on the Bot page to get the Bot Token', '在 Bot 頁面点擊「Reset Token」取得 Bot Token'), @@ -159,6 +190,7 @@ export default { dmDisabled: _('禁用私信', 'Disable DMs', '停用私信'), groupPolicy: _('群组策略', 'Group Policy', '群組策略'), groupAllChannels: _('所有频道', 'All channels', '所有頻道'), + groupAllSpaces: _('所有 Space', 'All Spaces'), groupMentionOnly: _('仅 @提及时', 'Only when @mentioned', '僅 @提及時'), groupAllowlist: _('白名单', 'Allowlist', '白名單'), groupDisabled: _('禁用群组', 'Disable groups', '停用群組'), diff --git a/src/locales/zh-CN.json b/src/locales/zh-CN.json index c9834a5..ad653fe 100644 --- a/src/locales/zh-CN.json +++ b/src/locales/zh-CN.json @@ -1018,11 +1018,48 @@ "matrixPasswordPh": "使用 Access Token 时可留空", "matrixAllowFromPh": "可选,逗号分隔用户 ID", "matrixAuthRequired": "Matrix 需要填写 Access Token,或填写 User ID + Password", + "synologyChatDesc": "接入群晖 Synology Chat,适合 NAS 内网团队协作", + "synologyChatGuide1": "在 Synology Chat 管理后台创建 Bot,并复制 Token", + "synologyChatGuide2": "配置 Incoming Webhook 或机器人发消息 URL,填入 Incoming URL", + "synologyChatGuide3": "在 Synology Chat 中配置 Outgoing Webhook,路径建议使用 /webhook/synology", + "synologyChatGuide4": "保存后重启或重载 Gateway,再在目标频道测试消息", + "synologyChatGuideFooter": "
内网 NAS 场景如需访问私有地址,请确认 Gateway 网络能访问 NAS。
", + "synologyChatTokenPh": "Synology Chat Bot Token", + "synologyChatIncomingUrlHint": "Synology Chat Incoming Webhook URL,用于机器人发送回复", + "synologyChatAllowedUserIdsHint": "DM 白名单用户 ID,逗号分隔;dmPolicy=allowlist 时生效", + "synologyChatNameMatching": "允许名称匹配", + "synologyChatNameMatchingHint": "仅在无法稳定获取用户 ID 时使用;更推荐填写用户 ID", + "synologyChatInheritedWebhookPath": "允许继承 Webhook Path", + "synologyChatInheritedWebhookPathHint": "多账号共享根配置 webhookPath 时开启", + "synologyChatAllowInsecureSsl": "允许不安全 SSL", + "synologyChatAllowInsecureSslHint": "仅用于自签名证书或内网测试环境", + "googleChatDesc": "接入 Google Chat App,支持空间消息和私聊", + "googleChatGuide1": "在 Google Cloud 中创建 Chat App,并启用 Google Chat API", + "googleChatGuide2": "创建 Service Account,下载 JSON 文件或准备内联 JSON", + "googleChatGuide3": "配置 Google Chat 回调 URL,并设置 audienceType 与 audience", + "googleChatGuide4": "保存后重启或重载 Gateway,再邀请应用到目标 Space 测试", + "googleChatGuideFooter": "
建议优先使用 Service Account File,避免在配置文件中直接粘贴长 JSON。
", + "googleChatServiceAccountFileHint": "Service Account JSON 文件路径,推荐用于生产环境", + "googleChatServiceAccountHint": "可直接粘贴 Service Account JSON;保存后会写入 openclaw.json", + "googleChatServiceAccountRefHint": "已有 SecretRef 时可保留或手动填写,如 SecretRef(env:default:GOOGLE_CHAT_SERVICE_ACCOUNT)", + "googleChatAudienceHint": "app-url 模式填回调 URL;project-number 模式填 Google Cloud 项目编号", + "googleChatAllowFromHint": "DM 白名单,使用 users/ 或邮箱,逗号分隔", + "googleChatGroupAllowFromHint": "Space 白名单,使用 spaces/,逗号分隔", + "googleChatNameMatching": "允许名称匹配", + "googleChatNameMatchingHint": "默认建议使用稳定 ID;仅在邮箱/名称匹配必需时开启", + "googleChatRequireMention": "群组要求提及", + "googleChatServiceAccountRequired": "Service Account File / JSON / SecretRef", "groupAllGroups": "所有群组", "groupAllRooms": "所有房间", "groupAllTeams": "所有团队", + "groupAllSpaces": "所有 Space", "groupMentionBot": "仅 @机器人时", - "optionalEg": "可选,如", + "groupDisabled": "禁用群组", + "dmPairing": "配对审批", + "dmOpen": "允许所有私信", + "dmAllowlist": "白名单", + "dmDisabled": "禁用私信", + "optionalEg": "可选,如 {example}", "editAccountLabel": "编辑 {id}", "bound": "已绑定", "notBoundAgent": "未绑定 Agent", diff --git a/src/pages/channels.js b/src/pages/channels.js index 40481fa..4dc8fe9 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -28,6 +28,13 @@ const DM_POLICY_OPTIONS = [ { value: 'disabled', label: t('channels.dmDisabled') }, ] +const SYNOLOGY_DM_POLICY_OPTIONS = [ + { value: '', label: t('channels.policyDefault') }, + { 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 }, @@ -267,6 +274,72 @@ const PLATFORM_REGISTRY = { pluginRequired: '@openclaw/mattermost@latest', pluginId: 'mattermost', }, + 'synology-chat': { + label: 'Synology Chat', + iconName: 'message-square', + desc: t('channels.synologyChatDesc'), + guide: [ + t('channels.synologyChatGuide1'), + t('channels.synologyChatGuide2'), + t('channels.synologyChatGuide3'), + t('channels.synologyChatGuide4'), + ], + guideFooter: t('channels.synologyChatGuideFooter'), + fields: [ + { key: 'token', label: 'Token', placeholder: t('channels.synologyChatTokenPh'), secret: true, required: true }, + { key: 'incomingUrl', label: 'Incoming URL', placeholder: 'https://nas.example.com/webapi/entry.cgi', required: true, hint: t('channels.synologyChatIncomingUrlHint') }, + { key: 'nasHost', label: 'NAS Host', placeholder: 'https://nas.example.com', required: false }, + { key: 'webhookPath', label: 'Webhook Path', placeholder: '/webhook/synology', required: false }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: SYNOLOGY_DM_POLICY_OPTIONS, required: false }, + { key: 'allowedUserIds', label: 'Allowed User IDs', placeholder: 'alice, bob', required: false, hint: t('channels.synologyChatAllowedUserIdsHint') }, + { key: 'rateLimitPerMinute', label: 'Rate Limit / Minute', placeholder: '30', required: false }, + { key: 'botName', label: 'Bot Name', placeholder: 'OpenClaw', required: false }, + { key: 'dangerouslyAllowNameMatching', label: t('channels.synologyChatNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.synologyChatNameMatchingHint') }, + { key: 'dangerouslyAllowInheritedWebhookPath', label: t('channels.synologyChatInheritedWebhookPath'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.synologyChatInheritedWebhookPathHint') }, + { key: 'allowInsecureSsl', label: t('channels.synologyChatAllowInsecureSsl'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.synologyChatAllowInsecureSslHint') }, + ], + configKey: 'synology-chat', + pluginRequired: '@openclaw/synology-chat@latest', + pluginId: 'synology-chat', + }, + googlechat: { + label: 'Google Chat', + iconName: 'message-square', + desc: t('channels.googleChatDesc'), + guide: [ + t('channels.googleChatGuide1'), + t('channels.googleChatGuide2'), + t('channels.googleChatGuide3'), + t('channels.googleChatGuide4'), + ], + guideFooter: t('channels.googleChatGuideFooter'), + fields: [ + { key: 'serviceAccountFile', label: 'Service Account File', placeholder: '/path/to/service-account.json', required: false, hint: t('channels.googleChatServiceAccountFileHint') }, + { key: 'serviceAccount', label: 'Service Account JSON', placeholder: '{"type":"service_account", ...}', secret: true, multiline: true, required: false, hint: t('channels.googleChatServiceAccountHint') }, + { key: 'serviceAccountRef', label: 'Service Account SecretRef', placeholder: 'SecretRef(env:default:GOOGLE_CHAT_SERVICE_ACCOUNT)', required: false, hint: t('channels.googleChatServiceAccountRefHint') }, + { key: 'audienceType', label: 'Audience Type', type: 'select', options: [ + { value: '', label: t('channels.policyDefault') }, + { value: 'app-url', label: 'App URL' }, + { value: 'project-number', label: 'Project Number' }, + ], required: false }, + { key: 'audience', label: 'Audience', placeholder: 'https://panel.example.com/googlechat', required: false, hint: t('channels.googleChatAudienceHint') }, + { key: 'webhookPath', label: 'Webhook Path', placeholder: '/googlechat', required: false }, + { key: 'webhookUrl', label: 'Webhook URL', placeholder: 'https://panel.example.com/googlechat', 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.groupAllSpaces'), { mention: true }), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: 'users/123456789, name@example.com', required: false, hint: t('channels.googleChatAllowFromHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: 'spaces/AAA, spaces/BBB', required: false, hint: t('channels.googleChatGroupAllowFromHint') }, + { key: 'dangerouslyAllowNameMatching', label: t('channels.googleChatNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.googleChatNameMatchingHint') }, + { key: 'requireMention', label: t('channels.googleChatRequireMention'), type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '20', required: false }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[AI]' }), required: false }, + ], + requiredAny: [{ keys: ['serviceAccountFile', 'serviceAccount', 'serviceAccountRef'], label: t('channels.googleChatServiceAccountRequired') }], + configKey: 'googlechat', + pairingChannel: 'googlechat', + pluginRequired: '@openclaw/googlechat@latest', + pluginId: 'googlechat', + }, discord: { label: 'Discord', iconName: 'hash', @@ -577,7 +650,7 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── // ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── -const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat'] function supportsMessagingMultiAccount(pid) { return MULTI_INSTANCE_PLATFORMS.includes(pid) @@ -2156,12 +2229,21 @@ async function openConfigDialog(pid, page, state, accountId) { ` } + if (f.multiline) { + return ` +
+ + + ${fieldHint ? `
${fieldHint}
` : ''} +
+ ` + } return `
${f.secret ? `` : ''}
@@ -2284,7 +2366,7 @@ async function openConfigDialog(pid, page, state, accountId) { const collectForm = () => { const obj = {} reg.fields.forEach(f => { - const el = modal.querySelector(`input[name="${f.key}"]`) || modal.querySelector(`select[name="${f.key}"]`) + const el = modal.querySelector(`input[name="${f.key}"]`) || modal.querySelector(`select[name="${f.key}"]`) || modal.querySelector(`textarea[name="${f.key}"]`) if (el) obj[f.key] = el.value.trim() }) return obj diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 30a8f70..61c270b 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -569,6 +569,149 @@ test('Mattermost 诊断要求 Bot Token 和 Base URL', () => { assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) }) +test('Synology Chat 渠道保存会写入上游运行时字段并支持多账号', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'synology-chat', + accountId: 'nas', + form: { + token: 'synology-token', + incomingUrl: 'https://nas.example.com/webapi/entry.cgi', + nasHost: 'https://nas.example.com', + webhookPath: '/webhook/synology', + dmPolicy: 'allowlist', + allowedUserIds: 'alice, bob', + rateLimitPerMinute: '45', + botName: 'OpenClaw Ops', + dangerouslyAllowNameMatching: 'true', + dangerouslyAllowInheritedWebhookPath: 'true', + allowInsecureSsl: 'true', + }, + }) + + const account = cfg.channels['synology-chat'].accounts.nas + assert.equal(cfg.channels['synology-chat'].defaultAccount, 'nas') + assert.equal(account.token, 'synology-token') + assert.equal(account.incomingUrl, 'https://nas.example.com/webapi/entry.cgi') + assert.equal(account.nasHost, 'https://nas.example.com') + assert.equal(account.webhookPath, '/webhook/synology') + assert.equal(account.dmPolicy, 'allowlist') + assert.deepEqual(account.allowedUserIds, ['alice', 'bob']) + assert.equal(account.rateLimitPerMinute, 45) + assert.equal(account.botName, 'OpenClaw Ops') + assert.equal(account.dangerouslyAllowNameMatching, true) + assert.equal(account.dangerouslyAllowInheritedWebhookPath, true) + assert.equal(account.allowInsecureSsl, true) + assert.equal(cfg.plugins.entries['synology-chat'].enabled, true) +}) + +test('Synology Chat 诊断要求 Token 和 Incoming URL', () => { + const missingUrl = buildOpenClawChannelDiagnosis({ + platform: 'synology-chat', + configExists: true, + channelEnabled: true, + form: { token: 'synology-token' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'synology-chat', + configExists: true, + channelEnabled: true, + form: { + token: 'synology-token', + incomingUrl: 'https://nas.example.com/webapi/entry.cgi', + }, + }) + + assert.equal(missingUrl.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingUrl.checks.find(item => item.id === 'credentials')?.detail || '', /Incoming URL/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + +test('Google Chat 渠道保存会写入 service account 与嵌套 DM 策略', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'googlechat', + accountId: 'workspace', + form: { + serviceAccountFile: '/run/secrets/googlechat.json', + audienceType: 'app-url', + audience: 'https://panel.example.com/googlechat', + webhookPath: '/googlechat', + webhookUrl: 'https://panel.example.com/googlechat', + dmPolicy: 'open', + allowFrom: 'users/123', + groupPolicy: 'mentioned', + groupAllowFrom: 'spaces/AAA', + dangerouslyAllowNameMatching: 'true', + requireMention: 'true', + mediaMaxMb: '20', + responsePrefix: '[AI]', + }, + }) + + const account = cfg.channels.googlechat.accounts.workspace + assert.equal(cfg.channels.googlechat.defaultAccount, 'workspace') + assert.equal(account.serviceAccountFile, '/run/secrets/googlechat.json') + assert.equal(account.audienceType, 'app-url') + assert.equal(account.audience, 'https://panel.example.com/googlechat') + assert.equal(account.webhookPath, '/googlechat') + assert.equal(account.webhookUrl, 'https://panel.example.com/googlechat') + assert.deepEqual(account.dm, { policy: 'open', allowFrom: ['users/123', '*'] }) + assert.equal(account.groupPolicy, 'open') + assert.equal(account.requireMention, true) + assert.deepEqual(account.groupAllowFrom, ['spaces/AAA']) + assert.equal(account.dangerouslyAllowNameMatching, true) + assert.equal(account.mediaMaxMb, 20) + assert.equal(account.responsePrefix, '[AI]') + assert.equal(cfg.plugins.entries.googlechat.enabled, true) +}) + +test('Google Chat 读取会把嵌套 DM 策略回显为表单字段', () => { + const values = buildMessagingPlatformFormValues('googlechat', { + serviceAccountFile: '/run/secrets/googlechat.json', + audienceType: 'project-number', + audience: '1234567890', + dm: { policy: 'allowlist', allowFrom: ['users/123', 'name@example.com'] }, + groupPolicy: 'allowlist', + groupAllowFrom: ['spaces/AAA'], + requireMention: true, + dangerouslyAllowNameMatching: true, + mediaMaxMb: 20, + }) + + assert.equal(values.serviceAccountFile, '/run/secrets/googlechat.json') + assert.equal(values.audienceType, 'project-number') + assert.equal(values.audience, '1234567890') + assert.equal(values.dmPolicy, 'allowlist') + assert.equal(values.allowFrom, 'users/123, name@example.com') + assert.equal(values.groupPolicy, 'allowlist') + assert.equal(values.groupAllowFrom, 'spaces/AAA') + assert.equal(values.requireMention, 'true') + assert.equal(values.dangerouslyAllowNameMatching, 'true') + assert.equal(values.mediaMaxMb, '20') +}) + +test('Google Chat 诊断要求 service account 文件或内联 JSON 其中一项', () => { + const missingCredential = buildOpenClawChannelDiagnosis({ + platform: 'googlechat', + configExists: true, + channelEnabled: true, + form: { audienceType: 'app-url', audience: 'https://panel.example.com/googlechat' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'googlechat', + configExists: true, + channelEnabled: true, + form: { serviceAccountFile: '/run/secrets/googlechat.json' }, + }) + + assert.equal(missingCredential.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingCredential.checks.find(item => item.id === 'credentials')?.detail || '', /Service Account/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Discord 渠道保存会保留运行时需要的 applicationId', () => { const cfg = { channels: {} }