diff --git a/scripts/dev-api.js b/scripts/dev-api.js index 0a5fb67..1938bf6 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2472,11 +2472,11 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds) } - for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots']) { + for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow']) { if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key]) } - for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs']) { + for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs', 'timeoutSeconds', 'reconnectMs']) { if (!Object.hasOwn(normalized, key)) continue const value = String(normalized[key] || '').trim() if (!value) { @@ -2489,7 +2489,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } - for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect']) { + for (const key of ['dangerouslyAllowNameMatching', 'dangerouslyAllowPrivateNetwork', 'dangerouslyAllowInheritedWebhookPath', 'allowInsecureSsl', 'enabled', 'allowBots', 'blockStreaming', 'useManagedIdentity', 'typingIndicator', 'welcomeCard', 'groupWelcomeCard', 'feedbackEnabled', 'feedbackReflection', 'delegatedAuthEnabled', 'ssoEnabled', 'configWrites', 'includeAttachments', 'sendReadReceipts', 'coalesceSameSenderDms', 'selfChatMode', 'ackDirect', 'senderIsOwner']) { if (Object.hasOwn(normalized, key)) { const value = String(normalized[key] || '').trim() if (!value) { @@ -2682,6 +2682,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { 'dingtalk-connector': [['clientId', 'Client ID'], ['clientSecret', 'Client Secret']], mattermost: [['botToken', 'Bot Token'], ['baseUrl', 'Base URL']], 'synology-chat': [['token', 'Token'], ['incomingUrl', 'Incoming URL']], + clickclack: [['baseUrl', 'Base URL'], ['token', 'Token'], ['workspace', 'Workspace']], signal: [['account', 'Signal 账号']], } @@ -3047,6 +3048,20 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'clickclack') { + for (const key of ['name', 'baseUrl', 'token', 'workspace', 'botUserId', 'agentId', 'replyMode', 'model', 'systemPrompt', 'defaultTo']) { + putSecretAwareFormValue(form, saved, key) + } + putBoolFormValue(form, saved, 'enabled') + putBoolFormValue(form, saved, 'senderIsOwner') + putCsvFormValue(form, saved, 'toolsAllow') + putCsvFormValue(form, saved, 'allowFrom') + for (const key of ['timeoutSeconds', 'reconnectMs']) { + 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) @@ -3800,6 +3815,17 @@ 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 === 'clickclack') { + entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true + for (const key of ['name', 'baseUrl', 'token', 'workspace', 'botUserId', 'agentId', 'replyMode', 'model', 'systemPrompt', 'defaultTo']) { + if (form[key]) entry[key] = form[key] + } + if (Array.isArray(form.toolsAllow) && form.toolsAllow.length) entry.toolsAllow = form.toolsAllow + if (Array.isArray(form.allowFrom) && form.allowFrom.length) entry.allowFrom = form.allowFrom + if (typeof form.senderIsOwner === 'boolean') entry.senderIsOwner = form.senderIsOwner + for (const key of ['timeoutSeconds', 'reconnectMs']) { + 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] @@ -3841,7 +3867,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', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { + if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { ensureMessagingPluginAllowed(cfg, storageKey) } return { entry, accountId: normalizedAccountId, storageKey } @@ -5311,7 +5337,7 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + } else if (['line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, built) ensureMessagingPluginAllowed(cfg, storageKey) @@ -5320,7 +5346,7 @@ const handlers = { preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) @@ -5445,6 +5471,9 @@ const handlers = { if (platform === 'whatsapp') { return { valid: true, warnings: ['WhatsApp 使用扫码登录维护本地会话,无需在线校验 Bot Token;请通过「启动扫码登录」完成配对。'] } } + if (platform === 'clickclack') { + return { valid: true, warnings: ['ClickClack 面板已完成基础字段校验;实际连通性请通过 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 ace20f7..4731aca 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -260,6 +260,11 @@ fn required_channel_credential_fields( "dingtalk-connector" => vec![("clientId", "Client ID"), ("clientSecret", "Client Secret")], "mattermost" => vec![("botToken", "Bot Token"), ("baseUrl", "Base URL")], "synology-chat" => vec![("token", "Token"), ("incomingUrl", "Incoming URL")], + "clickclack" => vec![ + ("baseUrl", "Base URL"), + ("token", "Token"), + ("workspace", "Workspace"), + ], "signal" => vec![("account", "Signal 账号")], "slack" => { let mode = form_string(form, "mode"); @@ -905,12 +910,15 @@ fn normalize_messaging_platform_form( normalize_numeric_form_value(&mut normalized, "httpPort"); normalize_numeric_form_value(&mut normalized, "webhookPort"); normalize_numeric_form_value(&mut normalized, "feedbackReflectionCooldownMs"); + normalize_numeric_form_value(&mut normalized, "timeoutSeconds"); + normalize_numeric_form_value(&mut normalized, "reconnectMs"); for key in [ "promptStarters", "delegatedAuthScopes", "attachmentRoots", "remoteAttachmentRoots", + "toolsAllow", ] { if normalized.contains_key(key) { let items = json_array_from_csv_value(normalized.get(key)); @@ -940,6 +948,7 @@ fn normalize_messaging_platform_form( "coalesceSameSenderDms", "selfChatMode", "ackDirect", + "senderIsOwner", ] { if normalized.contains_key(key) { let value = match normalized.get(key) { @@ -1756,6 +1765,28 @@ pub async fn read_platform_config( insert_string_if_present(&mut form, commands, "callbackUrl"); } } + "clickclack" => { + for key in [ + "name", + "baseUrl", + "token", + "workspace", + "botUserId", + "agentId", + "replyMode", + "model", + "systemPrompt", + "defaultTo", + ] { + insert_secret_aware_form_value(&mut form, &saved, key); + } + insert_bool_as_string(&mut form, &saved, "enabled"); + insert_bool_as_string(&mut form, &saved, "senderIsOwner"); + insert_array_as_csv(&mut form, &saved, "toolsAllow"); + insert_array_as_csv(&mut form, &saved, "allowFrom"); + insert_number_as_string(&mut form, &saved, "timeoutSeconds"); + insert_number_as_string(&mut form, &saved, "reconnectMs"); + } "synology-chat" => { for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] { insert_secret_aware_form_value(&mut form, &saved, key); @@ -2775,6 +2806,55 @@ pub async fn save_messaging_platform( )?; ensure_plugin_allowed(&mut cfg, "mattermost")?; } + "clickclack" => { + let base_url = form_string(form_obj, "baseUrl"); + let token = form_string(form_obj, "token"); + let workspace = form_string(form_obj, "workspace"); + if base_url.is_empty() { + return Err("ClickClack Base URL 不能为空".into()); + } + if token.is_empty() { + return Err("ClickClack Token 不能为空".into()); + } + if workspace.is_empty() { + return Err("ClickClack Workspace 不能为空".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")); + put_string(&mut entry, "baseUrl", base_url); + put_string(&mut entry, "token", token); + put_string(&mut entry, "workspace", workspace); + for key in [ + "name", + "botUserId", + "agentId", + "replyMode", + "model", + "systemPrompt", + "defaultTo", + ] { + put_string(&mut entry, key, form_string(form_obj, key)); + } + put_array_from_form_value(&mut entry, "toolsAllow", form_obj.get("toolsAllow")); + put_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + put_bool_value_if_present(&mut entry, "senderIsOwner", form_obj.get("senderIsOwner")); + put_number_value_if_present( + &mut entry, + "timeoutSeconds", + form_obj.get("timeoutSeconds"), + ); + put_number_value_if_present(&mut entry, "reconnectMs", form_obj.get("reconnectMs")); + 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, "clickclack")?; + } "synology-chat" => { let token = form_string(form_obj, "token"); let incoming_url = form_string(form_obj, "incomingUrl"); @@ -3072,6 +3152,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result Ok(json!({ + "valid": true, + "warnings": ["ClickClack 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"] + })), _ => Ok(json!({ "valid": true, "warnings": ["该平台暂不支持在线校验"] @@ -6144,6 +6228,82 @@ mod tests { ); } + #[test] + fn normalize_clickclack_form_preserves_workspace_runtime_fields() { + let form = json!({ + "enabled": "true", + "baseUrl": "https://clickclack.example.com", + "token": "clickclack-token", + "workspace": "ops", + "replyMode": "model", + "timeoutSeconds": "120", + "toolsAllow": "shell, browser.search", + "senderIsOwner": "true", + "defaultTo": "channel:ops", + "allowFrom": "channel:ops, dm:alice", + "reconnectMs": "2500" + }); + let normalized = + normalize_messaging_platform_form("clickclack", form.as_object().expect("object")); + + assert_eq!( + normalized.get("enabled").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized.get("timeoutSeconds").and_then(|v| v.as_f64()), + Some(120.0) + ); + assert_eq!( + normalized.get("reconnectMs").and_then(|v| v.as_f64()), + Some(2500.0) + ); + assert_eq!( + normalized.get("senderIsOwner").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized + .get("toolsAllow") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized + .get("allowFrom") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert!(channel_diagnosis_credentials_ready( + "clickclack", + &normalized + )); + + let missing_workspace = json!({ + "baseUrl": "https://clickclack.example.com", + "token": "clickclack-token" + }); + let missing = normalize_messaging_platform_form( + "clickclack", + missing_workspace.as_object().expect("object"), + ); + assert!(!channel_diagnosis_credentials_ready("clickclack", &missing)); + let diagnosis = + build_openclaw_channel_diagnosis("clickclack", 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("Workspace")); + } + #[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 b28df41..8bed747 100644 --- a/src/lib/channel-labels.js +++ b/src/lib/channel-labels.js @@ -19,6 +19,7 @@ export const CHANNEL_LABELS = { line: 'LINE', nostr: 'Nostr', mattermost: 'Mattermost', + clickclack: 'ClickClack', 'openclaw-weixin': '微信', weixin: '微信', } diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 77e6cdb..c0bf0e1 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -128,6 +128,22 @@ 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.'), + clickclackDesc: _('接入自托管 ClickClack 工作区,支持频道、线程和私信目标', 'Connect a self-hosted ClickClack workspace with channel, thread, and DM targets'), + clickclackGuide1: _('确认 ClickClack 服务已部署并可被 Gateway 访问,复制工作区 Base URL', 'Make sure ClickClack is deployed and reachable by Gateway, then copy the workspace Base URL'), + clickclackGuide2: _('准备访问 Token 与 Workspace 标识;多账号时建议填写账号标识,例如 work', 'Prepare the access Token and Workspace identifier; for multi-account setup, use an account id such as work'), + clickclackGuide3: _('Default To 支持 channel:<id>thread:<id>dm:<id>,例如 channel:general', 'Default To supports channel:<id>, thread:<id>, and dm:<id>, e.g. channel:general'), + clickclackGuide4: _('保存后面板会启用 bundled ClickClack 插件并重载 Gateway;连通性以 Gateway 日志或 channels status 为准', 'After saving, the panel enables the bundled ClickClack plugin and reloads Gateway; verify connectivity through Gateway logs or channels status'), + clickclackGuideFooter: _('
ClickClack 最小配置需要 Base URL、Token 与 Workspace。
', '
ClickClack minimally requires Base URL, Token, and Workspace.
'), + clickclackBaseUrlHint: _('填写 ClickClack 服务根地址,例如 https://clickclack.example.com。', 'Use the ClickClack service root URL, for example https://clickclack.example.com.'), + clickclackTokenPh: _('ClickClack Access Token', 'ClickClack Access Token'), + clickclackWorkspaceHint: _('工作区标识必须与 ClickClack 服务端配置一致。', 'Workspace must match the ClickClack server configuration.'), + clickclackSystemPromptPh: _('可选,覆盖该渠道使用的系统提示词', 'Optional system prompt override for this channel'), + clickclackTimeoutHint: _('请求超时时间,单位秒;上游允许 1 到 3600。', 'Request timeout in seconds; upstream allows 1 to 3600.'), + clickclackToolsAllowHint: _('可选,逗号分隔允许调用的工具名。', 'Optional comma-separated tool names allowed for this channel.'), + clickclackSenderIsOwner: _('发送者作为 Owner', 'Sender Is Owner'), + 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.'), 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 e0f07aa..6a3651b 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -274,6 +274,42 @@ const PLATFORM_REGISTRY = { pluginRequired: '@openclaw/mattermost@latest', pluginId: 'mattermost', }, + clickclack: { + label: 'ClickClack', + iconName: 'message-square', + desc: t('channels.clickclackDesc'), + guide: [ + t('channels.clickclackGuide1'), + t('channels.clickclackGuide2'), + t('channels.clickclackGuide3'), + t('channels.clickclackGuide4'), + ], + guideFooter: t('channels.clickclackGuideFooter'), + fields: [ + { key: 'baseUrl', label: 'Base URL', placeholder: 'https://clickclack.example.com', required: true, hint: t('channels.clickclackBaseUrlHint') }, + { key: 'token', label: 'Token', placeholder: t('channels.clickclackTokenPh'), secret: true, required: true }, + { key: 'workspace', label: 'Workspace', placeholder: 'ops', required: true, hint: t('channels.clickclackWorkspaceHint') }, + { key: 'name', label: t('channels.accountName'), placeholder: t('channels.optionalEg', { example: 'ops' }), required: false }, + { key: 'botUserId', label: 'Bot User ID', placeholder: t('channels.optionalEg', { example: 'bot-1' }), required: false }, + { key: 'agentId', label: 'Agent ID', placeholder: t('channels.optionalEg', { example: 'default' }), required: false }, + { key: 'replyMode', label: 'Reply Mode', type: 'select', options: [ + { value: '', label: t('channels.policyDefault') }, + { value: 'agent', label: 'Agent' }, + { value: 'model', label: 'Model' }, + ], required: false }, + { key: 'model', label: 'Model', placeholder: t('channels.optionalEg', { example: 'claude-sonnet-4-5' }), required: false }, + { key: 'systemPrompt', label: 'System Prompt', placeholder: t('channels.clickclackSystemPromptPh'), multiline: true, required: false }, + { key: 'timeoutSeconds', label: 'Timeout Seconds', placeholder: '120', required: false, hint: t('channels.clickclackTimeoutHint') }, + { key: 'toolsAllow', label: 'Tools Allow', placeholder: 'shell, browser.search', required: false, hint: t('channels.clickclackToolsAllowHint') }, + { key: 'senderIsOwner', label: t('channels.clickclackSenderIsOwner'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.clickclackSenderIsOwnerHint') }, + { key: 'defaultTo', label: 'Default To', placeholder: 'channel:general', required: false, hint: t('channels.clickclackDefaultToHint') }, + { key: 'allowFrom', label: 'Allow From', placeholder: '*, channel:general, dm:alice', required: false, hint: t('channels.clickclackAllowFromHint') }, + { key: 'reconnectMs', label: 'Reconnect MS', placeholder: '1500', required: false }, + ], + configKey: 'clickclack', + pairingChannel: 'clickclack', + pluginId: 'clickclack', + }, 'synology-chat': { label: 'Synology Chat', iconName: 'message-square', @@ -796,7 +832,7 @@ function applyRouteIntent(page, state) { // ── 已配置平台渲染 ── // ── 多账号支持的平台:与 OpenClaw 的 accounts/defaultAccount 配置模型保持一致 ── -const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'synology-chat', 'googlechat', 'signal'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', '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 2c40891..9c8bce4 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -151,6 +151,104 @@ test('WhatsApp 读取会回显扫码运行字段且诊断不要求 Bot Token', ( assert.match(diagnosis.checks.find(item => item.id === 'credentials')?.title || '', /扫码|会话/) }) +test('ClickClack 渠道保存会写入自托管运行字段并启用插件', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'clickclack', + accountId: 'work', + form: { + name: 'Ops Workspace', + enabled: 'true', + baseUrl: 'https://clickclack.example.com', + token: 'clickclack-token', + workspace: 'ops', + botUserId: 'bot-1', + agentId: 'agent-1', + replyMode: 'model', + model: 'claude-sonnet-4-5', + systemPrompt: 'You are an ops assistant.', + timeoutSeconds: '120', + toolsAllow: 'shell, browser.search', + senderIsOwner: 'true', + defaultTo: 'channel:ops', + allowFrom: 'channel:ops, dm:alice', + reconnectMs: '2500', + }, + }) + + const root = cfg.channels.clickclack + const account = root.accounts.work + assert.equal(root.defaultAccount, 'work') + assert.equal(account.enabled, true) + assert.equal(account.name, 'Ops Workspace') + assert.equal(account.baseUrl, 'https://clickclack.example.com') + assert.equal(account.token, 'clickclack-token') + assert.equal(account.workspace, 'ops') + assert.equal(account.botUserId, 'bot-1') + assert.equal(account.agentId, 'agent-1') + assert.equal(account.replyMode, 'model') + assert.equal(account.model, 'claude-sonnet-4-5') + assert.equal(account.systemPrompt, 'You are an ops assistant.') + assert.equal(account.timeoutSeconds, 120) + assert.deepEqual(account.toolsAllow, ['shell', 'browser.search']) + assert.equal(account.senderIsOwner, true) + assert.equal(account.defaultTo, 'channel:ops') + assert.deepEqual(account.allowFrom, ['channel:ops', 'dm:alice']) + assert.equal(account.reconnectMs, 2500) + assert.equal(cfg.plugins.entries.clickclack.enabled, true) +}) + +test('ClickClack 读取会回显运行字段且诊断要求 Base URL、Token 和 Workspace', () => { + const values = buildMessagingPlatformFormValues('clickclack', { + name: 'Ops Workspace', + enabled: true, + baseUrl: 'https://clickclack.example.com', + token: 'clickclack-token', + workspace: 'ops', + botUserId: 'bot-1', + agentId: 'agent-1', + replyMode: 'agent', + model: 'claude-sonnet-4-5', + systemPrompt: 'You are an ops assistant.', + timeoutSeconds: 90, + toolsAllow: ['shell', 'browser.search'], + senderIsOwner: false, + defaultTo: 'channel:general', + allowFrom: ['*'], + reconnectMs: 1500, + }) + const missingWorkspace = buildOpenClawChannelDiagnosis({ + platform: 'clickclack', + configExists: true, + channelEnabled: true, + form: { + baseUrl: 'https://clickclack.example.com', + token: 'clickclack-token', + }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'clickclack', + configExists: true, + channelEnabled: true, + form: values, + }) + + assert.equal(values.enabled, 'true') + assert.equal(values.baseUrl, 'https://clickclack.example.com') + assert.equal(values.token, 'clickclack-token') + assert.equal(values.workspace, 'ops') + assert.equal(values.replyMode, 'agent') + assert.equal(values.timeoutSeconds, '90') + assert.equal(values.toolsAllow, 'shell, browser.search') + assert.equal(values.senderIsOwner, 'false') + assert.equal(values.allowFrom, '*') + assert.equal(values.reconnectMs, '1500') + assert.equal(missingWorkspace.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingWorkspace.checks.find(item => item.id === 'credentials')?.detail || '', /Workspace/) + 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 4db060b..98cb387 100644 --- a/tests/channel-ui-registry.test.js +++ b/tests/channel-ui-registry.test.js @@ -66,3 +66,27 @@ test('WhatsApp 渠道 UI 会恢复扫码登录和运行配置入口', () => { assert.match(whatsappBlock, /pluginRequired:\s*'@openclaw\/whatsapp@latest'/) assert.match(whatsappBlock, /pluginId:\s*'whatsapp'/) }) + +test('ClickClack 渠道 UI 会暴露自托管工作区配置字段', () => { + const clickclackBlock = getRegistryBlock('clickclack') + + for (const field of [ + 'baseUrl', + 'token', + 'workspace', + 'botUserId', + 'agentId', + 'replyMode', + 'model', + 'systemPrompt', + 'timeoutSeconds', + 'toolsAllow', + 'senderIsOwner', + 'defaultTo', + 'allowFrom', + 'reconnectMs', + ]) { + assert.match(clickclackBlock, new RegExp(`key:\\s*'${field}'`)) + } + assert.match(clickclackBlock, /pluginId:\s*'clickclack'/) +})