feat(channels): add IRC config compatibility

This commit is contained in:
晴天
2026-05-24 00:57:37 +08:00
parent 326c5597df
commit 7e3bb71fca
6 changed files with 836 additions and 12 deletions

View File

@@ -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', {

View File

@@ -103,6 +103,32 @@ fn insert_secret_aware_form_value(form: &mut Map<String, Value>, source: &Value,
}
}
fn insert_secret_aware_form_alias(
form: &mut Map<String, Value>,
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<String, Value>,
current: &Value,
@@ -127,6 +153,31 @@ fn resolve_messaging_credential_value_for_save(
}
}
fn resolve_messaging_credential_value_for_save_alias(
form_obj: &Map<String, Value>,
current: &Value,
form_key: &str,
current_key: &str,
) -> Option<Value> {
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<String, Value>,
form_obj: &Map<String, Value>,
@@ -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<String, Value>, source: &Value, key: &str)
}
}
fn insert_irc_groups_form_values(form: &mut Map<String, Value>, 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::<Vec<_>>();
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::<Vec<_>>();
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<String, Value>, 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<String, Value>, key: &str, value: O
}
}
fn build_irc_groups_from_form(form_obj: &Map<String, Value>) -> Option<Value> {
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, &current_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<Value, St
"valid": true,
"warnings": ["Nostr 面板已完成基础字段校验;实际连通性请通过 Gateway 启动日志或 openclaw channels status --probe 验证"]
})),
"irc" => 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<Value, String> {
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!({

View File

@@ -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 服务器地址,例如 <code>irc.libera.chat</code>,并确认 Gateway 网络可以连接', 'Prepare the IRC server host, for example <code>irc.libera.chat</code>, 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: _('<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">IRC 最小配置需要 Host 与 Nick真实连接状态以 Gateway 日志或 channels status 为准。</div>', '<div style="margin-top:8px;font-size:var(--font-size-xs);color:var(--text-tertiary)">IRC minimally requires Host and Nick; verify real connectivity through Gateway logs or channels status.</div>'),
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'),

View File

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

View File

@@ -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({

View File

@@ -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'/)
})