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:
Vignesh
2026-02-10 15:33:57 -08:00
committed by GitHub
parent 90f58333e9
commit fa906b26ad
50 changed files with 4907 additions and 791 deletions

17
extensions/irc/index.ts Normal file
View 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;

View File

@@ -0,0 +1,9 @@
{
"id": "irc",
"channels": ["irc"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View 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"
]
}
}

View 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);
}

View 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 };
},
},
};

View 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"]);
});
});

View 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,
};
}

View 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"]);
});
});

View 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 "*"',
});
});

View 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;
}

View 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,
},
});
}

View 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",
});
});
});

View 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;
},
};
}

View 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);
});
});

View 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 };
}

View 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();
});
});

View 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,
},
},
}),
};

View 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);
});
});

View 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";
}

View 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),
};
}
}

View 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);
});
});

View 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();
}

View 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;
}

View 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,
};
}

View 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;
};