Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

13
extensions/irc/README.md Normal file
View File

@@ -0,0 +1,13 @@
# IRC OpenClaw channel
Official OpenClaw channel plugin for IRC.
## Install
```sh
openclaw plugins install @openclaw/irc
```
## Docs
See `docs/channels/irc.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/irc`.

11
extensions/irc/api.ts Normal file
View File

@@ -0,0 +1,11 @@
// Irc API module exposes the plugin public contract.
export { ircPlugin } from "./src/channel.js";
export { setIrcRuntime } from "./src/runtime.js";
export {
listEnabledIrcAccounts,
listIrcAccountIds,
resolveDefaultIrcAccountId,
type ResolvedIrcAccount,
resolveIrcAccount,
} from "./src/accounts.js";
export { ircSetupAdapter, ircSetupWizard } from "./src/setup-surface.js";

View File

@@ -0,0 +1,2 @@
// Irc API module exposes the plugin public contract.
export { IrcChannelConfigSchema } from "./src/config-schema.js";

View File

@@ -0,0 +1,3 @@
// Keep bundled channel entry imports narrow so bootstrap/discovery paths do
// not drag IRC runtime/send/monitor surfaces into lightweight plugin loads.
export { ircPlugin } from "./src/channel.js";

View File

@@ -0,0 +1,9 @@
// Irc helper module supports configured state behavior.
export function hasIrcConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
return (
typeof params.env?.IRC_HOST === "string" &&
params.env.IRC_HOST.trim().length > 0 &&
typeof params.env?.IRC_NICK === "string" &&
params.env.IRC_NICK.trim().length > 0
);
}

View File

@@ -0,0 +1,14 @@
// Irc tests cover index plugin behavior.
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
import { describe } from "vitest";
import entry from "./index.js";
import setupEntry from "./setup-entry.js";
describe("irc bundled entries", () => {
assertBundledChannelEntries({
entry,
expectedId: "irc",
expectedName: "IRC",
setupEntry,
});
});

21
extensions/irc/index.ts Normal file
View File

@@ -0,0 +1,21 @@
// Irc plugin entrypoint registers its OpenClaw integration.
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "irc",
name: "IRC",
description: "IRC channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "ircPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setIrcRuntime",
},
});

24
extensions/irc/npm-shrinkwrap.json generated Normal file
View File

@@ -0,0 +1,24 @@
{
"name": "@openclaw/irc",
"version": "2026.6.11",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "@openclaw/irc",
"version": "2026.6.11",
"dependencies": {
"zod": "4.4.3"
}
},
"node_modules/zod": {
"version": "4.4.3",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}

View File

@@ -0,0 +1,26 @@
{
"id": "irc",
"activation": {
"onStartup": false
},
"channels": ["irc"],
"channelEnvVars": {
"irc": [
"IRC_HOST",
"IRC_PORT",
"IRC_TLS",
"IRC_NICK",
"IRC_USERNAME",
"IRC_REALNAME",
"IRC_PASSWORD",
"IRC_CHANNELS",
"IRC_NICKSERV_PASSWORD",
"IRC_NICKSERV_REGISTER_EMAIL"
]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,63 @@
{
"name": "@openclaw/irc",
"version": "2026.6.11",
"description": "OpenClaw IRC channel plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*"
},
"openclaw": {
"extensions": [
"./index.ts"
],
"install": {
"clawhubSpec": "clawhub:@openclaw/irc",
"npmSpec": "@openclaw/irc",
"defaultChoice": "npm",
"minHostVersion": ">=2026.6.9",
"allowInvalidConfigRecovery": true
},
"setupEntry": "./setup-entry.ts",
"channel": {
"id": "irc",
"label": "IRC",
"selectionLabel": "IRC (Server + Nick)",
"detailLabel": "IRC",
"docsPath": "/channels/irc",
"docsLabel": "irc",
"blurb": "classic IRC networks with DM/channel routing and pairing controls.",
"aliases": [
"internet-relay-chat"
],
"systemImage": "network",
"configuredState": {
"env": {
"allOf": [
"IRC_HOST",
"IRC_NICK"
]
},
"specifier": "./configured-state",
"exportName": "hasIrcConfiguredState"
}
},
"compat": {
"pluginApi": ">=2026.6.11"
},
"build": {
"openclawVersion": "2026.6.11",
"bundledDist": false
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
},
"dependencies": {
"zod": "4.4.3"
},
"repository": {
"type": "git",
"url": "https://github.com/openclaw/openclaw"
}
}

View File

@@ -0,0 +1,24 @@
// Irc tests cover runtime api plugin behavior.
import { runDirectImportSmoke } from "openclaw/plugin-sdk/plugin-test-contracts";
import { beforeAll, describe, expect, it } from "vitest";
describe("irc bundled api seams", () => {
let directSmokeStdout = "";
beforeAll(async () => {
directSmokeStdout = await runDirectImportSmoke(
`const channel = await import("./extensions/irc/channel-plugin-api.ts");
const runtime = await import("./extensions/irc/runtime-api.ts");
process.stdout.write(JSON.stringify({
channel: { keys: Object.keys(channel).sort(), id: channel.ircPlugin.id },
runtime: { keys: Object.keys(runtime).sort(), type: typeof runtime.setIrcRuntime },
}));`,
);
}, 45_000);
it("loads narrow public api modules in direct smoke", () => {
expect(directSmokeStdout).toBe(
'{"channel":{"keys":["ircPlugin"],"id":"irc"},"runtime":{"keys":["setIrcRuntime"],"type":"function"}}',
);
});
});

View File

@@ -0,0 +1,3 @@
// Keep the bundled runtime entry narrow so generic runtime activation does not
// import the broad IRC API barrel just to install runtime state.
export { setIrcRuntime } from "./src/runtime.js";

View File

@@ -0,0 +1,6 @@
// Irc API module exposes the plugin public contract.
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View File

@@ -0,0 +1,14 @@
// Irc plugin module implements setup entry behavior.
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./channel-plugin-api.js",
exportName: "ircPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View File

@@ -0,0 +1,224 @@
// Irc tests cover accounts plugin behavior.
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it, vi } from "vitest";
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
import type { CoreConfig } from "./types.js";
function asConfig(value: unknown): CoreConfig {
return value as CoreConfig;
}
describe("listIrcAccountIds", () => {
it("returns default when no accounts are configured", () => {
expect(listIrcAccountIds(asConfig({}))).toEqual(["default"]);
});
it("normalizes, deduplicates, and sorts configured account ids", () => {
const cfg = asConfig({
channels: {
irc: {
accounts: {
"Ops Team": {},
"ops-team": {},
Work: {},
},
},
},
});
expect(listIrcAccountIds(cfg)).toEqual(["ops-team", "work"]);
});
it("keeps the implicit default account when named accounts are added to top-level connection config", () => {
const cfg = asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
accounts: {
work: {
enabled: false,
host: "irc-work.example.com",
nick: "claw-work",
},
},
},
},
});
expect(listIrcAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultIrcAccountId(cfg)).toBe("default");
});
});
describe("resolveDefaultIrcAccountId", () => {
it("prefers configured defaultAccount when it matches", () => {
const cfg = asConfig({
channels: {
irc: {
defaultAccount: "Ops Team",
accounts: {
default: {},
"ops-team": {},
},
},
},
});
expect(resolveDefaultIrcAccountId(cfg)).toBe("ops-team");
});
it("falls back to default when configured defaultAccount is missing", () => {
const cfg = asConfig({
channels: {
irc: {
defaultAccount: "missing",
accounts: {
default: {},
work: {},
},
},
},
});
expect(resolveDefaultIrcAccountId(cfg)).toBe("default");
});
it("falls back to first sorted account when default is absent", () => {
const cfg = asConfig({
channels: {
irc: {
accounts: {
zzz: {},
aaa: {},
},
},
},
});
expect(resolveDefaultIrcAccountId(cfg)).toBe("aaa");
});
});
describe("resolveIrcAccount", () => {
it("matches normalized configured account ids", () => {
const account = resolveIrcAccount({
cfg: asConfig({
channels: {
irc: {
accounts: {
"Ops Team": {
host: "irc.example.com",
nick: "claw",
},
},
},
},
}),
accountId: "ops-team",
});
expect(account.accountId).toBe("ops-team");
expect(account.host).toBe("irc.example.com");
expect(account.nick).toBe("claw");
expect(account.configured).toBe(true);
});
it("parses delimited IRC_CHANNELS env values for the default account", () => {
vi.stubEnv("IRC_CHANNELS", "alpha, beta\ngamma; delta");
try {
const account = resolveIrcAccount({
cfg: asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
},
},
}),
});
expect(account.config.channels).toEqual(["alpha", "beta", "gamma", "delta"]);
} finally {
vi.unstubAllEnvs();
}
});
it.runIf(process.platform !== "win32")("rejects symlinked password files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-account-"));
const passwordFile = path.join(dir, "password.txt");
const passwordLink = path.join(dir, "password-link.txt");
fs.writeFileSync(passwordFile, "secret-pass\n", "utf8");
fs.symlinkSync(passwordFile, passwordLink);
const cfg = asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
passwordFile: passwordLink,
},
},
});
expect(() => resolveIrcAccount({ cfg })).toThrow(/IRC password file.*must not be a symlink/);
fs.rmSync(dir, { recursive: true, force: true });
});
it.runIf(process.platform !== "win32")("rejects symlinked NickServ password files", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-nickserv-"));
const passwordFile = path.join(dir, "nickserv-password.txt");
const passwordLink = path.join(dir, "nickserv-password-link.txt");
fs.writeFileSync(passwordFile, "nickserv-pass\n", "utf8");
fs.symlinkSync(passwordFile, passwordLink);
const cfg = asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
nickserv: {
passwordFile: passwordLink,
},
},
},
});
expect(() => resolveIrcAccount({ cfg })).toThrow(
/IRC NickServ password file.*must not be a symlink/,
);
fs.rmSync(dir, { recursive: true, force: true });
});
it("preserves shared NickServ config when an account overrides one NickServ field", () => {
const account = resolveIrcAccount({
cfg: asConfig({
channels: {
irc: {
host: "irc.example.com",
nick: "claw",
nickserv: {
service: "NickServ",
},
accounts: {
work: {
nickserv: {
registerEmail: "work@example.com",
},
},
},
},
},
}),
accountId: "work",
});
expect(account.config.nickserv).toEqual({
service: "NickServ",
registerEmail: "work@example.com",
});
});
});

View File

@@ -0,0 +1,240 @@
// Irc plugin module implements accounts behavior.
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import {
parseOptionalDelimitedEntries,
tryReadSecretFileSync,
} from "openclaw/plugin-sdk/channel-core";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
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(normalizeLowercaseStringOrEmpty(value));
}
function parseIntEnv(value?: string): number | undefined {
if (!value?.trim()) {
return undefined;
}
const parsed = parseStrictPositiveInteger(value);
if (parsed === undefined || parsed > 65535) {
return undefined;
}
return parsed;
}
const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } =
createAccountListHelpers("irc", {
normalizeAccountId,
hasImplicitDefaultAccount: (cfg) =>
Boolean(
(cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) &&
(cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()),
),
});
export { listIrcAccountIds, resolveDefaultIrcAccountId };
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
return resolveMergedAccountConfig<IrcAccountConfig>({
channelConfig: cfg.channels?.irc as IrcAccountConfig | undefined,
accounts: cfg.channels?.irc?.accounts as Record<string, Partial<IrcAccountConfig>> | undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
nestedObjectKeys: ["nickserv"],
});
}
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()) {
const filePassword = tryReadSecretFileSync(merged.passwordFile, "IRC password file", {
rejectSymlink: true,
});
if (filePassword) {
return { password: filePassword, source: "passwordFile" as const };
}
}
const configPassword = normalizeResolvedSecretInputString({
value: merged.password,
path: `channels.irc.accounts.${accountId}.password`,
});
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 =
normalizeResolvedSecretInputString({
value: base.password,
path: `channels.irc.accounts.${accountId}.nickserv.password`,
}) ||
envPassword ||
"";
if (!resolvedPassword && passwordFile) {
resolvedPassword =
tryReadSecretFileSync(passwordFile, "IRC NickServ password file", {
rejectSymlink: true,
}) ?? "";
}
const merged: IrcNickServConfig = {
...base,
service: normalizeOptionalString(base.service),
passwordFile: passwordFile || undefined,
password: resolvedPassword || undefined,
registerEmail: base.registerEmail?.trim() || envRegisterEmail || undefined,
};
return merged;
}
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
? parseOptionalDelimitedEntries(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: normalizeOptionalString(merged.name),
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,7 @@
// Irc API module exposes the plugin public contract.
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
export { buildBaseChannelStatusSummary } from "openclaw/plugin-sdk/status-helpers";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";

View File

@@ -0,0 +1,4 @@
// Runtime-only IRC helpers for lazy chat plugin hooks.
// Keeping this boundary separate keeps bundled entry loads off monitor/send.
export { monitorIrcProvider } from "./monitor.js";
export { sendMessageIrc } from "./send.js";

View File

@@ -0,0 +1,35 @@
// Irc tests cover channel plugin behavior.
import { afterEach, describe, expect, it } from "vitest";
import { ircOutboundBaseAdapter } from "./outbound-base.js";
import { clearIrcRuntime } from "./runtime.js";
describe("irc outbound chunking", () => {
afterEach(() => {
clearIrcRuntime();
});
it("chunks outbound text without requiring IRC runtime initialization", () => {
expect(ircOutboundBaseAdapter.chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
expect(ircOutboundBaseAdapter.deliveryMode).toBe("direct");
expect(ircOutboundBaseAdapter.chunkerMode).toBe("markdown");
expect(ircOutboundBaseAdapter.textChunkLimit).toBe(350);
});
});
describe("irc outbound sanitizeText", () => {
afterEach(() => {
clearIrcRuntime();
});
it("strips internal tool-trace banners before outbound delivery", () => {
const text = "Done.\n⚠ 🛠️ `search repos (agent)` failed";
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe("Done.");
});
it("preserves ordinary assistant prose while sanitizing", () => {
const text = "The pipeline has 3 open deals.";
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe(text);
});
});

View File

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

View File

@@ -0,0 +1,79 @@
// Irc tests cover client plugin behavior.
import { describe, expect, it } from "vitest";
import { buildFallbackNick, 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"]);
});
});
describe("irc client fallback nick", () => {
it("produces unique fallback nicks across sequential calls", () => {
const first = buildFallbackNick("bot");
const second = buildFallbackNick("bot");
const third = buildFallbackNick("bot");
// First call gets suffix _ (seq=1), subsequent calls get _2, _3, ...
expect(first).toBe("bot_");
expect(second).toMatch(/^bot_\d+$/);
expect(third).toMatch(/^bot_\d+$/);
expect(new Set([first, second, third]).size).toBe(3);
});
it("sanitizes whitespace and special characters in nick", () => {
const nick = buildFallbackNick("my bot!");
expect(nick).toMatch(/^mybot_\d*$/);
});
it("falls back to openclaw when nick consists entirely of special characters", () => {
const nick = buildFallbackNick("!!!");
expect(nick).toMatch(/^openclaw_\d*$/);
});
it("falls back to openclaw when nick is empty after sanitization", () => {
const nick = buildFallbackNick("");
expect(nick).toMatch(/^openclaw_\d*$/);
});
it("truncates long nicks to max 30 chars", () => {
const longNick = "a".repeat(50);
const nick = buildFallbackNick(longNick);
expect(nick.length).toBeLessThanOrEqual(30);
expect(nick).toMatch(/^a+_\d*$/);
});
});

View File

@@ -0,0 +1,446 @@
// Irc plugin module implements client behavior.
import net from "node:net";
import tls from "node:tls";
import { withTimeout } from "openclaw/plugin-sdk/security-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
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"]);
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;
};
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));
}
let nickCollisionFallbackSeq = 0;
export 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 seq = ++nickCollisionFallbackSeq;
const suffix = seq === 1 ? "_" : `_${seq}`;
const maxNickLen = 30;
if (base.length >= maxNickLen) {
return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
}
return `${base}${suffix}`;
}
function normalizeIrcNick(value: string): string {
return normalizeLowercaseStringOrEmpty(value);
}
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;
let removeAbortListener: (() => void) | null = null;
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 failAndClose = (err: unknown) => {
fail(err);
close();
};
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 (normalizeIrcNick(fallbackNick) !== normalizeIrcNick(currentNick)) {
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;
removeAbortListener?.();
removeAbortListener = null;
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;
removeAbortListener?.();
removeAbortListener = null;
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 && normalizeIrcNick(prefix.nick) === normalizeIrcNick(currentNick)) {
const next =
line.trailing != null
? line.trailing
: line.params[0] != null
? line.params[0]
: currentNick;
currentNick = 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: unknown) => {
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: unknown) => {
fail(err);
});
socket.once("close", () => {
if (!closed) {
closed = true;
if (!ready) {
fail(new Error("IRC connection closed before ready"));
}
}
});
if (options.abortSignal) {
const abort = () => {
if (!ready) {
failAndClose(new Error("IRC connect aborted"));
return;
}
quit("shutdown");
};
if (options.abortSignal.aborted) {
abort();
} else {
options.abortSignal.addEventListener("abort", abort, { once: true });
removeAbortListener = () => options.abortSignal?.removeEventListener("abort", abort);
}
}
await withTimeout(readyPromise, timeoutMs, "IRC connect");
return {
get nick() {
return currentNick;
},
isReady: () => ready && !closed,
sendRaw,
join,
sendPrivmsg,
quit,
close,
};
}

View File

@@ -0,0 +1,117 @@
// Irc tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { IrcConfigSchema } from "./config-schema.js";
function expectValidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
expect(result.success).toBe(true);
if (!result.success) {
throw new Error("expected config to be valid");
}
return result.data;
}
function expectInvalidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
expect(result.success).toBe(false);
if (result.success) {
throw new Error("expected config to be invalid");
}
return result.error.issues;
}
describe("irc config schema", () => {
it("accepts basic config", () => {
const config = expectValidConfig(
IrcConfigSchema.safeParse({
host: "irc.libera.chat",
nick: "openclaw-bot",
channels: ["#openclaw"],
}),
);
expect(config.host).toBe("irc.libera.chat");
expect(config.nick).toBe("openclaw-bot");
});
it('rejects dmPolicy="open" without allowFrom "*"', () => {
const issues = expectInvalidConfig(
IrcConfigSchema.safeParse({
dmPolicy: "open",
allowFrom: ["alice"],
}),
);
expect(issues[0]?.path.join(".")).toBe("allowFrom");
});
it('accepts dmPolicy="open" with allowFrom "*"', () => {
const config = expectValidConfig(
IrcConfigSchema.safeParse({
dmPolicy: "open",
allowFrom: ["*"],
}),
);
expect(config.dmPolicy).toBe("open");
});
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"]);
});
it("rejects nickserv register without registerEmail", () => {
const issues = expectInvalidConfig(
IrcConfigSchema.safeParse({
nickserv: {
register: true,
password: "secret",
},
}),
);
expect(issues[0]?.path.join(".")).toBe("nickserv.registerEmail");
});
it("accepts nickserv register with password and registerEmail", () => {
const config = expectValidConfig(
IrcConfigSchema.safeParse({
nickserv: {
register: true,
password: "secret",
registerEmail: "bot@example.com",
},
}),
);
expect(config.nickserv?.register).toBe(true);
});
it("accepts nickserv register with registerEmail only", () => {
expectValidConfig(
IrcConfigSchema.safeParse({
nickserv: {
register: true,
registerEmail: "bot@example.com",
},
}),
);
});
});

View File

@@ -0,0 +1,97 @@
// Irc helper module supports config schema behavior.
import {
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ReplyRuntimeConfigSchemaShape,
ToolPolicySchema,
buildChannelConfigSchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-schema";
import { z } from "zod";
import { ircChannelConfigUiHints } from "./config-ui-hints.js";
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",
});
}
});
const IrcAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
dangerouslyAllowNameMatching: 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,
...ReplyRuntimeConfigSchemaShape,
})
.strict();
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(),
defaultAccount: z.string().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 "*"',
});
});
export const IrcChannelConfigSchema = buildChannelConfigSchema(IrcConfigSchema, {
uiHints: ircChannelConfigUiHints,
});

View File

@@ -0,0 +1,41 @@
// Irc helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
export const ircChannelConfigUiHints = {
"": {
label: "IRC",
help: "IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.",
},
dmPolicy: {
label: "IRC DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.irc.allowFrom=["*"].',
},
"nickserv.enabled": {
label: "IRC NickServ Enabled",
help: "Enable NickServ identify/register after connect (defaults to enabled when password is configured).",
},
"nickserv.service": {
label: "IRC NickServ Service",
help: "NickServ service nick (default: NickServ).",
},
"nickserv.password": {
label: "IRC NickServ Password",
help: "NickServ password used for IDENTIFY/REGISTER (sensitive).",
},
"nickserv.passwordFile": {
label: "IRC NickServ Password File",
help: "Optional file path containing NickServ password.",
},
"nickserv.register": {
label: "IRC NickServ Register",
help: "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.",
},
"nickserv.registerEmail": {
label: "IRC NickServ Register Email",
help: "Email used with NickServ REGISTER (required when register=true).",
},
configWrites: {
label: "IRC Config Writes",
help: "Allow IRC to write config in response to channel events/commands (default: true).",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,48 @@
// Irc tests cover connect options plugin behavior.
import { describe, expect, it } from "vitest";
import { buildIrcConnectOptions } from "./connect-options.js";
describe("buildIrcConnectOptions", () => {
it("copies resolved account connection fields and NickServ config", () => {
const account = {
host: "irc.libera.chat",
port: 6697,
tls: true,
nick: "openclaw",
username: "openclaw",
realname: "OpenClaw Bot",
password: "server-pass",
config: {
nickserv: {
enabled: true,
service: "NickServ",
password: "nickserv-pass",
register: true,
registerEmail: "bot@example.com",
},
},
};
expect(
buildIrcConnectOptions(account as never, {
connectTimeoutMs: 1234,
}),
).toEqual({
host: "irc.libera.chat",
port: 6697,
tls: true,
nick: "openclaw",
username: "openclaw",
realname: "OpenClaw Bot",
password: "server-pass",
nickserv: {
enabled: true,
service: "NickServ",
password: "nickserv-pass",
register: true,
registerEmail: "bot@example.com",
},
connectTimeoutMs: 1234,
});
});
});

View File

@@ -0,0 +1,31 @@
// Irc plugin module implements connect options behavior.
import type { ResolvedIrcAccount } from "./accounts.js";
import type { IrcClientOptions } from "./client.js";
type IrcConnectOverrides = Omit<
Partial<IrcClientOptions>,
"host" | "port" | "tls" | "nick" | "username" | "realname" | "password" | "nickserv"
>;
export function buildIrcConnectOptions(
account: ResolvedIrcAccount,
overrides: IrcConnectOverrides = {},
): IrcClientOptions {
return {
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,
},
...overrides,
};
}

View File

@@ -0,0 +1,18 @@
// Irc tests cover control chars plugin behavior.
import { describe, expect, it } from "vitest";
import { hasIrcControlChars, isIrcControlChar, stripIrcControlChars } from "./control-chars.js";
describe("irc control char helpers", () => {
it("detects IRC control characters by codepoint", () => {
expect(isIrcControlChar(0x00)).toBe(true);
expect(isIrcControlChar(0x1f)).toBe(true);
expect(isIrcControlChar(0x7f)).toBe(true);
expect(isIrcControlChar(0x20)).toBe(false);
});
it("detects and strips IRC control characters from strings", () => {
expect(hasIrcControlChars("hello\u0002world")).toBe(true);
expect(hasIrcControlChars("hello world")).toBe(false);
expect(stripIrcControlChars("he\u0002llo\u007f world")).toBe("hello world");
});
});

View File

@@ -0,0 +1,23 @@
// Irc plugin module implements control chars behavior.
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,31 @@
// Irc tests cover doctor mutable allowlist warnings.
import { describe, expect, it } from "vitest";
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
describe("collectIrcMutableAllowlistWarnings", () => {
it("warns on a host-less nick!user allowlist entry", () => {
const warnings = collectIrcMutableAllowlistWarnings({
cfg: {
channels: {
irc: {
allowFrom: ["alice!ident"],
},
},
} as never,
});
expect(warnings).toContain("- channels.irc.allowFrom: alice!ident");
});
it("does not warn on a full nick!user@host allowlist entry", () => {
const warnings = collectIrcMutableAllowlistWarnings({
cfg: {
channels: {
irc: {
allowFrom: ["alice!ident@example.com"],
},
},
} as never,
});
expect(warnings).toStrictEqual([]);
});
});

View File

@@ -0,0 +1,55 @@
// Irc plugin module implements doctor behavior.
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
function isIrcMutableAllowEntry(raw: string): boolean {
const text = normalizeLowercaseStringOrEmpty(raw);
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^irc:/, "")
.replace(/^user:/, "")
.trim();
return !normalized.includes("@");
}
export const collectIrcMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "irc",
detector: isIrcMutableAllowEntry,
collectLists: (scope) => {
const lists = [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
];
const groups = asObjectRecord(scope.account.groups);
if (groups) {
for (const [groupKey, groupRaw] of Object.entries(groups)) {
const group = asObjectRecord(groupRaw);
if (!group) {
continue;
}
lists.push({
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
list: group.allowFrom,
});
}
}
return lists;
},
});

View File

@@ -0,0 +1,48 @@
// Irc plugin module implements gateway behavior.
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
import type { ResolvedIrcAccount } from "./accounts.js";
import { createAccountStatusSink } from "./channel-api.js";
import type { RuntimeEnv } from "./runtime-api.js";
import type { CoreConfig } from "./types.js";
const loadIrcChannelRuntime = createLazyRuntimeModule(() => import("./channel-runtime.js"));
export async function startIrcGatewayAccount(ctx: {
cfg: CoreConfig;
accountId: string;
account: ResolvedIrcAccount;
runtime: RuntimeEnv;
abortSignal: AbortSignal;
setStatus: (next: ChannelAccountSnapshot) => void;
log?: {
info?: (message: string) => void;
};
}): Promise<void> {
const account = ctx.account;
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
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 { monitorIrcProvider } = await loadIrcChannelRuntime();
await runStoppablePassiveMonitor({
abortSignal: ctx.abortSignal,
start: async () =>
await monitorIrcProvider({
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink,
}),
});
}

View File

@@ -0,0 +1,314 @@
// Irc tests cover inbound.behavior plugin behavior.
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedIrcAccount } from "./accounts.js";
import { handleIrcInbound } from "./inbound.js";
import type { RuntimeEnv } from "./runtime-api.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
const {
buildMentionRegexesMock,
hasControlCommandMock,
matchesMentionPatternsMock,
readAllowFromStoreMock,
shouldHandleTextCommandsMock,
upsertPairingRequestMock,
} = vi.hoisted(() => {
return {
buildMentionRegexesMock: vi.fn(() => []),
hasControlCommandMock: vi.fn(() => false),
matchesMentionPatternsMock: vi.fn(() => false),
readAllowFromStoreMock: vi.fn(async () => []),
shouldHandleTextCommandsMock: vi.fn(() => false),
upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
};
});
function installIrcRuntime() {
setIrcRuntime({
channel: {
pairing: {
readAllowFromStore: readAllowFromStoreMock,
upsertPairingRequest: upsertPairingRequestMock,
},
commands: {
shouldHandleTextCommands: shouldHandleTextCommandsMock,
},
text: {
hasControlCommand: hasControlCommandMock,
},
mentions: {
buildMentionRegexes: buildMentionRegexesMock,
matchesMentionPatterns: matchesMentionPatternsMock,
},
},
} as never);
}
function createRuntimeEnv() {
return {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
}
function createAccount(overrides?: Partial<ResolvedIrcAccount>): ResolvedIrcAccount {
return {
accountId: "default",
enabled: true,
server: "irc.example.com",
nick: "OpenClaw",
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
...overrides,
} as ResolvedIrcAccount;
}
function createMessage(overrides?: Partial<IrcInboundMessage>): IrcInboundMessage {
return {
messageId: "msg-1",
target: "alice",
senderNick: "alice",
senderUser: "ident",
senderHost: "example.com",
text: "hello",
timestamp: Date.now(),
isGroup: false,
...overrides,
};
}
function resetInboundMocks() {
buildMentionRegexesMock.mockReset().mockReturnValue([]);
hasControlCommandMock.mockReset().mockReturnValue(false);
matchesMentionPatternsMock.mockReset().mockReturnValue(false);
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
shouldHandleTextCommandsMock.mockReset().mockReturnValue(false);
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "CODE", created: true });
}
describe("irc inbound behavior", () => {
beforeEach(() => {
resetInboundMocks();
installIrcRuntime();
});
afterEach(() => {
clearIrcRuntime();
});
it("issues a DM pairing challenge and sends the reply to the sender nick", async () => {
const sendReply = vi.fn<(target: string, text: string, replyToId?: string) => Promise<void>>(
async () => {},
);
await handleIrcInbound({
message: createMessage(),
account: createAccount(),
config: { channels: { irc: {} } } as CoreConfig,
runtime: createRuntimeEnv(),
sendReply,
});
expect(upsertPairingRequestMock).toHaveBeenCalledWith({
channel: "irc",
accountId: "default",
id: "alice!ident@example.com",
meta: { name: "alice" },
});
expect(sendReply).toHaveBeenCalledTimes(1);
expect(sendReply).toHaveBeenCalledWith(
"alice",
[
"OpenClaw: access not configured.",
"",
"Your IRC id: alice!ident@example.com",
"Pairing code:",
"```",
"CODE",
"```",
"",
"Ask the bot owner to approve with:",
"```",
"openclaw pairing approve irc CODE",
"```",
].join("\n"),
undefined,
);
});
it("drops unauthorized group control commands before dispatch", async () => {
const runtime = createRuntimeEnv();
shouldHandleTextCommandsMock.mockReturnValue(true);
hasControlCommandMock.mockReturnValue(true);
await handleIrcInbound({
message: createMessage({
target: "#ops",
isGroup: true,
text: "/admin",
}),
account: createAccount({
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: ["bob!ident@example.com"],
groups: {
"#ops": {
allowFrom: ["alice!ident@example.com"],
},
},
},
}),
config: { channels: { irc: {} }, commands: { useAccessGroups: true } } as CoreConfig,
runtime,
});
expect(runtime.log).toHaveBeenCalledWith(
"irc: drop control command (unauthorized) target=alice!ident@example.com",
);
});
it("passes the shared reply pipeline for dispatched replies", async () => {
const coreRuntime = createPluginRuntimeMock();
setIrcRuntime(coreRuntime as never);
await handleIrcInbound({
message: createMessage(),
account: createAccount({
config: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
}),
config: { channels: { irc: {} } } as CoreConfig,
runtime: createRuntimeEnv(),
sendReply: vi.fn(async () => {}),
});
const assembledRequest = (
coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } }
).mock.calls[0]?.[0] as { replyPipeline?: unknown } | undefined;
expect(assembledRequest?.replyPipeline).toEqual({});
});
it("uses channel:# prefix for group channel From and OriginatingTo fields", async () => {
const coreRuntime = createPluginRuntimeMock();
const runtime = createRuntimeEnv();
setIrcRuntime(coreRuntime as never);
await handleIrcInbound({
message: createMessage({
target: "#ops",
isGroup: true,
senderNick: "alice",
senderUser: "ident",
senderHost: "example.com",
text: "hello",
}),
account: createAccount({
config: {
dmPolicy: "open",
allowFrom: ["*"],
groupPolicy: "open",
groupAllowFrom: [],
groups: {
"#ops": { enabled: true, requireMention: false },
},
},
}),
config: { channels: { irc: {} } } as CoreConfig,
runtime,
sendReply: vi.fn(async () => {}),
});
const ctx = (
coreRuntime.channel.reply.finalizeInboundContext as unknown as {
mock: { calls: unknown[][] };
}
).mock.calls[0]?.[0] as Record<string, unknown> | undefined;
expect(
(coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } })
.mock.calls.length,
).toBe(1);
expect(runtime.log).not.toHaveBeenCalled();
expect(ctx?.From).toBe("channel:#ops");
expect(ctx?.To).toBe("channel:#ops");
expect(ctx?.OriginatingTo).toBe("channel:#ops");
});
it("drops a spoofed sender for a host-less nick!user DM allowlist entry", async () => {
const coreRuntime = createPluginRuntimeMock();
const runtime = createRuntimeEnv();
setIrcRuntime(coreRuntime as never);
await handleIrcInbound({
message: createMessage({
target: "alice",
senderNick: "alice",
senderUser: "ident",
senderHost: "attacker.example",
text: "hello",
}),
account: createAccount({
config: {
dmPolicy: "allowlist",
allowFrom: ["alice!ident"],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
}),
config: { channels: { irc: {} } } as CoreConfig,
runtime,
sendReply: vi.fn(async () => {}),
});
expect(
(coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } })
.mock.calls.length,
).toBe(0);
expect(runtime.log).toHaveBeenCalledWith(
"irc: drop DM sender alice!ident@attacker.example (dmPolicy=allowlist)",
);
});
it("admits a sender matching a full nick!user@host DM allowlist entry", async () => {
const coreRuntime = createPluginRuntimeMock();
const runtime = createRuntimeEnv();
setIrcRuntime(coreRuntime as never);
await handleIrcInbound({
message: createMessage({
target: "alice",
senderNick: "alice",
senderUser: "ident",
senderHost: "example.com",
text: "hello",
}),
account: createAccount({
config: {
dmPolicy: "allowlist",
allowFrom: ["alice!ident@example.com"],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
}),
config: { channels: { irc: {} } } as CoreConfig,
runtime,
sendReply: vi.fn(async () => {}),
});
expect(
(coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } })
.mock.calls.length,
).toBe(1);
});
});

View File

@@ -0,0 +1,462 @@
// Irc plugin module implements inbound behavior.
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
import {
channelIngressRoutes,
createChannelIngressResolver,
defineStableChannelIngressIdentity,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
import {
deliverFormattedTextWithAttachments,
type OutboundReplyPayload,
} from "openclaw/plugin-sdk/reply-payload";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
normalizeStringEntries,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ResolvedIrcAccount } from "./accounts.js";
import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js";
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
import { getIrcRuntime } from "./runtime.js";
import { sendMessageIrc } from "./send.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
const CHANNEL_ID = "irc" as const;
const IRC_NICK_KIND = "plugin:irc-nick" as const;
type IrcGroupPolicy = "open" | "allowlist" | "disabled";
const ircIngressIdentity = defineStableChannelIngressIdentity({
key: "irc-id",
normalizeEntry: normalizeIrcStableEntry,
normalizeSubject: normalizeLowercaseStringOrEmpty,
sensitivity: "pii",
aliases: [
{
key: "irc-id-nick-user",
kind: "stable-id" as const,
normalizeEntry: normalizeIrcNickUserEntry,
normalizeSubject: normalizeLowercaseStringOrEmpty,
dangerous: true,
sensitivity: "pii" as const,
},
{
key: "irc-id-nick-host",
kind: "stable-id" as const,
normalizeEntry: () => null,
normalizeSubject: normalizeLowercaseStringOrEmpty,
sensitivity: "pii" as const,
},
{
key: "irc-nick",
kind: IRC_NICK_KIND,
normalizeEntry: normalizeIrcNickEntry,
normalizeSubject: normalizeLowercaseStringOrEmpty,
dangerous: true,
sensitivity: "pii",
},
],
isWildcardEntry: (entry) => normalizeIrcAllowEntry(entry) === "*",
resolveEntryId: ({ entryIndex, fieldKey }) =>
`irc-entry-${entryIndex + 1}:${fieldKey === "irc-nick" ? "nick" : "id"}`,
});
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
function isBareNick(value: string): boolean {
return !value.includes("!") && !value.includes("@");
}
function hasVerifiedHost(value: string): boolean {
return value.includes("@");
}
function isHostlessNickUser(value: string): boolean {
return value.includes("!") && !value.includes("@");
}
function normalizeIrcStableEntry(value: string): string | null {
const normalized = normalizeIrcAllowEntry(value);
if (!normalized || normalized === "*" || !hasVerifiedHost(normalized)) {
return null;
}
return normalized;
}
function normalizeIrcNickUserEntry(value: string): string | null {
const normalized = normalizeIrcAllowEntry(value);
if (!normalized || normalized === "*" || !isHostlessNickUser(normalized)) {
return null;
}
return normalized;
}
function normalizeIrcNickEntry(value: string): string | null {
const normalized = normalizeIrcAllowEntry(value);
if (!normalized || normalized === "*" || !isBareNick(normalized)) {
return null;
}
return normalized;
}
function hasEntries(entries: Array<string | number> | undefined): boolean {
return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry));
}
function createIrcIngressSubject(message: IrcInboundMessage) {
const candidates = buildIrcAllowlistCandidates(message, { allowNameMatching: true });
const stableCandidates = candidates.filter((candidate) => hasVerifiedHost(candidate));
const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
return {
stableId: stableCandidates[stableCandidates.length - 1] ?? nick,
aliases: {
"irc-id-nick-user": candidates.find((candidate) => isHostlessNickUser(candidate)),
"irc-id-nick-host": stableCandidates.find(
(candidate) => !candidate.includes("!") && candidate.includes("@"),
),
"irc-nick": nick,
},
};
}
function routeDescriptorsForIrcGroup(params: {
isGroup: boolean;
groupPolicy: IrcGroupPolicy;
groupAllowed: boolean;
hasConfiguredGroups: boolean;
groupEnabled: boolean;
routeGroupAllowFrom: string[];
}) {
if (!params.isGroup) {
return [];
}
return channelIngressRoutes(
params.groupPolicy === "allowlist" && {
id: "irc:channel",
allowed: params.hasConfiguredGroups && params.groupAllowed,
precedence: 0,
matchId: "irc-channel",
blockReason: "channel_not_allowlisted",
},
!params.groupEnabled && {
id: "irc:channel-enabled",
enabled: false,
precedence: 10,
blockReason: "channel_disabled",
},
hasEntries(params.routeGroupAllowFrom) && {
id: "irc:channel-sender",
precedence: 20,
senderPolicy: "replace",
senderAllowFrom: params.routeGroupAllowFrom,
},
);
}
async function deliverIrcReply(params: {
payload: OutboundReplyPayload;
cfg: CoreConfig;
target: string;
accountId: string;
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}) {
await deliverFormattedTextWithAttachments({
payload: params.payload,
send: async ({ text, replyToId }) => {
if (params.sendReply) {
await params.sendReply(params.target, text, replyToId);
} else {
await sendMessageIrc(params.target, text, {
cfg: params.cfg,
accountId: params.accountId,
replyTo: 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 pairing = createChannelPairingController({
core,
channel: CHANNEL_ID,
accountId: account.accountId,
});
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 allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: config.channels?.irc !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "irc",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
log: (messageLocal) => runtime.log?.(messageLocal),
});
const groupMatch = resolveIrcGroupMatch({
groups: account.config.groups,
target: message.target,
});
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as OpenClawConfig,
surface: CHANNEL_ID,
});
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
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 routeGroupAllowFrom = normalizeStringEntries(
groupMatch.groupConfig?.allowFrom?.length
? groupMatch.groupConfig.allowFrom
: groupMatch.wildcardConfig?.allowFrom,
);
const accessGroupPolicy: IrcGroupPolicy =
groupPolicy === "open" &&
(hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom))
? "allowlist"
: groupPolicy;
const access = await createChannelIngressResolver({
channelId: CHANNEL_ID,
accountId: account.accountId,
identity: ircIngressIdentity,
cfg: config as OpenClawConfig,
readStoreAllowFrom: async () => await pairing.readAllowFromStore(),
}).message({
subject: createIrcIngressSubject(message),
conversation: {
kind: message.isGroup ? "group" : "direct",
id: message.target,
},
route: routeDescriptorsForIrcGroup({
isGroup: message.isGroup,
groupPolicy,
groupAllowed: groupMatch.allowed,
hasConfiguredGroups: groupMatch.hasConfiguredGroups,
groupEnabled:
groupMatch.groupConfig?.enabled !== false && groupMatch.wildcardConfig?.enabled !== false,
routeGroupAllowFrom,
}),
mentionFacts: message.isGroup
? {
canDetectMention: true,
wasMentioned,
hasAnyMention: wasMentioned,
}
: undefined,
dmPolicy,
groupPolicy: accessGroupPolicy,
policy: {
groupAllowFromFallbackToAllowFrom: false,
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
activation: {
requireMention: message.isGroup && requireMention,
allowTextCommands,
},
},
allowFrom: account.config.allowFrom,
groupAllowFrom: account.config.groupAllowFrom,
command: {
allowTextCommands,
hasControlCommand,
},
});
const commandAuthorized = access.commandAccess.authorized;
if (access.ingress.admission === "pairing-required") {
await pairing.issueChallenge({
senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
senderIdLine: `Your IRC id: ${senderDisplay}`,
meta: { name: message.senderNick || undefined },
sendPairingReply: async (text) => {
await deliverIrcReply({
payload: { text },
cfg: config,
target: message.senderNick,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
},
onReplyError: (err) => {
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
},
});
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
return;
}
if (access.ingress.admission === "skip") {
runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
return;
}
if (access.ingress.admission !== "dispatch") {
if (
message.isGroup &&
access.ingress.decisiveGateId === "command" &&
access.commandAccess.shouldBlockControlCommand
) {
logInboundDrop({
log: (line) => runtime.log?.(line),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderDisplay,
});
return;
}
if (message.isGroup) {
if (access.routeAccess.reason === "channel_not_allowlisted") {
runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
} else if (access.routeAccess.reason === "channel_disabled") {
runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
} else {
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
}
} else {
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
}
return;
}
const channelTarget =
message.target.startsWith("#") || message.target.startsWith("&")
? message.target
: `#${message.target}`;
const peerId = message.isGroup ? channelTarget : message.senderNick;
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: message.isGroup ? "group" : "direct",
id: peerId,
},
runtime: core.channel,
sessionStore: config.session?.store,
});
const fromLabel = message.isGroup ? message.target : senderDisplay;
const { storePath, body } = buildEnvelope({
channel: "IRC",
from: fromLabel,
timestamp: message.timestamp,
body: rawBody,
});
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
RawBody: rawBody,
CommandBody: rawBody,
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
To: message.isGroup ? `channel:${channelTarget}` : `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: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
CommandAuthorized: commandAuthorized,
});
await core.channel.inbound.dispatchReply({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
agentId: route.agentId,
routeSessionKey: route.sessionKey,
storePath,
ctxPayload,
recordInboundSession: core.channel.session.recordInboundSession,
dispatchReplyWithBufferedBlockDispatcher:
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
delivery: {
deliver: async (payload) => {
await deliverIrcReply({
payload,
cfg: config,
target: peerId,
accountId: account.accountId,
sendReply: params.sendReply,
statusSink,
});
},
onError: (err, info) => {
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
},
},
replyPipeline: {},
replyOptions: {
skillFilter: groupMatch.groupConfig?.skills,
disableBlockStreaming:
typeof account.config.blockStreaming === "boolean"
? !account.config.blockStreaming
: undefined,
},
record: {
onRecordError: (err) => {
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
},
},
});
}

View File

@@ -0,0 +1,29 @@
// Irc plugin module implements message adapter behavior.
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
import { sendMessageIrc } from "./send.js";
import type { CoreConfig } from "./types.js";
export const ircMessageAdapter = defineChannelMessageAdapter({
id: "irc",
durableFinal: {
capabilities: {
text: true,
media: true,
replyTo: true,
},
},
send: {
text: async ({ cfg, to, text, accountId, replyToId }) =>
await sendMessageIrc(to, text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
}),
media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
cfg: cfg as CoreConfig,
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
}),
},
});

View File

@@ -0,0 +1,44 @@
// Irc tests cover monitor plugin behavior.
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,150 @@
// Irc plugin module implements monitor behavior.
import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient, type IrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { handleIrcInbound } from "./inbound.js";
import { isChannelTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import type { RuntimeEnv } from "./runtime-api.js";
import { getIrcRuntime } from "./runtime.js";
import type { CoreConfig, IrcInboundMessage } from "./types.js";
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.current() as CoreConfig);
const account = resolveIrcAccount({
cfg,
accountId: opts.accountId,
});
const runtime: RuntimeEnv = resolveLoggerBackedRuntime(
opts.runtime,
core.logging.getChildLogger(),
);
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(
buildIrcConnectOptions(account, {
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 (
normalizeLowercaseStringOrEmpty(event.senderNick) ===
normalizeLowercaseStringOrEmpty(client.nick)
) {
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,36 @@
// Irc tests cover normalize plugin behavior.
import { describe, expect, it } from "vitest";
import {
buildIrcAllowlistCandidates,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
} 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(buildIrcAllowlistCandidates(message)).not.toContain("alice");
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
});
});

View File

@@ -0,0 +1,86 @@
// Irc helper module supports normalize behavior.
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalLowercaseString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { hasIrcControlChars } from "./control-chars.js";
import type { IrcInboundMessage } from "./types.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 = normalizeLowercaseStringOrEmpty(target);
if (lowered.startsWith("irc:")) {
target = target.slice("irc:".length).trim();
}
if (normalizeLowercaseStringOrEmpty(target).startsWith("channel:")) {
target = target.slice("channel:".length).trim();
if (!target.startsWith("#") && !target.startsWith("&")) {
target = `#${target}`;
}
}
if (normalizeLowercaseStringOrEmpty(target).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 = normalizeLowercaseStringOrEmpty(raw);
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 buildIrcAllowlistCandidates(
message: IrcInboundMessage,
params?: { allowNameMatching?: boolean },
): string[] {
const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
const user = normalizeOptionalLowercaseString(message.senderUser);
const host = normalizeOptionalLowercaseString(message.senderHost);
const candidates = new Set<string>();
if (nick && params?.allowNameMatching === true) {
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];
}

View File

@@ -0,0 +1,16 @@
// Irc plugin module implements outbound base behavior.
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
import { chunkTextForOutbound } from "./channel-api.js";
export const ircOutboundBaseAdapter = {
deliveryMode: "direct" as const,
chunker: chunkTextForOutbound,
chunkerMode: "markdown" as const,
textChunkLimit: 350,
// IRC's plain-text pass does not remove assistant scaffolding. Run the
// canonical delivery sanitizer first so internal tool traces are dropped
// before channel formatting.
sanitizeText: ({ text }: { text: string }) =>
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
};

View File

@@ -0,0 +1,56 @@
// Irc tests cover policy plugin behavior.
import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy";
import { describe, expect, it } from "vitest";
import { resolveIrcGroupMatch, 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("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(inboundDisabled.groupConfig?.enabled).toBe(false);
});
});

View File

@@ -0,0 +1,79 @@
// Irc plugin module implements policy behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { IrcChannelConfig } from "./types.js";
export type IrcGroupMatch = {
allowed: boolean;
groupConfig?: IrcChannelConfig;
wildcardConfig?: IrcChannelConfig;
hasConfiguredGroups: boolean;
};
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 represented later as ingress route facts.
allowed: true,
groupConfig: direct,
wildcardConfig: groups["*"],
hasConfiguredGroups,
};
}
const targetLower = normalizeLowercaseStringOrEmpty(params.target);
const directKey = Object.keys(groups).find(
(key) => normalizeLowercaseStringOrEmpty(key) === targetLower,
);
if (directKey) {
const matched = groups[directKey];
if (matched) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are represented later as ingress route facts.
allowed: true,
groupConfig: matched,
wildcardConfig: groups["*"],
hasConfiguredGroups,
};
}
}
const wildcard = groups["*"];
if (wildcard) {
return {
// "allowed" means the target matched an allowlisted key.
// Explicit disables are represented later as ingress route facts.
allowed: true,
wildcardConfig: wildcard,
hasConfiguredGroups,
};
}
return {
allowed: false,
hasConfiguredGroups,
};
}
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;
}

View File

@@ -0,0 +1,111 @@
// Irc tests cover probe plugin behavior.
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeIrc } from "./probe.js";
const resolveIrcAccountMock = vi.hoisted(() => vi.fn());
const buildIrcConnectOptionsMock = vi.hoisted(() => vi.fn());
const connectIrcClientMock = vi.hoisted(() => vi.fn());
vi.mock("./accounts.js", () => ({
resolveIrcAccount: resolveIrcAccountMock,
}));
vi.mock("./connect-options.js", () => ({
buildIrcConnectOptions: buildIrcConnectOptionsMock,
}));
vi.mock("./client.js", () => ({
connectIrcClient: connectIrcClientMock,
}));
afterAll(() => {
vi.doUnmock("./accounts.js");
vi.doUnmock("./connect-options.js");
vi.doUnmock("./client.js");
vi.resetModules();
});
describe("probeIrc", () => {
beforeEach(() => {
resolveIrcAccountMock.mockReset();
buildIrcConnectOptionsMock.mockReset();
connectIrcClientMock.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns a configuration error when the IRC account is incomplete", async () => {
resolveIrcAccountMock.mockReturnValue({
configured: false,
host: "",
port: 6667,
tls: false,
nick: "",
});
await expect(probeIrc({} as never)).resolves.toEqual({
ok: false,
host: "",
port: 6667,
tls: false,
nick: "",
error: "missing host or nick",
});
expect(connectIrcClientMock).not.toHaveBeenCalled();
});
it("returns latency and quits the probe client on success", async () => {
const account = {
configured: true,
host: "irc.libera.chat",
port: 6697,
tls: true,
nick: "openclaw",
};
resolveIrcAccountMock.mockReturnValue(account);
buildIrcConnectOptionsMock.mockReturnValue({ host: "irc.libera.chat" });
const quit = vi.fn();
connectIrcClientMock.mockResolvedValue({ quit });
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(100).mockReturnValueOnce(145);
try {
const result = await probeIrc({} as never, { timeoutMs: 5000 });
expect(buildIrcConnectOptionsMock).toHaveBeenCalledWith(account, { connectTimeoutMs: 5000 });
expect(result).toEqual({
ok: true,
host: "irc.libera.chat",
port: 6697,
tls: true,
nick: "openclaw",
latencyMs: 45,
});
expect(quit).toHaveBeenCalledWith("probe");
} finally {
nowSpy.mockRestore();
}
});
it("formats non-Error probe failures into the returned error field", async () => {
resolveIrcAccountMock.mockReturnValue({
configured: true,
host: "irc.libera.chat",
port: 6667,
tls: false,
nick: "openclaw",
});
buildIrcConnectOptionsMock.mockReturnValue({ host: "irc.libera.chat" });
connectIrcClientMock.mockRejectedValue({ code: "ECONNREFUSED" });
await expect(probeIrc({} as never)).resolves.toEqual({
ok: false,
host: "irc.libera.chat",
port: 6667,
tls: false,
nick: "openclaw",
error: JSON.stringify({ code: "ECONNREFUSED" }),
});
});
});

View File

@@ -0,0 +1,54 @@
// Irc plugin module implements probe behavior.
import { resolveIrcAccount } from "./accounts.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import type { CoreConfig, IrcProbe } from "./types.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(
buildIrcConnectOptions(account, {
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,87 @@
// Irc tests cover protocol plugin behavior.
import { describe, expect, it } from "vitest";
import {
parseIrcLine,
parseIrcPrefix,
sanitizeIrcOutboundText,
sanitizeIrcTarget,
} 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/);
});
describe("\\u escape surrogate-range guard", () => {
const LONE_SURROGATE = /[\uD800-\uDFFF]/;
it("preserves literal \\uXXXX when codepoint is a high surrogate", () => {
const out = sanitizeIrcOutboundText("\\uD800");
expect(LONE_SURROGATE.test(out)).toBe(false);
});
it("preserves literal \\uXXXX when codepoint is a low surrogate", () => {
const out = sanitizeIrcOutboundText("\\uDFFF");
expect(LONE_SURROGATE.test(out)).toBe(false);
});
it("still decodes valid BMP codepoints outside the surrogate range", () => {
expect(sanitizeIrcOutboundText("\\u0041")).toBe("A");
expect(sanitizeIrcOutboundText("\\u00e9")).toBe("é"); // é
});
it("decodes adjacent surrogate-pair escapes to the astral character", () => {
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00")).toBe("😀");
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00\\uD83D\\uDE01")).toBe("😀😁");
});
it("preserves lone high surrogate even when followed by a non-surrogate \\u", () => {
const out = sanitizeIrcOutboundText("\\uD800\\u0041");
expect(LONE_SURROGATE.test(out)).toBe(false);
expect(out).toContain("A");
});
it("decodes BMP-escaped prefix before a surrogate pair correctly", () => {
// Regression: \\u0041\\uD83D\\uDE00 must yield A😀, not A\\uD83D\\uDE00.
// The old step-1 regex \\u(xxxx)\\u(xxxx) would consume \\u0041\\uD83D as a
// non-pair, leaving \\uDE00 as a lone surrogate.
expect(sanitizeIrcOutboundText("\\u0041\\uD83D\\uDE00")).toBe("A😀");
});
it("handles lone high surrogate followed by a different surrogate pair", () => {
// \\uD800\\uD83D\\uDE00: D800 is lone (no matching low), D83D+DE00 form 😀.
// Use toBe rather than LONE_SURROGATE regex: emoji contains surrogate
// code units internally that would trigger a naive /[\uD800-\uDFFF]/ check.
expect(sanitizeIrcOutboundText("\\uD800\\uD83D\\uDE00")).toBe("\\uD800😀");
});
it("preserves two consecutive lone high surrogates", () => {
const out = sanitizeIrcOutboundText("\\uD800\\uD801");
expect(LONE_SURROGATE.test(out)).toBe(false);
});
});
});

View File

@@ -0,0 +1,161 @@
// Irc plugin module implements protocol behavior.
import { randomUUID } from "node:crypto";
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
type ParsedIrcLine = {
raw: string;
prefix?: string;
command: string;
params: string[];
trailing?: string;
};
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)))
// Step 1: decode surrogate pairs — first group locked to high surrogate range
// (U+D800U+DBFF: [dD][89abAB]xx), second to low surrogate range
// (U+DC00U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX
// from being greedily paired with the following high surrogate and consuming it.
.replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) =>
String.fromCodePoint(
0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00),
),
)
// Step 2: decode BMP codepoints; preserve lone surrogates as literals
.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
const codePoint = Number.parseInt(hex, 16);
return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint);
})
);
}
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 makeIrcMessageId() {
return randomUUID();
}

View File

@@ -0,0 +1,42 @@
// Private runtime barrel for the bundled IRC extension.
// Keep this barrel thin and generic-only.
export type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
MarkdownConfig,
} from "openclaw/plugin-sdk/config-contracts";
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
export {
PAIRING_APPROVED_MESSAGE,
buildBaseChannelStatusSummary,
} from "openclaw/plugin-sdk/channel-status";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
deliverFormattedTextWithAttachments,
formatTextWithAttachmentLinks,
resolveOutboundMediaUrls,
} from "openclaw/plugin-sdk/reply-payload";
export {
GROUP_POLICY_BLOCKED_LABEL,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";

View File

@@ -0,0 +1,16 @@
// Irc plugin module implements runtime behavior.
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const {
setRuntime: setIrcRuntime,
clearRuntime: clearStoredIrcRuntime,
getRuntime: getIrcRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "irc",
errorMessage: "IRC runtime not initialized",
});
export { getIrcRuntime, setIrcRuntime };
export function clearIrcRuntime() {
clearStoredIrcRuntime();
}

View File

@@ -0,0 +1,104 @@
// Irc plugin module implements secret contract behavior.
import {
collectNestedChannelFieldAssignments,
collectSimpleChannelFieldAssignments,
getChannelSurface,
isBaseFieldActiveForChannelSurface,
isEnabledFlag,
isRecord,
type ResolverContext,
type SecretDefaults,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] =
[
{
id: "channels.irc.accounts.*.nickserv.password",
targetType: "channels.irc.accounts.*.nickserv.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.accounts.*.nickserv.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.accounts.*.password",
targetType: "channels.irc.accounts.*.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.accounts.*.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.nickserv.password",
targetType: "channels.irc.nickserv.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.nickserv.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.irc.password",
targetType: "channels.irc.password",
configFile: "openclaw.json",
pathPattern: "channels.irc.password",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "irc");
if (!resolved) {
return;
}
const { channel: irc, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "irc",
field: "password",
channel: irc,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level IRC password.",
accountInactiveReason: "IRC account is disabled.",
});
collectNestedChannelFieldAssignments({
channelKey: "irc",
nestedKey: "nickserv",
field: "password",
channel: irc,
surface,
defaults: params.defaults,
context: params.context,
topLevelActive:
isBaseFieldActiveForChannelSurface(surface, "nickserv") &&
isRecord(irc.nickserv) &&
isEnabledFlag(irc.nickserv),
topInactiveReason:
"no enabled account inherits this top-level IRC nickserv config or NickServ is disabled.",
accountActive: ({ account, enabled }) =>
enabled && isRecord(account.nickserv) && isEnabledFlag(account.nickserv),
accountInactiveReason: "IRC account is disabled or NickServ is disabled for this account.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View File

@@ -0,0 +1,327 @@
// Irc tests cover send plugin behavior.
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
import { createSendCfgThreadingRuntime } from "openclaw/plugin-sdk/channel-test-helpers";
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { IrcClient } from "./client.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
const hoisted = vi.hoisted(() => {
const loadConfig = vi.fn();
const resolveMarkdownTableMode = vi.fn(() => "preserve");
const convertMarkdownTables = vi.fn((text: string) => text);
const record = vi.fn();
return {
loadConfig,
resolveMarkdownTableMode,
convertMarkdownTables,
record,
normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()),
connectIrcClient: vi.fn(),
buildIrcConnectOptions: vi.fn(() => ({})),
};
});
vi.mock("./normalize.js", () => ({
normalizeIrcMessagingTarget: hoisted.normalizeIrcMessagingTarget,
}));
vi.mock("./client.js", () => ({
connectIrcClient: hoisted.connectIrcClient,
}));
vi.mock("./connect-options.js", () => ({
buildIrcConnectOptions: hoisted.buildIrcConnectOptions,
}));
vi.mock("./protocol.js", async () => {
const actual = await vi.importActual<typeof import("./protocol.js")>("./protocol.js");
return {
...actual,
makeIrcMessageId: () => "irc-msg-1",
};
});
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
const original = (await vi.importActual("openclaw/plugin-sdk/plugin-config-runtime")) as Record<
string,
unknown
>;
return {
...original,
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
};
});
vi.mock("openclaw/plugin-sdk/text-chunking", async () => {
const original = (await vi.importActual("openclaw/plugin-sdk/text-chunking")) as Record<
string,
unknown
>;
return {
...original,
convertMarkdownTables: hoisted.convertMarkdownTables,
};
});
import { ircMessageAdapter } from "./message-adapter.js";
import { sendMessageIrc } from "./send.js";
function resetHoistedMocks() {
hoisted.loadConfig.mockReset();
hoisted.resolveMarkdownTableMode.mockReset().mockReturnValue("preserve");
hoisted.convertMarkdownTables.mockReset().mockImplementation((text: string) => text);
hoisted.record.mockReset();
hoisted.normalizeIrcMessagingTarget
.mockReset()
.mockImplementation((value: string) => value.trim());
hoisted.connectIrcClient.mockReset();
hoisted.buildIrcConnectOptions.mockReset().mockReturnValue({});
}
afterAll(() => {
vi.doUnmock("./normalize.js");
vi.doUnmock("./client.js");
vi.doUnmock("./connect-options.js");
vi.doUnmock("./protocol.js");
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
vi.doUnmock("openclaw/plugin-sdk/text-chunking");
vi.resetModules();
});
describe("sendMessageIrc cfg threading", () => {
beforeEach(() => {
resetHoistedMocks();
setIrcRuntime(createSendCfgThreadingRuntime(hoisted) as never);
});
afterEach(() => {
clearIrcRuntime();
});
it("uses explicitly provided cfg without loading runtime config", async () => {
const providedCfg = {
channels: {
irc: {
host: "irc.example.com",
nick: "openclaw",
accounts: {
work: {
host: "irc.example.com",
nick: "workbot",
},
},
},
},
} as unknown as CoreConfig;
const client = {
isReady: vi.fn(() => true),
sendPrivmsg: vi.fn(),
} as unknown as IrcClient;
const result = await sendMessageIrc("#room", "hello", {
cfg: providedCfg,
client,
accountId: "work",
});
expect(hoisted.loadConfig).not.toHaveBeenCalled();
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
expect(hoisted.record).toHaveBeenCalledWith({
channel: "irc",
accountId: "work",
direction: "outbound",
});
expect(result.target).toBe("#room");
expect(result.messageId).toBeTypeOf("string");
expect(result.messageId.length).toBeGreaterThan(0);
expect(result.receipt.sentAt).toBeTypeOf("number");
expect(result.receipt.sentAt).toBeGreaterThan(0);
expect({ ...result.receipt, sentAt: 123 }).toEqual({
primaryPlatformMessageId: "irc-msg-1",
platformMessageIds: ["irc-msg-1"],
parts: [
{
platformMessageId: "irc-msg-1",
kind: "text",
index: 0,
raw: {
channel: "irc",
conversationId: "#room",
messageId: "irc-msg-1",
},
},
],
sentAt: 123,
raw: [
{
channel: "irc",
conversationId: "#room",
messageId: "irc-msg-1",
},
],
});
});
it("fails hard when cfg is omitted", async () => {
const client = {
isReady: vi.fn(() => true),
sendPrivmsg: vi.fn(),
} as unknown as IrcClient;
await expect(sendMessageIrc("#ops", "ping", { client } as never)).rejects.toThrow(
"IRC send requires a resolved runtime config",
);
expect(hoisted.loadConfig).not.toHaveBeenCalled();
expect(client.sendPrivmsg).not.toHaveBeenCalled();
expect(hoisted.record).not.toHaveBeenCalled();
});
it("sends with provided cfg even when the runtime store is not initialized", async () => {
const providedCfg = {
channels: {
irc: {
host: "irc.example.com",
nick: "openclaw",
},
},
} as unknown as CoreConfig;
const client = {
isReady: vi.fn(() => true),
sendPrivmsg: vi.fn(),
} as unknown as IrcClient;
hoisted.record.mockImplementation(() => {
throw new Error("IRC runtime not initialized");
});
const result = await sendMessageIrc("#room", "hello", {
cfg: providedCfg,
client,
});
expect(hoisted.loadConfig).not.toHaveBeenCalled();
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
expect(result.target).toBe("#room");
expect(result.messageId).toBeTypeOf("string");
expect(result.messageId.length).toBeGreaterThan(0);
});
it("preserves reply ids in receipts", async () => {
const providedCfg = {
channels: {
irc: {
host: "irc.example.com",
nick: "openclaw",
},
},
} as unknown as CoreConfig;
const client = {
isReady: vi.fn(() => true),
sendPrivmsg: vi.fn(),
} as unknown as IrcClient;
const result = await sendMessageIrc("#room", "hello", {
cfg: providedCfg,
client,
replyTo: "irc-parent-1",
});
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello\n\n[reply:irc-parent-1]");
expect(result.receipt.sentAt).toBeTypeOf("number");
expect(result.receipt.sentAt).toBeGreaterThan(0);
expect({ ...result.receipt, sentAt: 123 }).toEqual({
primaryPlatformMessageId: "irc-msg-1",
platformMessageIds: ["irc-msg-1"],
replyToId: "irc-parent-1",
parts: [
{
platformMessageId: "irc-msg-1",
kind: "text",
index: 0,
replyToId: "irc-parent-1",
raw: {
channel: "irc",
conversationId: "#room",
messageId: "irc-msg-1",
},
},
],
sentAt: 123,
raw: [
{
channel: "irc",
conversationId: "#room",
messageId: "irc-msg-1",
},
],
});
});
it("declares message adapter durable text, media, and reply with receipt proofs", async () => {
const providedCfg = {
channels: {
irc: {
host: "irc.example.com",
nick: "openclaw",
},
},
} as unknown as CoreConfig;
const client = {
isReady: vi.fn(() => true),
join: vi.fn(),
sendPrivmsg: vi.fn(),
quit: vi.fn(),
} as unknown as IrcClient & {
join: ReturnType<typeof vi.fn>;
quit: ReturnType<typeof vi.fn>;
};
hoisted.connectIrcClient.mockResolvedValue(client);
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "irc",
adapter: ircMessageAdapter,
proofs: {
text: async () => {
const result = await ircMessageAdapter.send?.text?.({
cfg: providedCfg,
to: "#room",
text: "hello",
});
expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]);
expect(client.join).toHaveBeenCalledWith("#room");
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
},
media: async () => {
const result = await ircMessageAdapter.send?.media?.({
cfg: providedCfg,
to: "#room",
text: "image",
mediaUrl: "https://example.com/image.png",
});
expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]);
expect(client.join).toHaveBeenCalledWith("#room");
expect(client.sendPrivmsg).toHaveBeenCalledWith(
"#room",
"image\n\nAttachment: https://example.com/image.png",
);
},
replyTo: async () => {
const result = await ircMessageAdapter.send?.text?.({
cfg: providedCfg,
to: "#room",
text: "threaded",
replyToId: "parent-1",
});
expect(result?.receipt.replyToId).toBe("parent-1");
expect(client.join).toHaveBeenCalledWith("#room");
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "threaded\n\n[reply:parent-1]");
},
},
});
expect(proofResults.find((result) => result.capability === "text")?.status).toBe("verified");
expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified");
expect(proofResults.find((result) => result.capability === "replyTo")?.status).toBe("verified");
});
});

122
extensions/irc/src/send.ts Normal file
View File

@@ -0,0 +1,122 @@
// Irc plugin module implements send behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
} from "openclaw/plugin-sdk/channel-outbound";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
import { resolveIrcAccount } from "./accounts.js";
import type { IrcClient } from "./client.js";
import { connectIrcClient } from "./client.js";
import { buildIrcConnectOptions } from "./connect-options.js";
import { normalizeIrcMessagingTarget } from "./normalize.js";
import { makeIrcMessageId } from "./protocol.js";
import { getIrcRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
type SendIrcOptions = {
cfg: CoreConfig;
accountId?: string;
replyTo?: string;
target?: string;
client?: IrcClient;
};
type SendIrcResult = {
messageId: string;
target: string;
receipt: MessageReceipt;
};
function recordIrcOutboundActivity(accountId: string): void {
try {
getIrcRuntime().channel.activity.record({
channel: "irc",
accountId,
direction: "outbound",
});
} catch (error) {
if (!(error instanceof Error) || error.message !== "IRC runtime not initialized") {
throw error;
}
}
}
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 cfg = requireRuntimeConfig(opts.cfg, "IRC send") 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 = resolveMarkdownTableMode({
cfg,
channel: "irc",
accountId: account.accountId,
});
const prepared = 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(
buildIrcConnectOptions(account, {
connectTimeoutMs: 12000,
}),
);
if (target.startsWith("#") || target.startsWith("&")) {
transient.join(target);
}
transient.sendPrivmsg(target, payload);
transient.quit("sent");
}
recordIrcOutboundActivity(account.accountId);
const messageId = makeIrcMessageId();
return {
messageId,
target,
receipt: createMessageReceiptFromOutboundResults({
results: [
{
channel: "irc",
messageId,
conversationId: target,
},
],
kind: "text",
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
}),
};
}

View File

@@ -0,0 +1,152 @@
// Irc plugin module implements setup core behavior.
import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import type { DmPolicy } from "openclaw/plugin-sdk/config-contracts";
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
applyAccountNameToChannelSection,
createSetupInputPresenceValidator,
createTopLevelChannelAllowFromSetter,
createTopLevelChannelDmPolicySetter,
patchScopedAccountConfig,
} from "openclaw/plugin-sdk/setup";
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
const channel = "irc" as const;
const setIrcTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({
channel,
});
const setIrcTopLevelAllowFrom = createTopLevelChannelAllowFromSetter({
channel,
});
const validateIrcRequiredSetupInput = createSetupInputPresenceValidator({
whenNotUseEnv: [
{ someOf: ["host"], message: "IRC requires host." },
{ someOf: ["nick"], message: "IRC requires nick." },
],
});
type IrcSetupInput = ChannelSetupInput & {
host?: string;
port?: number | string;
tls?: boolean;
nick?: string;
username?: string;
realname?: string;
channels?: string[];
password?: string;
};
export function parsePort(raw: string, fallback: number): number {
const trimmed = raw.trim();
if (!trimmed) {
return fallback;
}
const parsed = parseStrictPositiveInteger(trimmed);
if (parsed === undefined || parsed > 65535) {
return fallback;
}
return parsed;
}
function validateIrcPortInput(input: ChannelSetupInput): string | null {
const raw = (input as IrcSetupInput).port;
if (raw === undefined || raw === null || raw === "") {
return null;
}
const parsed = parseStrictPositiveInteger(String(raw));
return parsed !== undefined && parsed <= 65535 ? null : "IRC port must be between 1 and 65535.";
}
export function updateIrcAccountConfig(
cfg: CoreConfig,
accountId: string,
patch: Partial<IrcAccountConfig>,
): CoreConfig {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch,
ensureChannelEnabled: false,
ensureAccountEnabled: false,
}) as CoreConfig;
}
export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
return setIrcTopLevelDmPolicy(cfg, dmPolicy) as CoreConfig;
}
export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
return setIrcTopLevelAllowFrom(cfg, allowFrom) as CoreConfig;
}
export function setIrcNickServ(
cfg: CoreConfig,
accountId: string,
nickserv?: IrcNickServConfig,
): CoreConfig {
return updateIrcAccountConfig(cfg, accountId, { nickserv });
}
export function setIrcGroupAccess(
cfg: CoreConfig,
accountId: string,
policy: "open" | "allowlist" | "disabled",
entries: string[],
normalizeGroupEntry: (raw: string) => string | null,
): CoreConfig {
if (policy !== "allowlist") {
return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy });
}
const normalizedEntries = [
...new Set(entries.flatMap((entry) => normalizeGroupEntry(entry) ?? [])),
];
const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
return updateIrcAccountConfig(cfg, accountId, {
enabled: true,
groupPolicy: "allowlist",
groups,
});
}
export const ircSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
validateInput: (params) =>
validateIrcRequiredSetupInput(params) ?? validateIrcPortInput(params.input),
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as IrcSetupInput;
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: setupInput.name,
});
const portInput =
typeof setupInput.port === "number" ? String(setupInput.port) : (setupInput.port ?? "");
const patch: Partial<IrcAccountConfig> = {
enabled: true,
host: setupInput.host?.trim(),
port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined,
tls: setupInput.tls,
nick: setupInput.nick?.trim(),
username: setupInput.username?.trim(),
realname: setupInput.realname?.trim(),
password: setupInput.password?.trim(),
channels: setupInput.channels,
};
return patchScopedAccountConfig({
cfg: namedConfig,
channelKey: channel,
accountId,
patch,
}) as CoreConfig;
},
};

View File

@@ -0,0 +1,451 @@
// Irc plugin module implements setup surface behavior.
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import type {
ChannelSetupDmPolicy,
ChannelSetupWizard,
WizardPrompter,
} from "openclaw/plugin-sdk/setup";
import {
createAllowFromSection,
createPromptParsedAllowFromForAccount,
createSetupTranslator,
createStandardChannelSetupStatus,
formatDocsLink,
setSetupChannelEnabled,
} from "openclaw/plugin-sdk/setup";
import {
normalizeOptionalString,
normalizeStringEntries,
normalizeStringifiedOptionalString,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
import {
isChannelTarget,
normalizeIrcAllowEntry,
normalizeIrcMessagingTarget,
} from "./normalize.js";
import {
ircSetupAdapter,
parsePort,
setIrcAllowFrom,
setIrcDmPolicy,
setIrcGroupAccess,
setIrcNickServ,
updateIrcAccountConfig,
} from "./setup-core.js";
import type { CoreConfig } from "./types.js";
const t = createSetupTranslator();
const channel = "irc" as const;
const USE_ENV_FLAG = "__ircUseEnv";
const TLS_FLAG = "__ircTls";
function parseListInput(raw: string): string[] {
return normalizeStringEntries(raw.split(/[\n,;]+/g));
}
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(/^#+/, "")}`;
}
const promptIrcAllowFrom = createPromptParsedAllowFromForAccount<CoreConfig>({
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg),
noteTitle: t("wizard.irc.allowlistTitle"),
noteLines: [
t("wizard.irc.allowlistIntro"),
t("wizard.irc.examples"),
"- alice",
"- alice!ident@example.org",
t("wizard.irc.multipleEntries"),
],
message: t("wizard.irc.allowFromPrompt"),
placeholder: "alice, bob!ident@example.org",
parseEntries: (raw) => ({
entries: normalizeStringEntries(
parseListInput(raw).map((entry) => normalizeIrcAllowEntry(entry)),
),
}),
getExistingAllowFrom: ({ cfg }) => cfg.channels?.irc?.allowFrom ?? [],
applyAllowFrom: ({ cfg, allowFrom }) => setIrcAllowFrom(cfg, allowFrom),
});
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
? t("wizard.irc.nickServUpdatePrompt")
: t("wizard.irc.nickServConfigurePrompt"),
initialValue: hasExisting,
});
if (!wants) {
return params.cfg;
}
const service = (
await params.prompter.text({
message: t("wizard.irc.nickServServicePrompt"),
initialValue: existing?.service || "NickServ",
validate: (value) => (normalizeStringifiedOptionalString(value) ? 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: t("wizard.irc.nickServPasswordEnvPrompt"),
initialValue: true,
})
: false;
const password = useEnvPassword
? undefined
: (
await params.prompter.text({
message: t("wizard.irc.nickServPasswordPrompt"),
validate: () => undefined,
})
).trim();
if (!password && !useEnvPassword) {
return setIrcNickServ(params.cfg, params.accountId, {
enabled: false,
service,
});
}
const register = await params.prompter.confirm({
message: t("wizard.irc.nickServRegisterPrompt"),
initialValue: existing?.register ?? false,
});
const registerEmail = register
? (
await params.prompter.text({
message: t("wizard.irc.nickServRegisterEmailPrompt"),
initialValue:
existing?.registerEmail ||
(params.accountId === DEFAULT_ACCOUNT_ID
? process.env.IRC_NICKSERV_REGISTER_EMAIL
: undefined),
validate: (value) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
})
).trim()
: undefined;
return setIrcNickServ(params.cfg, params.accountId, {
enabled: true,
service,
...(password ? { password } : {}),
register,
...(registerEmail ? { registerEmail } : {}),
});
}
const ircDmPolicy: ChannelSetupDmPolicy = {
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: async ({ cfg, prompter, accountId }) =>
await promptIrcAllowFrom({
cfg: cfg as CoreConfig,
prompter,
accountId,
}),
};
export const ircSetupWizard: ChannelSetupWizard = {
channel,
status: createStandardChannelSetupStatus({
channelLabel: "IRC",
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsHostNick"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusNeedsHostNick"),
configuredScore: 1,
unconfiguredScore: 0,
includeStatusLine: true,
resolveConfigured: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
}),
introNote: {
title: t("wizard.irc.setupTitle"),
lines: [
t("wizard.irc.helpNeedsHostNick"),
t("wizard.irc.helpRecommendedTls"),
t("wizard.irc.helpNickServOptional"),
t("wizard.irc.helpGroupControl"),
t("wizard.irc.helpMentionGate"),
t("wizard.irc.helpEnvVars"),
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
],
shouldShow: ({ cfg, accountId }) =>
!resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
},
prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
const envHost = isDefaultAccount ? (normalizeOptionalString(process.env.IRC_HOST) ?? "") : "";
const envNick = isDefaultAccount ? (normalizeOptionalString(process.env.IRC_NICK) ?? "") : "";
const envReady = Boolean(envHost && envNick && !resolved.config.host && !resolved.config.nick);
if (envReady) {
const useEnv = await prompter.confirm({
message: t("wizard.irc.envPrompt"),
initialValue: true,
});
if (useEnv) {
return {
cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, { enabled: true }),
credentialValues: {
...credentialValues,
[USE_ENV_FLAG]: "1",
},
};
}
}
const tls = await prompter.confirm({
message: t("wizard.irc.tlsPrompt"),
initialValue: resolved.config.tls ?? true,
});
return {
cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
tls,
}),
credentialValues: {
...credentialValues,
[USE_ENV_FLAG]: "0",
[TLS_FLAG]: tls ? "1" : "0",
},
};
},
credentials: [],
textInputs: [
{
inputKey: "httpHost",
message: t("wizard.irc.serverHostPrompt"),
currentValue: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.host || undefined,
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
applySet: async ({ cfg, accountId, value }) =>
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
host: value,
}),
},
{
inputKey: "httpPort",
message: t("wizard.irc.serverPortPrompt"),
currentValue: ({ cfg, accountId }) =>
String(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.port ?? ""),
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
initialValue: ({ cfg, accountId, credentialValues }) => {
const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
const tls = credentialValues[TLS_FLAG] !== "0";
const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667);
return String(defaultPort);
},
validate: ({ value }) => {
const raw = normalizeStringifiedOptionalString(value) ?? "";
const parsed = parseStrictPositiveInteger(raw);
return parsed !== undefined && parsed <= 65535
? undefined
: "Use a port between 1 and 65535";
},
normalizeValue: ({ value }) => String(parsePort(value, 6697)),
applySet: async ({ cfg, accountId, value }) =>
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
port: parsePort(value, 6697),
}),
},
{
inputKey: "token",
message: t("wizard.irc.nickPrompt"),
currentValue: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.nick || undefined,
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
applySet: async ({ cfg, accountId, value }) =>
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
nick: value,
}),
},
{
inputKey: "userId",
message: t("wizard.irc.usernamePrompt"),
currentValue: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || undefined,
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
initialValue: ({ cfg, accountId, credentialValues }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username ||
credentialValues.token ||
"openclaw",
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
applySet: async ({ cfg, accountId, value }) =>
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
username: value,
}),
},
{
inputKey: "deviceName",
message: t("wizard.irc.realNamePrompt"),
currentValue: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || undefined,
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
initialValue: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || "OpenClaw",
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
applySet: async ({ cfg, accountId, value }) =>
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
realname: value,
}),
},
{
inputKey: "groupChannels",
message: t("wizard.irc.autoJoinPrompt"),
placeholder: "#openclaw, #ops",
required: false,
applyEmptyValue: true,
currentValue: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.channels?.join(", "),
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
normalizeValue: ({ value }) =>
parseListInput(value)
.map((entry) => normalizeGroupEntry(entry))
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.filter((entry) => isChannelTarget(entry))
.join(", "),
applySet: async ({ cfg, accountId, value }) => {
const channels = parseListInput(value)
.map((entry) => normalizeGroupEntry(entry))
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
.filter((entry) => isChannelTarget(entry));
return updateIrcAccountConfig(cfg as CoreConfig, accountId, {
enabled: true,
channels: channels.length > 0 ? channels : undefined,
});
},
},
],
groupAccess: {
label: "IRC channels",
placeholder: "#openclaw, #ops, *",
currentPolicy: ({ cfg, accountId }) =>
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groupPolicy ?? "allowlist",
currentEntries: ({ cfg, accountId }) =>
Object.keys(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups ?? {}),
updatePrompt: ({ cfg, accountId }) =>
Boolean(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups),
setPolicy: ({ cfg, accountId, policy }) =>
setIrcGroupAccess(cfg as CoreConfig, accountId, policy, [], normalizeGroupEntry),
resolveAllowlist: async ({ entries }) =>
uniqueStrings(
entries
.map((entry) => normalizeGroupEntry(entry))
.filter((entry): entry is string => Boolean(entry)),
),
applyAllowlist: ({ cfg, accountId, resolved }) =>
setIrcGroupAccess(
cfg as CoreConfig,
accountId,
"allowlist",
resolved as string[],
normalizeGroupEntry,
),
},
allowFrom: createAllowFromSection({
helpTitle: t("wizard.irc.allowlistTitle"),
helpLines: [
t("wizard.irc.allowlistIntro"),
t("wizard.irc.examples"),
"- alice",
"- alice!ident@example.org",
t("wizard.irc.multipleEntries"),
],
message: t("wizard.irc.allowFromPrompt"),
placeholder: "alice, bob!ident@example.org",
invalidWithoutCredentialNote: t("wizard.irc.allowFromInvalid"),
parseId: (raw) => {
const normalized = normalizeIrcAllowEntry(raw);
return normalized || null;
},
apply: async ({ cfg, allowFrom }) => setIrcAllowFrom(cfg as CoreConfig, allowFrom),
}),
finalize: async ({ cfg, accountId, prompter }) => {
let next = cfg as CoreConfig;
const resolvedAfterGroups = resolveIrcAccount({ cfg: next, accountId });
if (resolvedAfterGroups.config.groupPolicy === "allowlist") {
const groupKeys = Object.keys(resolvedAfterGroups.config.groups ?? {});
if (groupKeys.length > 0) {
const wantsMentions = await prompter.confirm({
message: t("wizard.irc.requireMentionPrompt"),
initialValue: true,
});
if (!wantsMentions) {
const groups = resolvedAfterGroups.config.groups ?? {};
const patched = Object.fromEntries(
Object.entries(groups).map(([key, value]) => [
key,
{ ...value, requireMention: false },
]),
);
next = updateIrcAccountConfig(next, accountId, { groups: patched });
}
}
}
next = await promptIrcNickServConfig({
cfg: next,
prompter,
accountId,
});
return { cfg: next };
},
completionNote: {
title: t("wizard.irc.nextStepsTitle"),
lines: [
t("wizard.irc.nextRestartGateway"),
t("wizard.irc.nextStatusCommand"),
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
],
},
dmPolicy: ircDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { ircSetupAdapter };

View File

@@ -0,0 +1,487 @@
// Irc tests cover setup plugin behavior.
import {
expectStopPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "openclaw/plugin-sdk/channel-test-helpers";
import {
createPluginSetupWizardAdapter,
createPluginSetupWizardStatus,
createTestWizardPrompter,
promptSetupWizardAllowFrom,
runSetupWizardConfigure,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
import {
listIrcAccountIds,
resolveDefaultIrcAccountId,
type ResolvedIrcAccount,
} from "./accounts.js";
import { startIrcGatewayAccount } from "./gateway.js";
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
import {
ircSetupAdapter,
parsePort,
setIrcAllowFrom,
setIrcDmPolicy,
setIrcGroupAccess,
setIrcNickServ,
updateIrcAccountConfig,
} from "./setup-core.js";
import { ircSetupWizard } from "./setup-surface.js";
import type { CoreConfig } from "./types.js";
const hoisted = vi.hoisted(() => ({
monitorIrcProvider: vi.fn(),
sendMessageIrc: vi.fn(),
}));
vi.mock("./channel-runtime.js", () => {
return {
monitorIrcProvider: hoisted.monitorIrcProvider,
sendMessageIrc: hoisted.sendMessageIrc,
};
});
afterAll(() => {
vi.doUnmock("./channel-runtime.js");
vi.resetModules();
});
const ircSetupPlugin = {
id: "irc",
meta: {
label: "IRC",
},
config: {
defaultAccountId: resolveDefaultIrcAccountId,
listAccountIds: listIrcAccountIds,
},
setupWizard: ircSetupWizard,
} as never;
const ircConfigureAdapter = createPluginSetupWizardAdapter(ircSetupPlugin);
const ircStatus = createPluginSetupWizardStatus(ircSetupPlugin);
function buildAccount(): ResolvedIrcAccount {
return {
accountId: "default",
enabled: true,
name: "default",
configured: true,
host: "irc.example.com",
port: 6697,
tls: true,
nick: "openclaw",
username: "openclaw",
realname: "OpenClaw",
password: "",
passwordSource: "none",
config: {} as ResolvedIrcAccount["config"],
};
}
function installIrcRuntime() {
setIrcRuntime({
logging: {
shouldLogVerbose: vi.fn(() => false),
getChildLogger: vi.fn(() => ({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
})),
},
channel: {
activity: {
record: vi.fn(),
get: vi.fn(),
},
},
} as never);
}
describe("irc setup", () => {
afterEach(() => {
vi.clearAllMocks();
clearIrcRuntime();
});
it("parses valid ports and falls back for invalid values", () => {
expect(parsePort("6697", 6667)).toBe(6697);
expect(parsePort(" 7000 ", 6667)).toBe(7000);
expect(parsePort("", 6667)).toBe(6667);
expect(parsePort("70000", 6667)).toBe(6667);
expect(parsePort("abc", 6667)).toBe(6667);
});
it("updates top-level dm policy and allowlist", () => {
const cfg: CoreConfig = { channels: { irc: {} } };
expect(setIrcDmPolicy(cfg, "open")).toStrictEqual({
channels: {
irc: {
dmPolicy: "open",
allowFrom: ["*"],
},
},
});
expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toStrictEqual({
channels: {
irc: {
allowFrom: ["alice", "bob"],
},
},
});
});
it("setup status honors the selected named account", async () => {
const status = await ircStatus({
cfg: {
channels: {
irc: {
accounts: {
ops: {
host: "irc.example.com",
nick: "ops-bot",
},
work: {
host: "irc.example.com",
},
},
},
},
} as CoreConfig,
accountOverrides: {
irc: "work",
},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
});
it("setup status honors the configured default account", async () => {
const status = await ircStatus({
cfg: {
channels: {
irc: {
defaultAccount: "work",
accounts: {
ops: {
host: "irc.example.com",
nick: "ops-bot",
},
work: {
host: "irc.example.com",
nick: "",
},
},
},
},
} as CoreConfig,
accountOverrides: {},
});
expect(status.configured).toBe(false);
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
});
it("stores nickserv and account config patches on the scoped account", () => {
const cfg: CoreConfig = { channels: { irc: {} } };
expect(
setIrcNickServ(cfg, "work", {
enabled: true,
service: "NickServ",
}),
).toStrictEqual({
channels: {
irc: {
accounts: {
work: {
nickserv: {
enabled: true,
service: "NickServ",
},
},
},
},
},
});
expect(
updateIrcAccountConfig(cfg, "work", {
host: "irc.libera.chat",
nick: "openclaw-work",
}),
).toStrictEqual({
channels: {
irc: {
accounts: {
work: {
host: "irc.libera.chat",
nick: "openclaw-work",
},
},
},
},
});
});
it("normalizes allowlist groups and handles non-allowlist policies", () => {
const cfg: CoreConfig = { channels: { irc: {} } };
expect(
setIrcGroupAccess(
cfg,
"default",
"allowlist",
["openclaw", "#ops", "openclaw", "*"],
(raw) => {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
if (trimmed === "*") {
return "*";
}
return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
},
),
).toStrictEqual({
channels: {
irc: {
enabled: true,
groupPolicy: "allowlist",
groups: {
"#openclaw": {},
"#ops": {},
"*": {},
},
},
},
});
expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toStrictEqual({
channels: {
irc: {
enabled: true,
groupPolicy: "disabled",
},
},
});
});
it("validates required input and applies normalized account config", () => {
const validateInput = ircSetupAdapter.validateInput;
const applyAccountConfig = ircSetupAdapter.applyAccountConfig;
expect(validateInput).toBeTypeOf("function");
expect(applyAccountConfig).toBeTypeOf("function");
if (!validateInput) {
throw new Error("Expected IRC setup validateInput");
}
expect(
validateInput({
input: { host: "", nick: "openclaw" },
} as never),
).toBe("IRC requires host.");
expect(
validateInput({
input: { host: "irc.libera.chat", nick: "" },
} as never),
).toBe("IRC requires nick.");
expect(
validateInput({
input: { host: "irc.libera.chat", nick: "openclaw" },
} as never),
).toBeNull();
expect(
validateInput({
input: { host: "irc.libera.chat", nick: "openclaw", port: "+07000" },
} as never),
).toBeNull();
expect(
validateInput({
input: { host: "irc.libera.chat", nick: "openclaw", port: "7000x" },
} as never),
).toBe("IRC port must be between 1 and 65535.");
expect(
validateInput({
input: { host: "irc.libera.chat", nick: "openclaw", port: "70000" },
} as never),
).toBe("IRC port must be between 1 and 65535.");
expect(
applyAccountConfig({
cfg: { channels: { irc: {} } },
accountId: "default",
input: {
name: "Default",
host: " irc.libera.chat ",
port: "7000",
tls: true,
nick: " openclaw ",
username: " claw ",
realname: " OpenClaw Bot ",
password: " secret ",
channels: ["#openclaw"],
},
} as never),
).toEqual({
channels: {
irc: {
enabled: true,
name: "Default",
host: "irc.libera.chat",
port: 7000,
tls: true,
nick: "openclaw",
username: "claw",
realname: "OpenClaw Bot",
password: "secret",
channels: ["#openclaw"],
},
},
});
});
it("configures host and nick via setup prompts", async () => {
const prompter = createTestWizardPrompter({
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;
}),
});
const result = await runSetupWizardConfigure({
configure: ircConfigureAdapter.configure,
cfg: {} as CoreConfig,
prompter,
options: {},
});
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("rejects partial IRC setup wizard ports", async () => {
const portPrompt = ircSetupWizard.textInputs?.find((step) => step.inputKey === "httpPort");
if (!portPrompt?.validate) {
throw new Error("expected IRC port prompt validator");
}
expect(portPrompt.validate({ value: "7000x" } as never)).toBe("Use a port between 1 and 65535");
expect(portPrompt.validate({ value: "+07000" } as never)).toBeUndefined();
expect(portPrompt.validate({ value: "7000" } as never)).toBeUndefined();
});
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
const prompter = createTestWizardPrompter({
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),
});
const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom;
if (!promptAllowFrom) {
throw new Error("promptAllowFrom unavailable");
}
const cfg: CoreConfig = {
channels: {
irc: {
accounts: {
work: {
host: "irc.libera.chat",
nick: "openclaw-work",
},
},
},
},
};
const updated = await promptSetupWizardAllowFrom({
promptAllowFrom,
cfg,
prompter,
accountId: "work",
});
if (!updated) {
throw new Error("expected IRC allowFrom setup to return updated config");
}
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
});
it("keeps startAccount pending until abort, then stops the monitor", async () => {
const stop = vi.fn();
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
installIrcRuntime();
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: async (ctx) =>
await startIrcGatewayAccount({
...ctx,
cfg: ctx.cfg as CoreConfig,
}),
account: buildAccount(),
});
await expectStopPendingUntilAbort({
waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
isSettled,
abort,
task,
stop,
});
});
});

101
extensions/irc/src/types.ts Normal file
View File

@@ -0,0 +1,101 @@
// Irc type declarations define plugin contracts.
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
GroupToolPolicyBySenderConfig,
GroupToolPolicyConfig,
MarkdownConfig,
OpenClawConfig,
BaseProbeResult,
} from "./runtime-api.js";
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;
/**
* Break-glass override: allow nick-only allowlist matching.
* Default behavior requires host/user-qualified identities.
*/
dangerouslyAllowNameMatching?: 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>;
defaultTo?: string;
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;
};
type IrcConfig = IrcAccountConfig & {
accounts?: Record<string, IrcAccountConfig>;
defaultAccount?: string;
};
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 = BaseProbeResult<string> & {
host: string;
port: number;
tls: boolean;
nick: string;
latencyMs?: number;
};

View File

@@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}