mirror of
https://github.com/qingchencloud/clawpanel.git
synced 2026-05-29 20:30:00 +08:00
feat(channels): add IRC config compatibility
This commit is contained in:
@@ -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', {
|
||||
|
||||
@@ -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, ¤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<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!({
|
||||
|
||||
@@ -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'),
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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'/)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user