Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
13
extensions/irc/README.md
Normal file
13
extensions/irc/README.md
Normal file
@@ -0,0 +1,13 @@
|
||||
# IRC OpenClaw channel
|
||||
|
||||
Official OpenClaw channel plugin for IRC.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
openclaw plugins install @openclaw/irc
|
||||
```
|
||||
|
||||
## Docs
|
||||
|
||||
See `docs/channels/irc.md` in the OpenClaw repository, or the published docs at `https://docs.openclaw.ai/channels/irc`.
|
||||
11
extensions/irc/api.ts
Normal file
11
extensions/irc/api.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// Irc API module exposes the plugin public contract.
|
||||
export { ircPlugin } from "./src/channel.js";
|
||||
export { setIrcRuntime } from "./src/runtime.js";
|
||||
export {
|
||||
listEnabledIrcAccounts,
|
||||
listIrcAccountIds,
|
||||
resolveDefaultIrcAccountId,
|
||||
type ResolvedIrcAccount,
|
||||
resolveIrcAccount,
|
||||
} from "./src/accounts.js";
|
||||
export { ircSetupAdapter, ircSetupWizard } from "./src/setup-surface.js";
|
||||
2
extensions/irc/channel-config-api.ts
Normal file
2
extensions/irc/channel-config-api.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
// Irc API module exposes the plugin public contract.
|
||||
export { IrcChannelConfigSchema } from "./src/config-schema.js";
|
||||
3
extensions/irc/channel-plugin-api.ts
Normal file
3
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";
|
||||
9
extensions/irc/configured-state.ts
Normal file
9
extensions/irc/configured-state.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Irc helper module supports configured state behavior.
|
||||
export function hasIrcConfiguredState(params: { env?: NodeJS.ProcessEnv }): boolean {
|
||||
return (
|
||||
typeof params.env?.IRC_HOST === "string" &&
|
||||
params.env.IRC_HOST.trim().length > 0 &&
|
||||
typeof params.env?.IRC_NICK === "string" &&
|
||||
params.env.IRC_NICK.trim().length > 0
|
||||
);
|
||||
}
|
||||
14
extensions/irc/index.test.ts
Normal file
14
extensions/irc/index.test.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Irc tests cover index plugin behavior.
|
||||
import { assertBundledChannelEntries } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { describe } from "vitest";
|
||||
import entry from "./index.js";
|
||||
import setupEntry from "./setup-entry.js";
|
||||
|
||||
describe("irc bundled entries", () => {
|
||||
assertBundledChannelEntries({
|
||||
entry,
|
||||
expectedId: "irc",
|
||||
expectedName: "IRC",
|
||||
setupEntry,
|
||||
});
|
||||
});
|
||||
21
extensions/irc/index.ts
Normal file
21
extensions/irc/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// Irc plugin entrypoint registers its OpenClaw integration.
|
||||
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelEntry({
|
||||
id: "irc",
|
||||
name: "IRC",
|
||||
description: "IRC channel plugin",
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "ircPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
runtime: {
|
||||
specifier: "./runtime-api.js",
|
||||
exportName: "setIrcRuntime",
|
||||
},
|
||||
});
|
||||
24
extensions/irc/npm-shrinkwrap.json
generated
Normal file
24
extensions/irc/npm-shrinkwrap.json
generated
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.6.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.6.11",
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
}
|
||||
},
|
||||
"node_modules/zod": {
|
||||
"version": "4.4.3",
|
||||
"resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
|
||||
"integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
26
extensions/irc/openclaw.plugin.json
Normal file
26
extensions/irc/openclaw.plugin.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"id": "irc",
|
||||
"activation": {
|
||||
"onStartup": false
|
||||
},
|
||||
"channels": ["irc"],
|
||||
"channelEnvVars": {
|
||||
"irc": [
|
||||
"IRC_HOST",
|
||||
"IRC_PORT",
|
||||
"IRC_TLS",
|
||||
"IRC_NICK",
|
||||
"IRC_USERNAME",
|
||||
"IRC_REALNAME",
|
||||
"IRC_PASSWORD",
|
||||
"IRC_CHANNELS",
|
||||
"IRC_NICKSERV_PASSWORD",
|
||||
"IRC_NICKSERV_REGISTER_EMAIL"
|
||||
]
|
||||
},
|
||||
"configSchema": {
|
||||
"type": "object",
|
||||
"additionalProperties": false,
|
||||
"properties": {}
|
||||
}
|
||||
}
|
||||
63
extensions/irc/package.json
Normal file
63
extensions/irc/package.json
Normal file
@@ -0,0 +1,63 @@
|
||||
{
|
||||
"name": "@openclaw/irc",
|
||||
"version": "2026.6.11",
|
||||
"description": "OpenClaw IRC channel plugin",
|
||||
"type": "module",
|
||||
"devDependencies": {
|
||||
"@openclaw/plugin-sdk": "workspace:*"
|
||||
},
|
||||
"openclaw": {
|
||||
"extensions": [
|
||||
"./index.ts"
|
||||
],
|
||||
"install": {
|
||||
"clawhubSpec": "clawhub:@openclaw/irc",
|
||||
"npmSpec": "@openclaw/irc",
|
||||
"defaultChoice": "npm",
|
||||
"minHostVersion": ">=2026.6.9",
|
||||
"allowInvalidConfigRecovery": true
|
||||
},
|
||||
"setupEntry": "./setup-entry.ts",
|
||||
"channel": {
|
||||
"id": "irc",
|
||||
"label": "IRC",
|
||||
"selectionLabel": "IRC (Server + Nick)",
|
||||
"detailLabel": "IRC",
|
||||
"docsPath": "/channels/irc",
|
||||
"docsLabel": "irc",
|
||||
"blurb": "classic IRC networks with DM/channel routing and pairing controls.",
|
||||
"aliases": [
|
||||
"internet-relay-chat"
|
||||
],
|
||||
"systemImage": "network",
|
||||
"configuredState": {
|
||||
"env": {
|
||||
"allOf": [
|
||||
"IRC_HOST",
|
||||
"IRC_NICK"
|
||||
]
|
||||
},
|
||||
"specifier": "./configured-state",
|
||||
"exportName": "hasIrcConfiguredState"
|
||||
}
|
||||
},
|
||||
"compat": {
|
||||
"pluginApi": ">=2026.6.11"
|
||||
},
|
||||
"build": {
|
||||
"openclawVersion": "2026.6.11",
|
||||
"bundledDist": false
|
||||
},
|
||||
"release": {
|
||||
"publishToClawHub": true,
|
||||
"publishToNpm": true
|
||||
}
|
||||
},
|
||||
"dependencies": {
|
||||
"zod": "4.4.3"
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/openclaw/openclaw"
|
||||
}
|
||||
}
|
||||
24
extensions/irc/runtime-api.test.ts
Normal file
24
extensions/irc/runtime-api.test.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// Irc tests cover runtime api plugin behavior.
|
||||
import { runDirectImportSmoke } from "openclaw/plugin-sdk/plugin-test-contracts";
|
||||
import { beforeAll, describe, expect, it } from "vitest";
|
||||
|
||||
describe("irc bundled api seams", () => {
|
||||
let directSmokeStdout = "";
|
||||
|
||||
beforeAll(async () => {
|
||||
directSmokeStdout = await runDirectImportSmoke(
|
||||
`const channel = await import("./extensions/irc/channel-plugin-api.ts");
|
||||
const runtime = await import("./extensions/irc/runtime-api.ts");
|
||||
process.stdout.write(JSON.stringify({
|
||||
channel: { keys: Object.keys(channel).sort(), id: channel.ircPlugin.id },
|
||||
runtime: { keys: Object.keys(runtime).sort(), type: typeof runtime.setIrcRuntime },
|
||||
}));`,
|
||||
);
|
||||
}, 45_000);
|
||||
|
||||
it("loads narrow public api modules in direct smoke", () => {
|
||||
expect(directSmokeStdout).toBe(
|
||||
'{"channel":{"keys":["ircPlugin"],"id":"irc"},"runtime":{"keys":["setIrcRuntime"],"type":"function"}}',
|
||||
);
|
||||
});
|
||||
});
|
||||
3
extensions/irc/runtime-api.ts
Normal file
3
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";
|
||||
6
extensions/irc/secret-contract-api.ts
Normal file
6
extensions/irc/secret-contract-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Irc API module exposes the plugin public contract.
|
||||
export {
|
||||
channelSecrets,
|
||||
collectRuntimeConfigAssignments,
|
||||
secretTargetRegistryEntries,
|
||||
} from "./src/secret-contract.js";
|
||||
14
extensions/irc/setup-entry.ts
Normal file
14
extensions/irc/setup-entry.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// Irc plugin module implements setup entry behavior.
|
||||
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
|
||||
|
||||
export default defineBundledChannelSetupEntry({
|
||||
importMetaUrl: import.meta.url,
|
||||
plugin: {
|
||||
specifier: "./channel-plugin-api.js",
|
||||
exportName: "ircPlugin",
|
||||
},
|
||||
secrets: {
|
||||
specifier: "./secret-contract-api.js",
|
||||
exportName: "channelSecrets",
|
||||
},
|
||||
});
|
||||
224
extensions/irc/src/accounts.test.ts
Normal file
224
extensions/irc/src/accounts.test.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
// Irc tests cover accounts plugin behavior.
|
||||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
function asConfig(value: unknown): CoreConfig {
|
||||
return value as CoreConfig;
|
||||
}
|
||||
|
||||
describe("listIrcAccountIds", () => {
|
||||
it("returns default when no accounts are configured", () => {
|
||||
expect(listIrcAccountIds(asConfig({}))).toEqual(["default"]);
|
||||
});
|
||||
|
||||
it("normalizes, deduplicates, and sorts configured account ids", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
"Ops Team": {},
|
||||
"ops-team": {},
|
||||
Work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(listIrcAccountIds(cfg)).toEqual(["ops-team", "work"]);
|
||||
});
|
||||
|
||||
it("keeps the implicit default account when named accounts are added to top-level connection config", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
accounts: {
|
||||
work: {
|
||||
enabled: false,
|
||||
host: "irc-work.example.com",
|
||||
nick: "claw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(listIrcAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultIrcAccountId(cfg)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveDefaultIrcAccountId", () => {
|
||||
it("prefers configured defaultAccount when it matches", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
defaultAccount: "Ops Team",
|
||||
accounts: {
|
||||
default: {},
|
||||
"ops-team": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultIrcAccountId(cfg)).toBe("ops-team");
|
||||
});
|
||||
|
||||
it("falls back to default when configured defaultAccount is missing", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: {},
|
||||
work: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultIrcAccountId(cfg)).toBe("default");
|
||||
});
|
||||
|
||||
it("falls back to first sorted account when default is absent", () => {
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
zzz: {},
|
||||
aaa: {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(resolveDefaultIrcAccountId(cfg)).toBe("aaa");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveIrcAccount", () => {
|
||||
it("matches normalized configured account ids", () => {
|
||||
const account = resolveIrcAccount({
|
||||
cfg: asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
"Ops Team": {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
accountId: "ops-team",
|
||||
});
|
||||
|
||||
expect(account.accountId).toBe("ops-team");
|
||||
expect(account.host).toBe("irc.example.com");
|
||||
expect(account.nick).toBe("claw");
|
||||
expect(account.configured).toBe(true);
|
||||
});
|
||||
|
||||
it("parses delimited IRC_CHANNELS env values for the default account", () => {
|
||||
vi.stubEnv("IRC_CHANNELS", "alpha, beta\ngamma; delta");
|
||||
|
||||
try {
|
||||
const account = resolveIrcAccount({
|
||||
cfg: asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
},
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
expect(account.config.channels).toEqual(["alpha", "beta", "gamma", "delta"]);
|
||||
} finally {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked password files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-account-"));
|
||||
const passwordFile = path.join(dir, "password.txt");
|
||||
const passwordLink = path.join(dir, "password-link.txt");
|
||||
fs.writeFileSync(passwordFile, "secret-pass\n", "utf8");
|
||||
fs.symlinkSync(passwordFile, passwordLink);
|
||||
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
passwordFile: passwordLink,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => resolveIrcAccount({ cfg })).toThrow(/IRC password file.*must not be a symlink/);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it.runIf(process.platform !== "win32")("rejects symlinked NickServ password files", () => {
|
||||
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-irc-nickserv-"));
|
||||
const passwordFile = path.join(dir, "nickserv-password.txt");
|
||||
const passwordLink = path.join(dir, "nickserv-password-link.txt");
|
||||
fs.writeFileSync(passwordFile, "nickserv-pass\n", "utf8");
|
||||
fs.symlinkSync(passwordFile, passwordLink);
|
||||
|
||||
const cfg = asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
nickserv: {
|
||||
passwordFile: passwordLink,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(() => resolveIrcAccount({ cfg })).toThrow(
|
||||
/IRC NickServ password file.*must not be a symlink/,
|
||||
);
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("preserves shared NickServ config when an account overrides one NickServ field", () => {
|
||||
const account = resolveIrcAccount({
|
||||
cfg: asConfig({
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "claw",
|
||||
nickserv: {
|
||||
service: "NickServ",
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
nickserv: {
|
||||
registerEmail: "work@example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(account.config.nickserv).toEqual({
|
||||
service: "NickServ",
|
||||
registerEmail: "work@example.com",
|
||||
});
|
||||
});
|
||||
});
|
||||
240
extensions/irc/src/accounts.ts
Normal file
240
extensions/irc/src/accounts.ts
Normal file
@@ -0,0 +1,240 @@
|
||||
// Irc plugin module implements accounts behavior.
|
||||
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
parseOptionalDelimitedEntries,
|
||||
tryReadSecretFileSync,
|
||||
} from "openclaw/plugin-sdk/channel-core";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
|
||||
|
||||
const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]);
|
||||
|
||||
export type ResolvedIrcAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
configured: boolean;
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
username: string;
|
||||
realname: string;
|
||||
password: string;
|
||||
passwordSource: "env" | "passwordFile" | "config" | "none";
|
||||
config: IrcAccountConfig;
|
||||
};
|
||||
|
||||
function parseTruthy(value?: string): boolean {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
return TRUTHY_ENV.has(normalizeLowercaseStringOrEmpty(value));
|
||||
}
|
||||
|
||||
function parseIntEnv(value?: string): number | undefined {
|
||||
if (!value?.trim()) {
|
||||
return undefined;
|
||||
}
|
||||
const parsed = parseStrictPositiveInteger(value);
|
||||
if (parsed === undefined || parsed > 65535) {
|
||||
return undefined;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } =
|
||||
createAccountListHelpers("irc", {
|
||||
normalizeAccountId,
|
||||
hasImplicitDefaultAccount: (cfg) =>
|
||||
Boolean(
|
||||
(cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) &&
|
||||
(cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()),
|
||||
),
|
||||
});
|
||||
export { listIrcAccountIds, resolveDefaultIrcAccountId };
|
||||
|
||||
function mergeIrcAccountConfig(cfg: CoreConfig, accountId: string): IrcAccountConfig {
|
||||
return resolveMergedAccountConfig<IrcAccountConfig>({
|
||||
channelConfig: cfg.channels?.irc as IrcAccountConfig | undefined,
|
||||
accounts: cfg.channels?.irc?.accounts as Record<string, Partial<IrcAccountConfig>> | undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
normalizeAccountId,
|
||||
nestedObjectKeys: ["nickserv"],
|
||||
});
|
||||
}
|
||||
|
||||
function resolvePassword(accountId: string, merged: IrcAccountConfig) {
|
||||
if (accountId === DEFAULT_ACCOUNT_ID) {
|
||||
const envPassword = process.env.IRC_PASSWORD?.trim();
|
||||
if (envPassword) {
|
||||
return { password: envPassword, source: "env" as const };
|
||||
}
|
||||
}
|
||||
|
||||
if (merged.passwordFile?.trim()) {
|
||||
const filePassword = tryReadSecretFileSync(merged.passwordFile, "IRC password file", {
|
||||
rejectSymlink: true,
|
||||
});
|
||||
if (filePassword) {
|
||||
return { password: filePassword, source: "passwordFile" as const };
|
||||
}
|
||||
}
|
||||
|
||||
const configPassword = normalizeResolvedSecretInputString({
|
||||
value: merged.password,
|
||||
path: `channels.irc.accounts.${accountId}.password`,
|
||||
});
|
||||
if (configPassword) {
|
||||
return { password: configPassword, source: "config" as const };
|
||||
}
|
||||
|
||||
return { password: "", source: "none" as const };
|
||||
}
|
||||
|
||||
function resolveNickServConfig(accountId: string, nickserv?: IrcNickServConfig): IrcNickServConfig {
|
||||
const base = nickserv ?? {};
|
||||
const envPassword =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_PASSWORD?.trim() : undefined;
|
||||
const envRegisterEmail =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICKSERV_REGISTER_EMAIL?.trim() : undefined;
|
||||
|
||||
const passwordFile = base.passwordFile?.trim();
|
||||
let resolvedPassword =
|
||||
normalizeResolvedSecretInputString({
|
||||
value: base.password,
|
||||
path: `channels.irc.accounts.${accountId}.nickserv.password`,
|
||||
}) ||
|
||||
envPassword ||
|
||||
"";
|
||||
if (!resolvedPassword && passwordFile) {
|
||||
resolvedPassword =
|
||||
tryReadSecretFileSync(passwordFile, "IRC NickServ password file", {
|
||||
rejectSymlink: true,
|
||||
}) ?? "";
|
||||
}
|
||||
|
||||
const merged: IrcNickServConfig = {
|
||||
...base,
|
||||
service: normalizeOptionalString(base.service),
|
||||
passwordFile: passwordFile || undefined,
|
||||
password: resolvedPassword || undefined,
|
||||
registerEmail: base.registerEmail?.trim() || envRegisterEmail || undefined,
|
||||
};
|
||||
return merged;
|
||||
}
|
||||
|
||||
export function resolveIrcAccount(params: {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string | null;
|
||||
}): ResolvedIrcAccount {
|
||||
const hasExplicitAccountId = Boolean(params.accountId?.trim());
|
||||
const baseEnabled = params.cfg.channels?.irc?.enabled !== false;
|
||||
|
||||
const resolve = (accountId: string) => {
|
||||
const merged = mergeIrcAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const tls =
|
||||
typeof merged.tls === "boolean"
|
||||
? merged.tls
|
||||
: accountId === DEFAULT_ACCOUNT_ID && process.env.IRC_TLS
|
||||
? parseTruthy(process.env.IRC_TLS)
|
||||
: true;
|
||||
|
||||
const envPort =
|
||||
accountId === DEFAULT_ACCOUNT_ID ? parseIntEnv(process.env.IRC_PORT) : undefined;
|
||||
const port = merged.port ?? envPort ?? (tls ? 6697 : 6667);
|
||||
const envChannels =
|
||||
accountId === DEFAULT_ACCOUNT_ID
|
||||
? parseOptionalDelimitedEntries(process.env.IRC_CHANNELS)
|
||||
: undefined;
|
||||
|
||||
const host = (
|
||||
merged.host?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_HOST?.trim() : "") ||
|
||||
""
|
||||
).trim();
|
||||
const nick = (
|
||||
merged.nick?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_NICK?.trim() : "") ||
|
||||
""
|
||||
).trim();
|
||||
const username = (
|
||||
merged.username?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_USERNAME?.trim() : "") ||
|
||||
nick ||
|
||||
"openclaw"
|
||||
).trim();
|
||||
const realname = (
|
||||
merged.realname?.trim() ||
|
||||
(accountId === DEFAULT_ACCOUNT_ID ? process.env.IRC_REALNAME?.trim() : "") ||
|
||||
"OpenClaw"
|
||||
).trim();
|
||||
|
||||
const passwordResolution = resolvePassword(accountId, merged);
|
||||
const nickserv = resolveNickServConfig(accountId, merged.nickserv);
|
||||
|
||||
const config: IrcAccountConfig = {
|
||||
...merged,
|
||||
channels: merged.channels ?? envChannels,
|
||||
tls,
|
||||
port,
|
||||
host,
|
||||
nick,
|
||||
username,
|
||||
realname,
|
||||
nickserv,
|
||||
};
|
||||
|
||||
return {
|
||||
accountId,
|
||||
enabled,
|
||||
name: normalizeOptionalString(merged.name),
|
||||
configured: Boolean(host && nick),
|
||||
host,
|
||||
port,
|
||||
tls,
|
||||
nick,
|
||||
username,
|
||||
realname,
|
||||
password: passwordResolution.password,
|
||||
passwordSource: passwordResolution.source,
|
||||
config,
|
||||
} satisfies ResolvedIrcAccount;
|
||||
};
|
||||
|
||||
const normalized = normalizeAccountId(params.accountId);
|
||||
const primary = resolve(normalized);
|
||||
if (hasExplicitAccountId) {
|
||||
return primary;
|
||||
}
|
||||
if (primary.configured) {
|
||||
return primary;
|
||||
}
|
||||
|
||||
const fallbackId = resolveDefaultIrcAccountId(params.cfg);
|
||||
if (fallbackId === primary.accountId) {
|
||||
return primary;
|
||||
}
|
||||
const fallback = resolve(fallbackId);
|
||||
if (!fallback.configured) {
|
||||
return primary;
|
||||
}
|
||||
return fallback;
|
||||
}
|
||||
|
||||
export function listEnabledIrcAccounts(cfg: CoreConfig): ResolvedIrcAccount[] {
|
||||
return listIrcAccountIds(cfg)
|
||||
.map((accountId) => resolveIrcAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
7
extensions/irc/src/channel-api.ts
Normal file
7
extensions/irc/src/channel-api.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
// Irc API module exposes the plugin public contract.
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
|
||||
export { buildBaseChannelStatusSummary } from "openclaw/plugin-sdk/status-helpers";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
4
extensions/irc/src/channel-runtime.ts
Normal file
4
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";
|
||||
35
extensions/irc/src/channel.test.ts
Normal file
35
extensions/irc/src/channel.test.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
// Irc tests cover channel plugin behavior.
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { ircOutboundBaseAdapter } from "./outbound-base.js";
|
||||
import { clearIrcRuntime } from "./runtime.js";
|
||||
|
||||
describe("irc outbound chunking", () => {
|
||||
afterEach(() => {
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("chunks outbound text without requiring IRC runtime initialization", () => {
|
||||
expect(ircOutboundBaseAdapter.chunker("alpha beta", 5)).toEqual(["alpha", "beta"]);
|
||||
expect(ircOutboundBaseAdapter.deliveryMode).toBe("direct");
|
||||
expect(ircOutboundBaseAdapter.chunkerMode).toBe("markdown");
|
||||
expect(ircOutboundBaseAdapter.textChunkLimit).toBe(350);
|
||||
});
|
||||
});
|
||||
|
||||
describe("irc outbound sanitizeText", () => {
|
||||
afterEach(() => {
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("strips internal tool-trace banners before outbound delivery", () => {
|
||||
const text = "Done.\n⚠️ 🛠️ `search repos (agent)` failed";
|
||||
|
||||
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe("Done.");
|
||||
});
|
||||
|
||||
it("preserves ordinary assistant prose while sanitizing", () => {
|
||||
const text = "The pipeline has 3 open deals.";
|
||||
|
||||
expect(ircOutboundBaseAdapter.sanitizeText({ text })).toBe(text);
|
||||
});
|
||||
});
|
||||
361
extensions/irc/src/channel.ts
Normal file
361
extensions/irc/src/channel.ts
Normal file
@@ -0,0 +1,361 @@
|
||||
// Irc plugin module implements channel behavior.
|
||||
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
|
||||
import {
|
||||
adaptScopedAccountAccessor,
|
||||
createScopedChannelConfigAdapter,
|
||||
createScopedDmSecurityResolver,
|
||||
} from "openclaw/plugin-sdk/channel-config-helpers";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import {
|
||||
composeAccountWarningCollectors,
|
||||
createAllowlistProviderOpenWarningCollector,
|
||||
} from "openclaw/plugin-sdk/channel-policy";
|
||||
import {
|
||||
createChannelDirectoryAdapter,
|
||||
createResolvedDirectoryEntriesLister,
|
||||
} from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import {
|
||||
listIrcAccountIds,
|
||||
resolveDefaultIrcAccountId,
|
||||
resolveIrcAccount,
|
||||
type ResolvedIrcAccount,
|
||||
} from "./accounts.js";
|
||||
import {
|
||||
buildBaseChannelStatusSummary,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
type ChannelPlugin,
|
||||
} from "./channel-api.js";
|
||||
import { IrcChannelConfigSchema } from "./config-schema.js";
|
||||
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
|
||||
import { startIrcGatewayAccount } from "./gateway.js";
|
||||
import { ircMessageAdapter } from "./message-adapter.js";
|
||||
import {
|
||||
isChannelTarget,
|
||||
looksLikeIrcTargetId,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
import { ircOutboundBaseAdapter } from "./outbound-base.js";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
import { probeIrc } from "./probe.js";
|
||||
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
|
||||
import { ircSetupAdapter } from "./setup-core.js";
|
||||
import { ircSetupWizard } from "./setup-surface.js";
|
||||
import type { CoreConfig, IrcProbe } from "./types.js";
|
||||
|
||||
const meta = {
|
||||
id: "irc",
|
||||
label: "IRC",
|
||||
selectionLabel: "IRC (Server + Nick)",
|
||||
docsPath: "/channels/irc",
|
||||
docsLabel: "irc",
|
||||
blurb: "classic IRC networks; host, nick, channels.",
|
||||
order: 80,
|
||||
detailLabel: "IRC",
|
||||
systemImage: "number",
|
||||
markdownCapable: true,
|
||||
};
|
||||
|
||||
const loadIrcChannelRuntime = createLazyRuntimeModule(() => import("./channel-runtime.js"));
|
||||
|
||||
function normalizePairingTarget(raw: string): string {
|
||||
const normalized = normalizeIrcAllowEntry(raw);
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
return normalized.split(/[!@]/, 1)[0]?.trim() ?? "";
|
||||
}
|
||||
|
||||
const listIrcDirectoryPeersFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
|
||||
kind: "user",
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
|
||||
resolveSources: (account) => [
|
||||
account.config.allowFrom ?? [],
|
||||
account.config.groupAllowFrom ?? [],
|
||||
...Object.values(account.config.groups ?? {}).map((group) => group.allowFrom ?? []),
|
||||
],
|
||||
normalizeId: (entry) => normalizePairingTarget(entry) || null,
|
||||
});
|
||||
|
||||
const listIrcDirectoryGroupsFromConfig = createResolvedDirectoryEntriesLister<ResolvedIrcAccount>({
|
||||
kind: "group",
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
|
||||
resolveSources: (account) => [
|
||||
account.config.channels ?? [],
|
||||
Object.keys(account.config.groups ?? {}),
|
||||
],
|
||||
normalizeId: (entry) => {
|
||||
const normalized = normalizeIrcMessagingTarget(entry);
|
||||
return normalized && isChannelTarget(normalized) ? normalized : null;
|
||||
},
|
||||
});
|
||||
|
||||
const ircConfigAdapter = createScopedChannelConfigAdapter<ResolvedIrcAccount, ResolvedIrcAccount>({
|
||||
sectionKey: "irc",
|
||||
listAccountIds: listIrcAccountIds,
|
||||
resolveAccount: adaptScopedAccountAccessor(resolveIrcAccount),
|
||||
defaultAccountId: resolveDefaultIrcAccountId,
|
||||
clearBaseFields: [
|
||||
"name",
|
||||
"host",
|
||||
"port",
|
||||
"tls",
|
||||
"nick",
|
||||
"username",
|
||||
"realname",
|
||||
"password",
|
||||
"passwordFile",
|
||||
"channels",
|
||||
],
|
||||
resolveAllowFrom: (account: ResolvedIrcAccount) => account.config.allowFrom,
|
||||
formatAllowFrom: (allowFrom) =>
|
||||
formatNormalizedAllowFromEntries({
|
||||
allowFrom,
|
||||
normalizeEntry: normalizeIrcAllowEntry,
|
||||
}),
|
||||
resolveDefaultTo: (account: ResolvedIrcAccount) => account.config.defaultTo,
|
||||
});
|
||||
|
||||
const resolveIrcDmPolicy = createScopedDmSecurityResolver<ResolvedIrcAccount>({
|
||||
channelKey: "irc",
|
||||
resolvePolicy: (account) => account.config.dmPolicy,
|
||||
resolveAllowFrom: (account) => account.config.allowFrom,
|
||||
policyPathSuffix: "dmPolicy",
|
||||
normalizeEntry: (raw) => normalizeIrcAllowEntry(raw),
|
||||
});
|
||||
|
||||
const collectIrcGroupPolicyWarnings =
|
||||
createAllowlistProviderOpenWarningCollector<ResolvedIrcAccount>({
|
||||
providerConfigPresent: (cfg) => cfg.channels?.irc !== undefined,
|
||||
resolveGroupPolicy: (account) => account.config.groupPolicy,
|
||||
buildOpenWarning: {
|
||||
surface: "IRC channels",
|
||||
openBehavior: "allows all channels and senders (mention-gated)",
|
||||
remediation: 'Prefer channels.irc.groupPolicy="allowlist" with channels.irc.groups',
|
||||
},
|
||||
});
|
||||
|
||||
const collectIrcSecurityWarnings = composeAccountWarningCollectors<
|
||||
ResolvedIrcAccount,
|
||||
{
|
||||
account: ResolvedIrcAccount;
|
||||
cfg: CoreConfig;
|
||||
}
|
||||
>(
|
||||
collectIrcGroupPolicyWarnings,
|
||||
(account) =>
|
||||
!account.config.tls &&
|
||||
"- IRC TLS is disabled (channels.irc.tls=false); traffic and credentials are plaintext.",
|
||||
(account) =>
|
||||
account.config.nickserv?.register &&
|
||||
'- IRC NickServ registration is enabled (channels.irc.nickserv.register=true); this sends "REGISTER" on every connect. Disable after first successful registration.',
|
||||
(account) =>
|
||||
account.config.nickserv?.register &&
|
||||
!account.config.nickserv.password?.trim() &&
|
||||
"- IRC NickServ registration is enabled but no NickServ password is resolved; set channels.irc.nickserv.password, channels.irc.nickserv.passwordFile, or IRC_NICKSERV_PASSWORD.",
|
||||
);
|
||||
|
||||
export const ircPlugin: ChannelPlugin<ResolvedIrcAccount, IrcProbe> = createChatChannelPlugin({
|
||||
base: {
|
||||
id: "irc",
|
||||
meta: {
|
||||
...meta,
|
||||
quickstartAllowFrom: true,
|
||||
},
|
||||
setup: ircSetupAdapter,
|
||||
setupWizard: ircSetupWizard,
|
||||
capabilities: {
|
||||
chatTypes: ["direct", "group"],
|
||||
media: true,
|
||||
blockStreaming: true,
|
||||
},
|
||||
reload: { configPrefixes: ["channels.irc"] },
|
||||
configSchema: IrcChannelConfigSchema,
|
||||
config: {
|
||||
...ircConfigAdapter,
|
||||
hasConfiguredState: ({ env }) =>
|
||||
typeof env?.IRC_HOST === "string" &&
|
||||
env.IRC_HOST.trim().length > 0 &&
|
||||
typeof env?.IRC_NICK === "string" &&
|
||||
env.IRC_NICK.trim().length > 0,
|
||||
isConfigured: (account) => account.configured,
|
||||
describeAccount: (account) =>
|
||||
describeAccountSnapshot({
|
||||
account,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
passwordSource: account.passwordSource,
|
||||
},
|
||||
}),
|
||||
},
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
doctor: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
collectMutableAllowlistWarnings: collectIrcMutableAllowlistWarnings,
|
||||
},
|
||||
groups: {
|
||||
resolveRequireMention: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
if (!groupId) {
|
||||
return true;
|
||||
}
|
||||
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
|
||||
return resolveIrcRequireMention({
|
||||
groupConfig: match.groupConfig,
|
||||
wildcardConfig: match.wildcardConfig,
|
||||
});
|
||||
},
|
||||
resolveToolPolicy: ({ cfg, accountId, groupId }) => {
|
||||
const account = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
if (!groupId) {
|
||||
return undefined;
|
||||
}
|
||||
const match = resolveIrcGroupMatch({ groups: account.config.groups, target: groupId });
|
||||
return match.groupConfig?.tools ?? match.wildcardConfig?.tools;
|
||||
},
|
||||
},
|
||||
messaging: {
|
||||
targetPrefixes: ["irc"],
|
||||
normalizeTarget: normalizeIrcMessagingTarget,
|
||||
targetResolver: {
|
||||
looksLikeId: looksLikeIrcTargetId,
|
||||
hint: "<#channel|nick>",
|
||||
},
|
||||
},
|
||||
message: ircMessageAdapter,
|
||||
resolver: {
|
||||
resolveTargets: async ({ inputs, kind }) => {
|
||||
return inputs.map((input) => {
|
||||
const normalized = normalizeIrcMessagingTarget(input);
|
||||
if (!normalized) {
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "invalid IRC target",
|
||||
};
|
||||
}
|
||||
if (kind === "group") {
|
||||
const groupId = isChannelTarget(normalized) ? normalized : `#${normalized}`;
|
||||
return {
|
||||
input,
|
||||
resolved: true,
|
||||
id: groupId,
|
||||
name: groupId,
|
||||
};
|
||||
}
|
||||
if (isChannelTarget(normalized)) {
|
||||
return {
|
||||
input,
|
||||
resolved: false,
|
||||
note: "expected user target",
|
||||
};
|
||||
}
|
||||
return {
|
||||
input,
|
||||
resolved: true,
|
||||
id: normalized,
|
||||
name: normalized,
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listPeers: async (params) => listIrcDirectoryPeersFromConfig(params),
|
||||
listGroups: async (params) => {
|
||||
const entries = await listIrcDirectoryGroupsFromConfig(params);
|
||||
return entries.map((entry) => Object.assign({}, entry, { name: entry.id }));
|
||||
},
|
||||
}),
|
||||
status: createComputedAccountStatusAdapter<ResolvedIrcAccount, IrcProbe>({
|
||||
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
|
||||
buildChannelSummary: ({ account, snapshot }) => ({
|
||||
...buildBaseChannelStatusSummary(snapshot),
|
||||
host: account.host,
|
||||
port: snapshot.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
probe: snapshot.probe,
|
||||
lastProbeAt: snapshot.lastProbeAt ?? null,
|
||||
}),
|
||||
probeAccount: async ({ cfg, account, timeoutMs }) =>
|
||||
probeIrc(cfg as CoreConfig, { accountId: account.accountId, timeoutMs }),
|
||||
resolveAccountSnapshot: ({ account }) => ({
|
||||
accountId: account.accountId,
|
||||
name: account.name,
|
||||
enabled: account.enabled,
|
||||
configured: account.configured,
|
||||
extra: {
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
passwordSource: account.passwordSource,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
gateway: {
|
||||
startAccount: async (ctx) =>
|
||||
await startIrcGatewayAccount({
|
||||
...ctx,
|
||||
cfg: ctx.cfg as CoreConfig,
|
||||
}),
|
||||
},
|
||||
},
|
||||
pairing: {
|
||||
text: {
|
||||
idLabel: "ircUser",
|
||||
message: PAIRING_APPROVED_MESSAGE,
|
||||
normalizeAllowEntry: (entry) => normalizeIrcAllowEntry(entry),
|
||||
notify: async ({ cfg, id, message }) => {
|
||||
const target = normalizePairingTarget(id);
|
||||
if (!target) {
|
||||
throw new Error(`invalid IRC pairing id: ${id}`);
|
||||
}
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
await sendMessageIrc(target, message, {
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
security: {
|
||||
resolveDmPolicy: resolveIrcDmPolicy,
|
||||
collectWarnings: collectIrcSecurityWarnings,
|
||||
},
|
||||
outbound: {
|
||||
base: ircOutboundBaseAdapter,
|
||||
attachedResults: {
|
||||
channel: "irc",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId }) => {
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
return await sendMessageIrc(to, text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
|
||||
const { sendMessageIrc } = await loadIrcChannelRuntime();
|
||||
return await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
79
extensions/irc/src/client.test.ts
Normal file
79
extensions/irc/src/client.test.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Irc tests cover client plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildFallbackNick, buildIrcNickServCommands } from "./client.js";
|
||||
|
||||
describe("irc client nickserv", () => {
|
||||
it("builds IDENTIFY command when password is set", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
}),
|
||||
).toEqual(["PRIVMSG NickServ :IDENTIFY secret"]);
|
||||
});
|
||||
|
||||
it("builds REGISTER command when enabled with email", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
register: true,
|
||||
registerEmail: "bot@example.com",
|
||||
}),
|
||||
).toEqual([
|
||||
"PRIVMSG NickServ :IDENTIFY secret",
|
||||
"PRIVMSG NickServ :REGISTER secret bot@example.com",
|
||||
]);
|
||||
});
|
||||
|
||||
it("rejects register without registerEmail", () => {
|
||||
expect(() =>
|
||||
buildIrcNickServCommands({
|
||||
password: "secret",
|
||||
register: true,
|
||||
}),
|
||||
).toThrow(/registerEmail/);
|
||||
});
|
||||
|
||||
it("sanitizes outbound NickServ payloads", () => {
|
||||
expect(
|
||||
buildIrcNickServCommands({
|
||||
service: "NickServ\n",
|
||||
password: "secret\r\nJOIN #bad",
|
||||
}),
|
||||
).toEqual(["PRIVMSG NickServ :IDENTIFY secret JOIN #bad"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("irc client fallback nick", () => {
|
||||
it("produces unique fallback nicks across sequential calls", () => {
|
||||
const first = buildFallbackNick("bot");
|
||||
const second = buildFallbackNick("bot");
|
||||
const third = buildFallbackNick("bot");
|
||||
// First call gets suffix _ (seq=1), subsequent calls get _2, _3, ...
|
||||
expect(first).toBe("bot_");
|
||||
expect(second).toMatch(/^bot_\d+$/);
|
||||
expect(third).toMatch(/^bot_\d+$/);
|
||||
expect(new Set([first, second, third]).size).toBe(3);
|
||||
});
|
||||
|
||||
it("sanitizes whitespace and special characters in nick", () => {
|
||||
const nick = buildFallbackNick("my bot!");
|
||||
expect(nick).toMatch(/^mybot_\d*$/);
|
||||
});
|
||||
|
||||
it("falls back to openclaw when nick consists entirely of special characters", () => {
|
||||
const nick = buildFallbackNick("!!!");
|
||||
expect(nick).toMatch(/^openclaw_\d*$/);
|
||||
});
|
||||
|
||||
it("falls back to openclaw when nick is empty after sanitization", () => {
|
||||
const nick = buildFallbackNick("");
|
||||
expect(nick).toMatch(/^openclaw_\d*$/);
|
||||
});
|
||||
|
||||
it("truncates long nicks to max 30 chars", () => {
|
||||
const longNick = "a".repeat(50);
|
||||
const nick = buildFallbackNick(longNick);
|
||||
expect(nick.length).toBeLessThanOrEqual(30);
|
||||
expect(nick).toMatch(/^a+_\d*$/);
|
||||
});
|
||||
});
|
||||
446
extensions/irc/src/client.ts
Normal file
446
extensions/irc/src/client.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
// Irc plugin module implements client behavior.
|
||||
import net from "node:net";
|
||||
import tls from "node:tls";
|
||||
import { withTimeout } from "openclaw/plugin-sdk/security-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
parseIrcLine,
|
||||
parseIrcPrefix,
|
||||
sanitizeIrcOutboundText,
|
||||
sanitizeIrcTarget,
|
||||
} from "./protocol.js";
|
||||
|
||||
const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
|
||||
const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
|
||||
|
||||
type IrcPrivmsgEvent = {
|
||||
senderNick: string;
|
||||
senderUser?: string;
|
||||
senderHost?: string;
|
||||
target: string;
|
||||
text: string;
|
||||
rawLine: string;
|
||||
};
|
||||
|
||||
export type IrcClientOptions = {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
username: string;
|
||||
realname: string;
|
||||
password?: string;
|
||||
nickserv?: IrcNickServOptions;
|
||||
channels?: string[];
|
||||
connectTimeoutMs?: number;
|
||||
messageChunkMaxChars?: number;
|
||||
abortSignal?: AbortSignal;
|
||||
onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>;
|
||||
onNotice?: (text: string, target?: string) => void;
|
||||
onError?: (error: Error) => void;
|
||||
onLine?: (line: string) => void;
|
||||
};
|
||||
|
||||
type IrcNickServOptions = {
|
||||
enabled?: boolean;
|
||||
service?: string;
|
||||
password?: string;
|
||||
register?: boolean;
|
||||
registerEmail?: string;
|
||||
};
|
||||
|
||||
export type IrcClient = {
|
||||
nick: string;
|
||||
isReady: () => boolean;
|
||||
sendRaw: (line: string) => void;
|
||||
join: (channel: string) => void;
|
||||
sendPrivmsg: (target: string, text: string) => void;
|
||||
quit: (reason?: string) => void;
|
||||
close: () => void;
|
||||
};
|
||||
|
||||
function toError(err: unknown): Error {
|
||||
if (err instanceof Error) {
|
||||
return err;
|
||||
}
|
||||
return new Error(typeof err === "string" ? err : JSON.stringify(err));
|
||||
}
|
||||
|
||||
let nickCollisionFallbackSeq = 0;
|
||||
|
||||
export function buildFallbackNick(nick: string): string {
|
||||
const normalized = nick.replace(/\s+/g, "");
|
||||
const safe = normalized.replace(/[^A-Za-z0-9_\-[\]\\`^{}|]/g, "");
|
||||
const base = safe || "openclaw";
|
||||
const seq = ++nickCollisionFallbackSeq;
|
||||
const suffix = seq === 1 ? "_" : `_${seq}`;
|
||||
const maxNickLen = 30;
|
||||
if (base.length >= maxNickLen) {
|
||||
return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
|
||||
}
|
||||
return `${base}${suffix}`;
|
||||
}
|
||||
|
||||
function normalizeIrcNick(value: string): string {
|
||||
return normalizeLowercaseStringOrEmpty(value);
|
||||
}
|
||||
|
||||
export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
|
||||
if (!options || options.enabled === false) {
|
||||
return [];
|
||||
}
|
||||
const password = sanitizeIrcOutboundText(options.password ?? "");
|
||||
if (!password) {
|
||||
return [];
|
||||
}
|
||||
const service = sanitizeIrcTarget(options.service?.trim() || "NickServ");
|
||||
const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`];
|
||||
if (options.register) {
|
||||
const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? "");
|
||||
if (!registerEmail) {
|
||||
throw new Error("IRC NickServ register requires registerEmail");
|
||||
}
|
||||
commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`);
|
||||
}
|
||||
return commands;
|
||||
}
|
||||
|
||||
export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
|
||||
const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
|
||||
const messageChunkMaxChars =
|
||||
options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350;
|
||||
|
||||
if (!options.host.trim()) {
|
||||
throw new Error("IRC host is required");
|
||||
}
|
||||
if (!options.nick.trim()) {
|
||||
throw new Error("IRC nick is required");
|
||||
}
|
||||
|
||||
const desiredNick = options.nick.trim();
|
||||
let currentNick = desiredNick;
|
||||
let ready = false;
|
||||
let closed = false;
|
||||
let nickServRecoverAttempted = false;
|
||||
let fallbackNickAttempted = false;
|
||||
let removeAbortListener: (() => void) | null = null;
|
||||
|
||||
const socket = options.tls
|
||||
? tls.connect({
|
||||
host: options.host,
|
||||
port: options.port,
|
||||
servername: options.host,
|
||||
})
|
||||
: net.connect({ host: options.host, port: options.port });
|
||||
|
||||
socket.setEncoding("utf8");
|
||||
|
||||
let resolveReady: (() => void) | null = null;
|
||||
let rejectReady: ((error: Error) => void) | null = null;
|
||||
const readyPromise = new Promise<void>((resolve, reject) => {
|
||||
resolveReady = resolve;
|
||||
rejectReady = reject;
|
||||
});
|
||||
|
||||
const fail = (err: unknown) => {
|
||||
const error = toError(err);
|
||||
if (options.onError) {
|
||||
options.onError(error);
|
||||
}
|
||||
if (!ready && rejectReady) {
|
||||
rejectReady(error);
|
||||
rejectReady = null;
|
||||
resolveReady = null;
|
||||
}
|
||||
};
|
||||
|
||||
const failAndClose = (err: unknown) => {
|
||||
fail(err);
|
||||
close();
|
||||
};
|
||||
|
||||
const sendRaw = (line: string) => {
|
||||
const cleaned = line.replace(/[\r\n]+/g, "").trim();
|
||||
if (!cleaned) {
|
||||
throw new Error("IRC command cannot be empty");
|
||||
}
|
||||
socket.write(`${cleaned}\r\n`);
|
||||
};
|
||||
|
||||
const tryRecoverNickCollision = (): boolean => {
|
||||
const nickServEnabled = options.nickserv?.enabled !== false;
|
||||
const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? "");
|
||||
if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) {
|
||||
nickServRecoverAttempted = true;
|
||||
try {
|
||||
const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ");
|
||||
sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`);
|
||||
sendRaw(`NICK ${desiredNick}`);
|
||||
return true;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
|
||||
if (!fallbackNickAttempted) {
|
||||
fallbackNickAttempted = true;
|
||||
const fallbackNick = buildFallbackNick(desiredNick);
|
||||
if (normalizeIrcNick(fallbackNick) !== normalizeIrcNick(currentNick)) {
|
||||
try {
|
||||
sendRaw(`NICK ${fallbackNick}`);
|
||||
currentNick = fallbackNick;
|
||||
return true;
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const join = (channel: string) => {
|
||||
const target = sanitizeIrcTarget(channel);
|
||||
if (!target.startsWith("#") && !target.startsWith("&")) {
|
||||
throw new Error(`IRC JOIN target must be a channel: ${channel}`);
|
||||
}
|
||||
sendRaw(`JOIN ${target}`);
|
||||
};
|
||||
|
||||
const sendPrivmsg = (target: string, text: string) => {
|
||||
const normalizedTarget = sanitizeIrcTarget(target);
|
||||
const cleaned = sanitizeIrcOutboundText(text);
|
||||
if (!cleaned) {
|
||||
return;
|
||||
}
|
||||
let remaining = cleaned;
|
||||
while (remaining.length > 0) {
|
||||
let chunk = remaining;
|
||||
if (chunk.length > messageChunkMaxChars) {
|
||||
let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars);
|
||||
if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
|
||||
splitAt = messageChunkMaxChars;
|
||||
}
|
||||
chunk = chunk.slice(0, splitAt).trim();
|
||||
}
|
||||
if (!chunk) {
|
||||
break;
|
||||
}
|
||||
sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`);
|
||||
remaining = remaining.slice(chunk.length).trimStart();
|
||||
}
|
||||
};
|
||||
|
||||
const quit = (reason?: string) => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
removeAbortListener?.();
|
||||
removeAbortListener = null;
|
||||
const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
|
||||
try {
|
||||
if (safeReason) {
|
||||
sendRaw(`QUIT :${safeReason}`);
|
||||
} else {
|
||||
sendRaw("QUIT");
|
||||
}
|
||||
} catch {
|
||||
// Ignore quit failures while shutting down.
|
||||
}
|
||||
socket.end();
|
||||
};
|
||||
|
||||
const close = () => {
|
||||
if (closed) {
|
||||
return;
|
||||
}
|
||||
closed = true;
|
||||
removeAbortListener?.();
|
||||
removeAbortListener = null;
|
||||
socket.destroy();
|
||||
};
|
||||
|
||||
let buffer = "";
|
||||
socket.on("data", (chunk: string) => {
|
||||
buffer += chunk;
|
||||
let idx = buffer.indexOf("\n");
|
||||
while (idx !== -1) {
|
||||
const rawLine = buffer.slice(0, idx).replace(/\r$/, "");
|
||||
buffer = buffer.slice(idx + 1);
|
||||
idx = buffer.indexOf("\n");
|
||||
|
||||
if (!rawLine) {
|
||||
continue;
|
||||
}
|
||||
if (options.onLine) {
|
||||
options.onLine(rawLine);
|
||||
}
|
||||
|
||||
const line = parseIrcLine(rawLine);
|
||||
if (!line) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "PING") {
|
||||
const payload =
|
||||
line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
|
||||
sendRaw(`PONG :${payload}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NICK") {
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
if (prefix.nick && normalizeIrcNick(prefix.nick) === normalizeIrcNick(currentNick)) {
|
||||
const next =
|
||||
line.trailing != null
|
||||
? line.trailing
|
||||
: line.params[0] != null
|
||||
? line.params[0]
|
||||
: currentNick;
|
||||
currentNick = next.trim();
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) {
|
||||
if (tryRecoverNickCollision()) {
|
||||
continue;
|
||||
}
|
||||
const detail =
|
||||
line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use";
|
||||
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!ready && IRC_ERROR_CODES.has(line.command)) {
|
||||
const detail =
|
||||
line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected";
|
||||
fail(new Error(`IRC login failed (${line.command}): ${detail}`));
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
if (line.command === "001") {
|
||||
ready = true;
|
||||
const nickParam = line.params[0];
|
||||
if (nickParam && nickParam.trim()) {
|
||||
currentNick = nickParam.trim();
|
||||
}
|
||||
try {
|
||||
const nickServCommands = buildIrcNickServCommands(options.nickserv);
|
||||
for (const command of nickServCommands) {
|
||||
sendRaw(command);
|
||||
}
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
for (const channel of options.channels || []) {
|
||||
const trimmed = channel.trim();
|
||||
if (!trimmed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
join(trimmed);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
}
|
||||
}
|
||||
if (resolveReady) {
|
||||
resolveReady();
|
||||
}
|
||||
resolveReady = null;
|
||||
rejectReady = null;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "NOTICE") {
|
||||
if (options.onNotice) {
|
||||
options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (line.command === "PRIVMSG") {
|
||||
const targetParam = line.params[0];
|
||||
const target = targetParam ? targetParam.trim() : "";
|
||||
const text = line.trailing != null ? line.trailing : "";
|
||||
const prefix = parseIrcPrefix(line.prefix);
|
||||
const senderNick = prefix.nick ? prefix.nick.trim() : "";
|
||||
if (!target || !senderNick || !text.trim()) {
|
||||
continue;
|
||||
}
|
||||
if (options.onPrivmsg) {
|
||||
void Promise.resolve(
|
||||
options.onPrivmsg({
|
||||
senderNick,
|
||||
senderUser: prefix.user ? prefix.user.trim() : undefined,
|
||||
senderHost: prefix.host ? prefix.host.trim() : undefined,
|
||||
target,
|
||||
text,
|
||||
rawLine,
|
||||
}),
|
||||
).catch((error: unknown) => {
|
||||
fail(error);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
socket.once("connect", () => {
|
||||
try {
|
||||
if (options.password && options.password.trim()) {
|
||||
sendRaw(`PASS ${options.password.trim()}`);
|
||||
}
|
||||
sendRaw(`NICK ${options.nick.trim()}`);
|
||||
sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`);
|
||||
} catch (err) {
|
||||
fail(err);
|
||||
close();
|
||||
}
|
||||
});
|
||||
|
||||
socket.once("error", (err: unknown) => {
|
||||
fail(err);
|
||||
});
|
||||
|
||||
socket.once("close", () => {
|
||||
if (!closed) {
|
||||
closed = true;
|
||||
if (!ready) {
|
||||
fail(new Error("IRC connection closed before ready"));
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
if (options.abortSignal) {
|
||||
const abort = () => {
|
||||
if (!ready) {
|
||||
failAndClose(new Error("IRC connect aborted"));
|
||||
return;
|
||||
}
|
||||
quit("shutdown");
|
||||
};
|
||||
if (options.abortSignal.aborted) {
|
||||
abort();
|
||||
} else {
|
||||
options.abortSignal.addEventListener("abort", abort, { once: true });
|
||||
removeAbortListener = () => options.abortSignal?.removeEventListener("abort", abort);
|
||||
}
|
||||
}
|
||||
|
||||
await withTimeout(readyPromise, timeoutMs, "IRC connect");
|
||||
|
||||
return {
|
||||
get nick() {
|
||||
return currentNick;
|
||||
},
|
||||
isReady: () => ready && !closed,
|
||||
sendRaw,
|
||||
join,
|
||||
sendPrivmsg,
|
||||
quit,
|
||||
close,
|
||||
};
|
||||
}
|
||||
117
extensions/irc/src/config-schema.test.ts
Normal file
117
extensions/irc/src/config-schema.test.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
// Irc tests cover config schema plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { IrcConfigSchema } from "./config-schema.js";
|
||||
|
||||
function expectValidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
|
||||
expect(result.success).toBe(true);
|
||||
if (!result.success) {
|
||||
throw new Error("expected config to be valid");
|
||||
}
|
||||
return result.data;
|
||||
}
|
||||
|
||||
function expectInvalidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
|
||||
expect(result.success).toBe(false);
|
||||
if (result.success) {
|
||||
throw new Error("expected config to be invalid");
|
||||
}
|
||||
return result.error.issues;
|
||||
}
|
||||
|
||||
describe("irc config schema", () => {
|
||||
it("accepts basic config", () => {
|
||||
const config = expectValidConfig(
|
||||
IrcConfigSchema.safeParse({
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-bot",
|
||||
channels: ["#openclaw"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.host).toBe("irc.libera.chat");
|
||||
expect(config.nick).toBe("openclaw-bot");
|
||||
});
|
||||
|
||||
it('rejects dmPolicy="open" without allowFrom "*"', () => {
|
||||
const issues = expectInvalidConfig(
|
||||
IrcConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["alice"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(issues[0]?.path.join(".")).toBe("allowFrom");
|
||||
});
|
||||
|
||||
it('accepts dmPolicy="open" with allowFrom "*"', () => {
|
||||
const config = expectValidConfig(
|
||||
IrcConfigSchema.safeParse({
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.dmPolicy).toBe("open");
|
||||
});
|
||||
|
||||
it("accepts numeric allowFrom and groupAllowFrom entries", () => {
|
||||
const parsed = IrcConfigSchema.parse({
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: [12345, "alice"],
|
||||
groupAllowFrom: [67890, "alice!ident@example.org"],
|
||||
});
|
||||
|
||||
expect(parsed.allowFrom).toEqual([12345, "alice"]);
|
||||
expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
|
||||
});
|
||||
|
||||
it("accepts numeric per-channel allowFrom entries", () => {
|
||||
const parsed = IrcConfigSchema.parse({
|
||||
groups: {
|
||||
"#ops": {
|
||||
allowFrom: [42, "alice"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
|
||||
});
|
||||
|
||||
it("rejects nickserv register without registerEmail", () => {
|
||||
const issues = expectInvalidConfig(
|
||||
IrcConfigSchema.safeParse({
|
||||
nickserv: {
|
||||
register: true,
|
||||
password: "secret",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(issues[0]?.path.join(".")).toBe("nickserv.registerEmail");
|
||||
});
|
||||
|
||||
it("accepts nickserv register with password and registerEmail", () => {
|
||||
const config = expectValidConfig(
|
||||
IrcConfigSchema.safeParse({
|
||||
nickserv: {
|
||||
register: true,
|
||||
password: "secret",
|
||||
registerEmail: "bot@example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(config.nickserv?.register).toBe(true);
|
||||
});
|
||||
|
||||
it("accepts nickserv register with registerEmail only", () => {
|
||||
expectValidConfig(
|
||||
IrcConfigSchema.safeParse({
|
||||
nickserv: {
|
||||
register: true,
|
||||
registerEmail: "bot@example.com",
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
97
extensions/irc/src/config-schema.ts
Normal file
97
extensions/irc/src/config-schema.ts
Normal file
@@ -0,0 +1,97 @@
|
||||
// Irc helper module supports config schema behavior.
|
||||
import {
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
ReplyRuntimeConfigSchemaShape,
|
||||
ToolPolicySchema,
|
||||
buildChannelConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk/channel-config-schema";
|
||||
import { z } from "zod";
|
||||
import { ircChannelConfigUiHints } from "./config-ui-hints.js";
|
||||
|
||||
const IrcGroupSchema = z
|
||||
.object({
|
||||
requireMention: z.boolean().optional(),
|
||||
tools: ToolPolicySchema,
|
||||
toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
|
||||
skills: z.array(z.string()).optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
systemPrompt: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const IrcNickServSchema = z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
service: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
passwordFile: z.string().optional(),
|
||||
register: z.boolean().optional(),
|
||||
registerEmail: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.superRefine((value, ctx) => {
|
||||
if (value.register && !value.registerEmail?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
path: ["registerEmail"],
|
||||
message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
const IrcAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
enabled: z.boolean().optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
host: z.string().optional(),
|
||||
port: z.number().int().min(1).max(65535).optional(),
|
||||
tls: z.boolean().optional(),
|
||||
nick: z.string().optional(),
|
||||
username: z.string().optional(),
|
||||
realname: z.string().optional(),
|
||||
password: z.string().optional(),
|
||||
passwordFile: z.string().optional(),
|
||||
nickserv: IrcNickServSchema.optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
|
||||
channels: z.array(z.string()).optional(),
|
||||
mentionPatterns: z.array(z.string()).optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
...ReplyRuntimeConfigSchemaShape,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const IrcConfigSchema = IrcAccountSchemaBase.extend({
|
||||
accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
|
||||
defaultAccount: z.string().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
requireOpenAllowFrom({
|
||||
policy: value.dmPolicy,
|
||||
allowFrom: value.allowFrom,
|
||||
ctx,
|
||||
path: ["allowFrom"],
|
||||
message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
|
||||
});
|
||||
});
|
||||
|
||||
export const IrcChannelConfigSchema = buildChannelConfigSchema(IrcConfigSchema, {
|
||||
uiHints: ircChannelConfigUiHints,
|
||||
});
|
||||
41
extensions/irc/src/config-ui-hints.ts
Normal file
41
extensions/irc/src/config-ui-hints.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Irc helper module supports config ui hints behavior.
|
||||
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/core";
|
||||
|
||||
export const ircChannelConfigUiHints = {
|
||||
"": {
|
||||
label: "IRC",
|
||||
help: "IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw.",
|
||||
},
|
||||
dmPolicy: {
|
||||
label: "IRC DM Policy",
|
||||
help: 'Direct message access control ("pairing" recommended). "open" requires channels.irc.allowFrom=["*"].',
|
||||
},
|
||||
"nickserv.enabled": {
|
||||
label: "IRC NickServ Enabled",
|
||||
help: "Enable NickServ identify/register after connect (defaults to enabled when password is configured).",
|
||||
},
|
||||
"nickserv.service": {
|
||||
label: "IRC NickServ Service",
|
||||
help: "NickServ service nick (default: NickServ).",
|
||||
},
|
||||
"nickserv.password": {
|
||||
label: "IRC NickServ Password",
|
||||
help: "NickServ password used for IDENTIFY/REGISTER (sensitive).",
|
||||
},
|
||||
"nickserv.passwordFile": {
|
||||
label: "IRC NickServ Password File",
|
||||
help: "Optional file path containing NickServ password.",
|
||||
},
|
||||
"nickserv.register": {
|
||||
label: "IRC NickServ Register",
|
||||
help: "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.",
|
||||
},
|
||||
"nickserv.registerEmail": {
|
||||
label: "IRC NickServ Register Email",
|
||||
help: "Email used with NickServ REGISTER (required when register=true).",
|
||||
},
|
||||
configWrites: {
|
||||
label: "IRC Config Writes",
|
||||
help: "Allow IRC to write config in response to channel events/commands (default: true).",
|
||||
},
|
||||
} satisfies Record<string, ChannelConfigUiHint>;
|
||||
48
extensions/irc/src/connect-options.test.ts
Normal file
48
extensions/irc/src/connect-options.test.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Irc tests cover connect options plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||
|
||||
describe("buildIrcConnectOptions", () => {
|
||||
it("copies resolved account connection fields and NickServ config", () => {
|
||||
const account = {
|
||||
host: "irc.libera.chat",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw Bot",
|
||||
password: "server-pass",
|
||||
config: {
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
password: "nickserv-pass",
|
||||
register: true,
|
||||
registerEmail: "bot@example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
buildIrcConnectOptions(account as never, {
|
||||
connectTimeoutMs: 1234,
|
||||
}),
|
||||
).toEqual({
|
||||
host: "irc.libera.chat",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw Bot",
|
||||
password: "server-pass",
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
password: "nickserv-pass",
|
||||
register: true,
|
||||
registerEmail: "bot@example.com",
|
||||
},
|
||||
connectTimeoutMs: 1234,
|
||||
});
|
||||
});
|
||||
});
|
||||
31
extensions/irc/src/connect-options.ts
Normal file
31
extensions/irc/src/connect-options.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Irc plugin module implements connect options behavior.
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import type { IrcClientOptions } from "./client.js";
|
||||
|
||||
type IrcConnectOverrides = Omit<
|
||||
Partial<IrcClientOptions>,
|
||||
"host" | "port" | "tls" | "nick" | "username" | "realname" | "password" | "nickserv"
|
||||
>;
|
||||
|
||||
export function buildIrcConnectOptions(
|
||||
account: ResolvedIrcAccount,
|
||||
overrides: IrcConnectOverrides = {},
|
||||
): IrcClientOptions {
|
||||
return {
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
username: account.username,
|
||||
realname: account.realname,
|
||||
password: account.password,
|
||||
nickserv: {
|
||||
enabled: account.config.nickserv?.enabled,
|
||||
service: account.config.nickserv?.service,
|
||||
password: account.config.nickserv?.password,
|
||||
register: account.config.nickserv?.register,
|
||||
registerEmail: account.config.nickserv?.registerEmail,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
18
extensions/irc/src/control-chars.test.ts
Normal file
18
extensions/irc/src/control-chars.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Irc tests cover control chars plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { hasIrcControlChars, isIrcControlChar, stripIrcControlChars } from "./control-chars.js";
|
||||
|
||||
describe("irc control char helpers", () => {
|
||||
it("detects IRC control characters by codepoint", () => {
|
||||
expect(isIrcControlChar(0x00)).toBe(true);
|
||||
expect(isIrcControlChar(0x1f)).toBe(true);
|
||||
expect(isIrcControlChar(0x7f)).toBe(true);
|
||||
expect(isIrcControlChar(0x20)).toBe(false);
|
||||
});
|
||||
|
||||
it("detects and strips IRC control characters from strings", () => {
|
||||
expect(hasIrcControlChars("hello\u0002world")).toBe(true);
|
||||
expect(hasIrcControlChars("hello world")).toBe(false);
|
||||
expect(stripIrcControlChars("he\u0002llo\u007f world")).toBe("hello world");
|
||||
});
|
||||
});
|
||||
23
extensions/irc/src/control-chars.ts
Normal file
23
extensions/irc/src/control-chars.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
// Irc plugin module implements control chars behavior.
|
||||
export function isIrcControlChar(charCode: number): boolean {
|
||||
return charCode <= 0x1f || charCode === 0x7f;
|
||||
}
|
||||
|
||||
export function hasIrcControlChars(value: string): boolean {
|
||||
for (const char of value) {
|
||||
if (isIrcControlChar(char.charCodeAt(0))) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
export function stripIrcControlChars(value: string): string {
|
||||
let out = "";
|
||||
for (const char of value) {
|
||||
if (!isIrcControlChar(char.charCodeAt(0))) {
|
||||
out += char;
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
31
extensions/irc/src/doctor.test.ts
Normal file
31
extensions/irc/src/doctor.test.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Irc tests cover doctor mutable allowlist warnings.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { collectIrcMutableAllowlistWarnings } from "./doctor.js";
|
||||
|
||||
describe("collectIrcMutableAllowlistWarnings", () => {
|
||||
it("warns on a host-less nick!user allowlist entry", () => {
|
||||
const warnings = collectIrcMutableAllowlistWarnings({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["alice!ident"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(warnings).toContain("- channels.irc.allowFrom: alice!ident");
|
||||
});
|
||||
|
||||
it("does not warn on a full nick!user@host allowlist entry", () => {
|
||||
const warnings = collectIrcMutableAllowlistWarnings({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["alice!ident@example.com"],
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
expect(warnings).toStrictEqual([]);
|
||||
});
|
||||
});
|
||||
55
extensions/irc/src/doctor.ts
Normal file
55
extensions/irc/src/doctor.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
// Irc plugin module implements doctor behavior.
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === "object" && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function isIrcMutableAllowEntry(raw: string): boolean {
|
||||
const text = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!text || text === "*") {
|
||||
return false;
|
||||
}
|
||||
|
||||
const normalized = text
|
||||
.replace(/^irc:/, "")
|
||||
.replace(/^user:/, "")
|
||||
.trim();
|
||||
|
||||
return !normalized.includes("@");
|
||||
}
|
||||
|
||||
export const collectIrcMutableAllowlistWarnings =
|
||||
createDangerousNameMatchingMutableAllowlistWarningCollector({
|
||||
channel: "irc",
|
||||
detector: isIrcMutableAllowEntry,
|
||||
collectLists: (scope) => {
|
||||
const lists = [
|
||||
{
|
||||
pathLabel: `${scope.prefix}.allowFrom`,
|
||||
list: scope.account.allowFrom,
|
||||
},
|
||||
{
|
||||
pathLabel: `${scope.prefix}.groupAllowFrom`,
|
||||
list: scope.account.groupAllowFrom,
|
||||
},
|
||||
];
|
||||
const groups = asObjectRecord(scope.account.groups);
|
||||
if (groups) {
|
||||
for (const [groupKey, groupRaw] of Object.entries(groups)) {
|
||||
const group = asObjectRecord(groupRaw);
|
||||
if (!group) {
|
||||
continue;
|
||||
}
|
||||
lists.push({
|
||||
pathLabel: `${scope.prefix}.groups.${groupKey}.allowFrom`,
|
||||
list: group.allowFrom,
|
||||
});
|
||||
}
|
||||
}
|
||||
return lists;
|
||||
},
|
||||
});
|
||||
48
extensions/irc/src/gateway.ts
Normal file
48
extensions/irc/src/gateway.ts
Normal file
@@ -0,0 +1,48 @@
|
||||
// Irc plugin module implements gateway behavior.
|
||||
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import type { ChannelAccountSnapshot } from "openclaw/plugin-sdk/status-helpers";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { createAccountStatusSink } from "./channel-api.js";
|
||||
import type { RuntimeEnv } from "./runtime-api.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const loadIrcChannelRuntime = createLazyRuntimeModule(() => import("./channel-runtime.js"));
|
||||
|
||||
export async function startIrcGatewayAccount(ctx: {
|
||||
cfg: CoreConfig;
|
||||
accountId: string;
|
||||
account: ResolvedIrcAccount;
|
||||
runtime: RuntimeEnv;
|
||||
abortSignal: AbortSignal;
|
||||
setStatus: (next: ChannelAccountSnapshot) => void;
|
||||
log?: {
|
||||
info?: (message: string) => void;
|
||||
};
|
||||
}): Promise<void> {
|
||||
const account = ctx.account;
|
||||
const statusSink = createAccountStatusSink({
|
||||
accountId: ctx.accountId,
|
||||
setStatus: ctx.setStatus,
|
||||
});
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
ctx.log?.info?.(
|
||||
`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`,
|
||||
);
|
||||
const { monitorIrcProvider } = await loadIrcChannelRuntime();
|
||||
await runStoppablePassiveMonitor({
|
||||
abortSignal: ctx.abortSignal,
|
||||
start: async () =>
|
||||
await monitorIrcProvider({
|
||||
accountId: account.accountId,
|
||||
config: ctx.cfg,
|
||||
runtime: ctx.runtime,
|
||||
abortSignal: ctx.abortSignal,
|
||||
statusSink,
|
||||
}),
|
||||
});
|
||||
}
|
||||
314
extensions/irc/src/inbound.behavior.test.ts
Normal file
314
extensions/irc/src/inbound.behavior.test.ts
Normal file
@@ -0,0 +1,314 @@
|
||||
// Irc tests cover inbound.behavior plugin behavior.
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { handleIrcInbound } from "./inbound.js";
|
||||
import type { RuntimeEnv } from "./runtime-api.js";
|
||||
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
|
||||
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||
|
||||
const {
|
||||
buildMentionRegexesMock,
|
||||
hasControlCommandMock,
|
||||
matchesMentionPatternsMock,
|
||||
readAllowFromStoreMock,
|
||||
shouldHandleTextCommandsMock,
|
||||
upsertPairingRequestMock,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
buildMentionRegexesMock: vi.fn(() => []),
|
||||
hasControlCommandMock: vi.fn(() => false),
|
||||
matchesMentionPatternsMock: vi.fn(() => false),
|
||||
readAllowFromStoreMock: vi.fn(async () => []),
|
||||
shouldHandleTextCommandsMock: vi.fn(() => false),
|
||||
upsertPairingRequestMock: vi.fn(async () => ({ code: "CODE", created: true })),
|
||||
};
|
||||
});
|
||||
|
||||
function installIrcRuntime() {
|
||||
setIrcRuntime({
|
||||
channel: {
|
||||
pairing: {
|
||||
readAllowFromStore: readAllowFromStoreMock,
|
||||
upsertPairingRequest: upsertPairingRequestMock,
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: shouldHandleTextCommandsMock,
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: hasControlCommandMock,
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: buildMentionRegexesMock,
|
||||
matchesMentionPatterns: matchesMentionPatternsMock,
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
function createRuntimeEnv() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
}
|
||||
|
||||
function createAccount(overrides?: Partial<ResolvedIrcAccount>): ResolvedIrcAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
server: "irc.example.com",
|
||||
nick: "OpenClaw",
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
...overrides,
|
||||
} as ResolvedIrcAccount;
|
||||
}
|
||||
|
||||
function createMessage(overrides?: Partial<IrcInboundMessage>): IrcInboundMessage {
|
||||
return {
|
||||
messageId: "msg-1",
|
||||
target: "alice",
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.com",
|
||||
text: "hello",
|
||||
timestamp: Date.now(),
|
||||
isGroup: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function resetInboundMocks() {
|
||||
buildMentionRegexesMock.mockReset().mockReturnValue([]);
|
||||
hasControlCommandMock.mockReset().mockReturnValue(false);
|
||||
matchesMentionPatternsMock.mockReset().mockReturnValue(false);
|
||||
readAllowFromStoreMock.mockReset().mockResolvedValue([]);
|
||||
shouldHandleTextCommandsMock.mockReset().mockReturnValue(false);
|
||||
upsertPairingRequestMock.mockReset().mockResolvedValue({ code: "CODE", created: true });
|
||||
}
|
||||
|
||||
describe("irc inbound behavior", () => {
|
||||
beforeEach(() => {
|
||||
resetInboundMocks();
|
||||
installIrcRuntime();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("issues a DM pairing challenge and sends the reply to the sender nick", async () => {
|
||||
const sendReply = vi.fn<(target: string, text: string, replyToId?: string) => Promise<void>>(
|
||||
async () => {},
|
||||
);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage(),
|
||||
account: createAccount(),
|
||||
config: { channels: { irc: {} } } as CoreConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
sendReply,
|
||||
});
|
||||
|
||||
expect(upsertPairingRequestMock).toHaveBeenCalledWith({
|
||||
channel: "irc",
|
||||
accountId: "default",
|
||||
id: "alice!ident@example.com",
|
||||
meta: { name: "alice" },
|
||||
});
|
||||
expect(sendReply).toHaveBeenCalledTimes(1);
|
||||
expect(sendReply).toHaveBeenCalledWith(
|
||||
"alice",
|
||||
[
|
||||
"OpenClaw: access not configured.",
|
||||
"",
|
||||
"Your IRC id: alice!ident@example.com",
|
||||
"Pairing code:",
|
||||
"```",
|
||||
"CODE",
|
||||
"```",
|
||||
"",
|
||||
"Ask the bot owner to approve with:",
|
||||
"```",
|
||||
"openclaw pairing approve irc CODE",
|
||||
"```",
|
||||
].join("\n"),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it("drops unauthorized group control commands before dispatch", async () => {
|
||||
const runtime = createRuntimeEnv();
|
||||
shouldHandleTextCommandsMock.mockReturnValue(true);
|
||||
hasControlCommandMock.mockReturnValue(true);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage({
|
||||
target: "#ops",
|
||||
isGroup: true,
|
||||
text: "/admin",
|
||||
}),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: ["bob!ident@example.com"],
|
||||
groups: {
|
||||
"#ops": {
|
||||
allowFrom: ["alice!ident@example.com"],
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
config: { channels: { irc: {} }, commands: { useAccessGroups: true } } as CoreConfig,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"irc: drop control command (unauthorized) target=alice!ident@example.com",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the shared reply pipeline for dispatched replies", async () => {
|
||||
const coreRuntime = createPluginRuntimeMock();
|
||||
setIrcRuntime(coreRuntime as never);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage(),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
}),
|
||||
config: { channels: { irc: {} } } as CoreConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
sendReply: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const assembledRequest = (
|
||||
coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } }
|
||||
).mock.calls[0]?.[0] as { replyPipeline?: unknown } | undefined;
|
||||
expect(assembledRequest?.replyPipeline).toEqual({});
|
||||
});
|
||||
|
||||
it("uses channel:# prefix for group channel From and OriginatingTo fields", async () => {
|
||||
const coreRuntime = createPluginRuntimeMock();
|
||||
const runtime = createRuntimeEnv();
|
||||
setIrcRuntime(coreRuntime as never);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage({
|
||||
target: "#ops",
|
||||
isGroup: true,
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.com",
|
||||
text: "hello",
|
||||
}),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
groupPolicy: "open",
|
||||
groupAllowFrom: [],
|
||||
groups: {
|
||||
"#ops": { enabled: true, requireMention: false },
|
||||
},
|
||||
},
|
||||
}),
|
||||
config: { channels: { irc: {} } } as CoreConfig,
|
||||
runtime,
|
||||
sendReply: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
const ctx = (
|
||||
coreRuntime.channel.reply.finalizeInboundContext as unknown as {
|
||||
mock: { calls: unknown[][] };
|
||||
}
|
||||
).mock.calls[0]?.[0] as Record<string, unknown> | undefined;
|
||||
expect(
|
||||
(coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls.length,
|
||||
).toBe(1);
|
||||
expect(runtime.log).not.toHaveBeenCalled();
|
||||
expect(ctx?.From).toBe("channel:#ops");
|
||||
expect(ctx?.To).toBe("channel:#ops");
|
||||
expect(ctx?.OriginatingTo).toBe("channel:#ops");
|
||||
});
|
||||
|
||||
it("drops a spoofed sender for a host-less nick!user DM allowlist entry", async () => {
|
||||
const coreRuntime = createPluginRuntimeMock();
|
||||
const runtime = createRuntimeEnv();
|
||||
setIrcRuntime(coreRuntime as never);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage({
|
||||
target: "alice",
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "attacker.example",
|
||||
text: "hello",
|
||||
}),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["alice!ident"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
}),
|
||||
config: { channels: { irc: {} } } as CoreConfig,
|
||||
runtime,
|
||||
sendReply: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
expect(
|
||||
(coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls.length,
|
||||
).toBe(0);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"irc: drop DM sender alice!ident@attacker.example (dmPolicy=allowlist)",
|
||||
);
|
||||
});
|
||||
|
||||
it("admits a sender matching a full nick!user@host DM allowlist entry", async () => {
|
||||
const coreRuntime = createPluginRuntimeMock();
|
||||
const runtime = createRuntimeEnv();
|
||||
setIrcRuntime(coreRuntime as never);
|
||||
|
||||
await handleIrcInbound({
|
||||
message: createMessage({
|
||||
target: "alice",
|
||||
senderNick: "alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.com",
|
||||
text: "hello",
|
||||
}),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["alice!ident@example.com"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
}),
|
||||
config: { channels: { irc: {} } } as CoreConfig,
|
||||
runtime,
|
||||
sendReply: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
expect(
|
||||
(coreRuntime.channel.inbound.dispatchReply as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls.length,
|
||||
).toBe(1);
|
||||
});
|
||||
});
|
||||
462
extensions/irc/src/inbound.ts
Normal file
462
extensions/irc/src/inbound.ts
Normal file
@@ -0,0 +1,462 @@
|
||||
// Irc plugin module implements inbound behavior.
|
||||
import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
createChannelIngressResolver,
|
||||
defineStableChannelIngressIdentity,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
deliverFormattedTextWithAttachments,
|
||||
type OutboundReplyPayload,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { ResolvedIrcAccount } from "./accounts.js";
|
||||
import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "irc" as const;
|
||||
const IRC_NICK_KIND = "plugin:irc-nick" as const;
|
||||
type IrcGroupPolicy = "open" | "allowlist" | "disabled";
|
||||
|
||||
const ircIngressIdentity = defineStableChannelIngressIdentity({
|
||||
key: "irc-id",
|
||||
normalizeEntry: normalizeIrcStableEntry,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
sensitivity: "pii",
|
||||
aliases: [
|
||||
{
|
||||
key: "irc-id-nick-user",
|
||||
kind: "stable-id" as const,
|
||||
normalizeEntry: normalizeIrcNickUserEntry,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
dangerous: true,
|
||||
sensitivity: "pii" as const,
|
||||
},
|
||||
{
|
||||
key: "irc-id-nick-host",
|
||||
kind: "stable-id" as const,
|
||||
normalizeEntry: () => null,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
sensitivity: "pii" as const,
|
||||
},
|
||||
{
|
||||
key: "irc-nick",
|
||||
kind: IRC_NICK_KIND,
|
||||
normalizeEntry: normalizeIrcNickEntry,
|
||||
normalizeSubject: normalizeLowercaseStringOrEmpty,
|
||||
dangerous: true,
|
||||
sensitivity: "pii",
|
||||
},
|
||||
],
|
||||
isWildcardEntry: (entry) => normalizeIrcAllowEntry(entry) === "*",
|
||||
resolveEntryId: ({ entryIndex, fieldKey }) =>
|
||||
`irc-entry-${entryIndex + 1}:${fieldKey === "irc-nick" ? "nick" : "id"}`,
|
||||
});
|
||||
|
||||
const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
|
||||
function isBareNick(value: string): boolean {
|
||||
return !value.includes("!") && !value.includes("@");
|
||||
}
|
||||
|
||||
function hasVerifiedHost(value: string): boolean {
|
||||
return value.includes("@");
|
||||
}
|
||||
|
||||
function isHostlessNickUser(value: string): boolean {
|
||||
return value.includes("!") && !value.includes("@");
|
||||
}
|
||||
|
||||
function normalizeIrcStableEntry(value: string): string | null {
|
||||
const normalized = normalizeIrcAllowEntry(value);
|
||||
if (!normalized || normalized === "*" || !hasVerifiedHost(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeIrcNickUserEntry(value: string): string | null {
|
||||
const normalized = normalizeIrcAllowEntry(value);
|
||||
if (!normalized || normalized === "*" || !isHostlessNickUser(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function normalizeIrcNickEntry(value: string): string | null {
|
||||
const normalized = normalizeIrcAllowEntry(value);
|
||||
if (!normalized || normalized === "*" || !isBareNick(normalized)) {
|
||||
return null;
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function hasEntries(entries: Array<string | number> | undefined): boolean {
|
||||
return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry));
|
||||
}
|
||||
|
||||
function createIrcIngressSubject(message: IrcInboundMessage) {
|
||||
const candidates = buildIrcAllowlistCandidates(message, { allowNameMatching: true });
|
||||
const stableCandidates = candidates.filter((candidate) => hasVerifiedHost(candidate));
|
||||
const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
|
||||
return {
|
||||
stableId: stableCandidates[stableCandidates.length - 1] ?? nick,
|
||||
aliases: {
|
||||
"irc-id-nick-user": candidates.find((candidate) => isHostlessNickUser(candidate)),
|
||||
"irc-id-nick-host": stableCandidates.find(
|
||||
(candidate) => !candidate.includes("!") && candidate.includes("@"),
|
||||
),
|
||||
"irc-nick": nick,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function routeDescriptorsForIrcGroup(params: {
|
||||
isGroup: boolean;
|
||||
groupPolicy: IrcGroupPolicy;
|
||||
groupAllowed: boolean;
|
||||
hasConfiguredGroups: boolean;
|
||||
groupEnabled: boolean;
|
||||
routeGroupAllowFrom: string[];
|
||||
}) {
|
||||
if (!params.isGroup) {
|
||||
return [];
|
||||
}
|
||||
return channelIngressRoutes(
|
||||
params.groupPolicy === "allowlist" && {
|
||||
id: "irc:channel",
|
||||
allowed: params.hasConfiguredGroups && params.groupAllowed,
|
||||
precedence: 0,
|
||||
matchId: "irc-channel",
|
||||
blockReason: "channel_not_allowlisted",
|
||||
},
|
||||
!params.groupEnabled && {
|
||||
id: "irc:channel-enabled",
|
||||
enabled: false,
|
||||
precedence: 10,
|
||||
blockReason: "channel_disabled",
|
||||
},
|
||||
hasEntries(params.routeGroupAllowFrom) && {
|
||||
id: "irc:channel-sender",
|
||||
precedence: 20,
|
||||
senderPolicy: "replace",
|
||||
senderAllowFrom: params.routeGroupAllowFrom,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverIrcReply(params: {
|
||||
payload: OutboundReplyPayload;
|
||||
cfg: CoreConfig;
|
||||
target: string;
|
||||
accountId: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}) {
|
||||
await deliverFormattedTextWithAttachments({
|
||||
payload: params.payload,
|
||||
send: async ({ text, replyToId }) => {
|
||||
if (params.sendReply) {
|
||||
await params.sendReply(params.target, text, replyToId);
|
||||
} else {
|
||||
await sendMessageIrc(params.target, text, {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
replyTo: replyToId,
|
||||
});
|
||||
}
|
||||
params.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function handleIrcInbound(params: {
|
||||
message: IrcInboundMessage;
|
||||
account: ResolvedIrcAccount;
|
||||
config: CoreConfig;
|
||||
runtime: RuntimeEnv;
|
||||
connectedNick?: string;
|
||||
sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { message, account, config, runtime, connectedNick, statusSink } = params;
|
||||
const core = getIrcRuntime();
|
||||
const pairing = createChannelPairingController({
|
||||
core,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const rawBody = message.text?.trim() ?? "";
|
||||
if (!rawBody) {
|
||||
return;
|
||||
}
|
||||
|
||||
statusSink?.({ lastInboundAt: message.timestamp });
|
||||
|
||||
const senderDisplay = message.senderHost
|
||||
? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
|
||||
: message.senderNick;
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent: config.channels?.irc !== undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy,
|
||||
});
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "irc",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
|
||||
log: (messageLocal) => runtime.log?.(messageLocal),
|
||||
});
|
||||
|
||||
const groupMatch = resolveIrcGroupMatch({
|
||||
groups: account.config.groups,
|
||||
target: message.target,
|
||||
});
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
surface: CHANNEL_ID,
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
|
||||
const mentionNick = connectedNick?.trim() || account.nick;
|
||||
const explicitMentionRegex = mentionNick
|
||||
? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
|
||||
: null;
|
||||
const wasMentioned =
|
||||
core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
|
||||
(explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
|
||||
const requireMention = message.isGroup
|
||||
? resolveIrcRequireMention({
|
||||
groupConfig: groupMatch.groupConfig,
|
||||
wildcardConfig: groupMatch.wildcardConfig,
|
||||
})
|
||||
: false;
|
||||
const routeGroupAllowFrom = normalizeStringEntries(
|
||||
groupMatch.groupConfig?.allowFrom?.length
|
||||
? groupMatch.groupConfig.allowFrom
|
||||
: groupMatch.wildcardConfig?.allowFrom,
|
||||
);
|
||||
const accessGroupPolicy: IrcGroupPolicy =
|
||||
groupPolicy === "open" &&
|
||||
(hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom))
|
||||
? "allowlist"
|
||||
: groupPolicy;
|
||||
const access = await createChannelIngressResolver({
|
||||
channelId: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
identity: ircIngressIdentity,
|
||||
cfg: config as OpenClawConfig,
|
||||
readStoreAllowFrom: async () => await pairing.readAllowFromStore(),
|
||||
}).message({
|
||||
subject: createIrcIngressSubject(message),
|
||||
conversation: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: message.target,
|
||||
},
|
||||
route: routeDescriptorsForIrcGroup({
|
||||
isGroup: message.isGroup,
|
||||
groupPolicy,
|
||||
groupAllowed: groupMatch.allowed,
|
||||
hasConfiguredGroups: groupMatch.hasConfiguredGroups,
|
||||
groupEnabled:
|
||||
groupMatch.groupConfig?.enabled !== false && groupMatch.wildcardConfig?.enabled !== false,
|
||||
routeGroupAllowFrom,
|
||||
}),
|
||||
mentionFacts: message.isGroup
|
||||
? {
|
||||
canDetectMention: true,
|
||||
wasMentioned,
|
||||
hasAnyMention: wasMentioned,
|
||||
}
|
||||
: undefined,
|
||||
dmPolicy,
|
||||
groupPolicy: accessGroupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: false,
|
||||
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
|
||||
activation: {
|
||||
requireMention: message.isGroup && requireMention,
|
||||
allowTextCommands,
|
||||
},
|
||||
},
|
||||
allowFrom: account.config.allowFrom,
|
||||
groupAllowFrom: account.config.groupAllowFrom,
|
||||
command: {
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
},
|
||||
});
|
||||
const commandAuthorized = access.commandAccess.authorized;
|
||||
|
||||
if (access.ingress.admission === "pairing-required") {
|
||||
await pairing.issueChallenge({
|
||||
senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
|
||||
senderIdLine: `Your IRC id: ${senderDisplay}`,
|
||||
meta: { name: message.senderNick || undefined },
|
||||
sendPairingReply: async (text) => {
|
||||
await deliverIrcReply({
|
||||
payload: { text },
|
||||
cfg: config,
|
||||
target: message.senderNick,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onReplyError: (err) => {
|
||||
runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
|
||||
},
|
||||
});
|
||||
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
||||
return;
|
||||
}
|
||||
if (access.ingress.admission === "skip") {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
|
||||
return;
|
||||
}
|
||||
if (access.ingress.admission !== "dispatch") {
|
||||
if (
|
||||
message.isGroup &&
|
||||
access.ingress.decisiveGateId === "command" &&
|
||||
access.commandAccess.shouldBlockControlCommand
|
||||
) {
|
||||
logInboundDrop({
|
||||
log: (line) => runtime.log?.(line),
|
||||
channel: CHANNEL_ID,
|
||||
reason: "control command (unauthorized)",
|
||||
target: senderDisplay,
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.isGroup) {
|
||||
if (access.routeAccess.reason === "channel_not_allowlisted") {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
|
||||
} else if (access.routeAccess.reason === "channel_disabled") {
|
||||
runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
|
||||
} else {
|
||||
runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
|
||||
}
|
||||
} else {
|
||||
runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const channelTarget =
|
||||
message.target.startsWith("#") || message.target.startsWith("&")
|
||||
? message.target
|
||||
: `#${message.target}`;
|
||||
const peerId = message.isGroup ? channelTarget : message.senderNick;
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: message.isGroup ? "group" : "direct",
|
||||
id: peerId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: config.session?.store,
|
||||
});
|
||||
|
||||
const fromLabel = message.isGroup ? message.target : senderDisplay;
|
||||
const { storePath, body } = buildEnvelope({
|
||||
channel: "IRC",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
body: rawBody,
|
||||
});
|
||||
|
||||
const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
|
||||
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: body,
|
||||
RawBody: rawBody,
|
||||
CommandBody: rawBody,
|
||||
From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
|
||||
To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: message.isGroup ? "group" : "direct",
|
||||
ConversationLabel: fromLabel,
|
||||
SenderName: message.senderNick || undefined,
|
||||
SenderId: senderDisplay,
|
||||
GroupSubject: message.isGroup ? message.target : undefined,
|
||||
GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
|
||||
Provider: CHANNEL_ID,
|
||||
Surface: CHANNEL_ID,
|
||||
WasMentioned: message.isGroup ? wasMentioned : undefined,
|
||||
MessageSid: message.messageId,
|
||||
Timestamp: message.timestamp,
|
||||
OriginatingChannel: CHANNEL_ID,
|
||||
OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
});
|
||||
|
||||
await core.channel.inbound.dispatchReply({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
await deliverIrcReply({
|
||||
payload,
|
||||
cfg: config,
|
||||
target: peerId,
|
||||
accountId: account.accountId,
|
||||
sendReply: params.sendReply,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
replyOptions: {
|
||||
skillFilter: groupMatch.groupConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
29
extensions/irc/src/message-adapter.ts
Normal file
29
extensions/irc/src/message-adapter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Irc plugin module implements message adapter behavior.
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export const ircMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "irc",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
replyTo: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async ({ cfg, to, text, accountId, replyToId }) =>
|
||||
await sendMessageIrc(to, text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
}),
|
||||
media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
|
||||
await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
|
||||
cfg: cfg as CoreConfig,
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
}),
|
||||
},
|
||||
});
|
||||
44
extensions/irc/src/monitor.test.ts
Normal file
44
extensions/irc/src/monitor.test.ts
Normal file
@@ -0,0 +1,44 @@
|
||||
// Irc tests cover monitor plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIrcInboundTarget } from "./monitor.js";
|
||||
|
||||
describe("irc monitor inbound target", () => {
|
||||
it("keeps channel target for group messages", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "#openclaw",
|
||||
senderNick: "alice",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: true,
|
||||
target: "#openclaw",
|
||||
rawTarget: "#openclaw",
|
||||
});
|
||||
});
|
||||
|
||||
it("maps DM target to sender nick and preserves raw target", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "openclaw-bot",
|
||||
senderNick: "alice",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: false,
|
||||
target: "alice",
|
||||
rawTarget: "openclaw-bot",
|
||||
});
|
||||
});
|
||||
|
||||
it("falls back to raw target when sender nick is empty", () => {
|
||||
expect(
|
||||
resolveIrcInboundTarget({
|
||||
target: "openclaw-bot",
|
||||
senderNick: " ",
|
||||
}),
|
||||
).toEqual({
|
||||
isGroup: false,
|
||||
target: "openclaw-bot",
|
||||
rawTarget: "openclaw-bot",
|
||||
});
|
||||
});
|
||||
});
|
||||
150
extensions/irc/src/monitor.ts
Normal file
150
extensions/irc/src/monitor.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Irc plugin module implements monitor behavior.
|
||||
import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import { connectIrcClient, type IrcClient } from "./client.js";
|
||||
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||
import { handleIrcInbound } from "./inbound.js";
|
||||
import { isChannelTarget } from "./normalize.js";
|
||||
import { makeIrcMessageId } from "./protocol.js";
|
||||
import type { RuntimeEnv } from "./runtime-api.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
import type { CoreConfig, IrcInboundMessage } from "./types.js";
|
||||
|
||||
type IrcMonitorOptions = {
|
||||
accountId?: string;
|
||||
config?: CoreConfig;
|
||||
runtime?: RuntimeEnv;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
|
||||
onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
|
||||
};
|
||||
|
||||
export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
|
||||
isGroup: boolean;
|
||||
target: string;
|
||||
rawTarget: string;
|
||||
} {
|
||||
const rawTarget = params.target;
|
||||
const isGroup = isChannelTarget(rawTarget);
|
||||
if (isGroup) {
|
||||
return { isGroup: true, target: rawTarget, rawTarget };
|
||||
}
|
||||
const senderNick = params.senderNick.trim();
|
||||
return { isGroup: false, target: senderNick || rawTarget, rawTarget };
|
||||
}
|
||||
|
||||
export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
|
||||
const core = getIrcRuntime();
|
||||
const cfg = opts.config ?? (core.config.current() as CoreConfig);
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
const runtime: RuntimeEnv = resolveLoggerBackedRuntime(
|
||||
opts.runtime,
|
||||
core.logging.getChildLogger(),
|
||||
);
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
|
||||
const logger = core.logging.getChildLogger({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
let client: IrcClient | null = null;
|
||||
|
||||
client = await connectIrcClient(
|
||||
buildIrcConnectOptions(account, {
|
||||
channels: account.config.channels,
|
||||
abortSignal: opts.abortSignal,
|
||||
onLine: (line) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(`[${account.accountId}] << ${line}`);
|
||||
}
|
||||
},
|
||||
onNotice: (text, target) => {
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
|
||||
}
|
||||
},
|
||||
onError: (error) => {
|
||||
logger.error(`[${account.accountId}] IRC error: ${error.message}`);
|
||||
},
|
||||
onPrivmsg: async (event) => {
|
||||
if (!client) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
normalizeLowercaseStringOrEmpty(event.senderNick) ===
|
||||
normalizeLowercaseStringOrEmpty(client.nick)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const inboundTarget = resolveIrcInboundTarget({
|
||||
target: event.target,
|
||||
senderNick: event.senderNick,
|
||||
});
|
||||
const message: IrcInboundMessage = {
|
||||
messageId: makeIrcMessageId(),
|
||||
target: inboundTarget.target,
|
||||
rawTarget: inboundTarget.rawTarget,
|
||||
senderNick: event.senderNick,
|
||||
senderUser: event.senderUser,
|
||||
senderHost: event.senderHost,
|
||||
text: event.text,
|
||||
timestamp: Date.now(),
|
||||
isGroup: inboundTarget.isGroup,
|
||||
};
|
||||
|
||||
core.channel.activity.record({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
direction: "inbound",
|
||||
at: message.timestamp,
|
||||
});
|
||||
|
||||
if (opts.onMessage) {
|
||||
await opts.onMessage(message, client);
|
||||
return;
|
||||
}
|
||||
|
||||
await handleIrcInbound({
|
||||
message,
|
||||
account,
|
||||
config: cfg,
|
||||
runtime,
|
||||
connectedNick: client.nick,
|
||||
sendReply: async (target, text) => {
|
||||
client?.sendPrivmsg(target, text);
|
||||
opts.statusSink?.({ lastOutboundAt: Date.now() });
|
||||
core.channel.activity.record({
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
},
|
||||
statusSink: opts.statusSink,
|
||||
});
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
logger.info(
|
||||
`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
|
||||
);
|
||||
|
||||
return {
|
||||
stop: () => {
|
||||
client?.quit("shutdown");
|
||||
client = null;
|
||||
},
|
||||
};
|
||||
}
|
||||
36
extensions/irc/src/normalize.test.ts
Normal file
36
extensions/irc/src/normalize.test.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
// Irc tests cover normalize plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
buildIrcAllowlistCandidates,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
|
||||
describe("irc normalize", () => {
|
||||
it("normalizes targets", () => {
|
||||
expect(normalizeIrcMessagingTarget("irc:channel:openclaw")).toBe("#openclaw");
|
||||
expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice");
|
||||
expect(normalizeIrcMessagingTarget("\n")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries", () => {
|
||||
expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h");
|
||||
});
|
||||
|
||||
it("matches senders by nick/user/host candidates", () => {
|
||||
const message = {
|
||||
messageId: "m1",
|
||||
target: "#chan",
|
||||
senderNick: "Alice",
|
||||
senderUser: "ident",
|
||||
senderHost: "example.org",
|
||||
text: "hi",
|
||||
timestamp: Date.now(),
|
||||
isGroup: true,
|
||||
};
|
||||
|
||||
expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
|
||||
expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
|
||||
expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
|
||||
});
|
||||
});
|
||||
86
extensions/irc/src/normalize.ts
Normal file
86
extensions/irc/src/normalize.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
// Irc helper module supports normalize behavior.
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalLowercaseString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { hasIrcControlChars } from "./control-chars.js";
|
||||
import type { IrcInboundMessage } from "./types.js";
|
||||
|
||||
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
|
||||
|
||||
export function isChannelTarget(target: string): boolean {
|
||||
return target.startsWith("#") || target.startsWith("&");
|
||||
}
|
||||
|
||||
export function normalizeIrcMessagingTarget(raw: string): string | undefined {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
let target = trimmed;
|
||||
const lowered = normalizeLowercaseStringOrEmpty(target);
|
||||
if (lowered.startsWith("irc:")) {
|
||||
target = target.slice("irc:".length).trim();
|
||||
}
|
||||
if (normalizeLowercaseStringOrEmpty(target).startsWith("channel:")) {
|
||||
target = target.slice("channel:".length).trim();
|
||||
if (!target.startsWith("#") && !target.startsWith("&")) {
|
||||
target = `#${target}`;
|
||||
}
|
||||
}
|
||||
if (normalizeLowercaseStringOrEmpty(target).startsWith("user:")) {
|
||||
target = target.slice("user:".length).trim();
|
||||
}
|
||||
if (!target || !looksLikeIrcTargetId(target)) {
|
||||
return undefined;
|
||||
}
|
||||
return target;
|
||||
}
|
||||
|
||||
export function looksLikeIrcTargetId(raw: string): boolean {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
if (hasIrcControlChars(trimmed)) {
|
||||
return false;
|
||||
}
|
||||
return IRC_TARGET_PATTERN.test(trimmed);
|
||||
}
|
||||
|
||||
export function normalizeIrcAllowEntry(raw: string): string {
|
||||
let value = normalizeLowercaseStringOrEmpty(raw);
|
||||
if (!value) {
|
||||
return "";
|
||||
}
|
||||
if (value.startsWith("irc:")) {
|
||||
value = value.slice("irc:".length);
|
||||
}
|
||||
if (value.startsWith("user:")) {
|
||||
value = value.slice("user:".length);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
export function buildIrcAllowlistCandidates(
|
||||
message: IrcInboundMessage,
|
||||
params?: { allowNameMatching?: boolean },
|
||||
): string[] {
|
||||
const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
|
||||
const user = normalizeOptionalLowercaseString(message.senderUser);
|
||||
const host = normalizeOptionalLowercaseString(message.senderHost);
|
||||
const candidates = new Set<string>();
|
||||
if (nick && params?.allowNameMatching === true) {
|
||||
candidates.add(nick);
|
||||
}
|
||||
if (nick && user) {
|
||||
candidates.add(`${nick}!${user}`);
|
||||
}
|
||||
if (nick && host) {
|
||||
candidates.add(`${nick}@${host}`);
|
||||
}
|
||||
if (nick && user && host) {
|
||||
candidates.add(`${nick}!${user}@${host}`);
|
||||
}
|
||||
return [...candidates];
|
||||
}
|
||||
16
extensions/irc/src/outbound-base.ts
Normal file
16
extensions/irc/src/outbound-base.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Irc plugin module implements outbound base behavior.
|
||||
import { sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { chunkTextForOutbound } from "./channel-api.js";
|
||||
|
||||
export const ircOutboundBaseAdapter = {
|
||||
deliveryMode: "direct" as const,
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown" as const,
|
||||
textChunkLimit: 350,
|
||||
// IRC's plain-text pass does not remove assistant scaffolding. Run the
|
||||
// canonical delivery sanitizer first so internal tool traces are dropped
|
||||
// before channel formatting.
|
||||
sanitizeText: ({ text }: { text: string }) =>
|
||||
sanitizeForPlainText(sanitizeAssistantVisibleText(text)),
|
||||
};
|
||||
56
extensions/irc/src/policy.test.ts
Normal file
56
extensions/irc/src/policy.test.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
// Irc tests cover policy plugin behavior.
|
||||
import { resolveChannelGroupPolicy } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
|
||||
|
||||
describe("irc policy", () => {
|
||||
it("matches direct and wildcard group entries", () => {
|
||||
const direct = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"#ops": { requireMention: false },
|
||||
},
|
||||
target: "#ops",
|
||||
});
|
||||
expect(direct.allowed).toBe(true);
|
||||
expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false);
|
||||
|
||||
const wildcard = resolveIrcGroupMatch({
|
||||
groups: {
|
||||
"*": { requireMention: true },
|
||||
},
|
||||
target: "#random",
|
||||
});
|
||||
expect(wildcard.allowed).toBe(true);
|
||||
expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
|
||||
const groups = {
|
||||
"#Ops": { requireMention: false },
|
||||
"#Hidden": { enabled: false },
|
||||
"*": { requireMention: true },
|
||||
};
|
||||
|
||||
const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" });
|
||||
const sharedDirect = resolveChannelGroupPolicy({
|
||||
cfg: { channels: { irc: { groups } } },
|
||||
channel: "irc",
|
||||
groupId: "#ops",
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
expect(sharedDirect.allowed).toBe(inboundDirect.allowed);
|
||||
expect(sharedDirect.groupConfig?.requireMention).toBe(
|
||||
inboundDirect.groupConfig?.requireMention,
|
||||
);
|
||||
|
||||
const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" });
|
||||
const sharedDisabled = resolveChannelGroupPolicy({
|
||||
cfg: { channels: { irc: { groups } } },
|
||||
channel: "irc",
|
||||
groupId: "#hidden",
|
||||
groupIdCaseInsensitive: true,
|
||||
});
|
||||
expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed);
|
||||
expect(inboundDisabled.groupConfig?.enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
79
extensions/irc/src/policy.ts
Normal file
79
extensions/irc/src/policy.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
// Irc plugin module implements policy behavior.
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { IrcChannelConfig } from "./types.js";
|
||||
|
||||
export type IrcGroupMatch = {
|
||||
allowed: boolean;
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
hasConfiguredGroups: boolean;
|
||||
};
|
||||
|
||||
export function resolveIrcGroupMatch(params: {
|
||||
groups?: Record<string, IrcChannelConfig>;
|
||||
target: string;
|
||||
}): IrcGroupMatch {
|
||||
const groups = params.groups ?? {};
|
||||
const hasConfiguredGroups = Object.keys(groups).length > 0;
|
||||
|
||||
// IRC channel targets are case-insensitive, but config keys are plain strings.
|
||||
// To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match
|
||||
// group config keys case-insensitively.
|
||||
const direct = groups[params.target];
|
||||
if (direct) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are represented later as ingress route facts.
|
||||
allowed: true,
|
||||
groupConfig: direct,
|
||||
wildcardConfig: groups["*"],
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
|
||||
const targetLower = normalizeLowercaseStringOrEmpty(params.target);
|
||||
const directKey = Object.keys(groups).find(
|
||||
(key) => normalizeLowercaseStringOrEmpty(key) === targetLower,
|
||||
);
|
||||
if (directKey) {
|
||||
const matched = groups[directKey];
|
||||
if (matched) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are represented later as ingress route facts.
|
||||
allowed: true,
|
||||
groupConfig: matched,
|
||||
wildcardConfig: groups["*"],
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const wildcard = groups["*"];
|
||||
if (wildcard) {
|
||||
return {
|
||||
// "allowed" means the target matched an allowlisted key.
|
||||
// Explicit disables are represented later as ingress route facts.
|
||||
allowed: true,
|
||||
wildcardConfig: wildcard,
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
return {
|
||||
allowed: false,
|
||||
hasConfiguredGroups,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveIrcRequireMention(params: {
|
||||
groupConfig?: IrcChannelConfig;
|
||||
wildcardConfig?: IrcChannelConfig;
|
||||
}): boolean {
|
||||
if (params.groupConfig?.requireMention !== undefined) {
|
||||
return params.groupConfig.requireMention;
|
||||
}
|
||||
if (params.wildcardConfig?.requireMention !== undefined) {
|
||||
return params.wildcardConfig.requireMention;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
111
extensions/irc/src/probe.test.ts
Normal file
111
extensions/irc/src/probe.test.ts
Normal file
@@ -0,0 +1,111 @@
|
||||
// Irc tests cover probe plugin behavior.
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeIrc } from "./probe.js";
|
||||
|
||||
const resolveIrcAccountMock = vi.hoisted(() => vi.fn());
|
||||
const buildIrcConnectOptionsMock = vi.hoisted(() => vi.fn());
|
||||
const connectIrcClientMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveIrcAccount: resolveIrcAccountMock,
|
||||
}));
|
||||
|
||||
vi.mock("./connect-options.js", () => ({
|
||||
buildIrcConnectOptions: buildIrcConnectOptionsMock,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
connectIrcClient: connectIrcClientMock,
|
||||
}));
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./accounts.js");
|
||||
vi.doUnmock("./connect-options.js");
|
||||
vi.doUnmock("./client.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("probeIrc", () => {
|
||||
beforeEach(() => {
|
||||
resolveIrcAccountMock.mockReset();
|
||||
buildIrcConnectOptionsMock.mockReset();
|
||||
connectIrcClientMock.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns a configuration error when the IRC account is incomplete", async () => {
|
||||
resolveIrcAccountMock.mockReturnValue({
|
||||
configured: false,
|
||||
host: "",
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "",
|
||||
});
|
||||
|
||||
await expect(probeIrc({} as never)).resolves.toEqual({
|
||||
ok: false,
|
||||
host: "",
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "",
|
||||
error: "missing host or nick",
|
||||
});
|
||||
expect(connectIrcClientMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns latency and quits the probe client on success", async () => {
|
||||
const account = {
|
||||
configured: true,
|
||||
host: "irc.libera.chat",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
};
|
||||
resolveIrcAccountMock.mockReturnValue(account);
|
||||
buildIrcConnectOptionsMock.mockReturnValue({ host: "irc.libera.chat" });
|
||||
const quit = vi.fn();
|
||||
connectIrcClientMock.mockResolvedValue({ quit });
|
||||
const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(100).mockReturnValueOnce(145);
|
||||
|
||||
try {
|
||||
const result = await probeIrc({} as never, { timeoutMs: 5000 });
|
||||
|
||||
expect(buildIrcConnectOptionsMock).toHaveBeenCalledWith(account, { connectTimeoutMs: 5000 });
|
||||
expect(result).toEqual({
|
||||
ok: true,
|
||||
host: "irc.libera.chat",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
latencyMs: 45,
|
||||
});
|
||||
expect(quit).toHaveBeenCalledWith("probe");
|
||||
} finally {
|
||||
nowSpy.mockRestore();
|
||||
}
|
||||
});
|
||||
|
||||
it("formats non-Error probe failures into the returned error field", async () => {
|
||||
resolveIrcAccountMock.mockReturnValue({
|
||||
configured: true,
|
||||
host: "irc.libera.chat",
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "openclaw",
|
||||
});
|
||||
buildIrcConnectOptionsMock.mockReturnValue({ host: "irc.libera.chat" });
|
||||
connectIrcClientMock.mockRejectedValue({ code: "ECONNREFUSED" });
|
||||
|
||||
await expect(probeIrc({} as never)).resolves.toEqual({
|
||||
ok: false,
|
||||
host: "irc.libera.chat",
|
||||
port: 6667,
|
||||
tls: false,
|
||||
nick: "openclaw",
|
||||
error: JSON.stringify({ code: "ECONNREFUSED" }),
|
||||
});
|
||||
});
|
||||
});
|
||||
54
extensions/irc/src/probe.ts
Normal file
54
extensions/irc/src/probe.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
// Irc plugin module implements probe behavior.
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import { connectIrcClient } from "./client.js";
|
||||
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||
import type { CoreConfig, IrcProbe } from "./types.js";
|
||||
|
||||
function formatError(err: unknown): string {
|
||||
if (err instanceof Error) {
|
||||
return err.message;
|
||||
}
|
||||
return typeof err === "string" ? err : JSON.stringify(err);
|
||||
}
|
||||
|
||||
export async function probeIrc(
|
||||
cfg: CoreConfig,
|
||||
opts?: { accountId?: string; timeoutMs?: number },
|
||||
): Promise<IrcProbe> {
|
||||
const account = resolveIrcAccount({ cfg, accountId: opts?.accountId });
|
||||
const base: IrcProbe = {
|
||||
ok: false,
|
||||
host: account.host,
|
||||
port: account.port,
|
||||
tls: account.tls,
|
||||
nick: account.nick,
|
||||
};
|
||||
|
||||
if (!account.configured) {
|
||||
return {
|
||||
...base,
|
||||
error: "missing host or nick",
|
||||
};
|
||||
}
|
||||
|
||||
const started = Date.now();
|
||||
try {
|
||||
const client = await connectIrcClient(
|
||||
buildIrcConnectOptions(account, {
|
||||
connectTimeoutMs: opts?.timeoutMs ?? 8000,
|
||||
}),
|
||||
);
|
||||
const elapsed = Date.now() - started;
|
||||
client.quit("probe");
|
||||
return {
|
||||
...base,
|
||||
ok: true,
|
||||
latencyMs: elapsed,
|
||||
};
|
||||
} catch (err) {
|
||||
return {
|
||||
...base,
|
||||
error: formatError(err),
|
||||
};
|
||||
}
|
||||
}
|
||||
87
extensions/irc/src/protocol.test.ts
Normal file
87
extensions/irc/src/protocol.test.ts
Normal file
@@ -0,0 +1,87 @@
|
||||
// Irc tests cover protocol plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
parseIrcLine,
|
||||
parseIrcPrefix,
|
||||
sanitizeIrcOutboundText,
|
||||
sanitizeIrcTarget,
|
||||
} from "./protocol.js";
|
||||
|
||||
describe("irc protocol", () => {
|
||||
it("parses PRIVMSG lines with prefix and trailing", () => {
|
||||
const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world");
|
||||
expect(parsed).toEqual({
|
||||
raw: ":alice!u@host PRIVMSG #room :hello world",
|
||||
prefix: "alice!u@host",
|
||||
command: "PRIVMSG",
|
||||
params: ["#room"],
|
||||
trailing: "hello world",
|
||||
});
|
||||
|
||||
expect(parseIrcPrefix(parsed?.prefix)).toEqual({
|
||||
nick: "alice",
|
||||
user: "u",
|
||||
host: "host",
|
||||
});
|
||||
});
|
||||
|
||||
it("sanitizes outbound text to prevent command injection", () => {
|
||||
expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops");
|
||||
expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test");
|
||||
});
|
||||
|
||||
it("validates targets and rejects control characters", () => {
|
||||
expect(sanitizeIrcTarget("#openclaw")).toBe("#openclaw");
|
||||
expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/);
|
||||
expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
|
||||
});
|
||||
|
||||
describe("\\u escape surrogate-range guard", () => {
|
||||
const LONE_SURROGATE = /[\uD800-\uDFFF]/;
|
||||
|
||||
it("preserves literal \\uXXXX when codepoint is a high surrogate", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uD800");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves literal \\uXXXX when codepoint is a low surrogate", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uDFFF");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
});
|
||||
|
||||
it("still decodes valid BMP codepoints outside the surrogate range", () => {
|
||||
expect(sanitizeIrcOutboundText("\\u0041")).toBe("A");
|
||||
expect(sanitizeIrcOutboundText("\\u00e9")).toBe("é"); // é
|
||||
});
|
||||
|
||||
it("decodes adjacent surrogate-pair escapes to the astral character", () => {
|
||||
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00")).toBe("😀");
|
||||
expect(sanitizeIrcOutboundText("\\uD83D\\uDE00\\uD83D\\uDE01")).toBe("😀😁");
|
||||
});
|
||||
|
||||
it("preserves lone high surrogate even when followed by a non-surrogate \\u", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uD800\\u0041");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
expect(out).toContain("A");
|
||||
});
|
||||
|
||||
it("decodes BMP-escaped prefix before a surrogate pair correctly", () => {
|
||||
// Regression: \\u0041\\uD83D\\uDE00 must yield A😀, not A\\uD83D\\uDE00.
|
||||
// The old step-1 regex \\u(xxxx)\\u(xxxx) would consume \\u0041\\uD83D as a
|
||||
// non-pair, leaving \\uDE00 as a lone surrogate.
|
||||
expect(sanitizeIrcOutboundText("\\u0041\\uD83D\\uDE00")).toBe("A😀");
|
||||
});
|
||||
|
||||
it("handles lone high surrogate followed by a different surrogate pair", () => {
|
||||
// \\uD800\\uD83D\\uDE00: D800 is lone (no matching low), D83D+DE00 form 😀.
|
||||
// Use toBe rather than LONE_SURROGATE regex: emoji contains surrogate
|
||||
// code units internally that would trigger a naive /[\uD800-\uDFFF]/ check.
|
||||
expect(sanitizeIrcOutboundText("\\uD800\\uD83D\\uDE00")).toBe("\\uD800😀");
|
||||
});
|
||||
|
||||
it("preserves two consecutive lone high surrogates", () => {
|
||||
const out = sanitizeIrcOutboundText("\\uD800\\uD801");
|
||||
expect(LONE_SURROGATE.test(out)).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
161
extensions/irc/src/protocol.ts
Normal file
161
extensions/irc/src/protocol.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
// Irc plugin module implements protocol behavior.
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
|
||||
|
||||
const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
|
||||
|
||||
type ParsedIrcLine = {
|
||||
raw: string;
|
||||
prefix?: string;
|
||||
command: string;
|
||||
params: string[];
|
||||
trailing?: string;
|
||||
};
|
||||
|
||||
type ParsedIrcPrefix = {
|
||||
nick?: string;
|
||||
user?: string;
|
||||
host?: string;
|
||||
server?: string;
|
||||
};
|
||||
|
||||
export function parseIrcLine(line: string): ParsedIrcLine | null {
|
||||
const raw = line.replace(/[\r\n]+/g, "").trim();
|
||||
if (!raw) {
|
||||
return null;
|
||||
}
|
||||
|
||||
let cursor = raw;
|
||||
let prefix: string | undefined;
|
||||
if (cursor.startsWith(":")) {
|
||||
const idx = cursor.indexOf(" ");
|
||||
if (idx <= 1) {
|
||||
return null;
|
||||
}
|
||||
prefix = cursor.slice(1, idx);
|
||||
cursor = cursor.slice(idx + 1).trimStart();
|
||||
}
|
||||
|
||||
if (!cursor) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const firstSpace = cursor.indexOf(" ");
|
||||
const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim();
|
||||
if (!command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1);
|
||||
const params: string[] = [];
|
||||
let trailing: string | undefined;
|
||||
|
||||
while (cursor.length > 0) {
|
||||
cursor = cursor.trimStart();
|
||||
if (!cursor) {
|
||||
break;
|
||||
}
|
||||
if (cursor.startsWith(":")) {
|
||||
trailing = cursor.slice(1);
|
||||
break;
|
||||
}
|
||||
const spaceIdx = cursor.indexOf(" ");
|
||||
if (spaceIdx === -1) {
|
||||
params.push(cursor);
|
||||
break;
|
||||
}
|
||||
params.push(cursor.slice(0, spaceIdx));
|
||||
cursor = cursor.slice(spaceIdx + 1);
|
||||
}
|
||||
|
||||
return {
|
||||
raw,
|
||||
prefix,
|
||||
command: command.toUpperCase(),
|
||||
params,
|
||||
trailing,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
|
||||
if (!prefix) {
|
||||
return {};
|
||||
}
|
||||
const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/);
|
||||
if (nickPart) {
|
||||
return {
|
||||
nick: nickPart[1],
|
||||
user: nickPart[2],
|
||||
host: nickPart[3],
|
||||
};
|
||||
}
|
||||
const nickHostPart = prefix.match(/^([^@]+)@(.+)$/);
|
||||
if (nickHostPart) {
|
||||
return {
|
||||
nick: nickHostPart[1],
|
||||
host: nickHostPart[2],
|
||||
};
|
||||
}
|
||||
if (prefix.includes("!")) {
|
||||
const [nick, user] = prefix.split("!", 2);
|
||||
return { nick, user };
|
||||
}
|
||||
if (prefix.includes(".")) {
|
||||
return { server: prefix };
|
||||
}
|
||||
return { nick: prefix };
|
||||
}
|
||||
|
||||
function decodeLiteralEscapes(input: string): string {
|
||||
// Defensive: this is not a full JS string unescaper.
|
||||
// It's just enough to catch common "\r\n" / "\u0001" style payloads.
|
||||
return (
|
||||
input
|
||||
.replace(/\\r/g, "\r")
|
||||
.replace(/\\n/g, "\n")
|
||||
.replace(/\\t/g, "\t")
|
||||
.replace(/\\0/g, "\0")
|
||||
.replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
|
||||
// Step 1: decode surrogate pairs — first group locked to high surrogate range
|
||||
// (U+D800–U+DBFF: [dD][89abAB]xx), second to low surrogate range
|
||||
// (U+DC00–U+DFFF: [dD][c-fC-F]xx). This prevents a non-surrogate \uXXXX
|
||||
// from being greedily paired with the following high surrogate and consuming it.
|
||||
.replace(/\\u([dD][89abAB][0-9a-fA-F]{2})\\u([dD][c-fC-F][0-9a-fA-F]{2})/g, (_match, h, l) =>
|
||||
String.fromCodePoint(
|
||||
0x10000 + ((Number.parseInt(h, 16) - 0xd800) << 10) + (Number.parseInt(l, 16) - 0xdc00),
|
||||
),
|
||||
)
|
||||
// Step 2: decode BMP codepoints; preserve lone surrogates as literals
|
||||
.replace(/\\u([0-9a-fA-F]{4})/g, (match, hex) => {
|
||||
const codePoint = Number.parseInt(hex, 16);
|
||||
return codePoint >= 0xd800 && codePoint <= 0xdfff ? match : String.fromCharCode(codePoint);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
export function sanitizeIrcOutboundText(text: string): string {
|
||||
const decoded = decodeLiteralEscapes(text);
|
||||
return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim();
|
||||
}
|
||||
|
||||
export function sanitizeIrcTarget(raw: string): string {
|
||||
const decoded = decodeLiteralEscapes(raw);
|
||||
if (!decoded) {
|
||||
throw new Error("IRC target is required");
|
||||
}
|
||||
// Reject any surrounding whitespace instead of trimming it away.
|
||||
if (decoded !== decoded.trim()) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
if (hasIrcControlChars(decoded)) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
if (!IRC_TARGET_PATTERN.test(decoded)) {
|
||||
throw new Error(`Invalid IRC target: ${raw}`);
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
export function makeIrcMessageId() {
|
||||
return randomUUID();
|
||||
}
|
||||
42
extensions/irc/src/runtime-api.ts
Normal file
42
extensions/irc/src/runtime-api.ts
Normal file
@@ -0,0 +1,42 @@
|
||||
// Private runtime barrel for the bundled IRC extension.
|
||||
// Keep this barrel thin and generic-only.
|
||||
|
||||
export type { BaseProbeResult } from "openclaw/plugin-sdk/channel-contract";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
|
||||
export type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyBySenderConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownConfig,
|
||||
} from "openclaw/plugin-sdk/config-contracts";
|
||||
export type { OutboundReplyPayload } from "openclaw/plugin-sdk/reply-payload";
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
||||
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
export {
|
||||
PAIRING_APPROVED_MESSAGE,
|
||||
buildBaseChannelStatusSummary,
|
||||
} from "openclaw/plugin-sdk/channel-status";
|
||||
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
|
||||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { resolveControlCommandGate } from "openclaw/plugin-sdk/command-auth-native";
|
||||
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
export {
|
||||
deliverFormattedTextWithAttachments,
|
||||
formatTextWithAttachmentLinks,
|
||||
resolveOutboundMediaUrls,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
export {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
} from "openclaw/plugin-sdk/runtime-group-policy";
|
||||
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
||||
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
|
||||
16
extensions/irc/src/runtime.ts
Normal file
16
extensions/irc/src/runtime.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
// Irc plugin module implements runtime behavior.
|
||||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "./runtime-api.js";
|
||||
|
||||
const {
|
||||
setRuntime: setIrcRuntime,
|
||||
clearRuntime: clearStoredIrcRuntime,
|
||||
getRuntime: getIrcRuntime,
|
||||
} = createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "irc",
|
||||
errorMessage: "IRC runtime not initialized",
|
||||
});
|
||||
export { getIrcRuntime, setIrcRuntime };
|
||||
export function clearIrcRuntime() {
|
||||
clearStoredIrcRuntime();
|
||||
}
|
||||
104
extensions/irc/src/secret-contract.ts
Normal file
104
extensions/irc/src/secret-contract.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Irc plugin module implements secret contract behavior.
|
||||
import {
|
||||
collectNestedChannelFieldAssignments,
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
isBaseFieldActiveForChannelSurface,
|
||||
isEnabledFlag,
|
||||
isRecord,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] =
|
||||
[
|
||||
{
|
||||
id: "channels.irc.accounts.*.nickserv.password",
|
||||
targetType: "channels.irc.accounts.*.nickserv.password",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.irc.accounts.*.nickserv.password",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.irc.accounts.*.password",
|
||||
targetType: "channels.irc.accounts.*.password",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.irc.accounts.*.password",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.irc.nickserv.password",
|
||||
targetType: "channels.irc.nickserv.password",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.irc.nickserv.password",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.irc.password",
|
||||
targetType: "channels.irc.password",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.irc.password",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults?: SecretDefaults;
|
||||
context: ResolverContext;
|
||||
}): void {
|
||||
const resolved = getChannelSurface(params.config, "irc");
|
||||
if (!resolved) {
|
||||
return;
|
||||
}
|
||||
const { channel: irc, surface } = resolved;
|
||||
collectSimpleChannelFieldAssignments({
|
||||
channelKey: "irc",
|
||||
field: "password",
|
||||
channel: irc,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topInactiveReason: "no enabled account inherits this top-level IRC password.",
|
||||
accountInactiveReason: "IRC account is disabled.",
|
||||
});
|
||||
collectNestedChannelFieldAssignments({
|
||||
channelKey: "irc",
|
||||
nestedKey: "nickserv",
|
||||
field: "password",
|
||||
channel: irc,
|
||||
surface,
|
||||
defaults: params.defaults,
|
||||
context: params.context,
|
||||
topLevelActive:
|
||||
isBaseFieldActiveForChannelSurface(surface, "nickserv") &&
|
||||
isRecord(irc.nickserv) &&
|
||||
isEnabledFlag(irc.nickserv),
|
||||
topInactiveReason:
|
||||
"no enabled account inherits this top-level IRC nickserv config or NickServ is disabled.",
|
||||
accountActive: ({ account, enabled }) =>
|
||||
enabled && isRecord(account.nickserv) && isEnabledFlag(account.nickserv),
|
||||
accountInactiveReason: "IRC account is disabled or NickServ is disabled for this account.",
|
||||
});
|
||||
}
|
||||
|
||||
export const channelSecrets = {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
};
|
||||
327
extensions/irc/src/send.test.ts
Normal file
327
extensions/irc/src/send.test.ts
Normal file
@@ -0,0 +1,327 @@
|
||||
// Irc tests cover send plugin behavior.
|
||||
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { createSendCfgThreadingRuntime } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { IrcClient } from "./client.js";
|
||||
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => {
|
||||
const loadConfig = vi.fn();
|
||||
const resolveMarkdownTableMode = vi.fn(() => "preserve");
|
||||
const convertMarkdownTables = vi.fn((text: string) => text);
|
||||
const record = vi.fn();
|
||||
return {
|
||||
loadConfig,
|
||||
resolveMarkdownTableMode,
|
||||
convertMarkdownTables,
|
||||
record,
|
||||
normalizeIrcMessagingTarget: vi.fn((value: string) => value.trim()),
|
||||
connectIrcClient: vi.fn(),
|
||||
buildIrcConnectOptions: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./normalize.js", () => ({
|
||||
normalizeIrcMessagingTarget: hoisted.normalizeIrcMessagingTarget,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
connectIrcClient: hoisted.connectIrcClient,
|
||||
}));
|
||||
|
||||
vi.mock("./connect-options.js", () => ({
|
||||
buildIrcConnectOptions: hoisted.buildIrcConnectOptions,
|
||||
}));
|
||||
|
||||
vi.mock("./protocol.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./protocol.js")>("./protocol.js");
|
||||
return {
|
||||
...actual,
|
||||
makeIrcMessageId: () => "irc-msg-1",
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/plugin-config-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
...original,
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/text-chunking", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/text-chunking")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return {
|
||||
...original,
|
||||
convertMarkdownTables: hoisted.convertMarkdownTables,
|
||||
};
|
||||
});
|
||||
|
||||
import { ircMessageAdapter } from "./message-adapter.js";
|
||||
import { sendMessageIrc } from "./send.js";
|
||||
|
||||
function resetHoistedMocks() {
|
||||
hoisted.loadConfig.mockReset();
|
||||
hoisted.resolveMarkdownTableMode.mockReset().mockReturnValue("preserve");
|
||||
hoisted.convertMarkdownTables.mockReset().mockImplementation((text: string) => text);
|
||||
hoisted.record.mockReset();
|
||||
hoisted.normalizeIrcMessagingTarget
|
||||
.mockReset()
|
||||
.mockImplementation((value: string) => value.trim());
|
||||
hoisted.connectIrcClient.mockReset();
|
||||
hoisted.buildIrcConnectOptions.mockReset().mockReturnValue({});
|
||||
}
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./normalize.js");
|
||||
vi.doUnmock("./client.js");
|
||||
vi.doUnmock("./connect-options.js");
|
||||
vi.doUnmock("./protocol.js");
|
||||
vi.doUnmock("openclaw/plugin-sdk/plugin-config-runtime");
|
||||
vi.doUnmock("openclaw/plugin-sdk/text-chunking");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
describe("sendMessageIrc cfg threading", () => {
|
||||
beforeEach(() => {
|
||||
resetHoistedMocks();
|
||||
setIrcRuntime(createSendCfgThreadingRuntime(hoisted) as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("uses explicitly provided cfg without loading runtime config", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.example.com",
|
||||
nick: "workbot",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
const result = await sendMessageIrc("#room", "hello", {
|
||||
cfg: providedCfg,
|
||||
client,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
|
||||
expect(hoisted.record).toHaveBeenCalledWith({
|
||||
channel: "irc",
|
||||
accountId: "work",
|
||||
direction: "outbound",
|
||||
});
|
||||
expect(result.target).toBe("#room");
|
||||
expect(result.messageId).toBeTypeOf("string");
|
||||
expect(result.messageId.length).toBeGreaterThan(0);
|
||||
expect(result.receipt.sentAt).toBeTypeOf("number");
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
expect({ ...result.receipt, sentAt: 123 }).toEqual({
|
||||
primaryPlatformMessageId: "irc-msg-1",
|
||||
platformMessageIds: ["irc-msg-1"],
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "irc-msg-1",
|
||||
kind: "text",
|
||||
index: 0,
|
||||
raw: {
|
||||
channel: "irc",
|
||||
conversationId: "#room",
|
||||
messageId: "irc-msg-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
sentAt: 123,
|
||||
raw: [
|
||||
{
|
||||
channel: "irc",
|
||||
conversationId: "#room",
|
||||
messageId: "irc-msg-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("fails hard when cfg is omitted", async () => {
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
await expect(sendMessageIrc("#ops", "ping", { client } as never)).rejects.toThrow(
|
||||
"IRC send requires a resolved runtime config",
|
||||
);
|
||||
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(client.sendPrivmsg).not.toHaveBeenCalled();
|
||||
expect(hoisted.record).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
hoisted.record.mockImplementation(() => {
|
||||
throw new Error("IRC runtime not initialized");
|
||||
});
|
||||
|
||||
const result = await sendMessageIrc("#room", "hello", {
|
||||
cfg: providedCfg,
|
||||
client,
|
||||
});
|
||||
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
|
||||
expect(result.target).toBe("#room");
|
||||
expect(result.messageId).toBeTypeOf("string");
|
||||
expect(result.messageId.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it("preserves reply ids in receipts", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
sendPrivmsg: vi.fn(),
|
||||
} as unknown as IrcClient;
|
||||
|
||||
const result = await sendMessageIrc("#room", "hello", {
|
||||
cfg: providedCfg,
|
||||
client,
|
||||
replyTo: "irc-parent-1",
|
||||
});
|
||||
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello\n\n[reply:irc-parent-1]");
|
||||
expect(result.receipt.sentAt).toBeTypeOf("number");
|
||||
expect(result.receipt.sentAt).toBeGreaterThan(0);
|
||||
expect({ ...result.receipt, sentAt: 123 }).toEqual({
|
||||
primaryPlatformMessageId: "irc-msg-1",
|
||||
platformMessageIds: ["irc-msg-1"],
|
||||
replyToId: "irc-parent-1",
|
||||
parts: [
|
||||
{
|
||||
platformMessageId: "irc-msg-1",
|
||||
kind: "text",
|
||||
index: 0,
|
||||
replyToId: "irc-parent-1",
|
||||
raw: {
|
||||
channel: "irc",
|
||||
conversationId: "#room",
|
||||
messageId: "irc-msg-1",
|
||||
},
|
||||
},
|
||||
],
|
||||
sentAt: 123,
|
||||
raw: [
|
||||
{
|
||||
channel: "irc",
|
||||
conversationId: "#room",
|
||||
messageId: "irc-msg-1",
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it("declares message adapter durable text, media, and reply with receipt proofs", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
irc: {
|
||||
host: "irc.example.com",
|
||||
nick: "openclaw",
|
||||
},
|
||||
},
|
||||
} as unknown as CoreConfig;
|
||||
const client = {
|
||||
isReady: vi.fn(() => true),
|
||||
join: vi.fn(),
|
||||
sendPrivmsg: vi.fn(),
|
||||
quit: vi.fn(),
|
||||
} as unknown as IrcClient & {
|
||||
join: ReturnType<typeof vi.fn>;
|
||||
quit: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
hoisted.connectIrcClient.mockResolvedValue(client);
|
||||
|
||||
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "irc",
|
||||
adapter: ircMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await ircMessageAdapter.send?.text?.({
|
||||
cfg: providedCfg,
|
||||
to: "#room",
|
||||
text: "hello",
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]);
|
||||
expect(client.join).toHaveBeenCalledWith("#room");
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "hello");
|
||||
},
|
||||
media: async () => {
|
||||
const result = await ircMessageAdapter.send?.media?.({
|
||||
cfg: providedCfg,
|
||||
to: "#room",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["irc-msg-1"]);
|
||||
expect(client.join).toHaveBeenCalledWith("#room");
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith(
|
||||
"#room",
|
||||
"image\n\nAttachment: https://example.com/image.png",
|
||||
);
|
||||
},
|
||||
replyTo: async () => {
|
||||
const result = await ircMessageAdapter.send?.text?.({
|
||||
cfg: providedCfg,
|
||||
to: "#room",
|
||||
text: "threaded",
|
||||
replyToId: "parent-1",
|
||||
});
|
||||
expect(result?.receipt.replyToId).toBe("parent-1");
|
||||
expect(client.join).toHaveBeenCalledWith("#room");
|
||||
expect(client.sendPrivmsg).toHaveBeenCalledWith("#room", "threaded\n\n[reply:parent-1]");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults.find((result) => result.capability === "text")?.status).toBe("verified");
|
||||
expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified");
|
||||
expect(proofResults.find((result) => result.capability === "replyTo")?.status).toBe("verified");
|
||||
});
|
||||
});
|
||||
122
extensions/irc/src/send.ts
Normal file
122
extensions/irc/src/send.ts
Normal file
@@ -0,0 +1,122 @@
|
||||
// Irc plugin module implements send behavior.
|
||||
import {
|
||||
createMessageReceiptFromOutboundResults,
|
||||
type MessageReceipt,
|
||||
} from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
import { resolveIrcAccount } from "./accounts.js";
|
||||
import type { IrcClient } from "./client.js";
|
||||
import { connectIrcClient } from "./client.js";
|
||||
import { buildIrcConnectOptions } from "./connect-options.js";
|
||||
import { normalizeIrcMessagingTarget } from "./normalize.js";
|
||||
import { makeIrcMessageId } from "./protocol.js";
|
||||
import { getIrcRuntime } from "./runtime.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
type SendIrcOptions = {
|
||||
cfg: CoreConfig;
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
target?: string;
|
||||
client?: IrcClient;
|
||||
};
|
||||
|
||||
type SendIrcResult = {
|
||||
messageId: string;
|
||||
target: string;
|
||||
receipt: MessageReceipt;
|
||||
};
|
||||
|
||||
function recordIrcOutboundActivity(accountId: string): void {
|
||||
try {
|
||||
getIrcRuntime().channel.activity.record({
|
||||
channel: "irc",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== "IRC runtime not initialized") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function resolveTarget(to: string, opts?: SendIrcOptions): string {
|
||||
const fromArg = normalizeIrcMessagingTarget(to);
|
||||
if (fromArg) {
|
||||
return fromArg;
|
||||
}
|
||||
const fromOpt = normalizeIrcMessagingTarget(opts?.target ?? "");
|
||||
if (fromOpt) {
|
||||
return fromOpt;
|
||||
}
|
||||
throw new Error(`Invalid IRC target: ${to}`);
|
||||
}
|
||||
|
||||
export async function sendMessageIrc(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: SendIrcOptions,
|
||||
): Promise<SendIrcResult> {
|
||||
const cfg = requireRuntimeConfig(opts.cfg, "IRC send") as CoreConfig;
|
||||
const account = resolveIrcAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
|
||||
if (!account.configured) {
|
||||
throw new Error(
|
||||
`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
|
||||
);
|
||||
}
|
||||
|
||||
const target = resolveTarget(to, opts);
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "irc",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
const prepared = convertMarkdownTables(text.trim(), tableMode);
|
||||
const payload = opts.replyTo ? `${prepared}\n\n[reply:${opts.replyTo}]` : prepared;
|
||||
|
||||
if (!payload.trim()) {
|
||||
throw new Error("Message must be non-empty for IRC sends");
|
||||
}
|
||||
|
||||
const client = opts.client;
|
||||
if (client?.isReady()) {
|
||||
client.sendPrivmsg(target, payload);
|
||||
} else {
|
||||
const transient = await connectIrcClient(
|
||||
buildIrcConnectOptions(account, {
|
||||
connectTimeoutMs: 12000,
|
||||
}),
|
||||
);
|
||||
if (target.startsWith("#") || target.startsWith("&")) {
|
||||
transient.join(target);
|
||||
}
|
||||
transient.sendPrivmsg(target, payload);
|
||||
transient.quit("sent");
|
||||
}
|
||||
|
||||
recordIrcOutboundActivity(account.accountId);
|
||||
|
||||
const messageId = makeIrcMessageId();
|
||||
return {
|
||||
messageId,
|
||||
target,
|
||||
receipt: createMessageReceiptFromOutboundResults({
|
||||
results: [
|
||||
{
|
||||
channel: "irc",
|
||||
messageId,
|
||||
conversationId: target,
|
||||
},
|
||||
],
|
||||
kind: "text",
|
||||
...(opts.replyTo ? { replyToId: opts.replyTo } : {}),
|
||||
}),
|
||||
};
|
||||
}
|
||||
152
extensions/irc/src/setup-core.ts
Normal file
152
extensions/irc/src/setup-core.ts
Normal file
@@ -0,0 +1,152 @@
|
||||
// Irc plugin module implements setup core behavior.
|
||||
import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { DmPolicy } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
createSetupInputPresenceValidator,
|
||||
createTopLevelChannelAllowFromSetter,
|
||||
createTopLevelChannelDmPolicySetter,
|
||||
patchScopedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import type { CoreConfig, IrcAccountConfig, IrcNickServConfig } from "./types.js";
|
||||
|
||||
const channel = "irc" as const;
|
||||
const setIrcTopLevelDmPolicy = createTopLevelChannelDmPolicySetter({
|
||||
channel,
|
||||
});
|
||||
const setIrcTopLevelAllowFrom = createTopLevelChannelAllowFromSetter({
|
||||
channel,
|
||||
});
|
||||
const validateIrcRequiredSetupInput = createSetupInputPresenceValidator({
|
||||
whenNotUseEnv: [
|
||||
{ someOf: ["host"], message: "IRC requires host." },
|
||||
{ someOf: ["nick"], message: "IRC requires nick." },
|
||||
],
|
||||
});
|
||||
|
||||
type IrcSetupInput = ChannelSetupInput & {
|
||||
host?: string;
|
||||
port?: number | string;
|
||||
tls?: boolean;
|
||||
nick?: string;
|
||||
username?: string;
|
||||
realname?: string;
|
||||
channels?: string[];
|
||||
password?: string;
|
||||
};
|
||||
|
||||
export function parsePort(raw: string, fallback: number): number {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return fallback;
|
||||
}
|
||||
const parsed = parseStrictPositiveInteger(trimmed);
|
||||
if (parsed === undefined || parsed > 65535) {
|
||||
return fallback;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function validateIrcPortInput(input: ChannelSetupInput): string | null {
|
||||
const raw = (input as IrcSetupInput).port;
|
||||
if (raw === undefined || raw === null || raw === "") {
|
||||
return null;
|
||||
}
|
||||
const parsed = parseStrictPositiveInteger(String(raw));
|
||||
return parsed !== undefined && parsed <= 65535 ? null : "IRC port must be between 1 and 65535.";
|
||||
}
|
||||
|
||||
export function updateIrcAccountConfig(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
patch: Partial<IrcAccountConfig>,
|
||||
): CoreConfig {
|
||||
return patchScopedAccountConfig({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch,
|
||||
ensureChannelEnabled: false,
|
||||
ensureAccountEnabled: false,
|
||||
}) as CoreConfig;
|
||||
}
|
||||
|
||||
export function setIrcDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
||||
return setIrcTopLevelDmPolicy(cfg, dmPolicy) as CoreConfig;
|
||||
}
|
||||
|
||||
export function setIrcAllowFrom(cfg: CoreConfig, allowFrom: string[]): CoreConfig {
|
||||
return setIrcTopLevelAllowFrom(cfg, allowFrom) as CoreConfig;
|
||||
}
|
||||
|
||||
export function setIrcNickServ(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
nickserv?: IrcNickServConfig,
|
||||
): CoreConfig {
|
||||
return updateIrcAccountConfig(cfg, accountId, { nickserv });
|
||||
}
|
||||
|
||||
export function setIrcGroupAccess(
|
||||
cfg: CoreConfig,
|
||||
accountId: string,
|
||||
policy: "open" | "allowlist" | "disabled",
|
||||
entries: string[],
|
||||
normalizeGroupEntry: (raw: string) => string | null,
|
||||
): CoreConfig {
|
||||
if (policy !== "allowlist") {
|
||||
return updateIrcAccountConfig(cfg, accountId, { enabled: true, groupPolicy: policy });
|
||||
}
|
||||
const normalizedEntries = [
|
||||
...new Set(entries.flatMap((entry) => normalizeGroupEntry(entry) ?? [])),
|
||||
];
|
||||
const groups = Object.fromEntries(normalizedEntries.map((entry) => [entry, {}]));
|
||||
return updateIrcAccountConfig(cfg, accountId, {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups,
|
||||
});
|
||||
}
|
||||
|
||||
export const ircSetupAdapter: ChannelSetupAdapter = {
|
||||
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
|
||||
applyAccountName: ({ cfg, accountId, name }) =>
|
||||
applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name,
|
||||
}),
|
||||
validateInput: (params) =>
|
||||
validateIrcRequiredSetupInput(params) ?? validateIrcPortInput(params.input),
|
||||
applyAccountConfig: ({ cfg, accountId, input }) => {
|
||||
const setupInput = input as IrcSetupInput;
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name: setupInput.name,
|
||||
});
|
||||
const portInput =
|
||||
typeof setupInput.port === "number" ? String(setupInput.port) : (setupInput.port ?? "");
|
||||
const patch: Partial<IrcAccountConfig> = {
|
||||
enabled: true,
|
||||
host: setupInput.host?.trim(),
|
||||
port: portInput ? parsePort(portInput, setupInput.tls === false ? 6667 : 6697) : undefined,
|
||||
tls: setupInput.tls,
|
||||
nick: setupInput.nick?.trim(),
|
||||
username: setupInput.username?.trim(),
|
||||
realname: setupInput.realname?.trim(),
|
||||
password: setupInput.password?.trim(),
|
||||
channels: setupInput.channels,
|
||||
};
|
||||
return patchScopedAccountConfig({
|
||||
cfg: namedConfig,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch,
|
||||
}) as CoreConfig;
|
||||
},
|
||||
};
|
||||
451
extensions/irc/src/setup-surface.ts
Normal file
451
extensions/irc/src/setup-surface.ts
Normal file
@@ -0,0 +1,451 @@
|
||||
// Irc plugin module implements setup surface behavior.
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import type {
|
||||
ChannelSetupDmPolicy,
|
||||
ChannelSetupWizard,
|
||||
WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
createAllowFromSection,
|
||||
createPromptParsedAllowFromForAccount,
|
||||
createSetupTranslator,
|
||||
createStandardChannelSetupStatus,
|
||||
formatDocsLink,
|
||||
setSetupChannelEnabled,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
normalizeStringifiedOptionalString,
|
||||
uniqueStrings,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveDefaultIrcAccountId, resolveIrcAccount } from "./accounts.js";
|
||||
import {
|
||||
isChannelTarget,
|
||||
normalizeIrcAllowEntry,
|
||||
normalizeIrcMessagingTarget,
|
||||
} from "./normalize.js";
|
||||
import {
|
||||
ircSetupAdapter,
|
||||
parsePort,
|
||||
setIrcAllowFrom,
|
||||
setIrcDmPolicy,
|
||||
setIrcGroupAccess,
|
||||
setIrcNickServ,
|
||||
updateIrcAccountConfig,
|
||||
} from "./setup-core.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "irc" as const;
|
||||
const USE_ENV_FLAG = "__ircUseEnv";
|
||||
const TLS_FLAG = "__ircTls";
|
||||
|
||||
function parseListInput(raw: string): string[] {
|
||||
return normalizeStringEntries(raw.split(/[\n,;]+/g));
|
||||
}
|
||||
|
||||
function normalizeGroupEntry(raw: string): string | null {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
const normalized = normalizeIrcMessagingTarget(trimmed) ?? trimmed;
|
||||
if (isChannelTarget(normalized)) {
|
||||
return normalized;
|
||||
}
|
||||
return `#${normalized.replace(/^#+/, "")}`;
|
||||
}
|
||||
|
||||
const promptIrcAllowFrom = createPromptParsedAllowFromForAccount<CoreConfig>({
|
||||
defaultAccountId: (cfg) => resolveDefaultIrcAccountId(cfg),
|
||||
noteTitle: t("wizard.irc.allowlistTitle"),
|
||||
noteLines: [
|
||||
t("wizard.irc.allowlistIntro"),
|
||||
t("wizard.irc.examples"),
|
||||
"- alice",
|
||||
"- alice!ident@example.org",
|
||||
t("wizard.irc.multipleEntries"),
|
||||
],
|
||||
message: t("wizard.irc.allowFromPrompt"),
|
||||
placeholder: "alice, bob!ident@example.org",
|
||||
parseEntries: (raw) => ({
|
||||
entries: normalizeStringEntries(
|
||||
parseListInput(raw).map((entry) => normalizeIrcAllowEntry(entry)),
|
||||
),
|
||||
}),
|
||||
getExistingAllowFrom: ({ cfg }) => cfg.channels?.irc?.allowFrom ?? [],
|
||||
applyAllowFrom: ({ cfg, allowFrom }) => setIrcAllowFrom(cfg, allowFrom),
|
||||
});
|
||||
|
||||
async function promptIrcNickServConfig(params: {
|
||||
cfg: CoreConfig;
|
||||
prompter: WizardPrompter;
|
||||
accountId: string;
|
||||
}): Promise<CoreConfig> {
|
||||
const resolved = resolveIrcAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const existing = resolved.config.nickserv;
|
||||
const hasExisting = Boolean(existing?.password || existing?.passwordFile);
|
||||
const wants = await params.prompter.confirm({
|
||||
message: hasExisting
|
||||
? t("wizard.irc.nickServUpdatePrompt")
|
||||
: t("wizard.irc.nickServConfigurePrompt"),
|
||||
initialValue: hasExisting,
|
||||
});
|
||||
if (!wants) {
|
||||
return params.cfg;
|
||||
}
|
||||
|
||||
const service = (
|
||||
await params.prompter.text({
|
||||
message: t("wizard.irc.nickServServicePrompt"),
|
||||
initialValue: existing?.service || "NickServ",
|
||||
validate: (value) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
})
|
||||
).trim();
|
||||
|
||||
const useEnvPassword =
|
||||
params.accountId === DEFAULT_ACCOUNT_ID &&
|
||||
Boolean(process.env.IRC_NICKSERV_PASSWORD?.trim()) &&
|
||||
!(existing?.password || existing?.passwordFile)
|
||||
? await params.prompter.confirm({
|
||||
message: t("wizard.irc.nickServPasswordEnvPrompt"),
|
||||
initialValue: true,
|
||||
})
|
||||
: false;
|
||||
|
||||
const password = useEnvPassword
|
||||
? undefined
|
||||
: (
|
||||
await params.prompter.text({
|
||||
message: t("wizard.irc.nickServPasswordPrompt"),
|
||||
validate: () => undefined,
|
||||
})
|
||||
).trim();
|
||||
|
||||
if (!password && !useEnvPassword) {
|
||||
return setIrcNickServ(params.cfg, params.accountId, {
|
||||
enabled: false,
|
||||
service,
|
||||
});
|
||||
}
|
||||
|
||||
const register = await params.prompter.confirm({
|
||||
message: t("wizard.irc.nickServRegisterPrompt"),
|
||||
initialValue: existing?.register ?? false,
|
||||
});
|
||||
const registerEmail = register
|
||||
? (
|
||||
await params.prompter.text({
|
||||
message: t("wizard.irc.nickServRegisterEmailPrompt"),
|
||||
initialValue:
|
||||
existing?.registerEmail ||
|
||||
(params.accountId === DEFAULT_ACCOUNT_ID
|
||||
? process.env.IRC_NICKSERV_REGISTER_EMAIL
|
||||
: undefined),
|
||||
validate: (value) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
})
|
||||
).trim()
|
||||
: undefined;
|
||||
|
||||
return setIrcNickServ(params.cfg, params.accountId, {
|
||||
enabled: true,
|
||||
service,
|
||||
...(password ? { password } : {}),
|
||||
register,
|
||||
...(registerEmail ? { registerEmail } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
const ircDmPolicy: ChannelSetupDmPolicy = {
|
||||
label: "IRC",
|
||||
channel,
|
||||
policyKey: "channels.irc.dmPolicy",
|
||||
allowFromKey: "channels.irc.allowFrom",
|
||||
getCurrent: (cfg) => (cfg as CoreConfig).channels?.irc?.dmPolicy ?? "pairing",
|
||||
setPolicy: (cfg, policy) => setIrcDmPolicy(cfg as CoreConfig, policy),
|
||||
promptAllowFrom: async ({ cfg, prompter, accountId }) =>
|
||||
await promptIrcAllowFrom({
|
||||
cfg: cfg as CoreConfig,
|
||||
prompter,
|
||||
accountId,
|
||||
}),
|
||||
};
|
||||
|
||||
export const ircSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "IRC",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsHostNick"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusNeedsHostNick"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 0,
|
||||
includeStatusLine: true,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
|
||||
}),
|
||||
introNote: {
|
||||
title: t("wizard.irc.setupTitle"),
|
||||
lines: [
|
||||
t("wizard.irc.helpNeedsHostNick"),
|
||||
t("wizard.irc.helpRecommendedTls"),
|
||||
t("wizard.irc.helpNickServOptional"),
|
||||
t("wizard.irc.helpGroupControl"),
|
||||
t("wizard.irc.helpMentionGate"),
|
||||
t("wizard.irc.helpEnvVars"),
|
||||
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||
],
|
||||
shouldShow: ({ cfg, accountId }) =>
|
||||
!resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).configured,
|
||||
},
|
||||
prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
|
||||
const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const isDefaultAccount = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envHost = isDefaultAccount ? (normalizeOptionalString(process.env.IRC_HOST) ?? "") : "";
|
||||
const envNick = isDefaultAccount ? (normalizeOptionalString(process.env.IRC_NICK) ?? "") : "";
|
||||
const envReady = Boolean(envHost && envNick && !resolved.config.host && !resolved.config.nick);
|
||||
|
||||
if (envReady) {
|
||||
const useEnv = await prompter.confirm({
|
||||
message: t("wizard.irc.envPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (useEnv) {
|
||||
return {
|
||||
cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, { enabled: true }),
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[USE_ENV_FLAG]: "1",
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const tls = await prompter.confirm({
|
||||
message: t("wizard.irc.tlsPrompt"),
|
||||
initialValue: resolved.config.tls ?? true,
|
||||
});
|
||||
return {
|
||||
cfg: updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
tls,
|
||||
}),
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[USE_ENV_FLAG]: "0",
|
||||
[TLS_FLAG]: tls ? "1" : "0",
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: [],
|
||||
textInputs: [
|
||||
{
|
||||
inputKey: "httpHost",
|
||||
message: t("wizard.irc.serverHostPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.host || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
host: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "httpPort",
|
||||
message: t("wizard.irc.serverPortPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
String(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.port ?? ""),
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
initialValue: ({ cfg, accountId, credentialValues }) => {
|
||||
const resolved = resolveIrcAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
const tls = credentialValues[TLS_FLAG] !== "0";
|
||||
const defaultPort = resolved.config.port ?? (tls ? 6697 : 6667);
|
||||
return String(defaultPort);
|
||||
},
|
||||
validate: ({ value }) => {
|
||||
const raw = normalizeStringifiedOptionalString(value) ?? "";
|
||||
const parsed = parseStrictPositiveInteger(raw);
|
||||
return parsed !== undefined && parsed <= 65535
|
||||
? undefined
|
||||
: "Use a port between 1 and 65535";
|
||||
},
|
||||
normalizeValue: ({ value }) => String(parsePort(value, 6697)),
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
port: parsePort(value, 6697),
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "token",
|
||||
message: t("wizard.irc.nickPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.nick || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
nick: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "userId",
|
||||
message: t("wizard.irc.usernamePrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
initialValue: ({ cfg, accountId, credentialValues }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.username ||
|
||||
credentialValues.token ||
|
||||
"openclaw",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
username: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "deviceName",
|
||||
message: t("wizard.irc.realNamePrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
initialValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.realname || "OpenClaw",
|
||||
validate: ({ value }) => (normalizeStringifiedOptionalString(value) ? undefined : "Required"),
|
||||
normalizeValue: ({ value }) => normalizeStringifiedOptionalString(value) ?? "",
|
||||
applySet: async ({ cfg, accountId, value }) =>
|
||||
updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
realname: value,
|
||||
}),
|
||||
},
|
||||
{
|
||||
inputKey: "groupChannels",
|
||||
message: t("wizard.irc.autoJoinPrompt"),
|
||||
placeholder: "#openclaw, #ops",
|
||||
required: false,
|
||||
applyEmptyValue: true,
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.channels?.join(", "),
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[USE_ENV_FLAG] !== "1",
|
||||
normalizeValue: ({ value }) =>
|
||||
parseListInput(value)
|
||||
.map((entry) => normalizeGroupEntry(entry))
|
||||
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
||||
.filter((entry) => isChannelTarget(entry))
|
||||
.join(", "),
|
||||
applySet: async ({ cfg, accountId, value }) => {
|
||||
const channels = parseListInput(value)
|
||||
.map((entry) => normalizeGroupEntry(entry))
|
||||
.filter((entry): entry is string => Boolean(entry && entry !== "*"))
|
||||
.filter((entry) => isChannelTarget(entry));
|
||||
return updateIrcAccountConfig(cfg as CoreConfig, accountId, {
|
||||
enabled: true,
|
||||
channels: channels.length > 0 ? channels : undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
],
|
||||
groupAccess: {
|
||||
label: "IRC channels",
|
||||
placeholder: "#openclaw, #ops, *",
|
||||
currentPolicy: ({ cfg, accountId }) =>
|
||||
resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groupPolicy ?? "allowlist",
|
||||
currentEntries: ({ cfg, accountId }) =>
|
||||
Object.keys(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups ?? {}),
|
||||
updatePrompt: ({ cfg, accountId }) =>
|
||||
Boolean(resolveIrcAccount({ cfg: cfg as CoreConfig, accountId }).config.groups),
|
||||
setPolicy: ({ cfg, accountId, policy }) =>
|
||||
setIrcGroupAccess(cfg as CoreConfig, accountId, policy, [], normalizeGroupEntry),
|
||||
resolveAllowlist: async ({ entries }) =>
|
||||
uniqueStrings(
|
||||
entries
|
||||
.map((entry) => normalizeGroupEntry(entry))
|
||||
.filter((entry): entry is string => Boolean(entry)),
|
||||
),
|
||||
applyAllowlist: ({ cfg, accountId, resolved }) =>
|
||||
setIrcGroupAccess(
|
||||
cfg as CoreConfig,
|
||||
accountId,
|
||||
"allowlist",
|
||||
resolved as string[],
|
||||
normalizeGroupEntry,
|
||||
),
|
||||
},
|
||||
allowFrom: createAllowFromSection({
|
||||
helpTitle: t("wizard.irc.allowlistTitle"),
|
||||
helpLines: [
|
||||
t("wizard.irc.allowlistIntro"),
|
||||
t("wizard.irc.examples"),
|
||||
"- alice",
|
||||
"- alice!ident@example.org",
|
||||
t("wizard.irc.multipleEntries"),
|
||||
],
|
||||
message: t("wizard.irc.allowFromPrompt"),
|
||||
placeholder: "alice, bob!ident@example.org",
|
||||
invalidWithoutCredentialNote: t("wizard.irc.allowFromInvalid"),
|
||||
parseId: (raw) => {
|
||||
const normalized = normalizeIrcAllowEntry(raw);
|
||||
return normalized || null;
|
||||
},
|
||||
apply: async ({ cfg, allowFrom }) => setIrcAllowFrom(cfg as CoreConfig, allowFrom),
|
||||
}),
|
||||
finalize: async ({ cfg, accountId, prompter }) => {
|
||||
let next = cfg as CoreConfig;
|
||||
|
||||
const resolvedAfterGroups = resolveIrcAccount({ cfg: next, accountId });
|
||||
if (resolvedAfterGroups.config.groupPolicy === "allowlist") {
|
||||
const groupKeys = Object.keys(resolvedAfterGroups.config.groups ?? {});
|
||||
if (groupKeys.length > 0) {
|
||||
const wantsMentions = await prompter.confirm({
|
||||
message: t("wizard.irc.requireMentionPrompt"),
|
||||
initialValue: true,
|
||||
});
|
||||
if (!wantsMentions) {
|
||||
const groups = resolvedAfterGroups.config.groups ?? {};
|
||||
const patched = Object.fromEntries(
|
||||
Object.entries(groups).map(([key, value]) => [
|
||||
key,
|
||||
{ ...value, requireMention: false },
|
||||
]),
|
||||
);
|
||||
next = updateIrcAccountConfig(next, accountId, { groups: patched });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
next = await promptIrcNickServConfig({
|
||||
cfg: next,
|
||||
prompter,
|
||||
accountId,
|
||||
});
|
||||
return { cfg: next };
|
||||
},
|
||||
completionNote: {
|
||||
title: t("wizard.irc.nextStepsTitle"),
|
||||
lines: [
|
||||
t("wizard.irc.nextRestartGateway"),
|
||||
t("wizard.irc.nextStatusCommand"),
|
||||
`Docs: ${formatDocsLink("/channels/irc", "channels/irc")}`,
|
||||
],
|
||||
},
|
||||
dmPolicy: ircDmPolicy,
|
||||
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
|
||||
export { ircSetupAdapter };
|
||||
487
extensions/irc/src/setup.test.ts
Normal file
487
extensions/irc/src/setup.test.ts
Normal file
@@ -0,0 +1,487 @@
|
||||
// Irc tests cover setup plugin behavior.
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import {
|
||||
createPluginSetupWizardAdapter,
|
||||
createPluginSetupWizardStatus,
|
||||
createTestWizardPrompter,
|
||||
promptSetupWizardAllowFrom,
|
||||
runSetupWizardConfigure,
|
||||
} from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import type { WizardPrompter } from "openclaw/plugin-sdk/plugin-test-runtime";
|
||||
import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
listIrcAccountIds,
|
||||
resolveDefaultIrcAccountId,
|
||||
type ResolvedIrcAccount,
|
||||
} from "./accounts.js";
|
||||
import { startIrcGatewayAccount } from "./gateway.js";
|
||||
import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
|
||||
import {
|
||||
ircSetupAdapter,
|
||||
parsePort,
|
||||
setIrcAllowFrom,
|
||||
setIrcDmPolicy,
|
||||
setIrcGroupAccess,
|
||||
setIrcNickServ,
|
||||
updateIrcAccountConfig,
|
||||
} from "./setup-core.js";
|
||||
import { ircSetupWizard } from "./setup-surface.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
monitorIrcProvider: vi.fn(),
|
||||
sendMessageIrc: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./channel-runtime.js", () => {
|
||||
return {
|
||||
monitorIrcProvider: hoisted.monitorIrcProvider,
|
||||
sendMessageIrc: hoisted.sendMessageIrc,
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
vi.doUnmock("./channel-runtime.js");
|
||||
vi.resetModules();
|
||||
});
|
||||
|
||||
const ircSetupPlugin = {
|
||||
id: "irc",
|
||||
meta: {
|
||||
label: "IRC",
|
||||
},
|
||||
config: {
|
||||
defaultAccountId: resolveDefaultIrcAccountId,
|
||||
listAccountIds: listIrcAccountIds,
|
||||
},
|
||||
setupWizard: ircSetupWizard,
|
||||
} as never;
|
||||
|
||||
const ircConfigureAdapter = createPluginSetupWizardAdapter(ircSetupPlugin);
|
||||
const ircStatus = createPluginSetupWizardStatus(ircSetupPlugin);
|
||||
|
||||
function buildAccount(): ResolvedIrcAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
name: "default",
|
||||
configured: true,
|
||||
host: "irc.example.com",
|
||||
port: 6697,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "openclaw",
|
||||
realname: "OpenClaw",
|
||||
password: "",
|
||||
passwordSource: "none",
|
||||
config: {} as ResolvedIrcAccount["config"],
|
||||
};
|
||||
}
|
||||
|
||||
function installIrcRuntime() {
|
||||
setIrcRuntime({
|
||||
logging: {
|
||||
shouldLogVerbose: vi.fn(() => false),
|
||||
getChildLogger: vi.fn(() => ({
|
||||
debug: vi.fn(),
|
||||
info: vi.fn(),
|
||||
warn: vi.fn(),
|
||||
error: vi.fn(),
|
||||
})),
|
||||
},
|
||||
channel: {
|
||||
activity: {
|
||||
record: vi.fn(),
|
||||
get: vi.fn(),
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
}
|
||||
|
||||
describe("irc setup", () => {
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
clearIrcRuntime();
|
||||
});
|
||||
|
||||
it("parses valid ports and falls back for invalid values", () => {
|
||||
expect(parsePort("6697", 6667)).toBe(6697);
|
||||
expect(parsePort(" 7000 ", 6667)).toBe(7000);
|
||||
expect(parsePort("", 6667)).toBe(6667);
|
||||
expect(parsePort("70000", 6667)).toBe(6667);
|
||||
expect(parsePort("abc", 6667)).toBe(6667);
|
||||
});
|
||||
|
||||
it("updates top-level dm policy and allowlist", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(setIrcDmPolicy(cfg, "open")).toStrictEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toStrictEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
allowFrom: ["alice", "bob"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("setup status honors the selected named account", async () => {
|
||||
const status = await ircStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
ops: {
|
||||
host: "irc.example.com",
|
||||
nick: "ops-bot",
|
||||
},
|
||||
work: {
|
||||
host: "irc.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountOverrides: {
|
||||
irc: "work",
|
||||
},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
|
||||
});
|
||||
|
||||
it("setup status honors the configured default account", async () => {
|
||||
const status = await ircStatus({
|
||||
cfg: {
|
||||
channels: {
|
||||
irc: {
|
||||
defaultAccount: "work",
|
||||
accounts: {
|
||||
ops: {
|
||||
host: "irc.example.com",
|
||||
nick: "ops-bot",
|
||||
},
|
||||
work: {
|
||||
host: "irc.example.com",
|
||||
nick: "",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as CoreConfig,
|
||||
accountOverrides: {},
|
||||
});
|
||||
|
||||
expect(status.configured).toBe(false);
|
||||
expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
|
||||
});
|
||||
|
||||
it("stores nickserv and account config patches on the scoped account", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(
|
||||
setIrcNickServ(cfg, "work", {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
}),
|
||||
).toStrictEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
nickserv: {
|
||||
enabled: true,
|
||||
service: "NickServ",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(
|
||||
updateIrcAccountConfig(cfg, "work", {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
}),
|
||||
).toStrictEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes allowlist groups and handles non-allowlist policies", () => {
|
||||
const cfg: CoreConfig = { channels: { irc: {} } };
|
||||
|
||||
expect(
|
||||
setIrcGroupAccess(
|
||||
cfg,
|
||||
"default",
|
||||
"allowlist",
|
||||
["openclaw", "#ops", "openclaw", "*"],
|
||||
(raw) => {
|
||||
const trimmed = raw.trim();
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
|
||||
},
|
||||
),
|
||||
).toStrictEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "allowlist",
|
||||
groups: {
|
||||
"#openclaw": {},
|
||||
"#ops": {},
|
||||
"*": {},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toStrictEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
groupPolicy: "disabled",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("validates required input and applies normalized account config", () => {
|
||||
const validateInput = ircSetupAdapter.validateInput;
|
||||
const applyAccountConfig = ircSetupAdapter.applyAccountConfig;
|
||||
expect(validateInput).toBeTypeOf("function");
|
||||
expect(applyAccountConfig).toBeTypeOf("function");
|
||||
if (!validateInput) {
|
||||
throw new Error("Expected IRC setup validateInput");
|
||||
}
|
||||
|
||||
expect(
|
||||
validateInput({
|
||||
input: { host: "", nick: "openclaw" },
|
||||
} as never),
|
||||
).toBe("IRC requires host.");
|
||||
|
||||
expect(
|
||||
validateInput({
|
||||
input: { host: "irc.libera.chat", nick: "" },
|
||||
} as never),
|
||||
).toBe("IRC requires nick.");
|
||||
|
||||
expect(
|
||||
validateInput({
|
||||
input: { host: "irc.libera.chat", nick: "openclaw" },
|
||||
} as never),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
validateInput({
|
||||
input: { host: "irc.libera.chat", nick: "openclaw", port: "+07000" },
|
||||
} as never),
|
||||
).toBeNull();
|
||||
|
||||
expect(
|
||||
validateInput({
|
||||
input: { host: "irc.libera.chat", nick: "openclaw", port: "7000x" },
|
||||
} as never),
|
||||
).toBe("IRC port must be between 1 and 65535.");
|
||||
|
||||
expect(
|
||||
validateInput({
|
||||
input: { host: "irc.libera.chat", nick: "openclaw", port: "70000" },
|
||||
} as never),
|
||||
).toBe("IRC port must be between 1 and 65535.");
|
||||
|
||||
expect(
|
||||
applyAccountConfig({
|
||||
cfg: { channels: { irc: {} } },
|
||||
accountId: "default",
|
||||
input: {
|
||||
name: "Default",
|
||||
host: " irc.libera.chat ",
|
||||
port: "7000",
|
||||
tls: true,
|
||||
nick: " openclaw ",
|
||||
username: " claw ",
|
||||
realname: " OpenClaw Bot ",
|
||||
password: " secret ",
|
||||
channels: ["#openclaw"],
|
||||
},
|
||||
} as never),
|
||||
).toEqual({
|
||||
channels: {
|
||||
irc: {
|
||||
enabled: true,
|
||||
name: "Default",
|
||||
host: "irc.libera.chat",
|
||||
port: 7000,
|
||||
tls: true,
|
||||
nick: "openclaw",
|
||||
username: "claw",
|
||||
realname: "OpenClaw Bot",
|
||||
password: "secret",
|
||||
channels: ["#openclaw"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("configures host and nick via setup prompts", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC server host") {
|
||||
return "irc.libera.chat";
|
||||
}
|
||||
if (message === "IRC server port") {
|
||||
return "6697";
|
||||
}
|
||||
if (message === "IRC nick") {
|
||||
return "openclaw-bot";
|
||||
}
|
||||
if (message === "IRC username") {
|
||||
return "openclaw";
|
||||
}
|
||||
if (message === "IRC real name") {
|
||||
return "OpenClaw Bot";
|
||||
}
|
||||
if (message.startsWith("Auto-join IRC channels")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
if (message.startsWith("IRC channels allowlist")) {
|
||||
return "#openclaw, #ops";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "Use TLS for IRC?") {
|
||||
return true;
|
||||
}
|
||||
if (message === "Configure IRC channels access?") {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}),
|
||||
});
|
||||
|
||||
const result = await runSetupWizardConfigure({
|
||||
configure: ircConfigureAdapter.configure,
|
||||
cfg: {} as CoreConfig,
|
||||
prompter,
|
||||
options: {},
|
||||
});
|
||||
|
||||
expect(result.accountId).toBe("default");
|
||||
expect(result.cfg.channels?.irc?.enabled).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
|
||||
expect(result.cfg.channels?.irc?.nick).toBe("openclaw-bot");
|
||||
expect(result.cfg.channels?.irc?.tls).toBe(true);
|
||||
expect(result.cfg.channels?.irc?.channels).toEqual(["#openclaw", "#ops"]);
|
||||
expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
|
||||
expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#openclaw", "#ops"]);
|
||||
});
|
||||
|
||||
it("rejects partial IRC setup wizard ports", async () => {
|
||||
const portPrompt = ircSetupWizard.textInputs?.find((step) => step.inputKey === "httpPort");
|
||||
if (!portPrompt?.validate) {
|
||||
throw new Error("expected IRC port prompt validator");
|
||||
}
|
||||
|
||||
expect(portPrompt.validate({ value: "7000x" } as never)).toBe("Use a port between 1 and 65535");
|
||||
expect(portPrompt.validate({ value: "+07000" } as never)).toBeUndefined();
|
||||
expect(portPrompt.validate({ value: "7000" } as never)).toBeUndefined();
|
||||
});
|
||||
|
||||
it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
|
||||
const prompter = createTestWizardPrompter({
|
||||
text: vi.fn(async ({ message }: { message: string }) => {
|
||||
if (message === "IRC allowFrom (nick or nick!user@host)") {
|
||||
return "Alice, Bob!ident@example.org";
|
||||
}
|
||||
throw new Error(`Unexpected prompt: ${message}`);
|
||||
}) as WizardPrompter["text"],
|
||||
confirm: vi.fn(async () => false),
|
||||
});
|
||||
|
||||
const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom;
|
||||
if (!promptAllowFrom) {
|
||||
throw new Error("promptAllowFrom unavailable");
|
||||
}
|
||||
|
||||
const cfg: CoreConfig = {
|
||||
channels: {
|
||||
irc: {
|
||||
accounts: {
|
||||
work: {
|
||||
host: "irc.libera.chat",
|
||||
nick: "openclaw-work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const updated = await promptSetupWizardAllowFrom({
|
||||
promptAllowFrom,
|
||||
cfg,
|
||||
prompter,
|
||||
accountId: "work",
|
||||
});
|
||||
if (!updated) {
|
||||
throw new Error("expected IRC allowFrom setup to return updated config");
|
||||
}
|
||||
|
||||
expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
|
||||
expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
|
||||
});
|
||||
|
||||
it("keeps startAccount pending until abort, then stops the monitor", async () => {
|
||||
const stop = vi.fn();
|
||||
hoisted.monitorIrcProvider.mockResolvedValue({ stop });
|
||||
installIrcRuntime();
|
||||
|
||||
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
|
||||
startAccount: async (ctx) =>
|
||||
await startIrcGatewayAccount({
|
||||
...ctx,
|
||||
cfg: ctx.cfg as CoreConfig,
|
||||
}),
|
||||
account: buildAccount(),
|
||||
});
|
||||
|
||||
await expectStopPendingUntilAbort({
|
||||
waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
|
||||
isSettled,
|
||||
abort,
|
||||
task,
|
||||
stop,
|
||||
});
|
||||
});
|
||||
});
|
||||
101
extensions/irc/src/types.ts
Normal file
101
extensions/irc/src/types.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Irc type declarations define plugin contracts.
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
GroupToolPolicyBySenderConfig,
|
||||
GroupToolPolicyConfig,
|
||||
MarkdownConfig,
|
||||
OpenClawConfig,
|
||||
BaseProbeResult,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export type IrcChannelConfig = {
|
||||
requireMention?: boolean;
|
||||
tools?: GroupToolPolicyConfig;
|
||||
toolsBySender?: GroupToolPolicyBySenderConfig;
|
||||
skills?: string[];
|
||||
enabled?: boolean;
|
||||
allowFrom?: Array<string | number>;
|
||||
systemPrompt?: string;
|
||||
};
|
||||
|
||||
export type IrcNickServConfig = {
|
||||
enabled?: boolean;
|
||||
service?: string;
|
||||
password?: string;
|
||||
passwordFile?: string;
|
||||
register?: boolean;
|
||||
registerEmail?: string;
|
||||
};
|
||||
|
||||
export type IrcAccountConfig = {
|
||||
name?: string;
|
||||
enabled?: boolean;
|
||||
/**
|
||||
* Break-glass override: allow nick-only allowlist matching.
|
||||
* Default behavior requires host/user-qualified identities.
|
||||
*/
|
||||
dangerouslyAllowNameMatching?: boolean;
|
||||
host?: string;
|
||||
port?: number;
|
||||
tls?: boolean;
|
||||
nick?: string;
|
||||
username?: string;
|
||||
realname?: string;
|
||||
password?: string;
|
||||
passwordFile?: string;
|
||||
nickserv?: IrcNickServConfig;
|
||||
dmPolicy?: DmPolicy;
|
||||
allowFrom?: Array<string | number>;
|
||||
defaultTo?: string;
|
||||
groupPolicy?: GroupPolicy;
|
||||
groupAllowFrom?: Array<string | number>;
|
||||
groups?: Record<string, IrcChannelConfig>;
|
||||
channels?: string[];
|
||||
mentionPatterns?: string[];
|
||||
markdown?: MarkdownConfig;
|
||||
historyLimit?: number;
|
||||
dmHistoryLimit?: number;
|
||||
dms?: Record<string, DmConfig>;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: "length" | "newline";
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
|
||||
responsePrefix?: string;
|
||||
mediaMaxMb?: number;
|
||||
};
|
||||
|
||||
type IrcConfig = IrcAccountConfig & {
|
||||
accounts?: Record<string, IrcAccountConfig>;
|
||||
defaultAccount?: string;
|
||||
};
|
||||
|
||||
export type CoreConfig = OpenClawConfig & {
|
||||
channels?: OpenClawConfig["channels"] & {
|
||||
irc?: IrcConfig;
|
||||
};
|
||||
};
|
||||
|
||||
export type IrcInboundMessage = {
|
||||
messageId: string;
|
||||
/** Conversation peer id: channel name for groups, sender nick for DMs. */
|
||||
target: string;
|
||||
/** Raw IRC PRIVMSG target (bot nick for DMs, channel for groups). */
|
||||
rawTarget?: string;
|
||||
senderNick: string;
|
||||
senderUser?: string;
|
||||
senderHost?: string;
|
||||
text: string;
|
||||
timestamp: number;
|
||||
isGroup: boolean;
|
||||
};
|
||||
|
||||
export type IrcProbe = BaseProbeResult<string> & {
|
||||
host: string;
|
||||
port: number;
|
||||
tls: boolean;
|
||||
nick: string;
|
||||
latencyMs?: number;
|
||||
};
|
||||
16
extensions/irc/tsconfig.json
Normal file
16
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"
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user