Files
openclaw/extensions/irc/src/channel.ts
2026-04-07 17:11:55 +01:00

366 lines
12 KiB
TypeScript

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<IrcChannelRuntimeModule> | undefined;
async function loadIrcChannelRuntime(): Promise<IrcChannelRuntimeModule> {
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<ResolvedIrcAccount>({
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<ResolvedIrcAccount>({
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<
ResolvedIrcAccount,
ResolvedIrcAccount,
CoreConfig
>({
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<ResolvedIrcAccount>({
channelKey: "irc",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
});
const collectIrcGroupPolicyWarnings =
createAllowlistProviderOpenWarningCollector<ResolvedIrcAccount>({
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<ResolvedIrcAccount, IrcProbe> = 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<ResolvedIrcAccount, IrcProbe>({
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,
});
},
},
},
});