import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from"; import { adaptScopedAccountAccessor, createScopedChannelConfigAdapter, createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/channel-config-helpers"; import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core"; import { composeAccountWarningCollectors, createAllowlistProviderOpenWarningCollector, } from "openclaw/plugin-sdk/channel-policy"; import { createChannelDirectoryAdapter, createResolvedDirectoryEntriesLister, } from "openclaw/plugin-sdk/directory-runtime"; import { createComputedAccountStatusAdapter, createDefaultChannelRuntimeState, } from "openclaw/plugin-sdk/status-helpers"; import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount, type ResolvedIrcAccount, } from "./accounts.js"; import { buildBaseChannelStatusSummary, DEFAULT_ACCOUNT_ID, PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "./channel-api.js"; import { IrcChannelConfigSchema } from "./config-schema.js"; import { collectIrcMutableAllowlistWarnings } from "./doctor.js"; import { startIrcGatewayAccount } from "./gateway.js"; import { isChannelTarget, looksLikeIrcTargetId, normalizeIrcAllowEntry, normalizeIrcMessagingTarget, } from "./normalize.js"; import { ircOutboundBaseAdapter } from "./outbound-base.js"; import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js"; import { probeIrc } from "./probe.js"; import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js"; import { ircSetupAdapter } from "./setup-core.js"; import { ircSetupWizard } from "./setup-surface.js"; import type { CoreConfig, IrcProbe } from "./types.js"; const meta = { id: "irc", label: "IRC", selectionLabel: "IRC (Server + Nick)", docsPath: "/channels/irc", docsLabel: "irc", blurb: "classic IRC networks; host, nick, channels.", order: 80, detailLabel: "IRC", systemImage: "number", markdownCapable: true, }; type IrcChannelRuntimeModule = typeof import("./channel-runtime.js"); let ircChannelRuntimePromise: Promise | undefined; async function loadIrcChannelRuntime(): Promise { ircChannelRuntimePromise ??= import("./channel-runtime.js"); return await ircChannelRuntimePromise; } function normalizePairingTarget(raw: string): string { const normalized = normalizeIrcAllowEntry(raw); if (!normalized) { return ""; } return normalized.split(/[!@]/, 1)[0]?.trim() ?? ""; } const listIrcDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister({ kind: "user", resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount), resolveSources: (account) => [ account.config.allowFrom ?? [], account.config.groupAllowFrom ?? [], ...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []), ], normalizeId: (entry) => normalizePairingTarget(entry) || null, }); const listIrcDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister({ kind: "group", resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount), resolveSources: (account) => [ account.config.channels ?? [], Object.keys(account.config.groups ?? {}), ], normalizeId: (entry) => { const normalized = normalizeIrcMessagingTarget(entry); return normalized && isChannelTarget(normalized) ? normalized : null; }, }); const ircConfigAdapter = createScopedChannelConfigAdapter({ sectionKey: "irc", listAccountIds: listIrcAccountIds, resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount), defaultAccountId: resolveDefaultIrcAccountId, clearBaseFields: [ "name", "host", "port", "tls", "nick", "username", "realname", "password", "passwordFile", "channels", ], resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom, formatAllowFrom: (allowFrom) => formatNormalizedAllowFromEntries({ allowFrom, normalizeEntry: normalizeIrcAllowEntry, }), resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo, }); const resolveIrcDmPolicy = createScopedDmSecurityResolver({ channelKey: "irc", resolvePolicy: (account) => account.config.dmPolicy, resolveAllowFrom: (account) => account.config.allowFrom, policyPathSuffix: "dmPolicy", normalizeEntry: (raw) => normalizeIrcAllowEntry(raw), }); const collectIrcGroupPolicyWarnings = createAllowlistProviderOpenWarningCollector({ providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined, resolveGroupPolicy: (account) => account.config.groupPolicy, buildOpenWarning: { surface: "IRC channels", openBehavior: "allows all channels and senders (mention-gated)", remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups', }, }); const collectIrcSecurityWarnings = composeAccountWarningCollectors< ResolvedIrcAccount, { account: ResolvedIrcAccount; cfg: CoreConfig; } >( collectIrcGroupPolicyWarnings, (account) => !account.config.tls && "- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.", (account) => account.config.nickserv?.register && '- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.', (account) => account.config.nickserv?.register && !account.config.nickserv.password?.trim() && "- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.", ); export const ircPlugin: ChannelPlugin = createChatChannelPlugin({ base: { id: "irc", meta: { ...meta, quickstartAllowFrom: true, }, setup: ircSetupAdapter, setupWizard: ircSetupWizard, capabilities: { chatTypes: ["direct", "group"], media: true, blockStreaming: true, }, reload: { configPrefixes: ["channels.irc"] }, configSchema: IrcChannelConfigSchema, config: { ...ircConfigAdapter, hasConfiguredState: ({ env }) => typeof env?.IRC_HOST === "string" && env.IRC_HOST.trim().length > 0 && typeof env?.IRC_NICK === "string" && env.IRC_NICK.trim().length > 0, isConfigured: (account) => account.configured, describeAccount: (account) => describeAccountSnapshot({ account, configured: account.configured, extra: { host: account.host, port: account.port, tls: account.tls, nick: account.nick, passwordSource: account.passwordSource, }, }), }, secrets: { secretTargetRegistryEntries, collectRuntimeConfigAssignments, }, doctor: { groupAllowFromFallbackToAllowFrom: false, collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); if (!groupId) { return true; } const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId }); return resolveIrcRequireMention({ groupConfig: match.groupConfig, wildcardConfig: match.wildcardConfig, }); }, resolveToolPolicy: ({ cfg, accountId, groupId }) => { const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }); if (!groupId) { return undefined; } const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId }); return match.groupConfig?.tools ?? match.wildcardConfig?.tools; }, }, messaging: { normalizeTarget: normalizeIrcMessagingTarget, targetResolver: { looksLikeId: looksLikeIrcTargetId, hint: "<#channel|nick>", }, }, resolver: { resolveTargets: async ({ inputs, kind }) => { return inputs.map((input) => { const normalized = normalizeIrcMessagingTarget(input); if (!normalized) { return { input, resolved: false, note: "invalid IRC target", }; } if (kind === "group") { const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`; return { input, resolved: true, id: groupId, name: groupId, }; } if (isChannelTarget(normalized)) { return { input, resolved: false, note: "expected user target", }; } return { input, resolved: true, id: normalized, name: normalized, }; }); }, }, directory: createChannelDirectoryAdapter({ listPeers: async (params) => listIrcDirectoryPeersFromConfig(params), listGroups: async (params) => { const entries = await listIrcDirectoryGroupsFromConfig(params); return entries.map((entry) => ({ ...entry, name: entry.id })); }, }), status: createComputedAccountStatusAdapter({ defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID), buildChannelSummary: ({ account, snapshot }) => ({ ...buildBaseChannelStatusSummary(snapshot), host: account.host, port: snapshot.port, tls: account.tls, nick: account.nick, probe: snapshot.probe, lastProbeAt: snapshot.lastProbeAt ?? null, }), probeAccount: async ({ cfg, account, timeoutMs }) => probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }), resolveAccountSnapshot: ({ account }) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: account.configured, extra: { host: account.host, port: account.port, tls: account.tls, nick: account.nick, passwordSource: account.passwordSource, }, }), }), gateway: { startAccount: async (ctx) => await startIrcGatewayAccount({ ...ctx, cfg: ctx.cfg as CoreConfig, }), }, }, pairing: { text: { idLabel: "ircUser", message: PAIRING_APPROVED_MESSAGE, normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry), notify: async ({ id, message }) => { const target = normalizePairingTarget(id); if (!target) { throw new Error(`invalid IRC pairing id: ${id}`); } const { sendMessageIrc } = await loadIrcChannelRuntime(); await sendMessageIrc(target, message); }, }, }, security: { resolveDmPolicy: resolveIrcDmPolicy, collectWarnings: collectIrcSecurityWarnings, }, outbound: { base: ircOutboundBaseAdapter, attachedResults: { channel: "irc", sendText: async ({ cfg, to, text, accountId, replyToId }) => { const { sendMessageIrc } = await loadIrcChannelRuntime(); return await sendMessageIrc(to, text, { cfg: cfg as CoreConfig, accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, }); }, sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => { const { sendMessageIrc } = await loadIrcChannelRuntime(); return await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, { cfg: cfg as CoreConfig, accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, }); }, }, }, });