diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 1938bf6..bc3f2d0 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', 'googlechat'].includes(platformStorageKey(platform)) + return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat', 'nextcloud-talk'].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', 'googlechat', 'imessage'].includes(storageKey) + const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat', 'nextcloud-talk', 'imessage'].includes(storageKey) const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults @@ -2592,6 +2592,10 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'appPassword', 'appSecret', 'appToken', + 'apiPassword', + 'apiPasswordFile', + 'botSecret', + 'botSecretFile', 'botToken', 'channelAccessToken', 'channelSecret', @@ -2671,6 +2675,11 @@ function channelAnyCredentialGroups(platform) { { label: 'Channel Secret 或 Secret File', fields: [['channelSecret', 'Channel Secret'], ['secretFile', 'Secret File']] }, ] } + if (storageKey === 'nextcloud-talk') { + return [ + { label: 'Bot Secret 或 Secret File', fields: [['botSecret', 'Bot Secret'], ['botSecretFile', 'Secret File']] }, + ] + } return [] } @@ -2683,6 +2692,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { mattermost: [['botToken', 'Bot Token'], ['baseUrl', 'Base URL']], 'synology-chat': [['token', 'Token'], ['incomingUrl', 'Incoming URL']], clickclack: [['baseUrl', 'Base URL'], ['token', 'Token'], ['workspace', 'Workspace']], + 'nextcloud-talk': [['baseUrl', 'Base URL']], signal: [['account', 'Signal 账号']], } @@ -2709,10 +2719,11 @@ function channelDiagnosisCredentialsReady(platform, form = {}) { if (['zalouser', 'whatsapp'].includes(platformStorageKey(platform))) return true if (platformStorageKey(platform) === 'msteams') return msteamsCredentialMissingLabels(form).length === 0 const requiredFields = requiredChannelCredentialFields(platform, form) + const anyGroups = channelAnyCredentialGroups(platform) if (requiredFields.length) { return requiredFields.every(([key]) => hasConfiguredMessagingValue(form?.[key])) + && anyGroups.every(group => group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key]))) } - const anyGroups = channelAnyCredentialGroups(platform) if (anyGroups.length) { return anyGroups.every(group => group.fields.some(([key]) => hasConfiguredMessagingValue(form?.[key]))) } @@ -2774,7 +2785,7 @@ export function buildOpenClawChannelDiagnosis({ const credentialOk = ['zalouser', 'imessage', 'whatsapp'].includes(storageKey) ? !!configExists : (requiredFields.length - ? missing.length === 0 + ? missing.length === 0 && missingGroups.length === 0 : (anyGroups.length ? missingGroups.length === 0 : (anyFields.length ? anyCredentialOk : hasAnyCredential))) @@ -2799,7 +2810,7 @@ export function buildOpenClawChannelDiagnosis({ : '尚未保存 WhatsApp 渠道配置,请先填写并保存。') : (credentialOk ? (requiredFields.length - ? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}。` + ? `已填写 ${requiredFields.map(([, label]) => label).join(' / ')}${anyGroups.length ? `;${anyGroups.map(group => group.label).join(';')}` : ''}。` : (anyGroups.length ? `已填写 ${anyGroups.map(group => group.label).join(';')}。` : (anyFields.length ? `已填写 ${anyLabels} 其中一项。` : '已检测到可用凭证字段。'))) @@ -3062,6 +3073,21 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'nextcloud-talk') { + for (const key of ['name', 'baseUrl', 'botSecret', 'botSecretFile', 'apiUser', 'apiPassword', 'apiPasswordFile', 'webhookHost', 'webhookPath', 'webhookPublicUrl', 'chunkMode', 'responsePrefix']) { + putSecretAwareFormValue(form, saved, key) + } + putBoolFormValue(form, saved, 'enabled') + putAccessPolicyFormValues(form, saved, { mentionCompat: true }) + putCsvFormValue(form, saved, 'groupAllowFrom') + putBoolFormValue(form, saved, 'blockStreaming') + putBoolFormValue(form, saved?.network, 'dangerouslyAllowPrivateNetwork') + for (const key of ['webhookPort', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) { + if (typeof saved[key] === 'number') form[key] = String(saved[key]) + } + return form + } + if (storageKey === 'synology-chat') { for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { putSecretAwareFormValue(form, saved, key) @@ -3826,6 +3852,23 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) for (const key of ['timeoutSeconds', 'reconnectMs']) { if (typeof form[key] === 'number') entry[key] = form[key] } + } else if (storageKey === 'nextcloud-talk') { + entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true + for (const key of ['name', 'baseUrl', 'botSecret', 'botSecretFile', 'apiUser', 'apiPassword', 'apiPasswordFile', 'webhookHost', 'webhookPath', 'webhookPublicUrl', 'chunkMode', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + if (Object.hasOwn(form, 'requireMention')) entry.requireMention = !!form.requireMention + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (Array.isArray(form.groupAllowFrom) && form.groupAllowFrom.length) entry.groupAllowFrom = form.groupAllowFrom + if (typeof form.blockStreaming === 'boolean') entry.blockStreaming = form.blockStreaming + if (typeof form.dangerouslyAllowPrivateNetwork === 'boolean') { + entry.network = { ...(currentSaved?.network || {}), dangerouslyAllowPrivateNetwork: form.dangerouslyAllowPrivateNetwork } + } + for (const key of ['webhookPort', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) { + if (typeof form[key] === 'number') entry[key] = form[key] + } } else if (storageKey === 'synology-chat') { for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { if (form[key]) entry[key] = form[key] @@ -3867,7 +3910,7 @@ 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', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { + if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { ensureMessagingPluginAllowed(cfg, storageKey) } return { entry, accountId: normalizedAccountId, storageKey } @@ -5337,7 +5380,7 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (['line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built) ensureMessagingPluginAllowed(cfg, storageKey) @@ -5346,7 +5389,7 @@ const handlers = { preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) @@ -5474,6 +5517,9 @@ const handlers = { if (platform === 'clickclack') { return { valid: true, warnings: ['ClickClack 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } } + if (platform === 'nextcloud-talk') { + return { valid: true, warnings: ['Nextcloud Talk 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } + } if (platform === 'discord') { try { const resp = await fetch('https://discord.com/api/v10/users/@me', { diff --git a/src-tauri/src/commands/messaging.rs b/src-tauri/src/commands/messaging.rs index 4731aca..51e4511 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -139,6 +139,10 @@ fn preserve_messaging_credential_refs( "appPassword", "appSecret", "appToken", + "apiPassword", + "apiPasswordFile", + "botSecret", + "botSecretFile", "botToken", "channelAccessToken", "channelSecret", @@ -221,6 +225,10 @@ fn channel_root_has_messaging_credential(root: &Map) -> bool { "appPassword", "appSecret", "appToken", + "apiPassword", + "apiPasswordFile", + "botSecret", + "botSecretFile", "botToken", "channelAccessToken", "channelSecret", @@ -265,6 +273,7 @@ fn required_channel_credential_fields( ("token", "Token"), ("workspace", "Workspace"), ], + "nextcloud-talk" => vec![("baseUrl", "Base URL")], "signal" => vec![("account", "Signal 账号")], "slack" => { let mode = form_string(form, "mode"); @@ -334,6 +343,13 @@ fn channel_any_credential_groups( ], ), ], + "nextcloud-talk" => vec![( + "Bot Secret 或 Secret File", + vec![ + ("botSecret", "Bot Secret"), + ("botSecretFile", "Secret File"), + ], + )], _ => vec![], } } @@ -349,12 +365,17 @@ fn channel_diagnosis_credentials_ready(platform: &str, form: &Map return msteams_credential_missing_labels(form).is_empty(); } let required_fields = required_channel_credential_fields(platform, form); + let any_groups = channel_any_credential_groups(platform); if !required_fields.is_empty() { return required_fields .iter() - .all(|(key, _)| has_configured_messaging_value(form.get(*key))); + .all(|(key, _)| has_configured_messaging_value(form.get(*key))) + && any_groups.iter().all(|(_, fields)| { + fields + .iter() + .any(|(key, _)| has_configured_messaging_value(form.get(*key))) + }); } - let any_groups = channel_any_credential_groups(platform); if !any_groups.is_empty() { return any_groups.iter().all(|(_, fields)| { fields @@ -476,7 +497,7 @@ fn build_openclaw_channel_diagnosis( let credential_ok = if matches!(storage_key, "zalouser" | "imessage" | "whatsapp") { config_exists } else if !required_fields.is_empty() { - missing.is_empty() + missing.is_empty() && missing_groups.is_empty() } else if !any_groups.is_empty() { missing_groups.is_empty() } else if !any_fields.is_empty() { @@ -514,7 +535,19 @@ fn build_openclaw_channel_diagnosis( } } else if credential_ok { if !required_fields.is_empty() { - format!("已填写 {}。", required_labels) + if !any_groups.is_empty() { + format!( + "已填写 {};{}。", + required_labels, + any_groups + .iter() + .map(|(label, _)| *label) + .collect::>() + .join(";") + ) + } else { + format!("已填写 {}。", required_labels) + } } else if !any_groups.is_empty() { format!( "已填写 {}。", @@ -808,7 +841,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" | "googlechat" + "feishu" | "slack" | "msteams" | "mattermost" | "googlechat" | "nextcloud-talk" ) } @@ -839,6 +872,7 @@ fn normalize_messaging_platform_form( | "line" | "mattermost" | "googlechat" + | "nextcloud-talk" | "imessage" ); let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults; @@ -1787,6 +1821,40 @@ pub async fn read_platform_config( insert_number_as_string(&mut form, &saved, "timeoutSeconds"); insert_number_as_string(&mut form, &saved, "reconnectMs"); } + "nextcloud-talk" => { + for key in [ + "name", + "baseUrl", + "botSecret", + "botSecretFile", + "apiUser", + "apiPassword", + "apiPasswordFile", + "webhookHost", + "webhookPath", + "webhookPublicUrl", + "chunkMode", + "responsePrefix", + ] { + insert_secret_aware_form_value(&mut form, &saved, key); + } + insert_bool_as_string(&mut form, &saved, "enabled"); + insert_access_policy_form_values(&mut form, &saved, false, true); + insert_array_as_csv(&mut form, &saved, "groupAllowFrom"); + insert_bool_as_string(&mut form, &saved, "blockStreaming"); + if let Some(network) = saved.get("network") { + insert_bool_as_string(&mut form, network, "dangerouslyAllowPrivateNetwork"); + } + for key in [ + "webhookPort", + "historyLimit", + "dmHistoryLimit", + "mediaMaxMb", + "textChunkLimit", + ] { + insert_number_as_string(&mut form, &saved, key); + } + } "synology-chat" => { for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] { insert_secret_aware_form_value(&mut form, &saved, key); @@ -2855,6 +2923,83 @@ pub async fn save_messaging_platform( )?; ensure_plugin_allowed(&mut cfg, "clickclack")?; } + "nextcloud-talk" => { + let base_url = form_string(form_obj, "baseUrl"); + let bot_secret = form_string(form_obj, "botSecret"); + let bot_secret_file = form_string(form_obj, "botSecretFile"); + if base_url.is_empty() { + return Err("Nextcloud Talk Base URL 不能为空".into()); + } + if bot_secret.is_empty() + && bot_secret_file.is_empty() + && !has_configured_messaging_value(form_obj.get("botSecret")) + && !has_configured_messaging_value(form_obj.get("botSecretFile")) + { + return Err("Nextcloud Talk Bot Secret 或 Secret File 至少填写一项".into()); + } + + let mut entry = Map::new(); + entry.insert("enabled".into(), Value::Bool(true)); + put_bool_value_if_present(&mut entry, "enabled", form_obj.get("enabled")); + for key in [ + "name", + "baseUrl", + "botSecret", + "botSecretFile", + "apiUser", + "apiPassword", + "apiPasswordFile", + "webhookHost", + "webhookPath", + "webhookPublicUrl", + "chunkMode", + "responsePrefix", + ] { + put_string(&mut entry, key, form_string(form_obj, key)); + } + put_string(&mut entry, "dmPolicy", form_string(form_obj, "dmPolicy")); + put_string( + &mut entry, + "groupPolicy", + form_string(form_obj, "groupPolicy"), + ); + put_bool_value_if_present(&mut entry, "requireMention", form_obj.get("requireMention")); + put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom")); + put_bool_value_if_present(&mut entry, "blockStreaming", form_obj.get("blockStreaming")); + for key in [ + "webhookPort", + "historyLimit", + "dmHistoryLimit", + "mediaMaxMb", + "textChunkLimit", + ] { + put_number_value_if_present(&mut entry, key, form_obj.get(key)); + } + if form_obj.contains_key("dangerouslyAllowPrivateNetwork") { + let mut network = current_saved + .get("network") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_bool_value_if_present( + &mut network, + "dangerouslyAllowPrivateNetwork", + form_obj.get("dangerouslyAllowPrivateNetwork"), + ); + if !network.is_empty() { + entry.insert("network".into(), Value::Object(network)); + } + } + 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, "nextcloud-talk")?; + } "synology-chat" => { let token = form_string(form_obj, "token"); let incoming_url = form_string(form_obj, "incomingUrl"); @@ -3156,6 +3301,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result Ok(json!({ + "valid": true, + "warnings": ["Nextcloud Talk 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"] + })), _ => Ok(json!({ "valid": true, "warnings": ["该平台暂不支持在线校验"] @@ -6304,6 +6453,114 @@ mod tests { .contains("Workspace")); } + #[test] + fn normalize_nextcloud_talk_form_preserves_self_hosted_runtime_fields() { + let form = json!({ + "enabled": "true", + "baseUrl": "https://cloud.example.com", + "botSecret": "bot-secret", + "apiUser": "openclaw-bot", + "apiPassword": "app-password", + "webhookPort": "8788", + "webhookHost": "0.0.0.0", + "webhookPath": "/nextcloud-talk-webhook", + "webhookPublicUrl": "https://panel.example.com/nextcloud-talk-webhook", + "dmPolicy": "allowlist", + "allowFrom": "alice, bob", + "groupPolicy": "mentioned", + "groupAllowFrom": "room-token-1, room-token-2", + "historyLimit": "80", + "dmHistoryLimit": "20", + "mediaMaxMb": "50", + "textChunkLimit": "4000", + "chunkMode": "newline", + "blockStreaming": "true", + "dangerouslyAllowPrivateNetwork": "true" + }); + let normalized = + normalize_messaging_platform_form("nextcloud-talk", form.as_object().expect("object")); + + assert_eq!( + normalized.get("enabled").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized.get("webhookPort").and_then(|v| v.as_f64()), + Some(8788.0) + ); + assert_eq!( + normalized.get("historyLimit").and_then(|v| v.as_f64()), + Some(80.0) + ); + assert_eq!( + normalized.get("blockStreaming").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized + .get("dangerouslyAllowPrivateNetwork") + .and_then(|v| v.as_bool()), + Some(true) + ); + 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("allowFrom") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized + .get("groupAllowFrom") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert!(channel_diagnosis_credentials_ready( + "nextcloud-talk", + &normalized + )); + + let missing_secret = json!({ + "baseUrl": "https://cloud.example.com" + }); + let missing = normalize_messaging_platform_form( + "nextcloud-talk", + missing_secret.as_object().expect("object"), + ); + assert!(!channel_diagnosis_credentials_ready( + "nextcloud-talk", + &missing + )); + let diagnosis = build_openclaw_channel_diagnosis( + "nextcloud-talk", + None, + true, + true, + &missing, + None, + None, + ); + assert!(diagnosis + .get("checks") + .and_then(|v| v.as_array()) + .and_then(|items| items + .iter() + .find(|item| { item.get("id").and_then(|v| v.as_str()) == Some("credentials") })) + .and_then(|item| item.get("detail")) + .and_then(|v| v.as_str()) + .unwrap_or("") + .contains("Bot Secret 或 Secret File")); + } + #[test] fn channel_form_readback_preserves_mention_policy_choice() { let saved = json!({ diff --git a/src/lib/channel-labels.js b/src/lib/channel-labels.js index 8bed747..9c1aba1 100644 --- a/src/lib/channel-labels.js +++ b/src/lib/channel-labels.js @@ -20,6 +20,7 @@ export const CHANNEL_LABELS = { nostr: 'Nostr', mattermost: 'Mattermost', clickclack: 'ClickClack', + 'nextcloud-talk': 'Nextcloud Talk', 'openclaw-weixin': '微信', weixin: '微信', } diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index c0bf0e1..0308661 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -144,6 +144,23 @@ export default { clickclackSenderIsOwnerHint: _('开启后将 ClickClack 消息发送者视为 owner 身份,适合可信内部工作区。', 'Treat the ClickClack sender as owner when enabled; use only in trusted internal workspaces.'), clickclackDefaultToHint: _('默认发送目标,例如 channel:general、thread:123 或 dm:alice。', 'Default target, e.g. channel:general, thread:123, or dm:alice.'), clickclackAllowFromHint: _('可选,逗号分隔允许来源;留空按上游默认处理,* 表示允许全部。', 'Optional comma-separated allowed sources; leave empty for upstream defaults, * allows all.'), + nextcloudTalkDesc: _('接入自托管 Nextcloud Talk,支持私聊、房间消息和 webhook 回调', 'Connect self-hosted Nextcloud Talk with DMs, rooms, and webhook callbacks'), + nextcloudTalkGuide1: _('在 Nextcloud Talk 管理端创建 Bot,并复制 Bot Secret', 'Create a bot in Nextcloud Talk administration and copy the Bot Secret'), + nextcloudTalkGuide2: _('填写 Nextcloud 站点 Base URL,例如 https://cloud.example.com', 'Fill the Nextcloud site Base URL, for example https://cloud.example.com'), + nextcloudTalkGuide3: _('如需面板主动探测 Bot 能力,可填写 API User 与应用密码或密码文件', 'To let the panel probe bot capabilities, provide API User plus an app password or password file'), + nextcloudTalkGuide4: _('保存后面板会启用 bundled Nextcloud Talk 插件并重载 Gateway;连通性以 Gateway 日志或 channels status 为准', 'After saving, the panel enables the bundled Nextcloud Talk plugin and reloads Gateway; verify connectivity through Gateway logs or channels status'), + nextcloudTalkGuideFooter: _('
Nextcloud Talk 最小配置需要 Base URL,以及 Bot Secret 或 Secret File 其中一项。
', '
Nextcloud Talk minimally requires Base URL plus either Bot Secret or Secret File.
'), + nextcloudTalkBaseUrlHint: _('填写 Nextcloud 站点根地址,不要包含 Talk API 路径;末尾斜杠会由上游归一化。', 'Use the Nextcloud site root URL without Talk API paths; upstream normalizes trailing slashes.'), + nextcloudTalkBotSecretPh: _('Nextcloud Talk Bot Secret', 'Nextcloud Talk Bot Secret'), + nextcloudTalkBotSecretHint: _('生产环境建议改用 Secret File 或 SecretRef,避免明文写入配置。', 'For production, prefer Secret File or SecretRef to avoid plain-text config secrets.'), + nextcloudTalkBotSecretFileHint: _('Bot Secret 文件路径;与 Bot Secret 二选一。', 'Path to the Bot Secret file; use this or Bot Secret.'), + nextcloudTalkApiPasswordPh: _('Nextcloud 应用密码', 'Nextcloud app password'), + nextcloudTalkApiPasswordHint: _('可选;用于管理接口探测 Bot response feature。', 'Optional; used for admin API probes of the bot response feature.'), + nextcloudTalkWebhookPublicUrlHint: _('Nextcloud 能访问的公网或反代地址,通常为面板/Gateway 的 webhook URL。', 'Public or reverse-proxy URL reachable by Nextcloud, usually the panel/Gateway webhook URL.'), + nextcloudTalkAllowFromHint: _('可选,逗号分隔允许私聊的用户 ID;选择“允许所有私信”时会自动加入 *。', 'Optional comma-separated user IDs allowed for DMs; choosing Allow all DMs automatically adds *.'), + nextcloudTalkGroupAllowFromHint: _('可选,逗号分隔允许的 room token。', 'Optional comma-separated room tokens.'), + nextcloudTalkPrivateNetworkHint: _('仅在 Nextcloud 部署于可信内网且 Gateway 可以访问时开启。', 'Enable only when Nextcloud runs on a trusted private network reachable by Gateway.'), + nextcloudTalkSecretOrFile: _('Bot Secret 或 Secret File', 'Bot Secret or Secret File'), 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'), diff --git a/src/pages/channels.js b/src/pages/channels.js index 6a3651b..31e62ec 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -310,6 +310,52 @@ const PLATFORM_REGISTRY = { pairingChannel: 'clickclack', pluginId: 'clickclack', }, + 'nextcloud-talk': { + label: 'Nextcloud Talk', + iconName: 'message-square', + desc: t('channels.nextcloudTalkDesc'), + guide: [ + t('channels.nextcloudTalkGuide1'), + t('channels.nextcloudTalkGuide2'), + t('channels.nextcloudTalkGuide3'), + t('channels.nextcloudTalkGuide4'), + ], + guideFooter: t('channels.nextcloudTalkGuideFooter'), + fields: [ + { key: 'baseUrl', label: 'Base URL', placeholder: 'https://cloud.example.com', required: true, hint: t('channels.nextcloudTalkBaseUrlHint') }, + { key: 'botSecret', label: 'Bot Secret', placeholder: t('channels.nextcloudTalkBotSecretPh'), secret: true, required: false, hint: t('channels.nextcloudTalkBotSecretHint') }, + { key: 'botSecretFile', label: 'Secret File', placeholder: '/run/secrets/nextcloud-talk-bot-secret', required: false, hint: t('channels.nextcloudTalkBotSecretFileHint') }, + { key: 'apiUser', label: 'API User', placeholder: t('channels.optionalEg', { example: 'openclaw-bot' }), required: false }, + { key: 'apiPassword', label: 'API Password', placeholder: t('channels.nextcloudTalkApiPasswordPh'), secret: true, required: false, hint: t('channels.nextcloudTalkApiPasswordHint') }, + { key: 'apiPasswordFile', label: 'API Password File', placeholder: '/run/secrets/nextcloud-talk-api-password', required: false }, + { key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'work' }), required: false }, + { key: 'webhookPort', label: 'Webhook Port', placeholder: '8788', required: false }, + { key: 'webhookHost', label: 'Webhook Host', placeholder: '0.0.0.0', required: false }, + { key: 'webhookPath', label: 'Webhook Path', placeholder: '/nextcloud-talk-webhook', required: false }, + { key: 'webhookPublicUrl', label: 'Webhook Public URL', placeholder: 'https://panel.example.com/nextcloud-talk-webhook', required: false, hint: t('channels.nextcloudTalkWebhookPublicUrlHint') }, + { 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'), { mention: true }), required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: 'alice, bob', required: false, hint: t('channels.nextcloudTalkAllowFromHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: 'room-token-1, room-token-2', required: false, hint: t('channels.nextcloudTalkGroupAllowFromHint') }, + { key: 'historyLimit', label: 'History Limit', placeholder: '80', required: false }, + { key: 'dmHistoryLimit', label: 'DM History Limit', placeholder: '20', required: false }, + { key: 'mediaMaxMb', label: 'Media Max MB', placeholder: '50', required: false }, + { key: 'textChunkLimit', label: 'Text Chunk Limit', placeholder: '4000', required: false }, + { key: 'chunkMode', label: 'Chunk Mode', type: 'select', options: [ + { value: '', label: t('channels.policyDefault') }, + { value: 'length', label: 'Length' }, + { value: 'newline', label: 'Newline' }, + ], required: false }, + { key: 'blockStreaming', label: t('channels.signalBlockStreaming'), type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'dangerouslyAllowPrivateNetwork', label: t('channels.mattermostPrivateNetwork'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.nextcloudTalkPrivateNetworkHint') }, + { key: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[Talk]' }), required: false }, + ], + requiredAny: [{ keys: ['botSecret', 'botSecretFile'], label: t('channels.nextcloudTalkSecretOrFile') }], + configKey: 'nextcloud-talk', + pairingChannel: 'nextcloud-talk', + pluginRequired: '@openclaw/nextcloud-talk@latest', + pluginId: 'nextcloud-talk', + }, 'synology-chat': { label: 'Synology Chat', iconName: 'message-square', @@ -832,7 +878,7 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── // ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── -const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'signal'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'synology-chat', 'googlechat', 'signal'] function supportsMessagingMultiAccount(pid) { return MULTI_INSTANCE_PLATFORMS.includes(pid) diff --git a/tests/channel-config-normalization.test.js b/tests/channel-config-normalization.test.js index 9c8bce4..aa7f213 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -249,6 +249,109 @@ test('ClickClack 读取会回显运行字段且诊断要求 Base URL、Token 和 assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) }) +test('Nextcloud Talk 渠道保存会写入自托管 Talk 字段并启用插件', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'nextcloud-talk', + accountId: 'work', + form: { + enabled: 'true', + name: 'Work Cloud', + baseUrl: 'https://cloud.example.com', + botSecret: 'bot-secret', + apiUser: 'openclaw-bot', + apiPassword: 'app-password', + webhookPort: '8788', + webhookHost: '0.0.0.0', + webhookPath: '/nextcloud-talk-webhook', + webhookPublicUrl: 'https://panel.example.com/nextcloud-talk-webhook', + dmPolicy: 'allowlist', + allowFrom: 'alice, bob', + groupPolicy: 'mentioned', + groupAllowFrom: 'room-token-1, room-token-2', + historyLimit: '80', + dmHistoryLimit: '20', + mediaMaxMb: '50', + textChunkLimit: '4000', + chunkMode: 'newline', + blockStreaming: 'true', + responsePrefix: '[Talk]', + dangerouslyAllowPrivateNetwork: 'true', + }, + }) + + const root = cfg.channels['nextcloud-talk'] + const account = root.accounts.work + assert.equal(root.defaultAccount, 'work') + assert.equal(account.enabled, true) + assert.equal(account.name, 'Work Cloud') + assert.equal(account.baseUrl, 'https://cloud.example.com') + assert.equal(account.botSecret, 'bot-secret') + assert.equal(account.apiUser, 'openclaw-bot') + assert.equal(account.apiPassword, 'app-password') + assert.equal(account.webhookPort, 8788) + assert.equal(account.webhookHost, '0.0.0.0') + assert.equal(account.webhookPath, '/nextcloud-talk-webhook') + assert.equal(account.webhookPublicUrl, 'https://panel.example.com/nextcloud-talk-webhook') + assert.equal(account.dmPolicy, 'allowlist') + assert.deepEqual(account.allowFrom, ['alice', 'bob']) + assert.equal(account.groupPolicy, 'open') + assert.equal(account.requireMention, true) + assert.deepEqual(account.groupAllowFrom, ['room-token-1', 'room-token-2']) + assert.equal(account.historyLimit, 80) + assert.equal(account.dmHistoryLimit, 20) + assert.equal(account.mediaMaxMb, 50) + assert.equal(account.textChunkLimit, 4000) + assert.equal(account.chunkMode, 'newline') + assert.equal(account.blockStreaming, true) + assert.equal(account.responsePrefix, '[Talk]') + assert.deepEqual(account.network, { dangerouslyAllowPrivateNetwork: true }) + assert.equal(cfg.plugins.entries['nextcloud-talk'].enabled, true) +}) + +test('Nextcloud Talk 读取和诊断支持 Bot Secret 或 Secret File 二选一', () => { + const values = buildMessagingPlatformFormValues('nextcloud-talk', { + enabled: true, + baseUrl: 'https://cloud.example.com', + botSecretFile: '/run/secrets/nextcloud-talk-secret', + apiUser: 'openclaw-bot', + allowFrom: ['alice'], + groupPolicy: 'open', + requireMention: true, + groupAllowFrom: ['room-token-1'], + webhookPort: 8788, + historyLimit: 80, + blockStreaming: true, + network: { dangerouslyAllowPrivateNetwork: true }, + }) + const missingSecret = buildOpenClawChannelDiagnosis({ + platform: 'nextcloud-talk', + configExists: true, + channelEnabled: true, + form: { baseUrl: 'https://cloud.example.com' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'nextcloud-talk', + configExists: true, + channelEnabled: true, + form: values, + }) + + assert.equal(values.enabled, 'true') + assert.equal(values.baseUrl, 'https://cloud.example.com') + assert.equal(values.botSecretFile, '/run/secrets/nextcloud-talk-secret') + assert.equal(values.groupPolicy, 'mentioned') + assert.equal(values.groupAllowFrom, 'room-token-1') + assert.equal(values.webhookPort, '8788') + assert.equal(values.historyLimit, '80') + assert.equal(values.blockStreaming, 'true') + assert.equal(values.dangerouslyAllowPrivateNetwork, 'true') + assert.equal(missingSecret.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingSecret.checks.find(item => item.id === 'credentials')?.detail || '', /Bot Secret.*Secret File/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Signal 渠道保存会保留多账号和上游运行字段', () => { const cfg = { channels: {} } diff --git a/tests/channel-ui-registry.test.js b/tests/channel-ui-registry.test.js index 98cb387..4985b44 100644 --- a/tests/channel-ui-registry.test.js +++ b/tests/channel-ui-registry.test.js @@ -90,3 +90,29 @@ test('ClickClack 渠道 UI 会暴露自托管工作区配置字段', () => { } assert.match(clickclackBlock, /pluginId:\s*'clickclack'/) }) + +test('Nextcloud Talk 渠道 UI 会暴露自托管 Talk 配置字段', () => { + const talkBlock = getRegistryBlock("'nextcloud-talk'") + + for (const field of [ + 'baseUrl', + 'botSecret', + 'botSecretFile', + 'apiUser', + 'apiPassword', + 'apiPasswordFile', + 'webhookPort', + 'webhookHost', + 'webhookPath', + 'webhookPublicUrl', + 'dmPolicy', + 'groupPolicy', + 'allowFrom', + 'groupAllowFrom', + 'dangerouslyAllowPrivateNetwork', + 'responsePrefix', + ]) { + assert.match(talkBlock, new RegExp(`key:\\s*'${field}'`)) + } + assert.match(talkBlock, /pluginId:\s*'nextcloud-talk'/) +})