mirror of
https://github.com/moltbot/moltbot.git
synced 2026-04-28 17:21:52 +00:00
feat: IRC — add first-class channel support
Adds IRC as a first-class channel with core config surfaces (schema/hints/dock), plugin auto-enable detection, routing/policy alignment, and docs/tests. Co-authored-by: Vignesh <vigneshnatarajan92@gmail.com>
This commit is contained in:
17
extensions/irc/index.ts
Normal file
17
extensions/irc/index.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import type { ChannelPlugin, OpenClawPluginApi } from "openclaw/plugin-sdk";
|
||||
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
|
||||
import { ircPlugin } from "./src/channel.js";
|
||||
import { setIrcRuntime } from "./src/runtime.js";
|
||||
|
||||
const plugin = {
|
||||
id: "irc",
|
||||
name: "IRC",
|
||||
description: "IRC channel plugin",
|
||||
configSchema: emptyPluginConfigSchema(),
|
||||
register(api: OpenClawPluginApi) {
|
||||
setIrcRuntime(api.runtime);
|
||||
api.registerChannel({ plugin: ircPlugin as ChannelPlugin });
|
||||
},
|
||||
};
|
||||
|
||||
export default plugin;
|
||||
9
extensions/irc/openclaw.plugin.json
Normal file
9
extensions/irc/openclaw.plugin.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"id": "irc",
|
||||
"channels": ["irc"],
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
14
extensions/irc/package.json
Normal file
14
extensions/irc/package.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.2.9",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"openclaw": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
]
|
||||
}
|
||||
}
|
||||
268
extensions/irc/src/accounts.ts
Normal file
268
extensions/irc/src/accounts.ts
Normal file
@@ -0,0 +1,268 @@
|
||||
import { readFileSync } from "node:fs";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
|
||||
|
||||
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
|
||||
|
||||
export type ResolvedIrcAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
username: string;
|
||||
realname: string;
|
||||
password: string;
|
||||
passwordSource: "env" | "passwordFile" | "config" | "none";
|
||||
config: IrcAccountConfig;
|
||||
};
|
||||
|
||||
function parseTruthy(value?: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return TRUTHY_ENV.has(value.trim().toLowerCase());
|
||||
}
|
||||
|
||||
function parseIntEnv(value?: string): number | undefined {
|
||||
if (!value?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function parseListEnv(value?: string): string[] | undefined {
|
||||
if (!value?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = value
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
return parsed.length > 0 ? parsed : undefined;
|
||||
}
|
||||
|
||||
function listConfiguredAccountIds(cfg: CoreConfig): string[] {
|
||||
const accounts = cfg.channels?.irc?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return [];
|
||||
}
|
||||
const ids = new Set<string>();
|
||||
for (const key of Object.keys(accounts)) {
|
||||
if (key.trim()) {
|
||||
ids.add(normalizeAccountId(key));
|
||||
}
|
||||
}
|
||||
return [...ids];
|
||||
}
|
||||
|
||||
function resolveAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig | undefined {
|
||||
const accounts = cfg.channels?.irc?.accounts;
|
||||
if (!accounts || typeof accounts !== "object") {
|
||||
return undefined;
|
||||
}
|
||||
const direct = accounts[accountId] as IrcAccountConfig | undefined;
|
||||
if (direct) {
|
||||
return direct;
|
||||
}
|
||||
const normalized = normalizeAccountId(accountId);
|
||||
const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized);
|
||||
return matchKey ? (accounts[matchKey] as IrcAccountConfig | undefined) : undefined;
|
||||
}
|
||||
|
||||
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
|
||||
const { accounts: _ignored, ...base } = (cfg.channels?.irc ?? {}) as IrcAccountConfig & {
|
||||
accounts?: unknown;
|
||||
};
|
||||
const account = resolveAccountConfig(cfg, accountId) ?? {};
|
||||
const merged: IrcAccountConfig = { ...base, ...account };
|
||||
if (base.nickserv || account.nickserv) {
|
||||
merged.nickserv = {
|
||||
...base.nickserv,
|
||||
...account.nickserv,
|
||||
};
|
||||
}
|
||||
return merged;
|
||||
}
|
||||
|
||||
function resolvePassword(accountId: string, merged: IrcAccountConfig) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const envPassword = process.env.IRC_PASSWORD?.trim();
|
||||
if (envPassword) {
|
||||
return { password: envPassword, source: "env" as const };
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.passwordFile?.trim()) {
|
||||
try {
|
||||
const filePassword = readFileSync(merged.passwordFile.trim(), "utf-8").trim();
|
||||
if (filePassword) {
|
||||
return { password: filePassword, source: "passwordFile" as const };
|
||||
}
|
||||
} catch {
|
||||
// Ignore unreadable files here; status will still surface missing configuration.
|
||||
}
|
||||
}
|
||||
|
||||
const configPassword = merged.password?.trim();
|
||||
if (configPassword) {
|
||||
return { password: configPassword, source: "config" as const };
|
||||
}
|
||||
|
||||
return { password: "", source: "none" as const };
|
||||
}
|
||||
|
||||
function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig): IrcNickServConfig {
|
||||
const base = nickserv ?? {};
|
||||
const envPassword =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_PASSWORD?.trim() : undefined;
|
||||
const envRegisterEmail =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_REGISTER_EMAIL?.trim() : undefined;
|
||||
|
||||
const passwordFile = base.passwordFile?.trim();
|
||||
let resolvedPassword = base.password?.trim() || envPassword || "";
|
||||
if (!resolvedPassword && passwordFile) {
|
||||
try {
|
||||
resolvedPassword = readFileSync(passwordFile, "utf-8").trim();
|
||||
} catch {
|
||||
// Ignore unreadable files; monitor/probe status will surface failures.
|
||||
}
|
||||
}
|
||||
|
||||
const merged: IrcNickServConfig = {
|
||||
...base,
|
||||
service: base.service?.trim() || undefined,
|
||||
passwordFile: passwordFile || undefined,
|
||||
password: resolvedPassword || undefined,
|
||||
registerEmail: base.registerEmail?.trim() || envRegisterEmail || undefined,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function listIrcAccountIds(cfg: CoreConfig): string[] {
|
||||
const ids = listConfiguredAccountIds(cfg);
|
||||
if (ids.length === 0) {
|
||||
return [DEFAULT_ACCOUNT_ID];
|
||||
}
|
||||
return ids.toSorted((a, b) => a.localeCompare(b));
|
||||
}
|
||||
|
||||
export function resolveDefaultIrcAccountId(cfg: CoreConfig): string {
|
||||
const ids = listIrcAccountIds(cfg);
|
||||
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
|
||||
return DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
return ids[0] ?? DEFAULT_ACCOUNT_ID;
|
||||
}
|
||||
|
||||
export function resolveIrcAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedIrcAccount {
|
||||
const hasExplicitAccountId = Boolean(params.accountId?.trim());
|
||||
const baseEnabled = params.cfg.channels?.irc?.enabled !== false;
|
||||
|
||||
const resolve = (accountId: string) => {
|
||||
const merged = mergeIrcAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const tls =
|
||||
typeof merged.tls === "boolean"
|
||||
? merged.tls
|
||||
: accountId === DEFAULT_ACCOUNT_ID && process.env.IRC_TLS
|
||||
? parseTruthy(process.env.IRC_TLS)
|
||||
: true;
|
||||
|
||||
const envPort =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? parseIntEnv(process.env.IRC_PORT) : undefined;
|
||||
const port = merged.port ?? envPort ?? (tls ? 6697 : 6667);
|
||||
const envChannels =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? parseListEnv(process.env.IRC_CHANNELS) : undefined;
|
||||
|
||||
const host = (
|
||||
merged.host?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_HOST?.trim() : "") ||
|
||||
""
|
||||
).trim();
|
||||
const nick = (
|
||||
merged.nick?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICK?.trim() : "") ||
|
||||
""
|
||||
).trim();
|
||||
const username = (
|
||||
merged.username?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_USERNAME?.trim() : "") ||
|
||||
nick ||
|
||||
"openclaw"
|
||||
).trim();
|
||||
const realname = (
|
||||
merged.realname?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_REALNAME?.trim() : "") ||
|
||||
"OpenClaw"
|
||||
).trim();
|
||||
|
||||
const passwordResolution = resolvePassword(accountId, merged);
|
||||
const nickserv = resolveNickServConfig(accountId, merged.nickserv);
|
||||
|
||||
const config: IrcAccountConfig = {
|
||||
...merged,
|
||||
channels: merged.channels ?? envChannels,
|
||||
tls,
|
||||
port,
|
||||
host,
|
||||
nick,
|
||||
username,
|
||||
realname,
|
||||
nickserv,
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: merged.name?.trim() || undefined,
|
||||
configured: Boolean(host && nick),
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
nick,
|
||||
username,
|
||||
realname,
|
||||
password: passwordResolution.password,
|
||||
passwordSource: passwordResolution.source,
|
||||
config,
|
||||
} satisfies ResolvedIrcAccount;
|
||||
};
|
||||
|
||||
const normalized = normalizeAccountId(params.accountId);
|
||||
const primary = resolve(normalized);
|
||||
if (hasExplicitAccountId) {
|
||||
return primary;
|
||||
}
|
||||
if (primary.configured) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const fallbackId = resolveDefaultIrcAccountId(params.cfg);
|
||||
if (fallbackId === primary.accountId) {
|
||||
return primary;
|
||||
}
|
||||
const fallback = resolve(fallbackId);
|
||||
if (!fallback.configured) {
|
||||
return primary;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] {
|
||||
return listIrcAccountIds(cfg)
|
||||
.map((accountId) => resolveIrcAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
367
extensions/irc/src/channel.ts
Normal file
367
extensions/irc/src/channel.ts
Normal file
@@ -0,0 +1,367 @@
|
||||
import {
|
||||
buildChannelConfigSchema,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatPairingApproveHint,
|
||||
getChatChannelMeta,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
setAccountEnabledInConfigSection,
|
||||
deleteAccountFromConfigSection,
|
||||
type ChannelPlugin,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, IrcProbe } from "./types.js";
|
||||
import {
|
||||
listIrcAccountIds,
|
||||
resolveDefaultIrcAccountId,
|
||||
resolveIrcAccount,
|
||||
type ResolvedIrcAccount,
|
||||
} from "./accounts.js";
|
||||
import { IrcConfigSchema } from "./config-schema.js";
|
||||
import { monitorIrcProvider } from "./monitor.js";
|
||||
import {
|
||||
normalizeIrcMessagingTarget,
|
||||
looksLikeIrcTargetId,
|
||||
isChannelTarget,
|
||||
normalizeIrcAllowEntry,
|
||||
} from "./normalize.js";
|
||||
import { ircOnboardingAdapter } from "./onboarding.js";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
import { probeIrc } from "./probe.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
|
||||
const meta = getChatChannelMeta("irc");
|
||||
|
||||
function normalizePairingTarget(raw: string): string {
|
||||
const normalized = normalizeIrcAllowEntry(raw);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = {
|
||||
id: "irc",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
onboarding: ircOnboardingAdapter,
|
||||
pairing: {
|
||||
idLabel: "ircUser",
|
||||
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
|
||||
notifyApproval: async ({ id }) => {
|
||||
const target = normalizePairingTarget(id);
|
||||
if (!target) {
|
||||
throw new Error(`invalid IRC pairing id: ${id}`);
|
||||
}
|
||||
await sendMessageIrc(target, PAIRING_APPROVED_MESSAGE);
|
||||
},
|
||||
},
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.irc"] },
|
||||
configSchema: buildChannelConfigSchema(IrcConfigSchema),
|
||||
config: {
|
||||
listAccountIds: (cfg) => listIrcAccountIds(cfg as CoreConfig),
|
||||
resolveAccount: (cfg, accountId) => resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }),
|
||||
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg as CoreConfig),
|
||||
setAccountEnabled: ({ cfg, accountId, enabled }) =>
|
||||
setAccountEnabledInConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "irc",
|
||||
accountId,
|
||||
enabled,
|
||||
allowTopLevel: true,
|
||||
}),
|
||||
deleteAccount: ({ cfg, accountId }) =>
|
||||
deleteAccountFromConfigSection({
|
||||
cfg: cfg as CoreConfig,
|
||||
sectionKey: "irc",
|
||||
accountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"host",
|
||||
"port",
|
||||
"tls",
|
||||
"nick",
|
||||
"username",
|
||||
"realname",
|
||||
"password",
|
||||
"passwordFile",
|
||||
"channels",
|
||||
],
|
||||
}),
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
passwordSource: account.passwordSource,
|
||||
}),
|
||||
resolveAllowFrom: ({ cfg, accountId }) =>
|
||||
(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map(
|
||||
(entry) => String(entry),
|
||||
),
|
||||
formatAllowFrom: ({ allowFrom }) =>
|
||||
allowFrom.map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean),
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: ({ cfg, accountId, account }) => {
|
||||
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
||||
const useAccountPath = Boolean(cfg.channels?.irc?.accounts?.[resolvedAccountId]);
|
||||
const basePath = useAccountPath
|
||||
? `channels.irc.accounts.${resolvedAccountId}.`
|
||||
: "channels.irc.";
|
||||
return {
|
||||
policy: account.config.dmPolicy ?? "pairing",
|
||||
allowFrom: account.config.allowFrom ?? [],
|
||||
policyPath: `${basePath}dmPolicy`,
|
||||
allowFromPath: `${basePath}allowFrom`,
|
||||
approveHint: formatPairingApproveHint("irc"),
|
||||
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
|
||||
};
|
||||
},
|
||||
collectWarnings: ({ account, cfg }) => {
|
||||
const warnings: string[] = [];
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
if (groupPolicy === "open") {
|
||||
warnings.push(
|
||||
'- IRC channels: groupPolicy="open" allows all channels and senders (mention-gated). Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups.',
|
||||
);
|
||||
}
|
||||
if (!account.config.tls) {
|
||||
warnings.push(
|
||||
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
|
||||
);
|
||||
}
|
||||
if (account.config.nickserv?.register) {
|
||||
warnings.push(
|
||||
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
|
||||
);
|
||||
if (!account.config.nickserv.password?.trim()) {
|
||||
warnings.push(
|
||||
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
|
||||
);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
},
|
||||
},
|
||||
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: {
|
||||
self: async () => null,
|
||||
listPeers: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() ?? "";
|
||||
const ids = new Set<string>();
|
||||
|
||||
for (const entry of account.config.allowFrom ?? []) {
|
||||
const normalized = normalizePairingTarget(String(entry));
|
||||
if (normalized && normalized !== "*") {
|
||||
ids.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const entry of account.config.groupAllowFrom ?? []) {
|
||||
const normalized = normalizePairingTarget(String(entry));
|
||||
if (normalized && normalized !== "*") {
|
||||
ids.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const group of Object.values(account.config.groups ?? {})) {
|
||||
for (const entry of group.allowFrom ?? []) {
|
||||
const normalized = normalizePairingTarget(String(entry));
|
||||
if (normalized && normalized !== "*") {
|
||||
ids.add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(ids)
|
||||
.filter((id) => (q ? id.includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "user", id }));
|
||||
},
|
||||
listGroups: async ({ cfg, accountId, query, limit }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const q = query?.trim().toLowerCase() ?? "";
|
||||
const groupIds = new Set<string>();
|
||||
|
||||
for (const channel of account.config.channels ?? []) {
|
||||
const normalized = normalizeIrcMessagingTarget(channel);
|
||||
if (normalized && isChannelTarget(normalized)) {
|
||||
groupIds.add(normalized);
|
||||
}
|
||||
}
|
||||
for (const group of Object.keys(account.config.groups ?? {})) {
|
||||
if (group === "*") {
|
||||
continue;
|
||||
}
|
||||
const normalized = normalizeIrcMessagingTarget(group);
|
||||
if (normalized && isChannelTarget(normalized)) {
|
||||
groupIds.add(normalized);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(groupIds)
|
||||
.filter((id) => (q ? id.toLowerCase().includes(q) : true))
|
||||
.slice(0, limit && limit > 0 ? limit : undefined)
|
||||
.map((id) => ({ kind: "group", id, name: id }));
|
||||
},
|
||||
},
|
||||
outbound: {
|
||||
deliveryMode: "direct",
|
||||
chunker: (text, limit) => getIrcRuntime().channel.text.chunkMarkdownText(text, limit),
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 350,
|
||||
sendText: async ({ to, text, accountId, replyToId }) => {
|
||||
const result = await sendMessageIrc(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "irc", ...result };
|
||||
},
|
||||
sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const combined = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
|
||||
const result = await sendMessageIrc(to, combined, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
return { channel: "irc", ...result };
|
||||
},
|
||||
},
|
||||
status: {
|
||||
defaultRuntime: {
|
||||
accountId: DEFAULT_ACCOUNT_ID,
|
||||
running: false,
|
||||
lastStartAt: null,
|
||||
lastStopAt: null,
|
||||
lastError: null,
|
||||
},
|
||||
buildChannelSummary: ({ account, snapshot }) => ({
|
||||
configured: snapshot.configured ?? false,
|
||||
host: account.host,
|
||||
port: snapshot.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
running: snapshot.running ?? false,
|
||||
lastStartAt: snapshot.lastStartAt ?? null,
|
||||
lastStopAt: snapshot.lastStopAt ?? null,
|
||||
lastError: snapshot.lastError ?? null,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ cfg, account, timeoutMs }) =>
|
||||
probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }),
|
||||
buildAccountSnapshot: ({ account, runtime, probe }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
passwordSource: account.passwordSource,
|
||||
running: runtime?.running ?? false,
|
||||
lastStartAt: runtime?.lastStartAt ?? null,
|
||||
lastStopAt: runtime?.lastStopAt ?? null,
|
||||
lastError: runtime?.lastError ?? null,
|
||||
probe,
|
||||
lastInboundAt: runtime?.lastInboundAt ?? null,
|
||||
lastOutboundAt: runtime?.lastOutboundAt ?? null,
|
||||
}),
|
||||
},
|
||||
gateway: {
|
||||
startAccount: async (ctx) => {
|
||||
const account = ctx.account;
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
ctx.log?.info(
|
||||
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
|
||||
);
|
||||
const { stop } = await monitorIrcProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg as CoreConfig,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
|
||||
});
|
||||
return { stop };
|
||||
},
|
||||
},
|
||||
};
|
||||
43
extensions/irc/src/client.test.ts
Normal file
43
extensions/irc/src/client.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildIrcNickServCommands } from "./client.js";
|
||||
|
||||
describe("irc client nickserv", () => {
|
||||
it("builds IDENTIFY command when password is set", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
}),
|
||||
).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]);
|
||||
});
|
||||
|
||||
it("builds REGISTER command when enabled with email", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
register: true,
|
||||
registerEmail: "bot@example.com",
|
||||
}),
|
||||
).toEqual([
|
||||
"PRIVMSG NickServ :IDENTIFY secret",
|
||||
"PRIVMSG NickServ :REGISTER secret bot@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects register without registerEmail", () => {
|
||||
expect(() =>
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
register: true,
|
||||
}),
|
||||
).toThrow(/registerEmail/);
|
||||
});
|
||||
|
||||
it("sanitizes outbound NickServ payloads", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
service: "NickServ\n",
|
||||
password: "secret\r\nJOIN #bad",
|
||||
}),
|
||||
).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]);
|
||||
});
|
||||
});
|
||||
439
extensions/irc/src/client.ts
Normal file
439
extensions/irc/src/client.ts
Normal file
@@ -0,0 +1,439 @@
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import {
|
||||
parseIrcLine,
|
||||
parseIrcPrefix,
|
||||
sanitizeIrcOutboundText,
|
||||
sanitizeIrcTarget,
|
||||
} from "./protocol.js";
|
||||
|
||||
const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
|
||||
const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
|
||||
|
||||
export type IrcPrivmsgEvent = {
|
||||
senderNick: string;
|
||||
senderUser?: string;
|
||||
senderHost?: string;
|
||||
target: string;
|
||||
text: string;
|
||||
rawLine: string;
|
||||
};
|
||||
|
||||
export type IrcClientOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
username: string;
|
||||
realname: string;
|
||||
password?: string;
|
||||
nickserv?: IrcNickServOptions;
|
||||
channels?: string[];
|
||||
connectTimeoutMs?: number;
|
||||
messageChunkMaxChars?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>;
|
||||
onNotice?: (text: string, target?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onLine?: (line: string) => void;
|
||||
};
|
||||
|
||||
export type IrcNickServOptions = {
|
||||
enabled?: boolean;
|
||||
service?: string;
|
||||
password?: string;
|
||||
register?: boolean;
|
||||
registerEmail?: string;
|
||||
};
|
||||
|
||||
export type IrcClient = {
|
||||
nick: string;
|
||||
isReady: () => boolean;
|
||||
sendRaw: (line: string) => void;
|
||||
join: (channel: string) => void;
|
||||
sendPrivmsg: (target: string, text: string) => void;
|
||||
quit: (reason?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
function toError(err: unknown): Error {
|
||||
if (err instanceof Error) {
|
||||
return err;
|
||||
}
|
||||
return new Error(typeof err === "string" ? err : JSON.stringify(err));
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
promise
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildFallbackNick(nick: string): string {
|
||||
const normalized = nick.replace(/\s+/g, "");
|
||||
const safe = normalized.replace(/[^A-Za-z0-9_\-\[\]\\`^{}|]/g, "");
|
||||
const base = safe || "openclaw";
|
||||
const suffix = "_";
|
||||
const maxNickLen = 30;
|
||||
if (base.length >= maxNickLen) {
|
||||
return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
|
||||
}
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
|
||||
if (!options || options.enabled === false) {
|
||||
return [];
|
||||
}
|
||||
const password = sanitizeIrcOutboundText(options.password ?? "");
|
||||
if (!password) {
|
||||
return [];
|
||||
}
|
||||
const service = sanitizeIrcTarget(options.service?.trim() || "NickServ");
|
||||
const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`];
|
||||
if (options.register) {
|
||||
const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? "");
|
||||
if (!registerEmail) {
|
||||
throw new Error("IRC NickServ register requires registerEmail");
|
||||
}
|
||||
commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
|
||||
const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
|
||||
const messageChunkMaxChars =
|
||||
options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350;
|
||||
|
||||
if (!options.host.trim()) {
|
||||
throw new Error("IRC host is required");
|
||||
}
|
||||
if (!options.nick.trim()) {
|
||||
throw new Error("IRC nick is required");
|
||||
}
|
||||
|
||||
const desiredNick = options.nick.trim();
|
||||
let currentNick = desiredNick;
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
let nickServRecoverAttempted = false;
|
||||
let fallbackNickAttempted = false;
|
||||
|
||||
const socket = options.tls
|
||||
? tls.connect({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
servername: options.host,
|
||||
})
|
||||
: net.connect({ host: options.host, port: options.port });
|
||||
|
||||
socket.setEncoding("utf8");
|
||||
|
||||
let resolveReady: (() => void) | null = null;
|
||||
let rejectReady: ((error: Error) => void) | null = null;
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const error = toError(err);
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
}
|
||||
if (!ready && rejectReady) {
|
||||
rejectReady(error);
|
||||
rejectReady = null;
|
||||
resolveReady = null;
|
||||
}
|
||||
};
|
||||
|
||||
const sendRaw = (line: string) => {
|
||||
const cleaned = line.replace(/[\r\n]+/g, "").trim();
|
||||
if (!cleaned) {
|
||||
throw new Error("IRC command cannot be empty");
|
||||
}
|
||||
socket.write(`${cleaned}\r\n`);
|
||||
};
|
||||
|
||||
const tryRecoverNickCollision = (): boolean => {
|
||||
const nickServEnabled = options.nickserv?.enabled !== false;
|
||||
const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? "");
|
||||
if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) {
|
||||
nickServRecoverAttempted = true;
|
||||
try {
|
||||
const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ");
|
||||
sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`);
|
||||
sendRaw(`NICK ${desiredNick}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackNickAttempted) {
|
||||
fallbackNickAttempted = true;
|
||||
const fallbackNick = buildFallbackNick(desiredNick);
|
||||
if (fallbackNick.toLowerCase() !== currentNick.toLowerCase()) {
|
||||
try {
|
||||
sendRaw(`NICK ${fallbackNick}`);
|
||||
currentNick = fallbackNick;
|
||||
return true;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const join = (channel: string) => {
|
||||
const target = sanitizeIrcTarget(channel);
|
||||
if (!target.startsWith("#") && !target.startsWith("&")) {
|
||||
throw new Error(`IRC JOIN target must be a channel: ${channel}`);
|
||||
}
|
||||
sendRaw(`JOIN ${target}`);
|
||||
};
|
||||
|
||||
const sendPrivmsg = (target: string, text: string) => {
|
||||
const normalizedTarget = sanitizeIrcTarget(target);
|
||||
const cleaned = sanitizeIrcOutboundText(text);
|
||||
if (!cleaned) {
|
||||
return;
|
||||
}
|
||||
let remaining = cleaned;
|
||||
while (remaining.length > 0) {
|
||||
let chunk = remaining;
|
||||
if (chunk.length > messageChunkMaxChars) {
|
||||
let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars);
|
||||
if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
|
||||
splitAt = messageChunkMaxChars;
|
||||
}
|
||||
chunk = chunk.slice(0, splitAt).trim();
|
||||
}
|
||||
if (!chunk) {
|
||||
break;
|
||||
}
|
||||
sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`);
|
||||
remaining = remaining.slice(chunk.length).trimStart();
|
||||
}
|
||||
};
|
||||
|
||||
const quit = (reason?: string) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
|
||||
try {
|
||||
if (safeReason) {
|
||||
sendRaw(`QUIT :${safeReason}`);
|
||||
} else {
|
||||
sendRaw("QUIT");
|
||||
}
|
||||
} catch {
|
||||
// Ignore quit failures while shutting down.
|
||||
}
|
||||
socket.end();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let idx = buffer.indexOf("\n");
|
||||
while (idx !== -1) {
|
||||
const rawLine = buffer.slice(0, idx).replace(/\r$/, "");
|
||||
buffer = buffer.slice(idx + 1);
|
||||
idx = buffer.indexOf("\n");
|
||||
|
||||
if (!rawLine) {
|
||||
continue;
|
||||
}
|
||||
if (options.onLine) {
|
||||
options.onLine(rawLine);
|
||||
}
|
||||
|
||||
const line = parseIrcLine(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "PING") {
|
||||
const payload =
|
||||
line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
|
||||
sendRaw(`PONG :${payload}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NICK") {
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
if (prefix.nick && prefix.nick.toLowerCase() === currentNick.toLowerCase()) {
|
||||
const next =
|
||||
line.trailing != null
|
||||
? line.trailing
|
||||
: line.params[0] != null
|
||||
? line.params[0]
|
||||
: currentNick;
|
||||
currentNick = String(next).trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) {
|
||||
if (tryRecoverNickCollision()) {
|
||||
continue;
|
||||
}
|
||||
const detail =
|
||||
line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use";
|
||||
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ready && IRC_ERROR_CODES.has(line.command)) {
|
||||
const detail =
|
||||
line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected";
|
||||
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.command === "001") {
|
||||
ready = true;
|
||||
const nickParam = line.params[0];
|
||||
if (nickParam && nickParam.trim()) {
|
||||
currentNick = nickParam.trim();
|
||||
}
|
||||
try {
|
||||
const nickServCommands = buildIrcNickServCommands(options.nickserv);
|
||||
for (const command of nickServCommands) {
|
||||
sendRaw(command);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
for (const channel of options.channels || []) {
|
||||
const trimmed = channel.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
join(trimmed);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
if (resolveReady) {
|
||||
resolveReady();
|
||||
}
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NOTICE") {
|
||||
if (options.onNotice) {
|
||||
options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "PRIVMSG") {
|
||||
const targetParam = line.params[0];
|
||||
const target = targetParam ? targetParam.trim() : "";
|
||||
const text = line.trailing != null ? line.trailing : "";
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
const senderNick = prefix.nick ? prefix.nick.trim() : "";
|
||||
if (!target || !senderNick || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (options.onPrivmsg) {
|
||||
void Promise.resolve(
|
||||
options.onPrivmsg({
|
||||
senderNick,
|
||||
senderUser: prefix.user ? prefix.user.trim() : undefined,
|
||||
senderHost: prefix.host ? prefix.host.trim() : undefined,
|
||||
target,
|
||||
text,
|
||||
rawLine,
|
||||
}),
|
||||
).catch((error) => {
|
||||
fail(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.once("connect", () => {
|
||||
try {
|
||||
if (options.password && options.password.trim()) {
|
||||
sendRaw(`PASS ${options.password.trim()}`);
|
||||
}
|
||||
sendRaw(`NICK ${options.nick.trim()}`);
|
||||
sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
socket.once("error", (err) => {
|
||||
fail(err);
|
||||
});
|
||||
|
||||
socket.once("close", () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
if (!ready) {
|
||||
fail(new Error("IRC connection closed before ready"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (options.abortSignal) {
|
||||
const abort = () => {
|
||||
quit("shutdown");
|
||||
};
|
||||
if (options.abortSignal.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
options.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
await withTimeout(readyPromise, timeoutMs, "IRC connect");
|
||||
|
||||
return {
|
||||
get nick() {
|
||||
return currentNick;
|
||||
},
|
||||
isReady: () => ready && !closed,
|
||||
sendRaw,
|
||||
join,
|
||||
sendPrivmsg,
|
||||
quit,
|
||||
close,
|
||||
};
|
||||
}
|
||||
27
extensions/irc/src/config-schema.test.ts
Normal file
27
extensions/irc/src/config-schema.test.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IrcConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("irc config schema", () => {
|
||||
it("accepts numeric allowFrom and groupAllowFrom entries", () => {
|
||||
const parsed = IrcConfigSchema.parse({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [12345, "alice"],
|
||||
groupAllowFrom: [67890, "alice!ident@example.org"],
|
||||
});
|
||||
|
||||
expect(parsed.allowFrom).toEqual([12345, "alice"]);
|
||||
expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
|
||||
});
|
||||
|
||||
it("accepts numeric per-channel allowFrom entries", () => {
|
||||
const parsed = IrcConfigSchema.parse({
|
||||
groups: {
|
||||
"#ops": {
|
||||
allowFrom: [42, "alice"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
|
||||
});
|
||||
});
|
||||
97
extensions/irc/src/config-schema.ts
Normal file
97
extensions/irc/src/config-schema.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmConfigSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ToolPolicySchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import { z } from "zod";
|
||||
|
||||
const IrcGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const IrcNickServSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
service: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
passwordFile: z.string().optional(),
|
||||
register: z.boolean().optional(),
|
||||
registerEmail: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.register && !value.registerEmail?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["registerEmail"],
|
||||
message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const IrcAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
tls: z.boolean().optional(),
|
||||
nick: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
realname: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
passwordFile: z.string().optional(),
|
||||
nickserv: IrcNickServSchema.optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
|
||||
channels: z.array(z.string()).optional(),
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
historyLimit: z.number().int().min(0).optional(),
|
||||
dmHistoryLimit: z.number().int().min(0).optional(),
|
||||
dms: z.record(z.string(), DmConfigSchema.optional()).optional(),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
responsePrefix: z.string().optional(),
|
||||
mediaMaxMb: z.number().positive().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
22
extensions/irc/src/control-chars.ts
Normal file
22
extensions/irc/src/control-chars.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
export function isIrcControlChar(charCode: number): boolean {
|
||||
return charCode <= 0x1f || charCode === 0x7f;
|
||||
}
|
||||
|
||||
export function hasIrcControlChars(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (isIrcControlChar(char.charCodeAt(0))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripIrcControlChars(value: string): string {
|
||||
let out = "";
|
||||
for (const char of value) {
|
||||
if (!isIrcControlChar(char.charCodeAt(0))) {
|
||||
out += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
334
extensions/irc/src/inbound.ts
Normal file
334
extensions/irc/src/inbound.ts
Normal file
@@ -0,0 +1,334 @@
|
||||
import {
|
||||
createReplyPrefixOptions,
|
||||
logInboundDrop,
|
||||
resolveControlCommandGate,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
|
||||
import {
|
||||
resolveIrcMentionGate,
|
||||
resolveIrcGroupAccessGate,
|
||||
resolveIrcGroupMatch,
|
||||
resolveIrcGroupSenderAllowed,
|
||||
resolveIrcRequireMention,
|
||||
} from "./policy.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
|
||||
const CHANNEL_ID = "irc" as const;
|
||||
|
||||
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
async function deliverIrcReply(params: {
|
||||
payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string };
|
||||
target: string;
|
||||
accountId: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}) {
|
||||
const text = params.payload.text ?? "";
|
||||
const mediaList = params.payload.mediaUrls?.length
|
||||
? params.payload.mediaUrls
|
||||
: params.payload.mediaUrl
|
||||
? [params.payload.mediaUrl]
|
||||
: [];
|
||||
|
||||
if (!text.trim() && mediaList.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const mediaBlock = mediaList.length
|
||||
? mediaList.map((url) => `Attachment: ${url}`).join("\n")
|
||||
: "";
|
||||
const combined = text.trim()
|
||||
? mediaBlock
|
||||
? `${text.trim()}\n\n${mediaBlock}`
|
||||
: text.trim()
|
||||
: mediaBlock;
|
||||
|
||||
if (params.sendReply) {
|
||||
await params.sendReply(params.target, combined, params.payload.replyToId);
|
||||
} else {
|
||||
await sendMessageIrc(params.target, combined, {
|
||||
accountId: params.accountId,
|
||||
replyTo: params.payload.replyToId,
|
||||
});
|
||||
}
|
||||
params.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
}
|
||||
|
||||
export async function handleIrcInbound(params: {
|
||||
message: IrcInboundMessage;
|
||||
account: ResolvedIrcAccount;
|
||||
config: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
connectedNick?: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { message, account, config, runtime, connectedNick, statusSink } = params;
|
||||
const core = getIrcRuntime();
|
||||
|
||||
const rawBody = message.text?.trim() ?? "";
|
||||
if (!rawBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusSink?.({ lastInboundAt: message.timestamp });
|
||||
|
||||
const senderDisplay = message.senderHost
|
||||
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
|
||||
: message.senderNick;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = config.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
|
||||
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
|
||||
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
|
||||
const storeAllowFrom = await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
|
||||
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
|
||||
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: account.config.groups,
|
||||
target: message.target,
|
||||
});
|
||||
|
||||
if (message.isGroup) {
|
||||
const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch });
|
||||
if (!groupAccess.allowed) {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom);
|
||||
const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom);
|
||||
const groupAllowFrom =
|
||||
directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
|
||||
|
||||
const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
|
||||
const effectiveGroupAllowFrom = [...configGroupAllowFrom, ...storeAllowList].filter(Boolean);
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
surface: CHANNEL_ID,
|
||||
});
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
||||
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||
message,
|
||||
}).allowed;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{
|
||||
configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
|
||||
allowed: senderAllowedForCommands,
|
||||
},
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
|
||||
if (message.isGroup) {
|
||||
const senderAllowed = resolveIrcGroupSenderAllowed({
|
||||
groupPolicy,
|
||||
message,
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: groupAllowFrom,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const dmAllowed = resolveIrcAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
message,
|
||||
}).allowed;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
const { code, created } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: CHANNEL_ID,
|
||||
id: senderDisplay.toLowerCase(),
|
||||
meta: { name: message.senderNick || undefined },
|
||||
});
|
||||
if (created) {
|
||||
try {
|
||||
const reply = core.channel.pairing.buildPairingReply({
|
||||
channel: CHANNEL_ID,
|
||||
idLine: `Your IRC id: ${senderDisplay}`,
|
||||
code,
|
||||
});
|
||||
await deliverIrcReply({
|
||||
payload: { text: reply },
|
||||
target: message.senderNick,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
} catch (err) {
|
||||
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (message.isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: (line) => runtime.log?.(line),
|
||||
channel: CHANNEL_ID,
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
|
||||
const mentionNick = connectedNick?.trim() || account.nick;
|
||||
const explicitMentionRegex = mentionNick
|
||||
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
|
||||
: null;
|
||||
const wasMentioned =
|
||||
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
|
||||
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
|
||||
|
||||
const requireMention = message.isGroup
|
||||
? resolveIrcRequireMention({
|
||||
groupConfig: groupMatch.groupConfig,
|
||||
wildcardConfig: groupMatch.wildcardConfig,
|
||||
})
|
||||
: false;
|
||||
|
||||
const mentionGate = resolveIrcMentionGate({
|
||||
isGroup: message.isGroup,
|
||||
requireMention,
|
||||
wasMentioned,
|
||||
hasControlCommand,
|
||||
allowTextCommands,
|
||||
commandAuthorized,
|
||||
});
|
||||
if (mentionGate.shouldSkip) {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = message.isGroup ? message.target : message.senderNick;
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "IRC",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = groupMatch.groupConfig?.systemPrompt?.trim() || undefined;
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: message.isGroup ? `irc:channel:${message.target}` : `irc:${senderDisplay}`,
|
||||
To: `irc:${peerId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: message.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: message.senderNick || undefined,
|
||||
SenderId: senderDisplay,
|
||||
GroupSubject: message.isGroup ? message.target : undefined,
|
||||
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: message.isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: `irc:${peerId}`,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
await core.channel.session.recordInboundSession({
|
||||
storePath,
|
||||
sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
|
||||
ctx: ctxPayload,
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
|
||||
const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
|
||||
cfg: config as OpenClawConfig,
|
||||
agentId: route.agentId,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
||||
ctx: ctxPayload,
|
||||
cfg: config as OpenClawConfig,
|
||||
dispatcherOptions: {
|
||||
...prefixOptions,
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload: payload as {
|
||||
text?: string;
|
||||
mediaUrls?: string[];
|
||||
mediaUrl?: string;
|
||||
replyToId?: string;
|
||||
},
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter: groupMatch.groupConfig?.skills,
|
||||
onModelSelected,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
43
extensions/irc/src/monitor.test.ts
Normal file
43
extensions/irc/src/monitor.test.ts
Normal file
@@ -0,0 +1,43 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIrcInboundTarget } from "./monitor.js";
|
||||
|
||||
describe("irc monitor inbound target", () => {
|
||||
it("keeps channel target for group messages", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "#openclaw",
|
||||
senderNick: "alice",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: true,
|
||||
target: "#openclaw",
|
||||
rawTarget: "#openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps DM target to sender nick and preserves raw target", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "openclaw-bot",
|
||||
senderNick: "alice",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: false,
|
||||
target: "alice",
|
||||
rawTarget: "openclaw-bot",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to raw target when sender nick is empty", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "openclaw-bot",
|
||||
senderNick: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: false,
|
||||
target: "openclaw-bot",
|
||||
rawTarget: "openclaw-bot",
|
||||
});
|
||||
});
|
||||
});
|
||||
158
extensions/irc/src/monitor.ts
Normal file
158
extensions/irc/src/monitor.ts
Normal file
@@ -0,0 +1,158 @@
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import { connectIrcClient, type IrcClient } from "./client.js";
|
||||
import { handleIrcInbound } from "./inbound.js";
|
||||
import { isChannelTarget } from "./normalize.js";
|
||||
import { makeIrcMessageId } from "./protocol.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
|
||||
export type IrcMonitorOptions = {
|
||||
accountId?: string;
|
||||
config?: CoreConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
|
||||
isGroup: boolean;
|
||||
target: string;
|
||||
rawTarget: string;
|
||||
} {
|
||||
const rawTarget = params.target;
|
||||
const isGroup = isChannelTarget(rawTarget);
|
||||
if (isGroup) {
|
||||
return { isGroup: true, target: rawTarget, rawTarget };
|
||||
}
|
||||
const senderNick = params.senderNick.trim();
|
||||
return { isGroup: false, target: senderNick || rawTarget, rawTarget };
|
||||
}
|
||||
|
||||
export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
|
||||
const core = getIrcRuntime();
|
||||
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const runtime: RuntimeEnv = opts.runtime ?? {
|
||||
log: (message: string) => core.logging.getChildLogger().info(message),
|
||||
error: (message: string) => core.logging.getChildLogger().error(message),
|
||||
exit: () => {
|
||||
throw new Error("Runtime exit not available");
|
||||
},
|
||||
};
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
|
||||
const logger = core.logging.getChildLogger({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
let client: IrcClient | null = null;
|
||||
|
||||
client = await connectIrcClient({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
username: account.username,
|
||||
realname: account.realname,
|
||||
password: account.password,
|
||||
nickserv: {
|
||||
enabled: account.config.nickserv?.enabled,
|
||||
service: account.config.nickserv?.service,
|
||||
password: account.config.nickserv?.password,
|
||||
register: account.config.nickserv?.register,
|
||||
registerEmail: account.config.nickserv?.registerEmail,
|
||||
},
|
||||
channels: account.config.channels,
|
||||
abortSignal: opts.abortSignal,
|
||||
onLine: (line) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(`[${account.accountId}] << ${line}`);
|
||||
}
|
||||
},
|
||||
onNotice: (text, target) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
|
||||
},
|
||||
onPrivmsg: async (event) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
if (event.senderNick.toLowerCase() === client.nick.toLowerCase()) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inboundTarget = resolveIrcInboundTarget({
|
||||
target: event.target,
|
||||
senderNick: event.senderNick,
|
||||
});
|
||||
const message: IrcInboundMessage = {
|
||||
messageId: makeIrcMessageId(),
|
||||
target: inboundTarget.target,
|
||||
rawTarget: inboundTarget.rawTarget,
|
||||
senderNick: event.senderNick,
|
||||
senderUser: event.senderUser,
|
||||
senderHost: event.senderHost,
|
||||
text: event.text,
|
||||
timestamp: Date.now(),
|
||||
isGroup: inboundTarget.isGroup,
|
||||
};
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
at: message.timestamp,
|
||||
});
|
||||
|
||||
if (opts.onMessage) {
|
||||
await opts.onMessage(message, client);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleIrcInbound({
|
||||
message,
|
||||
account,
|
||||
config: cfg,
|
||||
runtime,
|
||||
connectedNick: client.nick,
|
||||
sendReply: async (target, text) => {
|
||||
client?.sendPrivmsg(target, text);
|
||||
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
core.channel.activity.record({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
},
|
||||
statusSink: opts.statusSink,
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
logger.info(
|
||||
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
|
||||
);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
client?.quit("shutdown");
|
||||
client = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
46
extensions/irc/src/normalize.test.ts
Normal file
46
extensions/irc/src/normalize.test.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIrcAllowlistCandidates,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
resolveIrcAllowlistMatch,
|
||||
} from "./normalize.js";
|
||||
|
||||
describe("irc normalize", () => {
|
||||
it("normalizes targets", () => {
|
||||
expect(normalizeIrcMessagingTarget("irc:channel:openclaw")).toBe("#openclaw");
|
||||
expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice");
|
||||
expect(normalizeIrcMessagingTarget("\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries", () => {
|
||||
expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h");
|
||||
});
|
||||
|
||||
it("matches senders by nick/user/host candidates", () => {
|
||||
const message = {
|
||||
messageId: "m1",
|
||||
target: "#chan",
|
||||
senderNick: "Alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.org",
|
||||
text: "hi",
|
||||
timestamp: Date.now(),
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice!ident@example.org"],
|
||||
message,
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["bob"],
|
||||
message,
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
117
extensions/irc/src/normalize.ts
Normal file
117
extensions/irc/src/normalize.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import type { IrcInboundMessage } from "./types.js";
|
||||
import { hasIrcControlChars } from "./control-chars.js";
|
||||
|
||||
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
|
||||
|
||||
export function isChannelTarget(target: string): boolean {
|
||||
return target.startsWith("#") || target.startsWith("&");
|
||||
}
|
||||
|
||||
export function normalizeIrcMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
let target = trimmed;
|
||||
const lowered = target.toLowerCase();
|
||||
if (lowered.startsWith("irc:")) {
|
||||
target = target.slice("irc:".length).trim();
|
||||
}
|
||||
if (target.toLowerCase().startsWith("channel:")) {
|
||||
target = target.slice("channel:".length).trim();
|
||||
if (!target.startsWith("#") && !target.startsWith("&")) {
|
||||
target = `#${target}`;
|
||||
}
|
||||
}
|
||||
if (target.toLowerCase().startsWith("user:")) {
|
||||
target = target.slice("user:".length).trim();
|
||||
}
|
||||
if (!target || !looksLikeIrcTargetId(target)) {
|
||||
return undefined;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function looksLikeIrcTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (hasIrcControlChars(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return IRC_TARGET_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeIrcAllowEntry(raw: string): string {
|
||||
let value = raw.trim().toLowerCase();
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (value.startsWith("irc:")) {
|
||||
value = value.slice("irc:".length);
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
value = value.slice("user:".length);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function normalizeIrcAllowlist(entries?: Array<string | number>): string[] {
|
||||
return (entries ?? []).map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean);
|
||||
}
|
||||
|
||||
export function formatIrcSenderId(message: IrcInboundMessage): string {
|
||||
const base = message.senderNick.trim();
|
||||
const user = message.senderUser?.trim();
|
||||
const host = message.senderHost?.trim();
|
||||
if (user && host) {
|
||||
return `${base}!${user}@${host}`;
|
||||
}
|
||||
if (user) {
|
||||
return `${base}!${user}`;
|
||||
}
|
||||
if (host) {
|
||||
return `${base}@${host}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildIrcAllowlistCandidates(message: IrcInboundMessage): string[] {
|
||||
const nick = message.senderNick.trim().toLowerCase();
|
||||
const user = message.senderUser?.trim().toLowerCase();
|
||||
const host = message.senderHost?.trim().toLowerCase();
|
||||
const candidates = new Set<string>();
|
||||
if (nick) {
|
||||
candidates.add(nick);
|
||||
}
|
||||
if (nick && user) {
|
||||
candidates.add(`${nick}!${user}`);
|
||||
}
|
||||
if (nick && host) {
|
||||
candidates.add(`${nick}@${host}`);
|
||||
}
|
||||
if (nick && user && host) {
|
||||
candidates.add(`${nick}!${user}@${host}`);
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
|
||||
export function resolveIrcAllowlistMatch(params: {
|
||||
allowFrom: string[];
|
||||
message: IrcInboundMessage;
|
||||
}): { allowed: boolean; source?: string } {
|
||||
const allowFrom = new Set(
|
||||
params.allowFrom.map((entry) => entry.trim().toLowerCase()).filter(Boolean),
|
||||
);
|
||||
if (allowFrom.has("*")) {
|
||||
return { allowed: true, source: "wildcard" };
|
||||
}
|
||||
const candidates = buildIrcAllowlistCandidates(params.message);
|
||||
for (const candidate of candidates) {
|
||||
if (allowFrom.has(candidate)) {
|
||||
return { allowed: true, source: candidate };
|
||||
}
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
118
extensions/irc/src/onboarding.test.ts
Normal file
118
extensions/irc/src/onboarding.test.ts
Normal file
@@ -0,0 +1,118 @@
|
||||
import type { RuntimeEnv, WizardPrompter } from "openclaw/plugin-sdk";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { ircOnboardingAdapter } from "./onboarding.js";
|
||||
|
||||
describe("irc onboarding", () => {
|
||||
it("configures host and nick via onboarding prompts", async () => {
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select: vi.fn(async () => "allowlist"),
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC server host") {
|
||||
return "irc.libera.chat";
|
||||
}
|
||||
if (message === "IRC server port") {
|
||||
return "6697";
|
||||
}
|
||||
if (message === "IRC nick") {
|
||||
return "openclaw-bot";
|
||||
}
|
||||
if (message === "IRC username") {
|
||||
return "openclaw";
|
||||
}
|
||||
if (message === "IRC real name") {
|
||||
return "OpenClaw Bot";
|
||||
}
|
||||
if (message.startsWith("Auto-join IRC channels")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
if (message.startsWith("IRC channels allowlist")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Use TLS for IRC?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Configure IRC channels access?") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
|
||||
const runtime: RuntimeEnv = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: vi.fn(),
|
||||
};
|
||||
|
||||
const result = await ircOnboardingAdapter.configure({
|
||||
cfg: {} as CoreConfig,
|
||||
runtime,
|
||||
prompter,
|
||||
options: {},
|
||||
accountOverrides: {},
|
||||
shouldPromptAccountIds: false,
|
||||
forceAllowFrom: false,
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.irc?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
|
||||
expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot");
|
||||
expect(result.cfg.channels?.irc?.tls).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]);
|
||||
expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
|
||||
expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]);
|
||||
});
|
||||
|
||||
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
|
||||
const prompter: WizardPrompter = {
|
||||
intro: vi.fn(async () => {}),
|
||||
outro: vi.fn(async () => {}),
|
||||
note: vi.fn(async () => {}),
|
||||
select: vi.fn(async () => "allowlist"),
|
||||
multiselect: vi.fn(async () => []),
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC allowFrom (nick or nick!user@host)") {
|
||||
return "Alice, Bob!ident@example.org";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })),
|
||||
};
|
||||
|
||||
const promptAllowFrom = ircOnboardingAdapter.dmPolicy?.promptAllowFrom;
|
||||
expect(promptAllowFrom).toBeTypeOf("function");
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updated = (await promptAllowFrom?.({
|
||||
cfg,
|
||||
prompter,
|
||||
accountId: "work",
|
||||
})) as CoreConfig;
|
||||
|
||||
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
|
||||
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
});
|
||||
});
|
||||
479
extensions/irc/src/onboarding.ts
Normal file
479
extensions/irc/src/onboarding.ts
Normal file
@@ -0,0 +1,479 @@
|
||||
import {
|
||||
addWildcardAllowFrom,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
formatDocsLink,
|
||||
promptAccountId,
|
||||
promptChannelAccessConfig,
|
||||
type ChannelOnboardingAdapter,
|
||||
type ChannelOnboardingDmPolicy,
|
||||
type DmPolicy,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk";
|
||||
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
|
||||
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
|
||||
import {
|
||||
isChannelTarget,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
|
||||
const channel = "irc" as const;
|
||||
|
||||
function parseListInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function parsePort(raw: string, fallback: number): number {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function normalizeGroupEntry(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed;
|
||||
if (isChannelTarget(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return `#${normalized.replace(/^#+/, "")}`;
|
||||
}
|
||||
|
||||
function updateIrcAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
patch: Partial<IrcAccountConfig>,
|
||||
): CoreConfig {
|
||||
const current = cfg.channels?.irc ?? {};
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
irc: {
|
||||
...current,
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
irc: {
|
||||
...current,
|
||||
accounts: {
|
||||
...current.accounts,
|
||||
[accountId]: {
|
||||
...current.accounts?.[accountId],
|
||||
...patch,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
||||
const allowFrom =
|
||||
dmPolicy === "open" ? addWildcardAllowFrom(cfg.channels?.irc?.allowFrom) : undefined;
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
irc: {
|
||||
...cfg.channels?.irc,
|
||||
dmPolicy,
|
||||
...(allowFrom ? { allowFrom } : {}),
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
|
||||
return {
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
irc: {
|
||||
...cfg.channels?.irc,
|
||||
allowFrom,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function setIrcNickServ(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
nickserv?: IrcNickServConfig,
|
||||
): CoreConfig {
|
||||
return updateIrcAccountConfig(cfg, accountId, { nickserv });
|
||||
}
|
||||
|
||||
function setIrcGroupAccess(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
policy: "open" | "allowlist" | "disabled",
|
||||
entries: string[],
|
||||
): CoreConfig {
|
||||
if (policy !== "allowlist") {
|
||||
return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy });
|
||||
}
|
||||
const normalizedEntries = [
|
||||
...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)),
|
||||
];
|
||||
const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
|
||||
return updateIrcAccountConfig(cfg, accountId, {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
async function noteIrcSetupHelp(prompter: WizardPrompter): Promise<void> {
|
||||
await prompter.note(
|
||||
[
|
||||
"IRC needs server host + bot nick.",
|
||||
"Recommended: TLS on port 6697.",
|
||||
"Optional: NickServ identify/register can be configured in onboarding.",
|
||||
'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.',
|
||||
'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).',
|
||||
"Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.",
|
||||
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||
].join("\n"),
|
||||
"IRC setup",
|
||||
);
|
||||
}
|
||||
|
||||
async function promptIrcAllowFrom(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId?: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const existing = params.cfg.channels?.irc?.allowFrom ?? [];
|
||||
|
||||
await params.prompter.note(
|
||||
[
|
||||
"Allowlist IRC DMs by sender.",
|
||||
"Examples:",
|
||||
"- alice",
|
||||
"- alice!ident@example.org",
|
||||
"Multiple entries: comma-separated.",
|
||||
].join("\n"),
|
||||
"IRC allowlist",
|
||||
);
|
||||
|
||||
const raw = await params.prompter.text({
|
||||
message: "IRC allowFrom (nick or nick!user@host)",
|
||||
placeholder: "alice, bob!ident@example.org",
|
||||
initialValue: existing[0] ? String(existing[0]) : undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
});
|
||||
|
||||
const parsed = parseListInput(String(raw));
|
||||
const normalized = [
|
||||
...new Set(
|
||||
parsed
|
||||
.map((entry) => normalizeIrcAllowEntry(entry))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
),
|
||||
];
|
||||
return setIrcAllowFrom(params.cfg, normalized);
|
||||
}
|
||||
|
||||
async function promptIrcNickServConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const existing = resolved.config.nickserv;
|
||||
const hasExisting = Boolean(existing?.password || existing?.passwordFile);
|
||||
const wants = await params.prompter.confirm({
|
||||
message: hasExisting ? "Update NickServ settings?" : "Configure NickServ identify/register?",
|
||||
initialValue: hasExisting,
|
||||
});
|
||||
if (!wants) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
const service = String(
|
||||
await params.prompter.text({
|
||||
message: "NickServ service nick",
|
||||
initialValue: existing?.service || "NickServ",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const useEnvPassword =
|
||||
params.accountId === DEFAULT_ACCOUNT_ID &&
|
||||
Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) &&
|
||||
!(existing?.password || existing?.passwordFile)
|
||||
? await params.prompter.confirm({
|
||||
message: "IRC_NICKSERV_PASSWORD detected. Use env var?",
|
||||
initialValue: true,
|
||||
})
|
||||
: false;
|
||||
|
||||
const password = useEnvPassword
|
||||
? undefined
|
||||
: String(
|
||||
await params.prompter.text({
|
||||
message: "NickServ password (blank to disable NickServ auth)",
|
||||
validate: () => undefined,
|
||||
}),
|
||||
).trim();
|
||||
|
||||
if (!password && !useEnvPassword) {
|
||||
return setIrcNickServ(params.cfg, params.accountId, {
|
||||
enabled: false,
|
||||
service,
|
||||
});
|
||||
}
|
||||
|
||||
const register = await params.prompter.confirm({
|
||||
message: "Send NickServ REGISTER on connect?",
|
||||
initialValue: existing?.register ?? false,
|
||||
});
|
||||
const registerEmail = register
|
||||
? String(
|
||||
await params.prompter.text({
|
||||
message: "NickServ register email",
|
||||
initialValue:
|
||||
existing?.registerEmail ||
|
||||
(params.accountId === DEFAULT_ACCOUNT_ID
|
||||
? process.env.IRC_NICKSERV_REGISTER_EMAIL
|
||||
: undefined),
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim()
|
||||
: undefined;
|
||||
|
||||
return setIrcNickServ(params.cfg, params.accountId, {
|
||||
enabled: true,
|
||||
service,
|
||||
...(password ? { password } : {}),
|
||||
register,
|
||||
...(registerEmail ? { registerEmail } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const dmPolicy: ChannelOnboardingDmPolicy = {
|
||||
label: "IRC",
|
||||
channel,
|
||||
policyKey: "channels.irc.dmPolicy",
|
||||
allowFromKey: "channels.irc.allowFrom",
|
||||
getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy),
|
||||
promptAllowFrom: promptIrcAllowFrom,
|
||||
};
|
||||
|
||||
export const ircOnboardingAdapter: ChannelOnboardingAdapter = {
|
||||
channel,
|
||||
getStatus: async ({ cfg }) => {
|
||||
const coreCfg = cfg as CoreConfig;
|
||||
const configured = listIrcAccountIds(coreCfg).some(
|
||||
(accountId) => resolveIrcAccount({ cfg: coreCfg, accountId }).configured,
|
||||
);
|
||||
return {
|
||||
channel,
|
||||
configured,
|
||||
statusLines: [`IRC: ${configured ? "configured" : "needs host + nick"}`],
|
||||
selectionHint: configured ? "configured" : "needs host + nick",
|
||||
quickstartScore: configured ? 1 : 0,
|
||||
};
|
||||
},
|
||||
configure: async ({
|
||||
cfg,
|
||||
prompter,
|
||||
accountOverrides,
|
||||
shouldPromptAccountIds,
|
||||
forceAllowFrom,
|
||||
}) => {
|
||||
let next = cfg as CoreConfig;
|
||||
const ircOverride = accountOverrides.irc?.trim();
|
||||
const defaultAccountId = resolveDefaultIrcAccountId(next);
|
||||
let accountId = ircOverride || defaultAccountId;
|
||||
if (shouldPromptAccountIds && !ircOverride) {
|
||||
accountId = await promptAccountId({
|
||||
cfg: next,
|
||||
prompter,
|
||||
label: "IRC",
|
||||
currentId: accountId,
|
||||
listAccountIds: listIrcAccountIds,
|
||||
defaultAccountId,
|
||||
});
|
||||
}
|
||||
|
||||
const resolved = resolveIrcAccount({ cfg: next, accountId });
|
||||
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envHost = isDefaultAccount ? process.env.IRC_HOST?.trim() : "";
|
||||
const envNick = isDefaultAccount ? process.env.IRC_NICK?.trim() : "";
|
||||
const envReady = Boolean(envHost && envNick);
|
||||
|
||||
if (!resolved.configured) {
|
||||
await noteIrcSetupHelp(prompter);
|
||||
}
|
||||
|
||||
let useEnv = false;
|
||||
if (envReady && isDefaultAccount && !resolved.config.host && !resolved.config.nick) {
|
||||
useEnv = await prompter.confirm({
|
||||
message: "IRC_HOST and IRC_NICK detected. Use env vars?",
|
||||
initialValue: true,
|
||||
});
|
||||
}
|
||||
|
||||
if (useEnv) {
|
||||
next = updateIrcAccountConfig(next, accountId, { enabled: true });
|
||||
} else {
|
||||
const host = String(
|
||||
await prompter.text({
|
||||
message: "IRC server host",
|
||||
initialValue: resolved.config.host || envHost || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const tls = await prompter.confirm({
|
||||
message: "Use TLS for IRC?",
|
||||
initialValue: resolved.config.tls ?? true,
|
||||
});
|
||||
const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667);
|
||||
const portInput = await prompter.text({
|
||||
message: "IRC server port",
|
||||
initialValue: String(defaultPort),
|
||||
validate: (value) => {
|
||||
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
||||
return Number.isFinite(parsed) && parsed >= 1 && parsed <= 65535
|
||||
? undefined
|
||||
: "Use a port between 1 and 65535";
|
||||
},
|
||||
});
|
||||
const port = parsePort(String(portInput), defaultPort);
|
||||
|
||||
const nick = String(
|
||||
await prompter.text({
|
||||
message: "IRC nick",
|
||||
initialValue: resolved.config.nick || envNick || undefined,
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const username = String(
|
||||
await prompter.text({
|
||||
message: "IRC username",
|
||||
initialValue: resolved.config.username || nick || "openclaw",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const realname = String(
|
||||
await prompter.text({
|
||||
message: "IRC real name",
|
||||
initialValue: resolved.config.realname || "OpenClaw",
|
||||
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
||||
}),
|
||||
).trim();
|
||||
|
||||
const channelsRaw = await prompter.text({
|
||||
message: "Auto-join IRC channels (optional, comma-separated)",
|
||||
placeholder: "#openclaw, #ops",
|
||||
initialValue: (resolved.config.channels ?? []).join(", "),
|
||||
});
|
||||
const channels = [
|
||||
...new Set(
|
||||
parseListInput(String(channelsRaw))
|
||||
.map((entry) => normalizeGroupEntry(entry))
|
||||
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
||||
.filter((entry) => isChannelTarget(entry)),
|
||||
),
|
||||
];
|
||||
|
||||
next = updateIrcAccountConfig(next, accountId, {
|
||||
enabled: true,
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
nick,
|
||||
username,
|
||||
realname,
|
||||
channels: channels.length > 0 ? channels : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const afterConfig = resolveIrcAccount({ cfg: next, accountId });
|
||||
const accessConfig = await promptChannelAccessConfig({
|
||||
prompter,
|
||||
label: "IRC channels",
|
||||
currentPolicy: afterConfig.config.groupPolicy ?? "allowlist",
|
||||
currentEntries: Object.keys(afterConfig.config.groups ?? {}),
|
||||
placeholder: "#openclaw, #ops, *",
|
||||
updatePrompt: Boolean(afterConfig.config.groups),
|
||||
});
|
||||
if (accessConfig) {
|
||||
next = setIrcGroupAccess(next, accountId, accessConfig.policy, accessConfig.entries);
|
||||
|
||||
// Mention gating: groups/channels are mention-gated by default. Make this explicit in onboarding.
|
||||
const wantsMentions = await prompter.confirm({
|
||||
message: "Require @mention to reply in IRC channels?",
|
||||
initialValue: true,
|
||||
});
|
||||
if (!wantsMentions) {
|
||||
const resolvedAfter = resolveIrcAccount({ cfg: next, accountId });
|
||||
const groups = resolvedAfter.config.groups ?? {};
|
||||
const patched = Object.fromEntries(
|
||||
Object.entries(groups).map(([key, value]) => [key, { ...value, requireMention: false }]),
|
||||
);
|
||||
next = updateIrcAccountConfig(next, accountId, { groups: patched });
|
||||
}
|
||||
}
|
||||
|
||||
if (forceAllowFrom) {
|
||||
next = await promptIrcAllowFrom({ cfg: next, prompter, accountId });
|
||||
}
|
||||
next = await promptIrcNickServConfig({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
|
||||
await prompter.note(
|
||||
[
|
||||
"Next: restart gateway and verify status.",
|
||||
"Command: openclaw channels status --probe",
|
||||
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||
].join("\n"),
|
||||
"IRC next steps",
|
||||
);
|
||||
|
||||
return { cfg: next, accountId };
|
||||
},
|
||||
dmPolicy,
|
||||
disable: (cfg) => ({
|
||||
...(cfg as CoreConfig),
|
||||
channels: {
|
||||
...(cfg as CoreConfig).channels,
|
||||
irc: {
|
||||
...(cfg as CoreConfig).channels?.irc,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
132
extensions/irc/src/policy.test.ts
Normal file
132
extensions/irc/src/policy.test.ts
Normal file
@@ -0,0 +1,132 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveChannelGroupPolicy } from "../../../src/config/group-policy.js";
|
||||
import {
|
||||
resolveIrcGroupAccessGate,
|
||||
resolveIrcGroupMatch,
|
||||
resolveIrcGroupSenderAllowed,
|
||||
resolveIrcMentionGate,
|
||||
resolveIrcRequireMention,
|
||||
} from "./policy.js";
|
||||
|
||||
describe("irc policy", () => {
|
||||
it("matches direct and wildcard group entries", () => {
|
||||
const direct = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"#ops": { requireMention: false },
|
||||
},
|
||||
target: "#ops",
|
||||
});
|
||||
expect(direct.allowed).toBe(true);
|
||||
expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false);
|
||||
|
||||
const wildcard = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
target: "#random",
|
||||
});
|
||||
expect(wildcard.allowed).toBe(true);
|
||||
expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces allowlist by default in groups", () => {
|
||||
const message = {
|
||||
messageId: "m1",
|
||||
target: "#ops",
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.org",
|
||||
text: "hi",
|
||||
timestamp: Date.now(),
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows unconfigured channels when groupPolicy is "open"', () => {
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: undefined,
|
||||
target: "#random",
|
||||
});
|
||||
const gate = resolveIrcGroupAccessGate({
|
||||
groupPolicy: "open",
|
||||
groupMatch,
|
||||
});
|
||||
expect(gate.allowed).toBe(true);
|
||||
expect(gate.reason).toBe("open");
|
||||
});
|
||||
|
||||
it("honors explicit group disable even in open mode", () => {
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"#ops": { enabled: false },
|
||||
},
|
||||
target: "#ops",
|
||||
});
|
||||
const gate = resolveIrcGroupAccessGate({
|
||||
groupPolicy: "open",
|
||||
groupMatch,
|
||||
});
|
||||
expect(gate.allowed).toBe(false);
|
||||
expect(gate.reason).toBe("disabled");
|
||||
});
|
||||
|
||||
it("allows authorized control commands without mention", () => {
|
||||
const gate = resolveIrcMentionGate({
|
||||
isGroup: true,
|
||||
requireMention: true,
|
||||
wasMentioned: false,
|
||||
hasControlCommand: true,
|
||||
allowTextCommands: true,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(gate.shouldSkip).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
|
||||
const groups = {
|
||||
"#Ops": { requireMention: false },
|
||||
"#Hidden": { enabled: false },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
|
||||
const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" });
|
||||
const sharedDirect = resolveChannelGroupPolicy({
|
||||
cfg: { channels: { irc: { groups } } },
|
||||
channel: "irc",
|
||||
groupId: "#ops",
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
expect(sharedDirect.allowed).toBe(inboundDirect.allowed);
|
||||
expect(sharedDirect.groupConfig?.requireMention).toBe(
|
||||
inboundDirect.groupConfig?.requireMention,
|
||||
);
|
||||
|
||||
const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" });
|
||||
const sharedDisabled = resolveChannelGroupPolicy({
|
||||
cfg: { channels: { irc: { groups } } },
|
||||
channel: "irc",
|
||||
groupId: "#hidden",
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed);
|
||||
expect(sharedDisabled.groupConfig?.enabled).toBe(inboundDisabled.groupConfig?.enabled);
|
||||
});
|
||||
});
|
||||
157
extensions/irc/src/policy.ts
Normal file
157
extensions/irc/src/policy.ts
Normal file
@@ -0,0 +1,157 @@
|
||||
import type { IrcAccountConfig, IrcChannelConfig } from "./types.js";
|
||||
import type { IrcInboundMessage } from "./types.js";
|
||||
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
|
||||
|
||||
export type IrcGroupMatch = {
|
||||
allowed: boolean;
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
hasConfiguredGroups: boolean;
|
||||
};
|
||||
|
||||
export type IrcGroupAccessGate = {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function resolveIrcGroupMatch(params: {
|
||||
groups?: Record<string, IrcChannelConfig>;
|
||||
target: string;
|
||||
}): IrcGroupMatch {
|
||||
const groups = params.groups ?? {};
|
||||
const hasConfiguredGroups = Object.keys(groups).length > 0;
|
||||
|
||||
// IRC channel targets are case-insensitive, but config keys are plain strings.
|
||||
// To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match
|
||||
// group config keys case-insensitively.
|
||||
const direct = groups[params.target];
|
||||
if (direct) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
allowed: true,
|
||||
groupConfig: direct,
|
||||
wildcardConfig: groups["*"],
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
|
||||
const targetLower = params.target.toLowerCase();
|
||||
const directKey = Object.keys(groups).find((key) => key.toLowerCase() === targetLower);
|
||||
if (directKey) {
|
||||
const matched = groups[directKey];
|
||||
if (matched) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
allowed: true,
|
||||
groupConfig: matched,
|
||||
wildcardConfig: groups["*"],
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wildcard = groups["*"];
|
||||
if (wildcard) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
allowed: true,
|
||||
wildcardConfig: wildcard,
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIrcGroupAccessGate(params: {
|
||||
groupPolicy: IrcAccountConfig["groupPolicy"];
|
||||
groupMatch: IrcGroupMatch;
|
||||
}): IrcGroupAccessGate {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
if (policy === "disabled") {
|
||||
return { allowed: false, reason: "groupPolicy=disabled" };
|
||||
}
|
||||
|
||||
// In open mode, unconfigured channels are allowed (mention-gated) but explicit
|
||||
// per-channel/wildcard disables still apply.
|
||||
if (policy === "allowlist") {
|
||||
if (!params.groupMatch.hasConfiguredGroups) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "groupPolicy=allowlist and no groups configured",
|
||||
};
|
||||
}
|
||||
if (!params.groupMatch.allowed) {
|
||||
return { allowed: false, reason: "not allowlisted" };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.groupMatch.groupConfig?.enabled === false ||
|
||||
params.groupMatch.wildcardConfig?.enabled === false
|
||||
) {
|
||||
return { allowed: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" };
|
||||
}
|
||||
|
||||
export function resolveIrcRequireMention(params: {
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
}): boolean {
|
||||
if (params.groupConfig?.requireMention !== undefined) {
|
||||
return params.groupConfig.requireMention;
|
||||
}
|
||||
if (params.wildcardConfig?.requireMention !== undefined) {
|
||||
return params.wildcardConfig.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveIrcMentionGate(params: {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
hasControlCommand: boolean;
|
||||
allowTextCommands: boolean;
|
||||
commandAuthorized: boolean;
|
||||
}): { shouldSkip: boolean; reason: string } {
|
||||
if (!params.isGroup) {
|
||||
return { shouldSkip: false, reason: "direct" };
|
||||
}
|
||||
if (!params.requireMention) {
|
||||
return { shouldSkip: false, reason: "mention-not-required" };
|
||||
}
|
||||
if (params.wasMentioned) {
|
||||
return { shouldSkip: false, reason: "mentioned" };
|
||||
}
|
||||
if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) {
|
||||
return { shouldSkip: false, reason: "authorized-command" };
|
||||
}
|
||||
return { shouldSkip: true, reason: "missing-mention" };
|
||||
}
|
||||
|
||||
export function resolveIrcGroupSenderAllowed(params: {
|
||||
groupPolicy: IrcAccountConfig["groupPolicy"];
|
||||
message: IrcInboundMessage;
|
||||
outerAllowFrom: string[];
|
||||
innerAllowFrom: string[];
|
||||
}): boolean {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
||||
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
||||
|
||||
if (inner.length > 0) {
|
||||
return resolveIrcAllowlistMatch({ allowFrom: inner, message: params.message }).allowed;
|
||||
}
|
||||
if (outer.length > 0) {
|
||||
return resolveIrcAllowlistMatch({ allowFrom: outer, message: params.message }).allowed;
|
||||
}
|
||||
return policy === "open";
|
||||
}
|
||||
64
extensions/irc/src/probe.ts
Normal file
64
extensions/irc/src/probe.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
import type { CoreConfig, IrcProbe } from "./types.js";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import { connectIrcClient } from "./client.js";
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return typeof err === "string" ? err : JSON.stringify(err);
|
||||
}
|
||||
|
||||
export async function probeIrc(
|
||||
cfg: CoreConfig,
|
||||
opts?: { accountId?: string; timeoutMs?: number },
|
||||
): Promise<IrcProbe> {
|
||||
const account = resolveIrcAccount({ cfg, accountId: opts?.accountId });
|
||||
const base: IrcProbe = {
|
||||
ok: false,
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
};
|
||||
|
||||
if (!account.configured) {
|
||||
return {
|
||||
...base,
|
||||
error: "missing host or nick",
|
||||
};
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const client = await connectIrcClient({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
username: account.username,
|
||||
realname: account.realname,
|
||||
password: account.password,
|
||||
nickserv: {
|
||||
enabled: account.config.nickserv?.enabled,
|
||||
service: account.config.nickserv?.service,
|
||||
password: account.config.nickserv?.password,
|
||||
register: account.config.nickserv?.register,
|
||||
registerEmail: account.config.nickserv?.registerEmail,
|
||||
},
|
||||
connectTimeoutMs: opts?.timeoutMs ?? 8000,
|
||||
});
|
||||
const elapsed = Date.now() - started;
|
||||
client.quit("probe");
|
||||
return {
|
||||
...base,
|
||||
ok: true,
|
||||
latencyMs: elapsed,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
...base,
|
||||
error: formatError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
extensions/irc/src/protocol.test.ts
Normal file
44
extensions/irc/src/protocol.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseIrcLine,
|
||||
parseIrcPrefix,
|
||||
sanitizeIrcOutboundText,
|
||||
sanitizeIrcTarget,
|
||||
splitIrcText,
|
||||
} from "./protocol.js";
|
||||
|
||||
describe("irc protocol", () => {
|
||||
it("parses PRIVMSG lines with prefix and trailing", () => {
|
||||
const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world");
|
||||
expect(parsed).toEqual({
|
||||
raw: ":alice!u@host PRIVMSG #room :hello world",
|
||||
prefix: "alice!u@host",
|
||||
command: "PRIVMSG",
|
||||
params: ["#room"],
|
||||
trailing: "hello world",
|
||||
});
|
||||
|
||||
expect(parseIrcPrefix(parsed?.prefix)).toEqual({
|
||||
nick: "alice",
|
||||
user: "u",
|
||||
host: "host",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes outbound text to prevent command injection", () => {
|
||||
expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops");
|
||||
expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test");
|
||||
});
|
||||
|
||||
it("validates targets and rejects control characters", () => {
|
||||
expect(sanitizeIrcTarget("#openclaw")).toBe("#openclaw");
|
||||
expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/);
|
||||
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
|
||||
});
|
||||
|
||||
it("splits long text on boundaries", () => {
|
||||
const chunks = splitIrcText("a ".repeat(300), 120);
|
||||
expect(chunks.length).toBeGreaterThan(2);
|
||||
expect(chunks.every((chunk) => chunk.length <= 120)).toBe(true);
|
||||
});
|
||||
});
|
||||
169
extensions/irc/src/protocol.ts
Normal file
169
extensions/irc/src/protocol.ts
Normal file
@@ -0,0 +1,169 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
|
||||
|
||||
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
|
||||
|
||||
export type ParsedIrcLine = {
|
||||
raw: string;
|
||||
prefix?: string;
|
||||
command: string;
|
||||
params: string[];
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
export type ParsedIrcPrefix = {
|
||||
nick?: string;
|
||||
user?: string;
|
||||
host?: string;
|
||||
server?: string;
|
||||
};
|
||||
|
||||
export function parseIrcLine(line: string): ParsedIrcLine | null {
|
||||
const raw = line.replace(/[\r\n]+/g, "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = raw;
|
||||
let prefix: string | undefined;
|
||||
if (cursor.startsWith(":")) {
|
||||
const idx = cursor.indexOf(" ");
|
||||
if (idx <= 1) {
|
||||
return null;
|
||||
}
|
||||
prefix = cursor.slice(1, idx);
|
||||
cursor = cursor.slice(idx + 1).trimStart();
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstSpace = cursor.indexOf(" ");
|
||||
const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim();
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1);
|
||||
const params: string[] = [];
|
||||
let trailing: string | undefined;
|
||||
|
||||
while (cursor.length > 0) {
|
||||
cursor = cursor.trimStart();
|
||||
if (!cursor) {
|
||||
break;
|
||||
}
|
||||
if (cursor.startsWith(":")) {
|
||||
trailing = cursor.slice(1);
|
||||
break;
|
||||
}
|
||||
const spaceIdx = cursor.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
params.push(cursor);
|
||||
break;
|
||||
}
|
||||
params.push(cursor.slice(0, spaceIdx));
|
||||
cursor = cursor.slice(spaceIdx + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
prefix,
|
||||
command: command.toUpperCase(),
|
||||
params,
|
||||
trailing,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
|
||||
if (!prefix) {
|
||||
return {};
|
||||
}
|
||||
const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/);
|
||||
if (nickPart) {
|
||||
return {
|
||||
nick: nickPart[1],
|
||||
user: nickPart[2],
|
||||
host: nickPart[3],
|
||||
};
|
||||
}
|
||||
const nickHostPart = prefix.match(/^([^@]+)@(.+)$/);
|
||||
if (nickHostPart) {
|
||||
return {
|
||||
nick: nickHostPart[1],
|
||||
host: nickHostPart[2],
|
||||
};
|
||||
}
|
||||
if (prefix.includes("!")) {
|
||||
const [nick, user] = prefix.split("!", 2);
|
||||
return { nick, user };
|
||||
}
|
||||
if (prefix.includes(".")) {
|
||||
return { server: prefix };
|
||||
}
|
||||
return { nick: prefix };
|
||||
}
|
||||
|
||||
function decodeLiteralEscapes(input: string): string {
|
||||
// Defensive: this is not a full JS string unescaper.
|
||||
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
|
||||
return input
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\0/g, "\0")
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
}
|
||||
|
||||
export function sanitizeIrcOutboundText(text: string): string {
|
||||
const decoded = decodeLiteralEscapes(text);
|
||||
return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim();
|
||||
}
|
||||
|
||||
export function sanitizeIrcTarget(raw: string): string {
|
||||
const decoded = decodeLiteralEscapes(raw);
|
||||
if (!decoded) {
|
||||
throw new Error("IRC target is required");
|
||||
}
|
||||
// Reject any surrounding whitespace instead of trimming it away.
|
||||
if (decoded !== decoded.trim()) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
if (hasIrcControlChars(decoded)) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
if (!IRC_TARGET_PATTERN.test(decoded)) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function splitIrcText(text: string, maxChars = 350): string[] {
|
||||
const cleaned = sanitizeIrcOutboundText(text);
|
||||
if (!cleaned) {
|
||||
return [];
|
||||
}
|
||||
if (cleaned.length <= maxChars) {
|
||||
return [cleaned];
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let remaining = cleaned;
|
||||
while (remaining.length > maxChars) {
|
||||
let splitAt = remaining.lastIndexOf(" ", maxChars);
|
||||
if (splitAt < Math.floor(maxChars * 0.5)) {
|
||||
splitAt = maxChars;
|
||||
}
|
||||
chunks.push(remaining.slice(0, splitAt).trim());
|
||||
remaining = remaining.slice(splitAt).trimStart();
|
||||
}
|
||||
if (remaining) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
return chunks.filter(Boolean);
|
||||
}
|
||||
|
||||
export function makeIrcMessageId() {
|
||||
return randomUUID();
|
||||
}
|
||||
14
extensions/irc/src/runtime.ts
Normal file
14
extensions/irc/src/runtime.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
||||
|
||||
let runtime: PluginRuntime | null = null;
|
||||
|
||||
export function setIrcRuntime(next: PluginRuntime) {
|
||||
runtime = next;
|
||||
}
|
||||
|
||||
export function getIrcRuntime(): PluginRuntime {
|
||||
if (!runtime) {
|
||||
throw new Error("IRC runtime not initialized");
|
||||
}
|
||||
return runtime;
|
||||
}
|
||||
99
extensions/irc/src/send.ts
Normal file
99
extensions/irc/src/send.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import type { IrcClient } from "./client.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import { connectIrcClient } from "./client.js";
|
||||
import { normalizeIrcMessagingTarget } from "./normalize.js";
|
||||
import { makeIrcMessageId } from "./protocol.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
|
||||
type SendIrcOptions = {
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
target?: string;
|
||||
client?: IrcClient;
|
||||
};
|
||||
|
||||
export type SendIrcResult = {
|
||||
messageId: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
function resolveTarget(to: string, opts?: SendIrcOptions): string {
|
||||
const fromArg = normalizeIrcMessagingTarget(to);
|
||||
if (fromArg) {
|
||||
return fromArg;
|
||||
}
|
||||
const fromOpt = normalizeIrcMessagingTarget(opts?.target ?? "");
|
||||
if (fromOpt) {
|
||||
return fromOpt;
|
||||
}
|
||||
throw new Error(`Invalid IRC target: ${to}`);
|
||||
}
|
||||
|
||||
export async function sendMessageIrc(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SendIrcOptions = {},
|
||||
): Promise<SendIrcResult> {
|
||||
const runtime = getIrcRuntime();
|
||||
const cfg = runtime.config.loadConfig() as CoreConfig;
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = resolveTarget(to, opts);
|
||||
const tableMode = runtime.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const prepared = runtime.channel.text.convertMarkdownTables(text.trim(), tableMode);
|
||||
const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared;
|
||||
|
||||
if (!payload.trim()) {
|
||||
throw new Error("Message must be non-empty for IRC sends");
|
||||
}
|
||||
|
||||
const client = opts.client;
|
||||
if (client?.isReady()) {
|
||||
client.sendPrivmsg(target, payload);
|
||||
} else {
|
||||
const transient = await connectIrcClient({
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
username: account.username,
|
||||
realname: account.realname,
|
||||
password: account.password,
|
||||
nickserv: {
|
||||
enabled: account.config.nickserv?.enabled,
|
||||
service: account.config.nickserv?.service,
|
||||
password: account.config.nickserv?.password,
|
||||
register: account.config.nickserv?.register,
|
||||
registerEmail: account.config.nickserv?.registerEmail,
|
||||
},
|
||||
connectTimeoutMs: 12000,
|
||||
});
|
||||
transient.sendPrivmsg(target, payload);
|
||||
transient.quit("sent");
|
||||
}
|
||||
|
||||
runtime.channel.activity.record({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
|
||||
return {
|
||||
messageId: makeIrcMessageId(),
|
||||
target,
|
||||
};
|
||||
}
|
||||
94
extensions/irc/src/types.ts
Normal file
94
extensions/irc/src/types.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyBySenderConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownConfig,
|
||||
OpenClawConfig,
|
||||
} from "openclaw/plugin-sdk";
|
||||
|
||||
export type IrcChannelConfig = {
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
skills?: string[];
|
||||
enabled?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type IrcNickServConfig = {
|
||||
enabled?: boolean;
|
||||
service?: string;
|
||||
password?: string;
|
||||
passwordFile?: string;
|
||||
register?: boolean;
|
||||
registerEmail?: string;
|
||||
};
|
||||
|
||||
export type IrcAccountConfig = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
nick?: string;
|
||||
username?: string;
|
||||
realname?: string;
|
||||
password?: string;
|
||||
passwordFile?: string;
|
||||
nickserv?: IrcNickServConfig;
|
||||
dmPolicy?: DmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
groupPolicy?: GroupPolicy;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
groups?: Record<string, IrcChannelConfig>;
|
||||
channels?: string[];
|
||||
mentionPatterns?: string[];
|
||||
markdown?: MarkdownConfig;
|
||||
historyLimit?: number;
|
||||
dmHistoryLimit?: number;
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
export type IrcConfig = IrcAccountConfig & {
|
||||
accounts?: Record<string, IrcAccountConfig>;
|
||||
};
|
||||
|
||||
export type CoreConfig = OpenClawConfig & {
|
||||
channels?: OpenClawConfig["channels"] & {
|
||||
irc?: IrcConfig;
|
||||
};
|
||||
};
|
||||
|
||||
export type IrcInboundMessage = {
|
||||
messageId: string;
|
||||
/** Conversation peer id: channel name for groups, sender nick for DMs. */
|
||||
target: string;
|
||||
/** Raw IRC PRIVMSG target (bot nick for DMs, channel for groups). */
|
||||
rawTarget?: string;
|
||||
senderNick: string;
|
||||
senderUser?: string;
|
||||
senderHost?: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
isGroup: boolean;
|
||||
};
|
||||
|
||||
export type IrcProbe = {
|
||||
ok: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
latencyMs?: number;
|
||||
error?: string;
|
||||
};
|
||||
Reference in New Issue
Block a user