feat(channels): add Synology and Google Chat config

This commit is contained in:
晴天
2026-05-23 05:37:06 +08:00
parent 53fe25a277
commit 09bc45ae4c
7 changed files with 645 additions and 13 deletions

View File

@@ -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)

View File

@@ -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<String, Value>) -> 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, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "synology-chat")?;
}
"googlechat" => {
let has_service_account =
has_configured_messaging_value(form_obj.get("serviceAccount"))
|| has_configured_messaging_value(form_obj.get("serviceAccountFile"))
|| has_configured_messaging_value(form_obj.get("serviceAccountRef"));
if !has_service_account {
return Err(
"Google Chat 需要填写 Service Account JSON、Service Account File 或 SecretRef"
.into(),
);
}
let mut entry = Map::new();
entry.insert("enabled".into(), Value::Bool(true));
for key in [
"serviceAccount",
"serviceAccountFile",
"serviceAccountRef",
"audienceType",
"audience",
"appPrincipal",
"webhookPath",
"webhookUrl",
"botUser",
"chunkMode",
"replyToMode",
"typingIndicator",
"responsePrefix",
] {
put_string(&mut entry, key, form_string(form_obj, key));
}
let mut dm = current_saved
.get("dm")
.and_then(|v| v.as_object())
.cloned()
.unwrap_or_default();
put_string(&mut dm, "policy", form_string(form_obj, "dmPolicy"));
let allow_from = json_array_from_csv_value(form_obj.get("allowFrom"));
if !allow_from.is_empty() {
dm.insert("allowFrom".into(), Value::Array(allow_from));
}
if !dm.is_empty() {
entry.insert("dm".into(), Value::Object(dm));
}
put_string(
&mut entry,
"groupPolicy",
form_string(form_obj, "groupPolicy"),
);
put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom"));
for key in [
"dangerouslyAllowNameMatching",
"requireMention",
"allowBots",
"blockStreaming",
] {
put_bool_value_if_present(&mut entry, key, form_obj.get(key));
}
for key in [
"historyLimit",
"dmHistoryLimit",
"textChunkLimit",
"mediaMaxMb",
] {
if let Some(value) = form_obj.get(key).and_then(|v| v.as_f64()) {
if let Some(number) = serde_json::Number::from_f64(value) {
entry.insert(key.into(), Value::Number(number));
}
} else {
put_number_from_form(&mut entry, key, &form_string(form_obj, key));
}
}
preserve_messaging_credential_refs(&mut entry, form_obj, &current_saved);
merge_channel_entry_for_account(
channels_map,
&storage_key,
account_id.as_deref(),
entry,
)?;
ensure_plugin_allowed(&mut cfg, "googlechat")?;
}
_ => {
// 通用平台:直接保存表单字段
let mut entry = Map::new();

View File

@@ -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": "<div style=\"margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)\">For private NAS networks, make sure Gateway can reach the NAS host.</div>",
"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": "<div style=\"margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)\">Prefer Service Account File in production to avoid storing long JSON inline.</div>",
"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/<id> or email entries separated by commas",
"googleChatGroupAllowFromHint": "Space allowlist, using spaces/<id> 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",

View File

@@ -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: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">内网 NAS 场景如需访问私有地址,请确认 Gateway 网络能访问 NAS。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">For private NAS networks, make sure Gateway can reach the NAS host.</div>'),
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: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">建议优先使用 Service Account File避免在配置文件中直接粘贴长 JSON。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">Prefer Service Account File in production to avoid storing long JSON inline.</div>'),
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 模式填回调 URLproject-number 模式填 Google Cloud 项目编号', 'Use callback URL for app-url mode; use Google Cloud project number for project-number mode'),
googleChatAllowFromHint: _('DM 白名单,使用 users/<id> 或邮箱,逗号分隔', 'DM allowlist, using users/<id> or email entries separated by commas'),
googleChatGroupAllowFromHint: _('Space 白名单,使用 spaces/<id>,逗号分隔', 'Space allowlist, using spaces/<id> 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: _('前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 创建 Application', '前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 创建 Application', '前往 <a href="https://discord.com/developers/applications" target="_blank" rel="noopener">Discord Developer Portal</a> 建立 Application'),
discordGuide2: _('在 Bot 页面点击「Reset Token」获取 <strong>Bot Token</strong>', 'Click "Reset Token" on the Bot page to get the <strong>Bot Token</strong>', '在 Bot 頁面点擊「Reset Token」取得 <strong>Bot Token</strong>'),
@@ -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', '停用群組'),

View File

@@ -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": "<div style=\"margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)\">内网 NAS 场景如需访问私有地址,请确认 Gateway 网络能访问 NAS。</div>",
"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": "<div style=\"margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)\">建议优先使用 Service Account File避免在配置文件中直接粘贴长 JSON。</div>",
"googleChatServiceAccountFileHint": "Service Account JSON 文件路径,推荐用于生产环境",
"googleChatServiceAccountHint": "可直接粘贴 Service Account JSON保存后会写入 openclaw.json",
"googleChatServiceAccountRefHint": "已有 SecretRef 时可保留或手动填写,如 SecretRef(env:default:GOOGLE_CHAT_SERVICE_ACCOUNT)",
"googleChatAudienceHint": "app-url 模式填回调 URLproject-number 模式填 Google Cloud 项目编号",
"googleChatAllowFromHint": "DM 白名单,使用 users/<id> 或邮箱,逗号分隔",
"googleChatGroupAllowFromHint": "Space 白名单,使用 spaces/<id>,逗号分隔",
"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",

View File

@@ -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) {
</div>
`
}
if (f.multiline) {
return `
<div class="form-group">
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<textarea class="form-input" name="${f.key}" rows="5" placeholder="${escapeAttr(f.placeholder || '')}" ${i === 0 ? 'autofocus' : ''} style="width:100%;min-height:112px;resize:vertical;font-family:var(--font-mono);line-height:1.5">${escapeAttr(val)}</textarea>
${fieldHint ? `<div class="form-hint">${fieldHint}</div>` : ''}
</div>
`
}
return `
<div class="form-group">
<label class="form-label">${labelWithHelp(f.label)}${f.required ? ' *' : ''}</label>
<div style="display:flex;gap:8px">
<input class="form-input" name="${f.key}" type="${f.secret ? 'password' : 'text'}"
value="${escapeAttr(val)}" placeholder="${f.placeholder || ''}"
value="${escapeAttr(val)}" placeholder="${escapeAttr(f.placeholder || '')}"
${i === 0 ? 'autofocus' : ''} style="flex:1">
${f.secret ? `<button type="button" class="btn btn-sm btn-secondary toggle-vis" data-field="${f.key}">${t('channels.show')}</button>` : ''}
</div>
@@ -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

View File

@@ -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: {} }