mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-10 18:35:48 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
4
openclaw/extensions/irc/api.ts
Normal file
4
openclaw/extensions/irc/api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export { ircPlugin } from "./src/channel.js";
|
||||
export { setIrcRuntime } from "./src/runtime.js";
|
||||
export * from "./src/accounts.js";
|
||||
export * from "./src/setup-surface.js";
|
||||
1
openclaw/extensions/irc/channel-config-api.ts
Normal file
1
openclaw/extensions/irc/channel-config-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { IrcChannelConfigSchema } from "./src/config-schema.js";
|
||||
3
openclaw/extensions/irc/channel-plugin-api.ts
Normal file
3
openclaw/extensions/irc/channel-plugin-api.ts
Normal 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";
|
||||
8
openclaw/extensions/irc/configured-state.ts
Normal file
8
openclaw/extensions/irc/configured-state.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
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
|
||||
);
|
||||
}
|
||||
4
openclaw/extensions/irc/contract-api.ts
Normal file
4
openclaw/extensions/irc/contract-api.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export {
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
13
openclaw/extensions/irc/index.test.ts
Normal file
13
openclaw/extensions/irc/index.test.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { describe } from "vitest";
|
||||
import { assertBundledChannelEntries } from "../../test/helpers/bundled-channel-entry.ts";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("irc bundled entries", () => {
|
||||
assertBundledChannelEntries({
|
||||
entry,
|
||||
expectedId: "irc",
|
||||
expectedName: "IRC",
|
||||
setupEntry,
|
||||
});
|
||||
});
|
||||
20
openclaw/extensions/irc/index.ts
Normal file
20
openclaw/extensions/irc/index.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
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",
|
||||
},
|
||||
});
|
||||
23
openclaw/extensions/irc/openclaw.plugin.json
Normal file
23
openclaw/extensions/irc/openclaw.plugin.json
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
{
|
||||
"id": "irc",
|
||||
"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": {}
|
||||
}
|
||||
}
|
||||
35
openclaw/extensions/irc/package.json
Normal file
35
openclaw/extensions/irc/package.json
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.4.20",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"minHostVersion": ">=2026.4.10"
|
||||
},
|
||||
"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": {
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasIrcConfiguredState"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
20
openclaw/extensions/irc/runtime-api.test.ts
Normal file
20
openclaw/extensions/irc/runtime-api.test.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { runDirectImportSmoke } from "../../test/helpers/plugins/direct-smoke.js";
|
||||
|
||||
describe("irc bundled api seams", () => {
|
||||
it("loads the narrow channel plugin api in direct smoke", async () => {
|
||||
const stdout = await runDirectImportSmoke(
|
||||
'const mod = await import("./extensions/irc/channel-plugin-api.ts"); process.stdout.write(JSON.stringify({keys:Object.keys(mod).sort(), id: mod.ircPlugin.id}));',
|
||||
);
|
||||
|
||||
expect(stdout).toBe('{"keys":["ircPlugin"],"id":"irc"}');
|
||||
}, 45_000);
|
||||
|
||||
it("loads the narrow runtime api in direct smoke", async () => {
|
||||
const stdout = await runDirectImportSmoke(
|
||||
'const mod = await import("./extensions/irc/runtime-api.ts"); process.stdout.write(JSON.stringify({keys:Object.keys(mod).sort(), type: typeof mod.setIrcRuntime}));',
|
||||
);
|
||||
|
||||
expect(stdout).toBe('{"keys":["setIrcRuntime"],"type":"function"}');
|
||||
}, 45_000);
|
||||
});
|
||||
3
openclaw/extensions/irc/runtime-api.ts
Normal file
3
openclaw/extensions/irc/runtime-api.ts
Normal 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";
|
||||
5
openclaw/extensions/irc/secret-contract-api.ts
Normal file
5
openclaw/extensions/irc/secret-contract-api.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
13
openclaw/extensions/irc/setup-entry.ts
Normal file
13
openclaw/extensions/irc/setup-entry.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
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",
|
||||
},
|
||||
});
|
||||
184
openclaw/extensions/irc/src/accounts.test.ts
Normal file
184
openclaw/extensions/irc/src/accounts.test.ts
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } 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"]);
|
||||
});
|
||||
});
|
||||
|
||||
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", () => {
|
||||
const previousChannels = process.env.IRC_CHANNELS;
|
||||
process.env.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 {
|
||||
if (previousChannels === undefined) {
|
||||
delete process.env.IRC_CHANNELS;
|
||||
} else {
|
||||
process.env.IRC_CHANNELS = previousChannels;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const account = resolveIrcAccount({ cfg });
|
||||
expect(account.password).toBe("");
|
||||
expect(account.passwordSource).toBe("none");
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
231
openclaw/extensions/irc/src/accounts.ts
Normal file
231
openclaw/extensions/irc/src/accounts.ts
Normal file
|
|
@ -0,0 +1,231 @@
|
|||
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 { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-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 = Number.parseInt(value.trim(), 10);
|
||||
if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65535) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } =
|
||||
createAccountListHelpers("irc", { normalizeAccountId });
|
||||
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);
|
||||
}
|
||||
6
openclaw/extensions/irc/src/channel-api.ts
Normal file
6
openclaw/extensions/irc/src/channel-api.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
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";
|
||||
4
openclaw/extensions/irc/src/channel-runtime.ts
Normal file
4
openclaw/extensions/irc/src/channel-runtime.ts
Normal 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";
|
||||
16
openclaw/extensions/irc/src/channel.test.ts
Normal file
16
openclaw/extensions/irc/src/channel.test.ts
Normal file
|
|
@ -0,0 +1,16 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
361
openclaw/extensions/irc/src/channel.ts
Normal file
361
openclaw/extensions/irc/src/channel.ts
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
composeAccountWarningCollectors,
|
||||
createAllowlistProviderOpenWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
createResolvedDirectoryEntriesLister,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
listIrcAccountIds,
|
||||
resolveDefaultIrcAccountId,
|
||||
resolveIrcAccount,
|
||||
type ResolvedIrcAccount,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
type ChannelPlugin,
|
||||
} from "./channel-api.js";
|
||||
import { IrcChannelConfigSchema } from "./config-schema.js";
|
||||
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { startIrcGatewayAccount } from "./gateway.js";
|
||||
import {
|
||||
isChannelTarget,
|
||||
looksLikeIrcTargetId,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
import { ircOutboundBaseAdapter } from "./outbound-base.js";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
import { probeIrc } from "./probe.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { ircSetupAdapter } from "./setup-core.js";
|
||||
import { ircSetupWizard } from "./setup-surface.js";
|
||||
import type { CoreConfig, IrcProbe } from "./types.js";
|
||||
|
||||
const meta = {
|
||||
id: "irc",
|
||||
label: "IRC",
|
||||
selectionLabel: "IRC (Server + Nick)",
|
||||
docsPath: "/channels/irc",
|
||||
docsLabel: "irc",
|
||||
blurb: "classic IRC networks; host, nick, channels.",
|
||||
order: 80,
|
||||
detailLabel: "IRC",
|
||||
systemImage: "number",
|
||||
markdownCapable: true,
|
||||
};
|
||||
|
||||
type IrcChannelRuntimeModule = typeof import("./channel-runtime.js");
|
||||
|
||||
let ircChannelRuntimePromise: Promise<IrcChannelRuntimeModule> | undefined;
|
||||
|
||||
async function loadIrcChannelRuntime(): Promise<IrcChannelRuntimeModule> {
|
||||
ircChannelRuntimePromise ??= import("./channel-runtime.js");
|
||||
return await ircChannelRuntimePromise;
|
||||
}
|
||||
|
||||
function normalizePairingTarget(raw: string): string {
|
||||
const normalized = normalizeIrcAllowEntry(raw);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
const listIrcDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
|
||||
kind: "user",
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
|
||||
resolveSources: (account) => [
|
||||
account.config.allowFrom ?? [],
|
||||
account.config.groupAllowFrom ?? [],
|
||||
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
|
||||
],
|
||||
normalizeId: (entry) => normalizePairingTarget(entry) || null,
|
||||
});
|
||||
|
||||
const listIrcDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
|
||||
kind: "group",
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
|
||||
resolveSources: (account) => [
|
||||
account.config.channels ?? [],
|
||||
Object.keys(account.config.groups ?? {}),
|
||||
],
|
||||
normalizeId: (entry) => {
|
||||
const normalized = normalizeIrcMessagingTarget(entry);
|
||||
return normalized && isChannelTarget(normalized) ? normalized : null;
|
||||
},
|
||||
});
|
||||
|
||||
const ircConfigAdapter = createScopedChannelConfigAdapter<ResolvedIrcAccount, ResolvedIrcAccount>({
|
||||
sectionKey: "irc",
|
||||
listAccountIds: listIrcAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
|
||||
defaultAccountId: resolveDefaultIrcAccountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"host",
|
||||
"port",
|
||||
"tls",
|
||||
"nick",
|
||||
"username",
|
||||
"realname",
|
||||
"password",
|
||||
"passwordFile",
|
||||
"channels",
|
||||
],
|
||||
resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: normalizeIrcAllowEntry,
|
||||
}),
|
||||
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
|
||||
channelKey: "irc",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
|
||||
});
|
||||
|
||||
const collectIrcGroupPolicyWarnings =
|
||||
createAllowlistProviderOpenWarningCollector<ResolvedIrcAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
buildOpenWarning: {
|
||||
surface: "IRC channels",
|
||||
openBehavior: "allows all channels and senders (mention-gated)",
|
||||
remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups',
|
||||
},
|
||||
});
|
||||
|
||||
const collectIrcSecurityWarnings = composeAccountWarningCollectors<
|
||||
ResolvedIrcAccount,
|
||||
{
|
||||
account: ResolvedIrcAccount;
|
||||
cfg: CoreConfig;
|
||||
}
|
||||
>(
|
||||
collectIrcGroupPolicyWarnings,
|
||||
(account) =>
|
||||
!account.config.tls &&
|
||||
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
|
||||
(account) =>
|
||||
account.config.nickserv?.register &&
|
||||
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
|
||||
(account) =>
|
||||
account.config.nickserv?.register &&
|
||||
!account.config.nickserv.password?.trim() &&
|
||||
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
|
||||
);
|
||||
|
||||
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "irc",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
setup: ircSetupAdapter,
|
||||
setupWizard: ircSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.irc"] },
|
||||
configSchema: IrcChannelConfigSchema,
|
||||
config: {
|
||||
...ircConfigAdapter,
|
||||
hasConfiguredState: ({ env }) =>
|
||||
typeof env?.IRC_HOST === "string" &&
|
||||
env.IRC_HOST.trim().length > 0 &&
|
||||
typeof env?.IRC_NICK === "string" &&
|
||||
env.IRC_NICK.trim().length > 0,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
passwordSource: account.passwordSource,
|
||||
},
|
||||
}),
|
||||
},
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
doctor: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
if (!groupId) {
|
||||
return true;
|
||||
}
|
||||
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
|
||||
return resolveIrcRequireMention({
|
||||
groupConfig: match.groupConfig,
|
||||
wildcardConfig: match.wildcardConfig,
|
||||
});
|
||||
},
|
||||
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
if (!groupId) {
|
||||
return undefined;
|
||||
}
|
||||
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
|
||||
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
normalizeTarget: normalizeIrcMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeIrcTargetId,
|
||||
hint: "<#channel|nick>",
|
||||
},
|
||||
},
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
return inputs.map((input) => {
|
||||
const normalized = normalizeIrcMessagingTarget(input);
|
||||
if (!normalized) {
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "invalid IRC target",
|
||||
};
|
||||
}
|
||||
if (kind === "group") {
|
||||
const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`;
|
||||
return {
|
||||
input,
|
||||
resolved: true,
|
||||
id: groupId,
|
||||
name: groupId,
|
||||
};
|
||||
}
|
||||
if (isChannelTarget(normalized)) {
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "expected user target",
|
||||
};
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: true,
|
||||
id: normalized,
|
||||
name: normalized,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => listIrcDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => {
|
||||
const entries = await listIrcDirectoryGroupsFromConfig(params);
|
||||
return entries.map((entry) => 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 ({ id, message }) => {
|
||||
const target = normalizePairingTarget(id);
|
||||
if (!target) {
|
||||
throw new Error(`invalid IRC pairing id: ${id}`);
|
||||
}
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
await sendMessageIrc(target, message);
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveIrcDmPolicy,
|
||||
collectWarnings: collectIrcSecurityWarnings,
|
||||
},
|
||||
outbound: {
|
||||
base: ircOutboundBaseAdapter,
|
||||
attachedResults: {
|
||||
channel: "irc",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
return await sendMessageIrc(to, text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
return await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
43
openclaw/extensions/irc/src/client.test.ts
Normal file
43
openclaw/extensions/irc/src/client.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { buildIrcNickServCommands } from "./client.js";
|
||||
|
||||
describe("irc client nickserv", () => {
|
||||
it("builds IDENTIFY command when password is set", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
}),
|
||||
).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]);
|
||||
});
|
||||
|
||||
it("builds REGISTER command when enabled with email", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
register: true,
|
||||
registerEmail: "bot@example.com",
|
||||
}),
|
||||
).toEqual([
|
||||
"PRIVMSG NickServ :IDENTIFY secret",
|
||||
"PRIVMSG NickServ :REGISTER secret bot@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects register without registerEmail", () => {
|
||||
expect(() =>
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
register: true,
|
||||
}),
|
||||
).toThrow(/registerEmail/);
|
||||
});
|
||||
|
||||
it("sanitizes outbound NickServ payloads", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
service: "NickServ\n",
|
||||
password: "secret\r\nJOIN #bad",
|
||||
}),
|
||||
).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]);
|
||||
});
|
||||
});
|
||||
444
openclaw/extensions/irc/src/client.ts
Normal file
444
openclaw/extensions/irc/src/client.ts
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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"]);
|
||||
|
||||
export type IrcPrivmsgEvent = {
|
||||
senderNick: string;
|
||||
senderUser?: string;
|
||||
senderHost?: string;
|
||||
target: string;
|
||||
text: string;
|
||||
rawLine: string;
|
||||
};
|
||||
|
||||
export type IrcClientOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
username: string;
|
||||
realname: string;
|
||||
password?: string;
|
||||
nickserv?: IrcNickServOptions;
|
||||
channels?: string[];
|
||||
connectTimeoutMs?: number;
|
||||
messageChunkMaxChars?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>;
|
||||
onNotice?: (text: string, target?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onLine?: (line: string) => void;
|
||||
};
|
||||
|
||||
export type IrcNickServOptions = {
|
||||
enabled?: boolean;
|
||||
service?: string;
|
||||
password?: string;
|
||||
register?: boolean;
|
||||
registerEmail?: string;
|
||||
};
|
||||
|
||||
export type IrcClient = {
|
||||
nick: string;
|
||||
isReady: () => boolean;
|
||||
sendRaw: (line: string) => void;
|
||||
join: (channel: string) => void;
|
||||
sendPrivmsg: (target: string, text: string) => void;
|
||||
quit: (reason?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
function toError(err: unknown): Error {
|
||||
if (err instanceof Error) {
|
||||
return err;
|
||||
}
|
||||
return new Error(typeof err === "string" ? err : JSON.stringify(err));
|
||||
}
|
||||
|
||||
function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string): Promise<T> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const timer = setTimeout(
|
||||
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
||||
timeoutMs,
|
||||
);
|
||||
promise
|
||||
.then((result) => {
|
||||
clearTimeout(timer);
|
||||
resolve(result);
|
||||
})
|
||||
.catch((error) => {
|
||||
clearTimeout(timer);
|
||||
reject(error);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildFallbackNick(nick: string): string {
|
||||
const normalized = nick.replace(/\s+/g, "");
|
||||
const safe = normalized.replace(/[^A-Za-z0-9_\-[\]\\`^{}|]/g, "");
|
||||
const base = safe || "openclaw";
|
||||
const suffix = "_";
|
||||
const maxNickLen = 30;
|
||||
if (base.length >= maxNickLen) {
|
||||
return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
|
||||
}
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
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;
|
||||
|
||||
const socket = options.tls
|
||||
? tls.connect({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
servername: options.host,
|
||||
})
|
||||
: net.connect({ host: options.host, port: options.port });
|
||||
|
||||
socket.setEncoding("utf8");
|
||||
|
||||
let resolveReady: (() => void) | null = null;
|
||||
let rejectReady: ((error: Error) => void) | null = null;
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const error = toError(err);
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
}
|
||||
if (!ready && rejectReady) {
|
||||
rejectReady(error);
|
||||
rejectReady = null;
|
||||
resolveReady = null;
|
||||
}
|
||||
};
|
||||
|
||||
const sendRaw = (line: string) => {
|
||||
const cleaned = line.replace(/[\r\n]+/g, "").trim();
|
||||
if (!cleaned) {
|
||||
throw new Error("IRC command cannot be empty");
|
||||
}
|
||||
socket.write(`${cleaned}\r\n`);
|
||||
};
|
||||
|
||||
const tryRecoverNickCollision = (): boolean => {
|
||||
const nickServEnabled = options.nickserv?.enabled !== false;
|
||||
const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? "");
|
||||
if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) {
|
||||
nickServRecoverAttempted = true;
|
||||
try {
|
||||
const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ");
|
||||
sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`);
|
||||
sendRaw(`NICK ${desiredNick}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackNickAttempted) {
|
||||
fallbackNickAttempted = true;
|
||||
const fallbackNick = buildFallbackNick(desiredNick);
|
||||
if (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;
|
||||
const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
|
||||
try {
|
||||
if (safeReason) {
|
||||
sendRaw(`QUIT :${safeReason}`);
|
||||
} else {
|
||||
sendRaw("QUIT");
|
||||
}
|
||||
} catch {
|
||||
// Ignore quit failures while shutting down.
|
||||
}
|
||||
socket.end();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let idx = buffer.indexOf("\n");
|
||||
while (idx !== -1) {
|
||||
const rawLine = buffer.slice(0, idx).replace(/\r$/, "");
|
||||
buffer = buffer.slice(idx + 1);
|
||||
idx = buffer.indexOf("\n");
|
||||
|
||||
if (!rawLine) {
|
||||
continue;
|
||||
}
|
||||
if (options.onLine) {
|
||||
options.onLine(rawLine);
|
||||
}
|
||||
|
||||
const line = parseIrcLine(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "PING") {
|
||||
const payload =
|
||||
line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
|
||||
sendRaw(`PONG :${payload}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NICK") {
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
if (prefix.nick && 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) => {
|
||||
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 = () => {
|
||||
quit("shutdown");
|
||||
};
|
||||
if (options.abortSignal.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
options.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
await withTimeout(readyPromise, timeoutMs, "IRC connect");
|
||||
|
||||
return {
|
||||
get nick() {
|
||||
return currentNick;
|
||||
},
|
||||
isReady: () => ready && !closed,
|
||||
sendRaw,
|
||||
join,
|
||||
sendPrivmsg,
|
||||
quit,
|
||||
close,
|
||||
};
|
||||
}
|
||||
27
openclaw/extensions/irc/src/config-schema.test.ts
Normal file
27
openclaw/extensions/irc/src/config-schema.test.ts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { IrcConfigSchema } from "./config-schema.js";
|
||||
|
||||
describe("irc config schema", () => {
|
||||
it("accepts numeric allowFrom and groupAllowFrom entries", () => {
|
||||
const parsed = IrcConfigSchema.parse({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [12345, "alice"],
|
||||
groupAllowFrom: [67890, "alice!ident@example.org"],
|
||||
});
|
||||
|
||||
expect(parsed.allowFrom).toEqual([12345, "alice"]);
|
||||
expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
|
||||
});
|
||||
|
||||
it("accepts numeric per-channel allowFrom entries", () => {
|
||||
const parsed = IrcConfigSchema.parse({
|
||||
groups: {
|
||||
"#ops": {
|
||||
allowFrom: [42, "alice"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
|
||||
});
|
||||
});
|
||||
96
openclaw/extensions/irc/src/config-schema.ts
Normal file
96
openclaw/extensions/irc/src/config-schema.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ReplyRuntimeConfigSchemaShape,
|
||||
ToolPolicySchema,
|
||||
buildChannelConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "openclaw/plugin-sdk/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",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export 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();
|
||||
|
||||
export const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
|
||||
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,
|
||||
});
|
||||
40
openclaw/extensions/irc/src/config-ui-hints.ts
Normal file
40
openclaw/extensions/irc/src/config-ui-hints.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
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>;
|
||||
47
openclaw/extensions/irc/src/connect-options.test.ts
Normal file
47
openclaw/extensions/irc/src/connect-options.test.ts
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
30
openclaw/extensions/irc/src/connect-options.ts
Normal file
30
openclaw/extensions/irc/src/connect-options.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
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,
|
||||
};
|
||||
}
|
||||
17
openclaw/extensions/irc/src/control-chars.test.ts
Normal file
17
openclaw/extensions/irc/src/control-chars.test.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
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");
|
||||
});
|
||||
});
|
||||
22
openclaw/extensions/irc/src/control-chars.ts
Normal file
22
openclaw/extensions/irc/src/control-chars.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export function isIrcControlChar(charCode: number): boolean {
|
||||
return charCode <= 0x1f || charCode === 0x7f;
|
||||
}
|
||||
|
||||
export function hasIrcControlChars(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (isIrcControlChar(char.charCodeAt(0))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripIrcControlChars(value: string): string {
|
||||
let out = "";
|
||||
for (const char of value) {
|
||||
if (!isIrcControlChar(char.charCodeAt(0))) {
|
||||
out += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
54
openclaw/extensions/irc/src/doctor.ts
Normal file
54
openclaw/extensions/irc/src/doctor.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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("!") && !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;
|
||||
},
|
||||
});
|
||||
53
openclaw/extensions/irc/src/gateway.ts
Normal file
53
openclaw/extensions/irc/src/gateway.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
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";
|
||||
|
||||
type IrcChannelRuntimeModule = typeof import("./channel-runtime.js");
|
||||
|
||||
let ircChannelRuntimePromise: Promise<IrcChannelRuntimeModule> | undefined;
|
||||
|
||||
async function loadIrcChannelRuntime(): Promise<IrcChannelRuntimeModule> {
|
||||
ircChannelRuntimePromise ??= import("./channel-runtime.js");
|
||||
return await ircChannelRuntimePromise;
|
||||
}
|
||||
|
||||
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,
|
||||
}),
|
||||
});
|
||||
}
|
||||
154
openclaw/extensions/irc/src/inbound.behavior.test.ts
Normal file
154
openclaw/extensions/irc/src/inbound.behavior.test.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { 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 { 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,
|
||||
};
|
||||
}
|
||||
|
||||
describe("irc inbound behavior", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
installIrcRuntime();
|
||||
readAllowFromStoreMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("issues a DM pairing challenge and sends the reply to the sender nick", async () => {
|
||||
const sendReply = vi.fn(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",
|
||||
expect.stringContaining("OpenClaw: access not configured."),
|
||||
undefined,
|
||||
);
|
||||
expect(sendReply).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
expect.stringContaining("Your IRC id: alice!ident@example.com"),
|
||||
undefined,
|
||||
);
|
||||
expect(sendReply).toHaveBeenCalledWith("alice", expect.stringContaining("CODE"), 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",
|
||||
);
|
||||
});
|
||||
});
|
||||
37
openclaw/extensions/irc/src/inbound.policy.test.ts
Normal file
37
openclaw/extensions/irc/src/inbound.policy.test.ts
Normal file
|
|
@ -0,0 +1,37 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { __testing } from "./inbound.js";
|
||||
|
||||
describe("irc inbound policy", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: [],
|
||||
storeAllowList: ["paired-user"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["owner", "paired-user"]);
|
||||
});
|
||||
|
||||
it("does not grant group access from pairing-store when explicit groupAllowFrom exists", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: ["group-owner"],
|
||||
storeAllowList: ["paired-user"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("does not grant group access from pairing-store when groupAllowFrom is empty", () => {
|
||||
const resolved = __testing.resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom: ["owner"],
|
||||
configGroupAllowFrom: [],
|
||||
storeAllowList: ["paired-user"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual([]);
|
||||
});
|
||||
});
|
||||
367
openclaw/extensions/irc/src/inbound.ts
Normal file
367
openclaw/extensions/irc/src/inbound.ts
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
|
||||
import {
|
||||
resolveIrcMentionGate,
|
||||
resolveIrcGroupAccessGate,
|
||||
resolveIrcGroupMatch,
|
||||
resolveIrcGroupSenderAllowed,
|
||||
resolveIrcRequireMention,
|
||||
} from "./policy.js";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
createChannelPairingController,
|
||||
deliverFormattedTextWithAttachments,
|
||||
dispatchInboundReplyWithBase,
|
||||
logInboundDrop,
|
||||
isDangerousNameMatchingEnabled,
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveControlCommandGate,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type OutboundReplyPayload,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.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 escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
function resolveIrcEffectiveAllowlists(params: {
|
||||
configAllowFrom: string[];
|
||||
configGroupAllowFrom: string[];
|
||||
storeAllowList: string[];
|
||||
dmPolicy: string;
|
||||
}): {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
} {
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveEffectiveAllowFromLists({
|
||||
allowFrom: params.configAllowFrom,
|
||||
groupAllowFrom: params.configGroupAllowFrom,
|
||||
storeAllowFrom: params.storeAllowList,
|
||||
dmPolicy: params.dmPolicy,
|
||||
// IRC intentionally requires explicit groupAllowFrom; do not fallback to allowFrom.
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
});
|
||||
return { effectiveAllowFrom, effectiveGroupAllowFrom };
|
||||
}
|
||||
|
||||
async function deliverIrcReply(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
target: string;
|
||||
accountId: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}) {
|
||||
const delivered = 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, {
|
||||
accountId: params.accountId,
|
||||
replyTo: replyToId,
|
||||
});
|
||||
}
|
||||
params.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
});
|
||||
if (!delivered) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
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: (message) => runtime.log?.(message),
|
||||
});
|
||||
|
||||
const configAllowFrom = normalizeIrcAllowlist(account.config.allowFrom);
|
||||
const configGroupAllowFrom = normalizeIrcAllowlist(account.config.groupAllowFrom);
|
||||
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
|
||||
provider: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
dmPolicy,
|
||||
readStore: pairing.readStoreForDmPolicy,
|
||||
});
|
||||
const storeAllowList = normalizeIrcAllowlist(storeAllowFrom);
|
||||
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: account.config.groups,
|
||||
target: message.target,
|
||||
});
|
||||
|
||||
if (message.isGroup) {
|
||||
const groupAccess = resolveIrcGroupAccessGate({ groupPolicy, groupMatch });
|
||||
if (!groupAccess.allowed) {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (${groupAccess.reason})`);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const directGroupAllowFrom = normalizeIrcAllowlist(groupMatch.groupConfig?.allowFrom);
|
||||
const wildcardGroupAllowFrom = normalizeIrcAllowlist(groupMatch.wildcardConfig?.allowFrom);
|
||||
const groupAllowFrom =
|
||||
directGroupAllowFrom.length > 0 ? directGroupAllowFrom : wildcardGroupAllowFrom;
|
||||
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveIrcEffectiveAllowlists({
|
||||
configAllowFrom,
|
||||
configGroupAllowFrom,
|
||||
storeAllowList,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
surface: CHANNEL_ID,
|
||||
});
|
||||
const useAccessGroups = config.commands?.useAccessGroups !== false;
|
||||
const senderAllowedForCommands = resolveIrcAllowlistMatch({
|
||||
allowFrom: message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{
|
||||
configured: (message.isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
|
||||
allowed: senderAllowedForCommands,
|
||||
},
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
const commandAuthorized = commandGate.commandAuthorized;
|
||||
|
||||
if (message.isGroup) {
|
||||
const senderAllowed = resolveIrcGroupSenderAllowed({
|
||||
groupPolicy,
|
||||
message,
|
||||
outerAllowFrom: effectiveGroupAllowFrom,
|
||||
innerAllowFrom: groupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
if (!senderAllowed) {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
return;
|
||||
}
|
||||
} else {
|
||||
if (dmPolicy === "disabled") {
|
||||
runtime.log?.(`irc: drop DM sender=${senderDisplay} (dmPolicy=disabled)`);
|
||||
return;
|
||||
}
|
||||
if (dmPolicy !== "open") {
|
||||
const dmAllowed = resolveIrcAllowlistMatch({
|
||||
allowFrom: effectiveAllowFrom,
|
||||
message,
|
||||
allowNameMatching,
|
||||
}).allowed;
|
||||
if (!dmAllowed) {
|
||||
if (dmPolicy === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
|
||||
senderIdLine: `Your IRC id: ${senderDisplay}`,
|
||||
meta: { name: message.senderNick || undefined },
|
||||
sendPairingReply: async (text) => {
|
||||
await deliverIrcReply({
|
||||
payload: { text },
|
||||
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 (message.isGroup && commandGate.shouldBlock) {
|
||||
logInboundDrop({
|
||||
log: (line) => runtime.log?.(line),
|
||||
channel: CHANNEL_ID,
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
|
||||
const mentionNick = connectedNick?.trim() || account.nick;
|
||||
const explicitMentionRegex = mentionNick
|
||||
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
|
||||
: null;
|
||||
const wasMentioned =
|
||||
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
|
||||
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
|
||||
|
||||
const requireMention = message.isGroup
|
||||
? resolveIrcRequireMention({
|
||||
groupConfig: groupMatch.groupConfig,
|
||||
wildcardConfig: groupMatch.wildcardConfig,
|
||||
})
|
||||
: false;
|
||||
|
||||
const mentionGate = resolveIrcMentionGate({
|
||||
isGroup: message.isGroup,
|
||||
requireMention,
|
||||
wasMentioned,
|
||||
hasControlCommand,
|
||||
allowTextCommands,
|
||||
commandAuthorized,
|
||||
});
|
||||
if (mentionGate.shouldSkip) {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (${mentionGate.reason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
const peerId = message.isGroup ? message.target : message.senderNick;
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
||||
const storePath = core.channel.session.resolveStorePath(config.session?.store, {
|
||||
agentId: route.agentId,
|
||||
});
|
||||
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
|
||||
const previousTimestamp = core.channel.session.readSessionUpdatedAt({
|
||||
storePath,
|
||||
sessionKey: route.sessionKey,
|
||||
});
|
||||
const body = core.channel.reply.formatAgentEnvelope({
|
||||
channel: "IRC",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
previousTimestamp,
|
||||
envelope: envelopeOptions,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: message.isGroup ? `irc:channel:${message.target}` : `irc:${senderDisplay}`,
|
||||
To: `irc:${peerId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: message.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: message.senderNick || undefined,
|
||||
SenderId: senderDisplay,
|
||||
GroupSubject: message.isGroup ? message.target : undefined,
|
||||
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: message.isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: `irc:${peerId}`,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
await dispatchInboundReplyWithBase({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
route,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
core,
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload,
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
onDispatchError: (err, info) => {
|
||||
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
replyOptions: {
|
||||
skillFilter: groupMatch.groupConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export const __testing = {
|
||||
resolveIrcEffectiveAllowlists,
|
||||
};
|
||||
43
openclaw/extensions/irc/src/monitor.test.ts
Normal file
43
openclaw/extensions/irc/src/monitor.test.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIrcInboundTarget } from "./monitor.js";
|
||||
|
||||
describe("irc monitor inbound target", () => {
|
||||
it("keeps channel target for group messages", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "#openclaw",
|
||||
senderNick: "alice",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: true,
|
||||
target: "#openclaw",
|
||||
rawTarget: "#openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps DM target to sender nick and preserves raw target", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "openclaw-bot",
|
||||
senderNick: "alice",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: false,
|
||||
target: "alice",
|
||||
rawTarget: "openclaw-bot",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to raw target when sender nick is empty", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "openclaw-bot",
|
||||
senderNick: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: false,
|
||||
target: "openclaw-bot",
|
||||
rawTarget: "openclaw-bot",
|
||||
});
|
||||
});
|
||||
});
|
||||
149
openclaw/extensions/irc/src/monitor.ts
Normal file
149
openclaw/extensions/irc/src/monitor.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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";
|
||||
|
||||
export type IrcMonitorOptions = {
|
||||
accountId?: string;
|
||||
config?: CoreConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
|
||||
isGroup: boolean;
|
||||
target: string;
|
||||
rawTarget: string;
|
||||
} {
|
||||
const rawTarget = params.target;
|
||||
const isGroup = isChannelTarget(rawTarget);
|
||||
if (isGroup) {
|
||||
return { isGroup: true, target: rawTarget, rawTarget };
|
||||
}
|
||||
const senderNick = params.senderNick.trim();
|
||||
return { isGroup: false, target: senderNick || rawTarget, rawTarget };
|
||||
}
|
||||
|
||||
export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
|
||||
const core = getIrcRuntime();
|
||||
const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const runtime: RuntimeEnv = 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;
|
||||
},
|
||||
};
|
||||
}
|
||||
55
openclaw/extensions/irc/src/normalize.test.ts
Normal file
55
openclaw/extensions/irc/src/normalize.test.ts
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIrcAllowlistCandidates,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
resolveIrcAllowlistMatch,
|
||||
} from "./normalize.js";
|
||||
|
||||
describe("irc normalize", () => {
|
||||
it("normalizes targets", () => {
|
||||
expect(normalizeIrcMessagingTarget("irc:channel:openclaw")).toBe("#openclaw");
|
||||
expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice");
|
||||
expect(normalizeIrcMessagingTarget("\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries", () => {
|
||||
expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h");
|
||||
});
|
||||
|
||||
it("matches senders by nick/user/host candidates", () => {
|
||||
const message = {
|
||||
messageId: "m1",
|
||||
target: "#chan",
|
||||
senderNick: "Alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.org",
|
||||
text: "hi",
|
||||
timestamp: Date.now(),
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
||||
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
|
||||
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice!ident@example.org"],
|
||||
message,
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice"],
|
||||
message,
|
||||
}).allowed,
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcAllowlistMatch({
|
||||
allowFrom: ["alice"],
|
||||
message,
|
||||
allowNameMatching: true,
|
||||
}).allowed,
|
||||
).toBe(true);
|
||||
});
|
||||
});
|
||||
125
openclaw/extensions/irc/src/normalize.ts
Normal file
125
openclaw/extensions/irc/src/normalize.ts
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/text-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 normalizeIrcAllowlist(entries?: Array<string | number>): string[] {
|
||||
return (entries ?? []).map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean);
|
||||
}
|
||||
|
||||
export function formatIrcSenderId(message: IrcInboundMessage): string {
|
||||
const base = message.senderNick.trim();
|
||||
const user = message.senderUser?.trim();
|
||||
const host = message.senderHost?.trim();
|
||||
if (user && host) {
|
||||
return `${base}!${user}@${host}`;
|
||||
}
|
||||
if (user) {
|
||||
return `${base}!${user}`;
|
||||
}
|
||||
if (host) {
|
||||
return `${base}@${host}`;
|
||||
}
|
||||
return base;
|
||||
}
|
||||
|
||||
export function buildIrcAllowlistCandidates(
|
||||
message: IrcInboundMessage,
|
||||
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];
|
||||
}
|
||||
|
||||
export function resolveIrcAllowlistMatch(params: {
|
||||
allowFrom: string[];
|
||||
message: IrcInboundMessage;
|
||||
allowNameMatching?: boolean;
|
||||
}): { allowed: boolean; source?: string } {
|
||||
const allowFrom = new Set(params.allowFrom.map(normalizeLowercaseStringOrEmpty).filter(Boolean));
|
||||
if (allowFrom.has("*")) {
|
||||
return { allowed: true, source: "wildcard" };
|
||||
}
|
||||
const candidates = buildIrcAllowlistCandidates(params.message, {
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
for (const candidate of candidates) {
|
||||
if (allowFrom.has(candidate)) {
|
||||
return { allowed: true, source: candidate };
|
||||
}
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
10
openclaw/extensions/irc/src/outbound-base.ts
Normal file
10
openclaw/extensions/irc/src/outbound-base.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/outbound-runtime";
|
||||
import { chunkTextForOutbound } from "./channel-api.js";
|
||||
|
||||
export const ircOutboundBaseAdapter = {
|
||||
deliveryMode: "direct" as const,
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
textChunkLimit: 350,
|
||||
sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
|
||||
};
|
||||
149
openclaw/extensions/irc/src/policy.test.ts
Normal file
149
openclaw/extensions/irc/src/policy.test.ts
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
resolveIrcGroupAccessGate,
|
||||
resolveIrcGroupMatch,
|
||||
resolveIrcGroupSenderAllowed,
|
||||
resolveIrcMentionGate,
|
||||
resolveIrcRequireMention,
|
||||
} from "./policy.js";
|
||||
|
||||
describe("irc policy", () => {
|
||||
it("matches direct and wildcard group entries", () => {
|
||||
const direct = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"#ops": { requireMention: false },
|
||||
},
|
||||
target: "#ops",
|
||||
});
|
||||
expect(direct.allowed).toBe(true);
|
||||
expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false);
|
||||
|
||||
const wildcard = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
target: "#random",
|
||||
});
|
||||
expect(wildcard.allowed).toBe(true);
|
||||
expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
|
||||
});
|
||||
|
||||
it("enforces allowlist by default in groups", () => {
|
||||
const message = {
|
||||
messageId: "m1",
|
||||
target: "#ops",
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.org",
|
||||
text: "hi",
|
||||
timestamp: Date.now(),
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: [],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice!ident@example.org"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
resolveIrcGroupSenderAllowed({
|
||||
groupPolicy: "allowlist",
|
||||
message,
|
||||
outerAllowFrom: ["alice"],
|
||||
innerAllowFrom: [],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it('allows unconfigured channels when groupPolicy is "open"', () => {
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: undefined,
|
||||
target: "#random",
|
||||
});
|
||||
const gate = resolveIrcGroupAccessGate({
|
||||
groupPolicy: "open",
|
||||
groupMatch,
|
||||
});
|
||||
expect(gate.allowed).toBe(true);
|
||||
expect(gate.reason).toBe("open");
|
||||
});
|
||||
|
||||
it("honors explicit group disable even in open mode", () => {
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"#ops": { enabled: false },
|
||||
},
|
||||
target: "#ops",
|
||||
});
|
||||
const gate = resolveIrcGroupAccessGate({
|
||||
groupPolicy: "open",
|
||||
groupMatch,
|
||||
});
|
||||
expect(gate.allowed).toBe(false);
|
||||
expect(gate.reason).toBe("disabled");
|
||||
});
|
||||
|
||||
it("allows authorized control commands without mention", () => {
|
||||
const gate = resolveIrcMentionGate({
|
||||
isGroup: true,
|
||||
requireMention: true,
|
||||
wasMentioned: false,
|
||||
hasControlCommand: true,
|
||||
allowTextCommands: true,
|
||||
commandAuthorized: true,
|
||||
});
|
||||
expect(gate.shouldSkip).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
|
||||
const groups = {
|
||||
"#Ops": { requireMention: false },
|
||||
"#Hidden": { enabled: false },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
|
||||
const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" });
|
||||
const sharedDirect = resolveChannelGroupPolicy({
|
||||
cfg: { channels: { irc: { groups } } },
|
||||
channel: "irc",
|
||||
groupId: "#ops",
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
expect(sharedDirect.allowed).toBe(inboundDirect.allowed);
|
||||
expect(sharedDirect.groupConfig?.requireMention).toBe(
|
||||
inboundDirect.groupConfig?.requireMention,
|
||||
);
|
||||
|
||||
const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" });
|
||||
const sharedDisabled = resolveChannelGroupPolicy({
|
||||
cfg: { channels: { irc: { groups } } },
|
||||
channel: "irc",
|
||||
groupId: "#hidden",
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed);
|
||||
expect(inboundDisabled.groupConfig?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
169
openclaw/extensions/irc/src/policy.ts
Normal file
169
openclaw/extensions/irc/src/policy.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import { normalizeIrcAllowlist, resolveIrcAllowlistMatch } from "./normalize.js";
|
||||
import type { IrcAccountConfig, IrcChannelConfig } from "./types.js";
|
||||
import type { IrcInboundMessage } from "./types.js";
|
||||
|
||||
export type IrcGroupMatch = {
|
||||
allowed: boolean;
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
hasConfiguredGroups: boolean;
|
||||
};
|
||||
|
||||
export type IrcGroupAccessGate = {
|
||||
allowed: boolean;
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function resolveIrcGroupMatch(params: {
|
||||
groups?: Record<string, IrcChannelConfig>;
|
||||
target: string;
|
||||
}): IrcGroupMatch {
|
||||
const groups = params.groups ?? {};
|
||||
const hasConfiguredGroups = Object.keys(groups).length > 0;
|
||||
|
||||
// IRC channel targets are case-insensitive, but config keys are plain strings.
|
||||
// To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match
|
||||
// group config keys case-insensitively.
|
||||
const direct = groups[params.target];
|
||||
if (direct) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
allowed: true,
|
||||
groupConfig: direct,
|
||||
wildcardConfig: groups["*"],
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
|
||||
const targetLower = 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 handled later by resolveIrcGroupAccessGate.
|
||||
allowed: true,
|
||||
groupConfig: matched,
|
||||
wildcardConfig: groups["*"],
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wildcard = groups["*"];
|
||||
if (wildcard) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are handled later by resolveIrcGroupAccessGate.
|
||||
allowed: true,
|
||||
wildcardConfig: wildcard,
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIrcGroupAccessGate(params: {
|
||||
groupPolicy: IrcAccountConfig["groupPolicy"];
|
||||
groupMatch: IrcGroupMatch;
|
||||
}): IrcGroupAccessGate {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
if (policy === "disabled") {
|
||||
return { allowed: false, reason: "groupPolicy=disabled" };
|
||||
}
|
||||
|
||||
// In open mode, unconfigured channels are allowed (mention-gated) but explicit
|
||||
// per-channel/wildcard disables still apply.
|
||||
if (policy === "allowlist") {
|
||||
if (!params.groupMatch.hasConfiguredGroups) {
|
||||
return {
|
||||
allowed: false,
|
||||
reason: "groupPolicy=allowlist and no groups configured",
|
||||
};
|
||||
}
|
||||
if (!params.groupMatch.allowed) {
|
||||
return { allowed: false, reason: "not allowlisted" };
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
params.groupMatch.groupConfig?.enabled === false ||
|
||||
params.groupMatch.wildcardConfig?.enabled === false
|
||||
) {
|
||||
return { allowed: false, reason: "disabled" };
|
||||
}
|
||||
|
||||
return { allowed: true, reason: policy === "open" ? "open" : "allowlisted" };
|
||||
}
|
||||
|
||||
export function resolveIrcRequireMention(params: {
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
}): boolean {
|
||||
if (params.groupConfig?.requireMention !== undefined) {
|
||||
return params.groupConfig.requireMention;
|
||||
}
|
||||
if (params.wildcardConfig?.requireMention !== undefined) {
|
||||
return params.wildcardConfig.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
export function resolveIrcMentionGate(params: {
|
||||
isGroup: boolean;
|
||||
requireMention: boolean;
|
||||
wasMentioned: boolean;
|
||||
hasControlCommand: boolean;
|
||||
allowTextCommands: boolean;
|
||||
commandAuthorized: boolean;
|
||||
}): { shouldSkip: boolean; reason: string } {
|
||||
if (!params.isGroup) {
|
||||
return { shouldSkip: false, reason: "direct" };
|
||||
}
|
||||
if (!params.requireMention) {
|
||||
return { shouldSkip: false, reason: "mention-not-required" };
|
||||
}
|
||||
if (params.wasMentioned) {
|
||||
return { shouldSkip: false, reason: "mentioned" };
|
||||
}
|
||||
if (params.hasControlCommand && params.allowTextCommands && params.commandAuthorized) {
|
||||
return { shouldSkip: false, reason: "authorized-command" };
|
||||
}
|
||||
return { shouldSkip: true, reason: "missing-mention" };
|
||||
}
|
||||
|
||||
export function resolveIrcGroupSenderAllowed(params: {
|
||||
groupPolicy: IrcAccountConfig["groupPolicy"];
|
||||
message: IrcInboundMessage;
|
||||
outerAllowFrom: string[];
|
||||
innerAllowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const policy = params.groupPolicy ?? "allowlist";
|
||||
const inner = normalizeIrcAllowlist(params.innerAllowFrom);
|
||||
const outer = normalizeIrcAllowlist(params.outerAllowFrom);
|
||||
|
||||
if (inner.length > 0) {
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: inner,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
if (outer.length > 0) {
|
||||
return resolveIrcAllowlistMatch({
|
||||
allowFrom: outer,
|
||||
message: params.message,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
}).allowed;
|
||||
}
|
||||
return policy === "open";
|
||||
}
|
||||
105
openclaw/extensions/irc/src/probe.test.ts
Normal file
105
openclaw/extensions/irc/src/probe.test.ts
Normal file
|
|
@ -0,0 +1,105 @@
|
|||
import { 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,
|
||||
}));
|
||||
|
||||
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 () => {
|
||||
resolveIrcAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
host: "irc.libera.chat",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
});
|
||||
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(
|
||||
expect.objectContaining({ host: "irc.libera.chat" }),
|
||||
{ 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" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
53
openclaw/extensions/irc/src/probe.ts
Normal file
53
openclaw/extensions/irc/src/probe.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
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),
|
||||
};
|
||||
}
|
||||
}
|
||||
44
openclaw/extensions/irc/src/protocol.test.ts
Normal file
44
openclaw/extensions/irc/src/protocol.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseIrcLine,
|
||||
parseIrcPrefix,
|
||||
sanitizeIrcOutboundText,
|
||||
sanitizeIrcTarget,
|
||||
splitIrcText,
|
||||
} from "./protocol.js";
|
||||
|
||||
describe("irc protocol", () => {
|
||||
it("parses PRIVMSG lines with prefix and trailing", () => {
|
||||
const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world");
|
||||
expect(parsed).toEqual({
|
||||
raw: ":alice!u@host PRIVMSG #room :hello world",
|
||||
prefix: "alice!u@host",
|
||||
command: "PRIVMSG",
|
||||
params: ["#room"],
|
||||
trailing: "hello world",
|
||||
});
|
||||
|
||||
expect(parseIrcPrefix(parsed?.prefix)).toEqual({
|
||||
nick: "alice",
|
||||
user: "u",
|
||||
host: "host",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes outbound text to prevent command injection", () => {
|
||||
expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops");
|
||||
expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test");
|
||||
});
|
||||
|
||||
it("validates targets and rejects control characters", () => {
|
||||
expect(sanitizeIrcTarget("#openclaw")).toBe("#openclaw");
|
||||
expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/);
|
||||
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
|
||||
});
|
||||
|
||||
it("splits long text on boundaries", () => {
|
||||
const chunks = splitIrcText("a ".repeat(300), 120);
|
||||
expect(chunks.length).toBeGreaterThan(2);
|
||||
expect(chunks.every((chunk) => chunk.length <= 120)).toBe(true);
|
||||
});
|
||||
});
|
||||
169
openclaw/extensions/irc/src/protocol.ts
Normal file
169
openclaw/extensions/irc/src/protocol.ts
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
import { randomUUID } from "node:crypto";
|
||||
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
|
||||
|
||||
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
|
||||
|
||||
export type ParsedIrcLine = {
|
||||
raw: string;
|
||||
prefix?: string;
|
||||
command: string;
|
||||
params: string[];
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
export type ParsedIrcPrefix = {
|
||||
nick?: string;
|
||||
user?: string;
|
||||
host?: string;
|
||||
server?: string;
|
||||
};
|
||||
|
||||
export function parseIrcLine(line: string): ParsedIrcLine | null {
|
||||
const raw = line.replace(/[\r\n]+/g, "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = raw;
|
||||
let prefix: string | undefined;
|
||||
if (cursor.startsWith(":")) {
|
||||
const idx = cursor.indexOf(" ");
|
||||
if (idx <= 1) {
|
||||
return null;
|
||||
}
|
||||
prefix = cursor.slice(1, idx);
|
||||
cursor = cursor.slice(idx + 1).trimStart();
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstSpace = cursor.indexOf(" ");
|
||||
const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim();
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1);
|
||||
const params: string[] = [];
|
||||
let trailing: string | undefined;
|
||||
|
||||
while (cursor.length > 0) {
|
||||
cursor = cursor.trimStart();
|
||||
if (!cursor) {
|
||||
break;
|
||||
}
|
||||
if (cursor.startsWith(":")) {
|
||||
trailing = cursor.slice(1);
|
||||
break;
|
||||
}
|
||||
const spaceIdx = cursor.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
params.push(cursor);
|
||||
break;
|
||||
}
|
||||
params.push(cursor.slice(0, spaceIdx));
|
||||
cursor = cursor.slice(spaceIdx + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
prefix,
|
||||
command: command.toUpperCase(),
|
||||
params,
|
||||
trailing,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
|
||||
if (!prefix) {
|
||||
return {};
|
||||
}
|
||||
const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/);
|
||||
if (nickPart) {
|
||||
return {
|
||||
nick: nickPart[1],
|
||||
user: nickPart[2],
|
||||
host: nickPart[3],
|
||||
};
|
||||
}
|
||||
const nickHostPart = prefix.match(/^([^@]+)@(.+)$/);
|
||||
if (nickHostPart) {
|
||||
return {
|
||||
nick: nickHostPart[1],
|
||||
host: nickHostPart[2],
|
||||
};
|
||||
}
|
||||
if (prefix.includes("!")) {
|
||||
const [nick, user] = prefix.split("!", 2);
|
||||
return { nick, user };
|
||||
}
|
||||
if (prefix.includes(".")) {
|
||||
return { server: prefix };
|
||||
}
|
||||
return { nick: prefix };
|
||||
}
|
||||
|
||||
function decodeLiteralEscapes(input: string): string {
|
||||
// Defensive: this is not a full JS string unescaper.
|
||||
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
|
||||
return input
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\0/g, "\0")
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
|
||||
}
|
||||
|
||||
export function sanitizeIrcOutboundText(text: string): string {
|
||||
const decoded = decodeLiteralEscapes(text);
|
||||
return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim();
|
||||
}
|
||||
|
||||
export function sanitizeIrcTarget(raw: string): string {
|
||||
const decoded = decodeLiteralEscapes(raw);
|
||||
if (!decoded) {
|
||||
throw new Error("IRC target is required");
|
||||
}
|
||||
// Reject any surrounding whitespace instead of trimming it away.
|
||||
if (decoded !== decoded.trim()) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
if (hasIrcControlChars(decoded)) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
if (!IRC_TARGET_PATTERN.test(decoded)) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function splitIrcText(text: string, maxChars = 350): string[] {
|
||||
const cleaned = sanitizeIrcOutboundText(text);
|
||||
if (!cleaned) {
|
||||
return [];
|
||||
}
|
||||
if (cleaned.length <= maxChars) {
|
||||
return [cleaned];
|
||||
}
|
||||
const chunks: string[] = [];
|
||||
let remaining = cleaned;
|
||||
while (remaining.length > maxChars) {
|
||||
let splitAt = remaining.lastIndexOf(" ", maxChars);
|
||||
if (splitAt < Math.floor(maxChars * 0.5)) {
|
||||
splitAt = maxChars;
|
||||
}
|
||||
chunks.push(remaining.slice(0, splitAt).trim());
|
||||
remaining = remaining.slice(splitAt).trimStart();
|
||||
}
|
||||
if (remaining) {
|
||||
chunks.push(remaining);
|
||||
}
|
||||
return chunks.filter(Boolean);
|
||||
}
|
||||
|
||||
export function makeIrcMessageId() {
|
||||
return randomUUID();
|
||||
}
|
||||
46
openclaw/extensions/irc/src/runtime-api.ts
Normal file
46
openclaw/extensions/irc/src/runtime-api.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// 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-runtime";
|
||||
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-runtime";
|
||||
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-lifecycle";
|
||||
export {
|
||||
readStoreAllowFromForDmPolicy,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth";
|
||||
export { dispatchInboundReplyWithBase } from "openclaw/plugin-sdk/inbound-reply-dispatch";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
deliverFormattedTextWithAttachments,
|
||||
formatTextWithAttachmentLinks,
|
||||
resolveOutboundMediaUrls,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
15
openclaw/extensions/irc/src/runtime.ts
Normal file
15
openclaw/extensions/irc/src/runtime.ts
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
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();
|
||||
}
|
||||
103
openclaw/extensions/irc/src/secret-contract.ts
Normal file
103
openclaw/extensions/irc/src/secret-contract.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
import {
|
||||
collectNestedChannelFieldAssignments,
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
isBaseFieldActiveForChannelSurface,
|
||||
isEnabledFlag,
|
||||
isRecord,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
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,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
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,
|
||||
};
|
||||
165
openclaw/extensions/irc/src/send.test.ts
Normal file
165
openclaw/extensions/irc/src/send.test.ts
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createSendCfgThreadingRuntime } from "../../../test/helpers/plugins/send-config.js";
|
||||
import type { IrcClient } from "./client.js";
|
||||
import { 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/config-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/config-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
...original,
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/text-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
...original,
|
||||
convertMarkdownTables: hoisted.convertMarkdownTables,
|
||||
};
|
||||
});
|
||||
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
|
||||
describe("sendMessageIrc cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
setIrcRuntime(createSendCfgThreadingRuntime(hoisted) as never);
|
||||
});
|
||||
|
||||
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).toEqual(expect.any(String));
|
||||
expect(result.messageId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("falls back to runtime config when cfg is omitted", async () => {
|
||||
const runtimeCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
await sendMessageIrc("#ops", "ping", { client });
|
||||
|
||||
expect(hoisted.loadConfig).toHaveBeenCalledTimes(1);
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#ops", "ping");
|
||||
expect(hoisted.record).toHaveBeenCalledWith({
|
||||
channel: "irc",
|
||||
accountId: "default",
|
||||
direction: "outbound",
|
||||
});
|
||||
});
|
||||
|
||||
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).toEqual(expect.any(String));
|
||||
expect(result.messageId.length).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
101
openclaw/extensions/irc/src/send.ts
Normal file
101
openclaw/extensions/irc/src/send.ts
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
|
||||
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;
|
||||
};
|
||||
|
||||
export type SendIrcResult = {
|
||||
messageId: string;
|
||||
target: string;
|
||||
};
|
||||
|
||||
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 runtime = getIrcRuntime();
|
||||
const cfg = (opts.cfg ?? runtime.config.loadConfig()) as CoreConfig;
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = resolveTarget(to, opts);
|
||||
const tableMode = 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,
|
||||
}),
|
||||
);
|
||||
transient.sendPrivmsg(target, payload);
|
||||
transient.quit("sent");
|
||||
}
|
||||
|
||||
recordIrcOutboundActivity(account.accountId);
|
||||
|
||||
return {
|
||||
messageId: makeIrcMessageId(),
|
||||
target,
|
||||
};
|
||||
}
|
||||
139
openclaw/extensions/irc/src/setup-core.ts
Normal file
139
openclaw/extensions/irc/src/setup-core.ts
Normal file
|
|
@ -0,0 +1,139 @@
|
|||
import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { DmPolicy } from "openclaw/plugin-sdk/config-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,
|
||||
});
|
||||
|
||||
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 = Number.parseInt(trimmed, 10);
|
||||
if (!Number.isFinite(parsed) || parsed < 1 || parsed > 65535) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
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.map((entry) => normalizeGroupEntry(entry)).filter(Boolean)),
|
||||
];
|
||||
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: createSetupInputPresenceValidator({
|
||||
whenNotUseEnv: [
|
||||
{ someOf: ["host"], message: "IRC requires host." },
|
||||
{ someOf: ["nick"], message: "IRC requires nick." },
|
||||
],
|
||||
}),
|
||||
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;
|
||||
},
|
||||
};
|
||||
441
openclaw/extensions/irc/src/setup-surface.ts
Normal file
441
openclaw/extensions/irc/src/setup-surface.ts
Normal file
|
|
@ -0,0 +1,441 @@
|
|||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import type {
|
||||
ChannelSetupDmPolicy,
|
||||
ChannelSetupWizard,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
createAllowFromSection,
|
||||
createPromptParsedAllowFromForAccount,
|
||||
createStandardChannelSetupStatus,
|
||||
formatDocsLink,
|
||||
setSetupChannelEnabled,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-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 channel = "irc" as const;
|
||||
const USE_ENV_FLAG = "__ircUseEnv";
|
||||
const TLS_FLAG = "__ircTls";
|
||||
|
||||
function parseListInput(raw: string): string[] {
|
||||
return raw
|
||||
.split(/[\n,;]+/g)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
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: "IRC allowlist",
|
||||
noteLines: [
|
||||
"Allowlist IRC DMs by sender.",
|
||||
"Examples:",
|
||||
"- alice",
|
||||
"- alice!ident@example.org",
|
||||
"Multiple entries: comma-separated.",
|
||||
],
|
||||
message: "IRC allowFrom (nick or nick!user@host)",
|
||||
placeholder: "alice, bob!ident@example.org",
|
||||
parseEntries: (raw) => ({
|
||||
entries: parseListInput(raw)
|
||||
.map((entry) => normalizeIrcAllowEntry(entry))
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean),
|
||||
}),
|
||||
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 ? "Update NickServ settings?" : "Configure NickServ identify/register?",
|
||||
initialValue: hasExisting,
|
||||
});
|
||||
if (!wants) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
const service = (
|
||||
await params.prompter.text({
|
||||
message: "NickServ service nick",
|
||||
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: "IRC_NICKSERV_PASSWORD detected. Use env var?",
|
||||
initialValue: true,
|
||||
})
|
||||
: false;
|
||||
|
||||
const password = useEnvPassword
|
||||
? undefined
|
||||
: (
|
||||
await params.prompter.text({
|
||||
message: "NickServ password (blank to disable NickServ auth)",
|
||||
validate: () => undefined,
|
||||
})
|
||||
).trim();
|
||||
|
||||
if (!password && !useEnvPassword) {
|
||||
return setIrcNickServ(params.cfg, params.accountId, {
|
||||
enabled: false,
|
||||
service,
|
||||
});
|
||||
}
|
||||
|
||||
const register = await params.prompter.confirm({
|
||||
message: "Send NickServ REGISTER on connect?",
|
||||
initialValue: existing?.register ?? false,
|
||||
});
|
||||
const registerEmail = register
|
||||
? (
|
||||
await params.prompter.text({
|
||||
message: "NickServ register email",
|
||||
initialValue:
|
||||
existing?.registerEmail ||
|
||||
(params.accountId === DEFAULT_ACCOUNT_ID
|
||||
? process.env.IRC_NICKSERV_REGISTER_EMAIL
|
||||
: undefined),
|
||||
validate: (value) => (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: "configured",
|
||||
unconfiguredLabel: "needs host + nick",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs host + nick",
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
|
||||
}),
|
||||
introNote: {
|
||||
title: "IRC setup",
|
||||
lines: [
|
||||
"IRC needs server host + bot nick.",
|
||||
"Recommended: TLS on port 6697.",
|
||||
"Optional: NickServ identify/register can be configured after the basic account fields.",
|
||||
'Set channels.irc.groupPolicy="allowlist" and channels.irc.groups for tighter channel control.',
|
||||
'Note: IRC channels are mention-gated by default. To allow unmentioned replies, set channels.irc.groups["#channel"].requireMention=false (or "*" for all).',
|
||||
"Env vars supported: IRC_HOST, IRC_PORT, IRC_TLS, IRC_NICK, IRC_USERNAME, IRC_REALNAME, IRC_PASSWORD, IRC_CHANNELS, IRC_NICKSERV_PASSWORD, IRC_NICKSERV_REGISTER_EMAIL.",
|
||||
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||
],
|
||||
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: "IRC_HOST and IRC_NICK detected. Use env vars?",
|
||||
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: "Use TLS for IRC?",
|
||||
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: "IRC server host",
|
||||
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: "IRC server port",
|
||||
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 parsed = Number.parseInt(normalizeStringifiedOptionalString(value) ?? "", 10);
|
||||
return Number.isFinite(parsed) && parsed >= 1 && 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: "IRC nick",
|
||||
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: "IRC username",
|
||||
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: "IRC real name",
|
||||
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: "Auto-join IRC channels (optional, comma-separated)",
|
||||
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 }) =>
|
||||
[...new Set(entries.map((entry) => normalizeGroupEntry(entry)).filter(Boolean))] as string[],
|
||||
applyAllowlist: ({ cfg, accountId, resolved }) =>
|
||||
setIrcGroupAccess(
|
||||
cfg as CoreConfig,
|
||||
accountId,
|
||||
"allowlist",
|
||||
resolved as string[],
|
||||
normalizeGroupEntry,
|
||||
),
|
||||
},
|
||||
allowFrom: createAllowFromSection({
|
||||
helpTitle: "IRC allowlist",
|
||||
helpLines: [
|
||||
"Allowlist IRC DMs by sender.",
|
||||
"Examples:",
|
||||
"- alice",
|
||||
"- alice!ident@example.org",
|
||||
"Multiple entries: comma-separated.",
|
||||
],
|
||||
message: "IRC allowFrom (nick or nick!user@host)",
|
||||
placeholder: "alice, bob!ident@example.org",
|
||||
invalidWithoutCredentialNote: "Use an IRC nick or nick!user@host entry.",
|
||||
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: "Require @mention to reply in IRC channels?",
|
||||
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: "IRC next steps",
|
||||
lines: [
|
||||
"Next: restart gateway and verify status.",
|
||||
"Command: openclaw channels status --probe",
|
||||
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||
],
|
||||
},
|
||||
dmPolicy: ircDmPolicy,
|
||||
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
|
||||
export { ircSetupAdapter };
|
||||
445
openclaw/extensions/irc/src/setup.test.ts
Normal file
445
openclaw/extensions/irc/src/setup.test.ts
Normal file
|
|
@ -0,0 +1,445 @@
|
|||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createPluginSetupWizardAdapter,
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
promptSetupWizardAllowFrom,
|
||||
runSetupWizardConfigure,
|
||||
type WizardPrompter,
|
||||
} from "../../../test/helpers/plugins/setup-wizard.js";
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
|
||||
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,
|
||||
};
|
||||
});
|
||||
|
||||
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")).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
dmPolicy: "open",
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toMatchObject({
|
||||
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",
|
||||
}),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
updateIrcAccountConfig(cfg, "work", {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
}),
|
||||
).toMatchObject({
|
||||
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}`;
|
||||
},
|
||||
),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"#openclaw": {},
|
||||
"#ops": {},
|
||||
"*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toMatchObject({
|
||||
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");
|
||||
|
||||
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(
|
||||
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("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",
|
||||
})) as CoreConfig;
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
});
|
||||
100
openclaw/extensions/irc/src/types.ts
Normal file
100
openclaw/extensions/irc/src/types.ts
Normal file
|
|
@ -0,0 +1,100 @@
|
|||
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;
|
||||
};
|
||||
|
||||
export 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;
|
||||
};
|
||||
16
openclaw/extensions/irc/tsconfig.json
Normal file
16
openclaw/extensions/irc/tsconfig.json
Normal 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"
|
||||
]
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue