diff --git a/scripts/dev-api.js b/scripts/dev-api.js index f605b31..7737c9f 100644 --- a/scripts/dev-api.js +++ b/scripts/dev-api.js @@ -2432,13 +2432,39 @@ function platformSupportsTopLevelRequireMention(platform) { return ['feishu', 'slack', 'msteams', 'mattermost', 'googlechat', 'nextcloud-talk', 'twitch'].includes(platformStorageKey(platform)) } +function buildIrcGroupsFromForm(form = {}) { + const groupIds = csvToStringArray(form.groups) + if (!groupIds.length) return null + const groups = {} + for (const groupId of groupIds) { + groups[groupId] = {} + if (typeof form.requireMention === 'boolean') groups[groupId].requireMention = form.requireMention + } + return groups +} + +function putIrcGroupFormValues(form, saved = {}) { + const groups = saved?.groups && typeof saved.groups === 'object' && !Array.isArray(saved.groups) + ? saved.groups + : null + if (!groups) return + const groupIds = Object.keys(groups).filter(Boolean) + if (groupIds.length) form.groups = groupIds.join(', ') + const mentionValues = groupIds + .map(groupId => groups[groupId]?.requireMention) + .filter(value => typeof value === 'boolean') + if (mentionValues.length && mentionValues.every(value => value === mentionValues[0])) { + form.requireMention = mentionValues[0] ? 'true' : 'false' + } +} + export function normalizeMessagingPlatformForm(platform, form = {}) { const storageKey = platformStorageKey(platform) const normalized = { ...(form || {}) } if (!Object.hasOwn(normalized, 'allowFrom') && Object.hasOwn(normalized, 'allowedUsers')) { normalized.allowFrom = normalized.allowedUsers } - const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat', 'nextcloud-talk', 'imessage'].includes(storageKey) + const needsAccessDefaults = ['telegram', 'discord', 'feishu', 'slack', 'signal', 'msteams', 'whatsapp', 'zalo', 'zalouser', 'line', 'mattermost', 'googlechat', 'nextcloud-talk', 'imessage', 'irc'].includes(storageKey) const hasDmField = Object.hasOwn(normalized, 'dmPolicy') || needsAccessDefaults const hasGroupField = Object.hasOwn(normalized, 'groupPolicy') || needsAccessDefaults @@ -2472,11 +2498,11 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { normalized.allowedUserIds = csvToStringArray(normalized.allowedUserIds) } - for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays']) { + for (const key of ['promptStarters', 'delegatedAuthScopes', 'attachmentRoots', 'remoteAttachmentRoots', 'toolsAllow', 'allowedRoles', 'relays', 'channels', 'groups', 'mentionPatterns']) { if (Object.hasOwn(normalized, key)) normalized[key] = csvToStringArray(normalized[key]) } - for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs', 'timeoutSeconds', 'reconnectMs', 'expiresIn', 'obtainmentTimestamp']) { + for (const key of ['mediaMaxMb', 'historyLimit', 'dmHistoryLimit', 'textChunkLimit', 'probeTimeoutMs', 'debounceMs', 'rateLimitPerMinute', 'httpPort', 'webhookPort', 'feedbackReflectionCooldownMs', 'timeoutSeconds', 'reconnectMs', 'expiresIn', 'obtainmentTimestamp', 'port']) { if (!Object.hasOwn(normalized, key)) continue const value = String(normalized[key] || '').trim() if (!value) { @@ -2489,7 +2515,7 @@ export function normalizeMessagingPlatformForm(platform, form = {}) { } } - 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', 'requireMention']) { + 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', 'requireMention', 'tls', 'nickservEnabled', 'nickservRegister']) { if (Object.hasOwn(normalized, key)) { const value = typeof normalized[key] === 'boolean' ? String(normalized[key]) @@ -2577,17 +2603,35 @@ function putSecretAwareFormValue(form, source, key) { } } -export function resolveMessagingCredentialValueForSave({ form = {}, current = {}, key }) { - const rawValue = form?.[key] +function putSecretAwareFormAlias(form, source, sourceKey, formKey) { + if (typeof source?.[sourceKey] === 'string') { + form[formKey] = source[sourceKey] + return + } + const ref = normalizeSecretRef(source?.[sourceKey]) + if (!ref) return + form[formKey] = formatSecretRefPlaceholder(ref) + form.__secretRefs = { + ...(form.__secretRefs || {}), + [formKey]: ref, + } +} + +function resolveMessagingCredentialFormValueForSave({ form = {}, current = {}, formKey, currentKey = formKey }) { + const rawValue = form?.[formKey] if (typeof rawValue !== 'string') return rawValue const value = rawValue.trim() - const currentRef = normalizeSecretRef(current?.[key]) + const currentRef = normalizeSecretRef(current?.[currentKey]) if (currentRef && (!value || value === formatSecretRefPlaceholder(currentRef))) { return currentRef } return value || undefined } +export function resolveMessagingCredentialValueForSave({ form = {}, current = {}, key }) { + return resolveMessagingCredentialFormValueForSave({ form, current, formKey: key }) +} + const MESSAGING_CREDENTIAL_FIELDS = [ 'accessToken', 'appId', @@ -2607,6 +2651,7 @@ const MESSAGING_CREDENTIAL_FIELDS = [ 'gatewayPassword', 'gatewayToken', 'password', + 'passwordFile', 'privateKey', 'secretFile', 'serviceAccount', @@ -2698,6 +2743,7 @@ const CHANNEL_DIAG_REQUIRED_FIELDS = { clickclack: [['baseUrl', 'Base URL'], ['token', 'Token'], ['workspace', 'Workspace']], 'nextcloud-talk': [['baseUrl', 'Base URL']], nostr: [['privateKey', 'Private Key']], + irc: [['host', 'Host'], ['nick', 'Nick']], twitch: [['username', 'Username'], ['accessToken', 'Access Token'], ['clientId', 'Client ID'], ['channel', 'Channel']], signal: [['account', 'Signal 账号']], } @@ -3133,6 +3179,38 @@ export function buildMessagingPlatformFormValues(platform, saved = {}, options = return form } + if (storageKey === 'irc') { + for (const key of ['name', 'host', 'nick', 'username', 'realname', 'password', 'passwordFile', 'defaultTo', 'chunkMode', 'responsePrefix']) { + putSecretAwareFormValue(form, saved, key) + } + putBoolFormValue(form, saved, 'enabled') + putBoolFormValue(form, saved, 'tls') + putBoolFormValue(form, saved, 'blockStreaming') + putBoolFormValue(form, saved, 'dangerouslyAllowNameMatching') + putAccessPolicyFormValues(form, saved) + putCsvFormValue(form, saved, 'groupAllowFrom') + putCsvFormValue(form, saved, 'channels') + putCsvFormValue(form, saved, 'mentionPatterns') + putIrcGroupFormValues(form, saved) + for (const key of ['port', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) { + if (typeof saved[key] === 'number') form[key] = String(saved[key]) + } + const nickserv = saved.nickserv && typeof saved.nickserv === 'object' ? saved.nickserv : {} + if (typeof nickserv.enabled === 'boolean') { + form.nickservEnabled = nickserv.enabled ? 'true' : 'false' + } + putSecretAwareFormAlias(form, nickserv, 'service', 'nickservService') + putSecretAwareFormAlias(form, nickserv, 'password', 'nickservPassword') + putSecretAwareFormAlias(form, nickserv, 'passwordFile', 'nickservPasswordFile') + if (typeof nickserv.register === 'boolean') { + form.nickservRegister = nickserv.register ? 'true' : 'false' + } + if (typeof nickserv.registerEmail === 'string') { + form.nickservRegisterEmail = nickserv.registerEmail + } + return form + } + if (storageKey === 'synology-chat') { for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { putSecretAwareFormValue(form, saved, key) @@ -3596,7 +3674,7 @@ export function listPlatformAccounts(channelRoot) { return Object.entries(channelRoot.accounts) .map(([accountId, value]) => { const entry = { accountId } - const displayId = ['appId', 'clientId', 'account'] + const displayId = ['appId', 'clientId', 'account', 'nick'] .map(key => secretAwareAccountDisplayValue(value?.[key])) .find(Boolean) if (displayId) entry.appId = displayId @@ -3947,6 +4025,37 @@ function buildOpenClawMessagingPlatformEntry(platform, form, currentSaved = {}) if (form[formKey]) profile[targetKey] = form[formKey] } if (Object.keys(profile).length) entry.profile = profile + } else if (storageKey === 'irc') { + entry.enabled = typeof form.enabled === 'boolean' ? form.enabled : true + for (const key of ['name', 'host', 'nick', 'username', 'realname', 'password', 'passwordFile', 'defaultTo', 'chunkMode', 'responsePrefix']) { + if (form[key]) entry[key] = form[key] + } + entry.dmPolicy = form.dmPolicy + entry.groupPolicy = form.groupPolicy + 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 (Array.isArray(form.channels) && form.channels.length) entry.channels = form.channels + if (Array.isArray(form.mentionPatterns) && form.mentionPatterns.length) entry.mentionPatterns = form.mentionPatterns + const groups = buildIrcGroupsFromForm(form) + if (groups) entry.groups = groups + for (const key of ['tls', 'blockStreaming', 'dangerouslyAllowNameMatching']) { + if (typeof form[key] === 'boolean') entry[key] = form[key] + } + for (const key of ['port', 'historyLimit', 'dmHistoryLimit', 'mediaMaxMb', 'textChunkLimit']) { + if (typeof form[key] === 'number') entry[key] = form[key] + } + const nickserv = { ...(currentSaved?.nickserv && typeof currentSaved.nickserv === 'object' ? currentSaved.nickserv : {}) } + if (typeof form.nickservEnabled === 'boolean') nickserv.enabled = form.nickservEnabled + if (form.nickservService) nickserv.service = form.nickservService + const nickservPassword = resolveMessagingCredentialFormValueForSave({ form, current: currentSaved?.nickserv || {}, formKey: 'nickservPassword', currentKey: 'password' }) + if (nickservPassword === undefined) delete nickserv.password + else nickserv.password = nickservPassword + const nickservPasswordFile = resolveMessagingCredentialFormValueForSave({ form, current: currentSaved?.nickserv || {}, formKey: 'nickservPasswordFile', currentKey: 'passwordFile' }) + if (nickservPasswordFile === undefined) delete nickserv.passwordFile + else nickserv.passwordFile = nickservPasswordFile + if (typeof form.nickservRegister === 'boolean') nickserv.register = form.nickservRegister + if (form.nickservRegisterEmail) nickserv.registerEmail = form.nickservRegisterEmail + if (Object.keys(nickserv).length) entry.nickserv = nickserv } else if (storageKey === 'synology-chat') { for (const key of ['token', 'incomingUrl', 'nasHost', 'webhookPath', 'botName']) { if (form[key]) entry[key] = form[key] @@ -3988,7 +4097,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, storageKey === 'nostr' ? '' : normalizedAccountId, entry) - if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { + if (['zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'imessage', 'whatsapp'].includes(storageKey)) { ensureMessagingPluginAllowed(cfg, storageKey) } return { entry, accountId: normalizedAccountId, storageKey } @@ -5458,7 +5567,7 @@ const handlers = { } else { setRootChannelEntry(entry) } - } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + } else if (['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { const built = buildOpenClawMessagingPlatformEntry(platform, form, currentSaved) applyMessagingPlatformEntry(cfg, storageKey, storageKey === 'nostr' ? '' : normalizedAccountId, built) ensureMessagingPluginAllowed(cfg, storageKey) @@ -5467,7 +5576,7 @@ const handlers = { preserveMessagingCredentialRefs(entry, form, currentSaved) } - if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { + if (platform !== 'qqbot' && platform !== 'feishu' && platform !== 'dingtalk' && platform !== 'dingtalk-connector' && !['line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'nostr', 'irc', 'synology-chat', 'googlechat', 'msteams', 'whatsapp'].includes(storageKey)) { preserveMessagingCredentialRefs(entry, form, currentSaved) // 合并模式:保留用户通过 CLI 或手动编辑的自定义字段 applyMessagingPlatformEntry(cfg, storageKey, normalizedAccountId, entry) @@ -5604,6 +5713,9 @@ const handlers = { if (platform === 'nostr') { return { valid: true, warnings: ['Nostr 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证。'] } } + if (platform === 'irc') { + return { valid: true, warnings: ['IRC 面板已完成基础字段校验;实际连通性请通过 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 877e556..ca52ea5 100644 --- a/src-tauri/src/commands/messaging.rs +++ b/src-tauri/src/commands/messaging.rs @@ -103,6 +103,32 @@ fn insert_secret_aware_form_value(form: &mut Map, source: &Value, } } +fn insert_secret_aware_form_alias( + form: &mut Map, + source: &Value, + source_key: &str, + form_key: &str, +) { + if let Some(v) = source.get(source_key).and_then(|v| v.as_str()) { + form.insert(form_key.into(), Value::String(v.into())); + return; + } + + let Some(value) = source.get(source_key) else { + return; + }; + let Some(placeholder) = secret_ref_placeholder(value) else { + return; + }; + form.insert(form_key.into(), Value::String(placeholder)); + let refs = form + .entry("__secretRefs") + .or_insert_with(|| Value::Object(Map::new())); + if let Some(obj) = refs.as_object_mut() { + obj.insert(form_key.into(), value.clone()); + } +} + fn resolve_messaging_credential_value_for_save( form_obj: &Map, current: &Value, @@ -127,6 +153,31 @@ fn resolve_messaging_credential_value_for_save( } } +fn resolve_messaging_credential_value_for_save_alias( + form_obj: &Map, + current: &Value, + form_key: &str, + current_key: &str, +) -> Option { + let raw_value = form_obj.get(form_key)?; + let Value::String(raw) = raw_value else { + return Some(raw_value.clone()); + }; + let value = raw.trim(); + if let Some(current_value) = current.get(current_key) { + if let Some(placeholder) = secret_ref_placeholder(current_value) { + if value.is_empty() || value == placeholder { + return Some(current_value.clone()); + } + } + } + if value.is_empty() { + None + } else { + Some(Value::String(value.to_string())) + } +} + fn preserve_messaging_credential_refs( entry: &mut Map, form_obj: &Map, @@ -152,6 +203,7 @@ fn preserve_messaging_credential_refs( "gatewayPassword", "gatewayToken", "password", + "passwordFile", "privateKey", "secretFile", "serviceAccount", @@ -279,6 +331,7 @@ fn required_channel_credential_fields( ], "nextcloud-talk" => vec![("baseUrl", "Base URL")], "nostr" => vec![("privateKey", "Private Key")], + "irc" => vec![("host", "Host"), ("nick", "Nick")], "twitch" => vec![ ("username", "Username"), ("accessToken", "Access Token"), @@ -675,6 +728,37 @@ fn insert_array_as_csv(form: &mut Map, source: &Value, key: &str) } } +fn insert_irc_groups_form_values(form: &mut Map, source: &Value) { + let Some(groups) = source.get("groups").and_then(|v| v.as_object()) else { + return; + }; + let group_ids = groups + .keys() + .filter(|key| !key.trim().is_empty()) + .cloned() + .collect::>(); + if !group_ids.is_empty() { + form.insert("groups".into(), Value::String(group_ids.join(", "))); + } + let mention_values = group_ids + .iter() + .filter_map(|group_id| { + groups + .get(group_id) + .and_then(|group| group.get("requireMention")) + .and_then(|v| v.as_bool()) + }) + .collect::>(); + if let Some(first) = mention_values.first() { + if mention_values.iter().all(|value| value == first) { + form.insert( + "requireMention".into(), + Value::String(if *first { "true" } else { "false" }.into()), + ); + } + } +} + fn insert_number_as_string(form: &mut Map, source: &Value, key: &str) { if let Some(v) = source.get(key).and_then(|v| v.as_f64()) { form.insert(key.into(), Value::String(v.to_string())); @@ -826,6 +910,30 @@ fn put_array_from_form_value(entry: &mut Map, key: &str, value: O } } +fn build_irc_groups_from_form(form_obj: &Map) -> Option { + let group_ids = json_array_from_csv_value(form_obj.get("groups")); + if group_ids.is_empty() { + return None; + } + let require_mention = form_obj.get("requireMention").and_then(|v| v.as_bool()); + let mut groups = Map::new(); + for value in group_ids { + let Some(group_id) = value.as_str().map(str::trim).filter(|s| !s.is_empty()) else { + continue; + }; + let mut group = Map::new(); + if let Some(require_mention) = require_mention { + group.insert("requireMention".into(), Value::Bool(require_mention)); + } + groups.insert(group_id.to_string(), Value::Object(group)); + } + if groups.is_empty() { + None + } else { + Some(Value::Object(groups)) + } +} + fn normalize_dm_policy_value(raw: Option<&Value>, fallback: &str) -> String { let value = raw.and_then(|v| v.as_str()).unwrap_or("").trim(); match value { @@ -885,6 +993,7 @@ fn normalize_messaging_platform_form( | "googlechat" | "nextcloud-talk" | "imessage" + | "irc" ); let has_dm_field = normalized.contains_key("dmPolicy") || needs_access_defaults; let has_group_field = normalized.contains_key("groupPolicy") || needs_access_defaults; @@ -959,6 +1068,7 @@ fn normalize_messaging_platform_form( normalize_numeric_form_value(&mut normalized, "reconnectMs"); normalize_numeric_form_value(&mut normalized, "expiresIn"); normalize_numeric_form_value(&mut normalized, "obtainmentTimestamp"); + normalize_numeric_form_value(&mut normalized, "port"); for key in [ "promptStarters", @@ -968,6 +1078,9 @@ fn normalize_messaging_platform_form( "toolsAllow", "allowedRoles", "relays", + "channels", + "groups", + "mentionPatterns", ] { if normalized.contains_key(key) { let items = json_array_from_csv_value(normalized.get(key)); @@ -999,6 +1112,9 @@ fn normalize_messaging_platform_form( "ackDirect", "senderIsOwner", "requireMention", + "tls", + "nickservEnabled", + "nickservRegister", ] { if normalized.contains_key(key) { let value = match normalized.get(key) { @@ -1915,6 +2031,69 @@ pub async fn read_platform_config( } } } + "irc" => { + for key in [ + "name", + "host", + "nick", + "username", + "realname", + "password", + "passwordFile", + "defaultTo", + "chunkMode", + "responsePrefix", + ] { + insert_secret_aware_form_value(&mut form, &saved, key); + } + for key in [ + "enabled", + "tls", + "blockStreaming", + "dangerouslyAllowNameMatching", + ] { + insert_bool_as_string(&mut form, &saved, key); + } + insert_access_policy_form_values(&mut form, &saved, false, false); + insert_array_as_csv(&mut form, &saved, "groupAllowFrom"); + insert_array_as_csv(&mut form, &saved, "channels"); + insert_array_as_csv(&mut form, &saved, "mentionPatterns"); + insert_irc_groups_form_values(&mut form, &saved); + for key in [ + "port", + "historyLimit", + "dmHistoryLimit", + "mediaMaxMb", + "textChunkLimit", + ] { + insert_number_as_string(&mut form, &saved, key); + } + if let Some(nickserv) = saved.get("nickserv") { + if let Some(v) = nickserv.get("enabled").and_then(|v| v.as_bool()) { + form.insert( + "nickservEnabled".into(), + Value::String(if v { "true" } else { "false" }.into()), + ); + } + insert_secret_aware_form_alias(&mut form, nickserv, "service", "nickservService"); + insert_secret_aware_form_alias(&mut form, nickserv, "password", "nickservPassword"); + insert_secret_aware_form_alias( + &mut form, + nickserv, + "passwordFile", + "nickservPasswordFile", + ); + if let Some(v) = nickserv.get("register").and_then(|v| v.as_bool()) { + form.insert( + "nickservRegister".into(), + Value::String(if v { "true" } else { "false" }.into()), + ); + } + if let Some(v) = nickserv.get("registerEmail").and_then(|v| v.as_str()) { + form.insert("nickservRegisterEmail".into(), Value::String(v.into())); + } + } + } "synology-chat" => { for key in ["token", "incomingUrl", "nasHost", "webhookPath", "botName"] { insert_secret_aware_form_value(&mut form, &saved, key); @@ -3153,6 +3332,119 @@ pub async fn save_messaging_platform( merge_channel_entry_for_account(channels_map, &storage_key, None, entry)?; ensure_plugin_allowed(&mut cfg, "nostr")?; } + "irc" => { + let host = form_string(form_obj, "host"); + let nick = form_string(form_obj, "nick"); + if host.is_empty() { + return Err("IRC Host 不能为空".into()); + } + if nick.is_empty() { + return Err("IRC Nick 不能为空".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", + "host", + "nick", + "username", + "realname", + "password", + "passwordFile", + "defaultTo", + "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_array_from_form_value(&mut entry, "allowFrom", form_obj.get("allowFrom")); + put_array_from_form_value(&mut entry, "groupAllowFrom", form_obj.get("groupAllowFrom")); + put_array_from_form_value(&mut entry, "channels", form_obj.get("channels")); + put_array_from_form_value( + &mut entry, + "mentionPatterns", + form_obj.get("mentionPatterns"), + ); + if let Some(groups) = build_irc_groups_from_form(form_obj) { + entry.insert("groups".into(), groups); + } + for key in ["tls", "blockStreaming", "dangerouslyAllowNameMatching"] { + put_bool_value_if_present(&mut entry, key, form_obj.get(key)); + } + for key in [ + "port", + "historyLimit", + "dmHistoryLimit", + "mediaMaxMb", + "textChunkLimit", + ] { + put_number_value_if_present(&mut entry, key, form_obj.get(key)); + } + + let mut nickserv = current_saved + .get("nickserv") + .and_then(|v| v.as_object()) + .cloned() + .unwrap_or_default(); + put_bool_value_if_present(&mut nickserv, "enabled", form_obj.get("nickservEnabled")); + put_string( + &mut nickserv, + "service", + form_string(form_obj, "nickservService"), + ); + match resolve_messaging_credential_value_for_save_alias( + form_obj, + current_saved.get("nickserv").unwrap_or(&Value::Null), + "nickservPassword", + "password", + ) { + Some(value) => { + nickserv.insert("password".into(), value); + } + None => { + nickserv.remove("password"); + } + } + match resolve_messaging_credential_value_for_save_alias( + form_obj, + current_saved.get("nickserv").unwrap_or(&Value::Null), + "nickservPasswordFile", + "passwordFile", + ) { + Some(value) => { + nickserv.insert("passwordFile".into(), value); + } + None => { + nickserv.remove("passwordFile"); + } + } + put_bool_value_if_present(&mut nickserv, "register", form_obj.get("nickservRegister")); + put_string( + &mut nickserv, + "registerEmail", + form_string(form_obj, "nickservRegisterEmail"), + ); + if !nickserv.is_empty() { + entry.insert("nickserv".into(), Value::Object(nickserv)); + } + + 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, "irc")?; + } "synology-chat" => { let token = form_string(form_obj, "token"); let incoming_url = form_string(form_obj, "incomingUrl"); @@ -3466,6 +3758,10 @@ pub async fn verify_bot_token(platform: String, form: Value) -> Result Ok(json!({ + "valid": true, + "warnings": ["IRC 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"] + })), _ => Ok(json!({ "valid": true, "warnings": ["该平台暂不支持在线校验"] @@ -4389,6 +4685,7 @@ pub async fn list_configured_platforms() -> Result { if let Some(display_id) = account_display_value(acct_val, "appId") .or_else(|| account_display_value(acct_val, "clientId")) .or_else(|| account_display_value(acct_val, "account")) + .or_else(|| account_display_value(acct_val, "nick")) { entry["appId"] = Value::String(display_id); } @@ -6869,6 +7166,136 @@ mod tests { .contains("Private Key")); } + #[test] + fn normalize_irc_form_preserves_server_nickserv_and_group_fields() { + let form = json!({ + "enabled": "true", + "host": "irc.libera.chat", + "port": "6697", + "tls": "true", + "nick": "openclaw-bot", + "username": "openclaw", + "realname": "OpenClaw Bot", + "passwordFile": "/run/secrets/irc-password", + "nickservEnabled": "true", + "nickservService": "NickServ", + "nickservPasswordFile": "/run/secrets/irc-nickserv", + "nickservRegister": "false", + "channels": "#openclaw, #ops", + "dmPolicy": "allowlist", + "allowFrom": "alice!ident@example.org, bob", + "groupPolicy": "allowlist", + "groups": "#openclaw, #ops", + "groupAllowFrom": "alice!ident@example.org", + "requireMention": "false", + "mentionPatterns": "openclaw:, @openclaw", + "historyLimit": "80", + "dmHistoryLimit": "20", + "mediaMaxMb": "25", + "textChunkLimit": "350", + "blockStreaming": "true", + "dangerouslyAllowNameMatching": "true" + }); + let normalized = + normalize_messaging_platform_form("irc", form.as_object().expect("object")); + + assert_eq!( + normalized.get("enabled").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized.get("port").and_then(|v| v.as_f64()), + Some(6697.0) + ); + assert_eq!(normalized.get("tls").and_then(|v| v.as_bool()), Some(true)); + assert_eq!( + normalized + .get("channels") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized + .get("groups") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert_eq!( + normalized.get("requireMention").and_then(|v| v.as_bool()), + Some(false) + ); + assert_eq!( + normalized.get("nickservEnabled").and_then(|v| v.as_bool()), + Some(true) + ); + assert_eq!( + normalized.get("nickservRegister").and_then(|v| v.as_bool()), + Some(false) + ); + assert_eq!( + normalized + .get("mentionPatterns") + .and_then(|v| v.as_array()) + .map(|items| items.len()), + Some(2) + ); + assert!(channel_diagnosis_credentials_ready("irc", &normalized)); + + let groups = build_irc_groups_from_form(&normalized).expect("groups"); + assert_eq!( + groups + .get("#openclaw") + .and_then(|group| group.get("requireMention")) + .and_then(|v| v.as_bool()), + Some(false) + ); + + let missing = normalize_messaging_platform_form( + "irc", + json!({ + "host": "irc.libera.chat" + }) + .as_object() + .expect("object"), + ); + assert!(!channel_diagnosis_credentials_ready("irc", &missing)); + let diagnosis = + build_openclaw_channel_diagnosis("irc", 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("Nick")); + } + + #[test] + fn verify_irc_token_returns_probe_guidance_warning() { + let result = tauri::async_runtime::block_on(verify_bot_token( + "irc".to_string(), + json!({ + "host": "irc.libera.chat", + "nick": "openclaw-bot" + }), + )) + .expect("verify result"); + + assert_eq!(result.get("valid").and_then(|v| v.as_bool()), Some(true)); + assert!(result + .get("warnings") + .and_then(|v| v.as_array()) + .and_then(|items| items.first()) + .and_then(|v| v.as_str()) + .unwrap_or("") + .contains("IRC 面板已完成基础字段校验")); + } + #[test] fn channel_form_readback_preserves_mention_policy_choice() { let saved = json!({ diff --git a/src/locales/modules/channels.js b/src/locales/modules/channels.js index 25d875b..eb1b037 100644 --- a/src/locales/modules/channels.js +++ b/src/locales/modules/channels.js @@ -190,6 +190,29 @@ export default { nostrDefaultAccountHint: _('上游当前通过根节点配置生成隐式账号,通常保持 default 即可。', 'Upstream currently derives an implicit account from the root config; default is usually enough.'), nostrProfileAboutPh: _('可选,展示在 Nostr Profile 中的机器人介绍', 'Optional bot introduction shown in the Nostr profile'), nostrProfileUrlHint: _('上游 Profile URL 字段要求使用 https:// 地址。', 'Upstream profile URL fields require https:// URLs.'), + ircDesc: _('接入 IRC 网络,支持服务器账号、NickServ 登录、频道白名单和提及策略', 'Connect IRC networks with server accounts, NickServ login, channel allowlists, and mention policy'), + ircGuide1: _('准备 IRC 服务器地址,例如 irc.libera.chat,并确认 Gateway 网络可以连接', 'Prepare the IRC server host, for example irc.libera.chat, and make sure Gateway can connect'), + ircGuide2: _('填写机器人 Nick;如服务器需要 SASL 或密码,可填写 Server Password 或文件路径', 'Fill the bot Nick; if the server requires SASL or a password, provide Server Password or a file path'), + ircGuide3: _('如账号已注册,开启 NickServ 并填写密码或密码文件;需要自动注册时再开启 Register', 'If the account is registered, enable NickServ and provide password or password file; enable Register only when automatic registration is needed'), + ircGuide4: _('填写自动加入频道和允许频道,保存后面板会安装 IRC 插件并重载 Gateway', 'Fill auto-join channels and allowed channels; after saving, the panel installs the IRC plugin and reloads Gateway'), + ircGuideFooter: _('
IRC 最小配置需要 Host 与 Nick;真实连接状态以 Gateway 日志或 channels status 为准。
', '
IRC minimally requires Host and Nick; verify real connectivity through Gateway logs or channels status.
'), + ircHostHint: _('IRC 服务器主机名,不包含 irc:// 前缀。', 'IRC server host name without the irc:// prefix.'), + ircPortHint: _('常见 TLS 端口为 6697,非 TLS 常见端口为 6667。', 'Common TLS port is 6697; common non-TLS port is 6667.'), + ircTlsHint: _('连接支持 TLS 的服务器时建议开启。', 'Recommended when connecting to servers that support TLS.'), + ircNickHint: _('机器人在 IRC 网络中的昵称,必须符合服务器昵称规则。', 'Bot nickname on the IRC network; it must satisfy server nickname rules.'), + ircPasswordHint: _('服务器连接密码或 SASL 密码;生产环境建议使用 SecretRef。', 'Server connection password or SASL password; prefer SecretRef in production.'), + ircPasswordFileHint: _('服务器密码文件路径;与 Server Password 二选一。', 'Path to the server password file; use this or Server Password.'), + ircNickservEnabled: _('启用 NickServ', 'Enable NickServ'), + ircNickservHint: _('用于向 NickServ 识别已注册昵称;可填写明文、SecretRef 或密码文件。', 'Used to identify a registered nick with NickServ; supports plain text, SecretRef, or password file.'), + ircNickservRegister: _('自动注册昵称', 'Auto-register nick'), + ircNickservRegisterHint: _('仅在你确认服务器允许机器人自动注册时开启。', 'Enable only when the server allows bot auto-registration.'), + ircChannelsHint: _('连接后自动加入的频道,使用 # 开头,多个值用逗号分隔。', 'Channels to auto-join after connect; use # prefixes and separate multiple values with commas.'), + ircGroupsHint: _('允许机器人响应的频道列表;Require Mention 会写入每个频道配置。', 'Channels where the bot may respond; Require Mention is written into each channel config.'), + ircGroupAllowFromHint: _('可选,限制频道内允许触发机器人的 nick 或 hostmask。', 'Optional nick or hostmask allowlist for channel messages.'), + ircRequireMention: _('频道要求提及机器人', 'Require mention in channels'), + ircMentionPatternsHint: _('额外提及模式,例如 openclaw: 或 @openclaw。', 'Additional mention patterns, for example openclaw: or @openclaw.'), + ircNameMatching: _('允许名称匹配', 'Allow name matching'), + ircNameMatchingHint: _('仅在无法使用稳定 hostmask 时开启;开启后同名用户存在误匹配风险。', 'Enable only when stable hostmasks are unavailable; duplicate nicks can be matched incorrectly.'), 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 ac432b9..f4e84d1 100644 --- a/src/pages/channels.js +++ b/src/pages/channels.js @@ -418,6 +418,59 @@ const PLATFORM_REGISTRY = { pluginRequired: '@openclaw/nostr@latest', pluginId: 'nostr', }, + irc: { + label: 'IRC', + iconName: 'hash', + desc: t('channels.ircDesc'), + guide: [ + t('channels.ircGuide1'), + t('channels.ircGuide2'), + t('channels.ircGuide3'), + t('channels.ircGuide4'), + ], + guideFooter: t('channels.ircGuideFooter'), + fields: [ + { key: 'host', label: 'Host', placeholder: 'irc.libera.chat', required: true, hint: t('channels.ircHostHint') }, + { key: 'port', label: 'Port', placeholder: '6697', required: false, hint: t('channels.ircPortHint') }, + { key: 'tls', label: 'TLS', type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.ircTlsHint') }, + { key: 'nick', label: 'Nick', placeholder: 'openclaw-bot', required: true, hint: t('channels.ircNickHint') }, + { key: 'username', label: 'Username', placeholder: 'openclaw', required: false }, + { key: 'realname', label: 'Real Name', placeholder: 'OpenClaw Bot', required: false }, + { key: 'password', label: 'Server Password', placeholder: t('channels.optionalEg', { example: 'server-password' }), secret: true, required: false, hint: t('channels.ircPasswordHint') }, + { key: 'passwordFile', label: 'Server Password File', placeholder: '/run/secrets/irc-password', required: false, hint: t('channels.ircPasswordFileHint') }, + { key: 'nickservEnabled', label: t('channels.ircNickservEnabled'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.ircNickservHint') }, + { key: 'nickservService', label: 'NickServ Service', placeholder: 'NickServ', required: false }, + { key: 'nickservPassword', label: 'NickServ Password', placeholder: t('channels.optionalEg', { example: 'nickserv-password' }), secret: true, required: false, hint: t('channels.ircNickservHint') }, + { key: 'nickservPasswordFile', label: 'NickServ Password File', placeholder: '/run/secrets/irc-nickserv', required: false }, + { key: 'nickservRegister', label: t('channels.ircNickservRegister'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.ircNickservRegisterHint') }, + { key: 'nickservRegisterEmail', label: 'NickServ Register Email', placeholder: 'bot@example.com', required: false }, + { key: 'channels', label: 'Auto Join Channels', placeholder: '#openclaw, #ops', required: false, hint: t('channels.ircChannelsHint') }, + { key: 'dmPolicy', label: t('channels.dmPolicy'), type: 'select', options: DM_POLICY_OPTIONS, required: false }, + { key: 'allowFrom', label: 'Allow From', placeholder: 'alice!ident@example.org, bob', required: false, hint: t('channels.allowFromHint') }, + { key: 'defaultTo', label: 'Default To', placeholder: '#openclaw', required: false }, + { key: 'groupPolicy', label: t('channels.groupPolicy'), type: 'select', options: GROUP_POLICY_OPTIONS(t('channels.groupAllChannels')), required: false }, + { key: 'groups', label: 'Allowed Channels', placeholder: '#openclaw, #ops', required: false, hint: t('channels.ircGroupsHint') }, + { key: 'groupAllowFrom', label: 'Group Allow From', placeholder: 'alice!ident@example.org', required: false, hint: t('channels.ircGroupAllowFromHint') }, + { key: 'requireMention', label: t('channels.ircRequireMention'), type: 'select', options: BOOLEAN_OPTIONS, required: false }, + { key: 'mentionPatterns', label: 'Mention Patterns', placeholder: 'openclaw:, @openclaw', required: false, hint: t('channels.ircMentionPatternsHint') }, + { 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: '25', required: false }, + { key: 'textChunkLimit', label: 'Text Chunk Limit', placeholder: '350', 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: 'responsePrefix', label: 'Response Prefix', placeholder: t('channels.optionalEg', { example: '[IRC]' }), required: false }, + { key: 'dangerouslyAllowNameMatching', label: t('channels.ircNameMatching'), type: 'select', options: BOOLEAN_OPTIONS, required: false, hint: t('channels.ircNameMatchingHint') }, + ], + configKey: 'irc', + pairingChannel: 'irc', + pluginRequired: '@openclaw/irc@latest', + pluginId: 'irc', + }, 'synology-chat': { label: 'Synology Chat', iconName: 'message-square', @@ -940,7 +993,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', 'nextcloud-talk', 'twitch', 'synology-chat', 'googlechat', 'signal'] +const MULTI_INSTANCE_PLATFORMS = ['telegram', 'discord', 'slack', 'feishu', 'dingtalk', 'dingtalk-connector', 'qqbot', 'zalo', 'zalouser', 'line', 'mattermost', 'clickclack', 'nextcloud-talk', 'twitch', 'irc', '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 f9c4bfc..0e1810c 100644 --- a/tests/channel-config-normalization.test.js +++ b/tests/channel-config-normalization.test.js @@ -530,6 +530,165 @@ test('Nostr 读取和诊断会回显 relay、访问控制和 profile 字段', () assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) }) +test('IRC 渠道保存会写入多账号服务器、NickServ 和频道访问结构并启用插件', () => { + const cfg = { channels: {} } + + mergeOpenClawMessagingPlatformConfig(cfg, { + platform: 'irc', + accountId: 'libera', + form: { + enabled: 'true', + name: 'Libera Ops', + host: 'irc.libera.chat', + port: '6697', + tls: 'true', + nick: 'openclaw-bot', + username: 'openclaw', + realname: 'OpenClaw Bot', + password: 'server-password', + passwordFile: '/run/secrets/irc-password', + nickservEnabled: 'true', + nickservService: 'NickServ', + nickservPassword: 'nickserv-password', + nickservPasswordFile: '/run/secrets/irc-nickserv', + nickservRegister: 'false', + nickservRegisterEmail: 'bot@example.com', + channels: '#openclaw, #ops', + dmPolicy: 'allowlist', + allowFrom: 'alice!ident@example.org, bob', + defaultTo: '#openclaw', + groupPolicy: 'allowlist', + groups: '#openclaw, #ops', + groupAllowFrom: 'alice!ident@example.org', + requireMention: 'false', + mentionPatterns: 'openclaw:, @openclaw', + historyLimit: '80', + dmHistoryLimit: '20', + mediaMaxMb: '25', + textChunkLimit: '350', + chunkMode: 'newline', + blockStreaming: 'true', + responsePrefix: '[IRC]', + dangerouslyAllowNameMatching: 'true', + }, + }) + + const root = cfg.channels.irc + const account = root.accounts.libera + assert.equal(root.defaultAccount, 'libera') + assert.equal(account.enabled, true) + assert.equal(account.name, 'Libera Ops') + assert.equal(account.host, 'irc.libera.chat') + assert.equal(account.port, 6697) + assert.equal(account.tls, true) + assert.equal(account.nick, 'openclaw-bot') + assert.equal(account.username, 'openclaw') + assert.equal(account.realname, 'OpenClaw Bot') + assert.equal(account.password, 'server-password') + assert.equal(account.passwordFile, '/run/secrets/irc-password') + assert.deepEqual(account.nickserv, { + enabled: true, + service: 'NickServ', + password: 'nickserv-password', + passwordFile: '/run/secrets/irc-nickserv', + register: false, + registerEmail: 'bot@example.com', + }) + assert.deepEqual(account.channels, ['#openclaw', '#ops']) + assert.equal(account.dmPolicy, 'allowlist') + assert.deepEqual(account.allowFrom, ['alice!ident@example.org', 'bob']) + assert.equal(account.defaultTo, '#openclaw') + assert.equal(account.groupPolicy, 'allowlist') + assert.deepEqual(Object.keys(account.groups), ['#openclaw', '#ops']) + assert.equal(account.groups['#openclaw'].requireMention, false) + assert.deepEqual(account.groupAllowFrom, ['alice!ident@example.org']) + assert.deepEqual(account.mentionPatterns, ['openclaw:', '@openclaw']) + assert.equal(account.historyLimit, 80) + assert.equal(account.dmHistoryLimit, 20) + assert.equal(account.mediaMaxMb, 25) + assert.equal(account.textChunkLimit, 350) + assert.equal(account.chunkMode, 'newline') + assert.equal(account.blockStreaming, true) + assert.equal(account.responsePrefix, '[IRC]') + assert.equal(account.dangerouslyAllowNameMatching, true) + assert.equal(cfg.plugins.entries.irc.enabled, true) +}) + +test('IRC 读取和诊断会回显服务器、NickServ 与频道字段', () => { + const values = buildMessagingPlatformFormValues('irc', { + enabled: true, + name: 'Libera Ops', + host: 'irc.libera.chat', + port: 6697, + tls: true, + nick: 'openclaw-bot', + username: 'openclaw', + realname: 'OpenClaw Bot', + password: 'server-password', + passwordFile: '/run/secrets/irc-password', + nickserv: { + enabled: true, + service: 'NickServ', + password: 'nickserv-password', + passwordFile: '/run/secrets/irc-nickserv', + register: false, + registerEmail: 'bot@example.com', + }, + channels: ['#openclaw', '#ops'], + dmPolicy: 'allowlist', + allowFrom: ['alice!ident@example.org'], + defaultTo: '#openclaw', + groupPolicy: 'allowlist', + groups: { + '#openclaw': { requireMention: false }, + '#ops': { requireMention: false }, + }, + groupAllowFrom: ['alice!ident@example.org'], + mentionPatterns: ['openclaw:', '@openclaw'], + historyLimit: 80, + dmHistoryLimit: 20, + mediaMaxMb: 25, + textChunkLimit: 350, + chunkMode: 'newline', + blockStreaming: true, + responsePrefix: '[IRC]', + dangerouslyAllowNameMatching: true, + }) + const missingNick = buildOpenClawChannelDiagnosis({ + platform: 'irc', + configExists: true, + channelEnabled: true, + form: { host: 'irc.libera.chat' }, + }) + const ready = buildOpenClawChannelDiagnosis({ + platform: 'irc', + configExists: true, + channelEnabled: true, + form: values, + }) + + assert.equal(values.enabled, 'true') + assert.equal(values.name, 'Libera Ops') + assert.equal(values.host, 'irc.libera.chat') + assert.equal(values.port, '6697') + assert.equal(values.tls, 'true') + assert.equal(values.nick, 'openclaw-bot') + assert.equal(values.nickservEnabled, 'true') + assert.equal(values.nickservService, 'NickServ') + assert.equal(values.nickservPassword, 'nickserv-password') + assert.equal(values.nickservRegister, 'false') + assert.equal(values.channels, '#openclaw, #ops') + assert.equal(values.groups, '#openclaw, #ops') + assert.equal(values.requireMention, 'false') + assert.equal(values.groupAllowFrom, 'alice!ident@example.org') + assert.equal(values.mentionPatterns, 'openclaw:, @openclaw') + assert.equal(values.blockStreaming, 'true') + assert.equal(values.dangerouslyAllowNameMatching, 'true') + assert.equal(missingNick.checks.find(item => item.id === 'credentials')?.ok, false) + assert.match(missingNick.checks.find(item => item.id === 'credentials')?.detail || '', /Nick/) + assert.equal(ready.checks.find(item => item.id === 'credentials')?.ok, true) +}) + test('Signal 渠道保存会保留多账号和上游运行字段', () => { const cfg = { channels: {} } @@ -770,6 +929,20 @@ test('渠道账号列表会把 SecretRef 标识显示为安全占位', () => { ]) }) +test('渠道账号列表会使用 IRC Nick 作为安全展示标识', () => { + const accounts = listPlatformAccounts({ + accounts: { + libera: { + nick: 'openclaw-bot', + }, + }, + }) + + assert.deepEqual(accounts, [ + { accountId: 'libera', appId: 'openclaw-bot' }, + ]) +}) + test('渠道保存时 clientId 未改动 SecretRef 占位会保留原始引用', () => { const secretRef = { source: 'env', provider: 'default', id: 'DINGTALK_CLIENT_ID' } const value = resolveMessagingCredentialValueForSave({ diff --git a/tests/channel-ui-registry.test.js b/tests/channel-ui-registry.test.js index 60f7d80..baa8408 100644 --- a/tests/channel-ui-registry.test.js +++ b/tests/channel-ui-registry.test.js @@ -161,3 +161,39 @@ test('Nostr 渠道 UI 会暴露私钥、Relay、访问控制和 Profile 配置 assert.match(nostrBlock, /pluginRequired:\s*'@openclaw\/nostr@latest'/) assert.match(nostrBlock, /pluginId:\s*'nostr'/) }) + +test('IRC 渠道 UI 会暴露服务器、NickServ 与频道访问配置字段', () => { + const ircBlock = getRegistryBlock('irc') + + for (const field of [ + 'host', + 'port', + 'tls', + 'nick', + 'username', + 'realname', + 'password', + 'passwordFile', + 'nickservEnabled', + 'nickservService', + 'nickservPassword', + 'nickservPasswordFile', + 'nickservRegister', + 'nickservRegisterEmail', + 'channels', + 'dmPolicy', + 'allowFrom', + 'defaultTo', + 'groupPolicy', + 'groups', + 'groupAllowFrom', + 'requireMention', + 'mentionPatterns', + 'dangerouslyAllowNameMatching', + 'blockStreaming', + ]) { + assert.match(ircBlock, new RegExp(`key:\\s*'${field}'`)) + } + assert.match(ircBlock, /pluginRequired:\s*'@openclaw\/irc@latest'/) + assert.match(ircBlock, /pluginId:\s*'irc'/) +})