@actagent/irc 2026.6.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/actagent.plugin.json +26 -0
  2. package/api.ts +11 -0
  3. package/channel-config-api.ts +2 -0
  4. package/channel-plugin-api.ts +3 -0
  5. package/configured-state.ts +9 -0
  6. package/contract-api.ts +5 -0
  7. package/index.test.ts +14 -0
  8. package/index.ts +21 -0
  9. package/package.json +44 -0
  10. package/runtime-api.test.ts +24 -0
  11. package/runtime-api.ts +3 -0
  12. package/secret-contract-api.ts +6 -0
  13. package/setup-entry.ts +14 -0
  14. package/src/accounts.test.ts +224 -0
  15. package/src/accounts.ts +240 -0
  16. package/src/channel-api.ts +7 -0
  17. package/src/channel-runtime.ts +4 -0
  18. package/src/channel.test.ts +17 -0
  19. package/src/channel.ts +367 -0
  20. package/src/client.test.ts +44 -0
  21. package/src/client.ts +443 -0
  22. package/src/config-schema.test.ts +117 -0
  23. package/src/config-schema.ts +97 -0
  24. package/src/config-ui-hints.ts +41 -0
  25. package/src/connect-options.test.ts +48 -0
  26. package/src/connect-options.ts +31 -0
  27. package/src/control-chars.test.ts +18 -0
  28. package/src/control-chars.ts +23 -0
  29. package/src/doctor.ts +55 -0
  30. package/src/gateway.ts +54 -0
  31. package/src/inbound.behavior.test.ts +247 -0
  32. package/src/inbound.ts +440 -0
  33. package/src/message-adapter.ts +29 -0
  34. package/src/monitor.test.ts +44 -0
  35. package/src/monitor.ts +150 -0
  36. package/src/normalize.test.ts +56 -0
  37. package/src/normalize.ts +111 -0
  38. package/src/outbound-base.ts +11 -0
  39. package/src/policy.test.ts +56 -0
  40. package/src/policy.ts +79 -0
  41. package/src/probe.test.ts +111 -0
  42. package/src/probe.ts +54 -0
  43. package/src/protocol.test.ts +49 -0
  44. package/src/protocol.ts +170 -0
  45. package/src/runtime-api.ts +42 -0
  46. package/src/runtime.ts +16 -0
  47. package/src/secret-contract.ts +104 -0
  48. package/src/send.test.ts +327 -0
  49. package/src/send.ts +122 -0
  50. package/src/setup-core.ts +152 -0
  51. package/src/setup-surface.ts +451 -0
  52. package/src/setup.test.ts +487 -0
  53. package/src/types.ts +101 -0
  54. package/tsconfig.json +16 -0
@@ -0,0 +1,487 @@
1
+ // Irc tests cover setup plugin behavior.
2
+ import {
3
+ expectStopPendingUntilAbort,
4
+ startAccountAndTrackLifecycle,
5
+ waitForStartedMocks,
6
+ } from "actagent/plugin-sdk/channel-test-helpers";
7
+ import {
8
+ createPluginSetupWizardAdapter,
9
+ createPluginSetupWizardStatus,
10
+ createTestWizardPrompter,
11
+ promptSetupWizardAllowFrom,
12
+ runSetupWizardConfigure,
13
+ } from "actagent/plugin-sdk/plugin-test-runtime";
14
+ import type { WizardPrompter } from "actagent/plugin-sdk/plugin-test-runtime";
15
+ import { afterAll, afterEach, describe, expect, it, vi } from "vitest";
16
+ import {
17
+ listIrcAccountIds,
18
+ resolveDefaultIrcAccountId,
19
+ type ResolvedIrcAccount,
20
+ } from "./accounts.js";
21
+ import { startIrcGatewayAccount } from "./gateway.js";
22
+ import { clearIrcRuntime, setIrcRuntime } from "./runtime.js";
23
+ import {
24
+ ircSetupAdapter,
25
+ parsePort,
26
+ setIrcAllowFrom,
27
+ setIrcDmPolicy,
28
+ setIrcGroupAccess,
29
+ setIrcNickServ,
30
+ updateIrcAccountConfig,
31
+ } from "./setup-core.js";
32
+ import { ircSetupWizard } from "./setup-surface.js";
33
+ import type { CoreConfig } from "./types.js";
34
+
35
+ const hoisted = vi.hoisted(() => ({
36
+ monitorIrcProvider: vi.fn(),
37
+ sendMessageIrc: vi.fn(),
38
+ }));
39
+
40
+ vi.mock("./channel-runtime.js", () => {
41
+ return {
42
+ monitorIrcProvider: hoisted.monitorIrcProvider,
43
+ sendMessageIrc: hoisted.sendMessageIrc,
44
+ };
45
+ });
46
+
47
+ afterAll(() => {
48
+ vi.doUnmock("./channel-runtime.js");
49
+ vi.resetModules();
50
+ });
51
+
52
+ const ircSetupPlugin = {
53
+ id: "irc",
54
+ meta: {
55
+ label: "IRC",
56
+ },
57
+ config: {
58
+ defaultAccountId: resolveDefaultIrcAccountId,
59
+ listAccountIds: listIrcAccountIds,
60
+ },
61
+ setupWizard: ircSetupWizard,
62
+ } as never;
63
+
64
+ const ircConfigureAdapter = createPluginSetupWizardAdapter(ircSetupPlugin);
65
+ const ircStatus = createPluginSetupWizardStatus(ircSetupPlugin);
66
+
67
+ function buildAccount(): ResolvedIrcAccount {
68
+ return {
69
+ accountId: "default",
70
+ enabled: true,
71
+ name: "default",
72
+ configured: true,
73
+ host: "irc.example.com",
74
+ port: 6697,
75
+ tls: true,
76
+ nick: "actagent",
77
+ username: "actagent",
78
+ realname: "ACTAgent",
79
+ password: "",
80
+ passwordSource: "none",
81
+ config: {} as ResolvedIrcAccount["config"],
82
+ };
83
+ }
84
+
85
+ function installIrcRuntime() {
86
+ setIrcRuntime({
87
+ logging: {
88
+ shouldLogVerbose: vi.fn(() => false),
89
+ getChildLogger: vi.fn(() => ({
90
+ debug: vi.fn(),
91
+ info: vi.fn(),
92
+ warn: vi.fn(),
93
+ error: vi.fn(),
94
+ })),
95
+ },
96
+ channel: {
97
+ activity: {
98
+ record: vi.fn(),
99
+ get: vi.fn(),
100
+ },
101
+ },
102
+ } as never);
103
+ }
104
+
105
+ describe("irc setup", () => {
106
+ afterEach(() => {
107
+ vi.clearAllMocks();
108
+ clearIrcRuntime();
109
+ });
110
+
111
+ it("parses valid ports and falls back for invalid values", () => {
112
+ expect(parsePort("6697", 6667)).toBe(6697);
113
+ expect(parsePort(" 7000 ", 6667)).toBe(7000);
114
+ expect(parsePort("", 6667)).toBe(6667);
115
+ expect(parsePort("70000", 6667)).toBe(6667);
116
+ expect(parsePort("abc", 6667)).toBe(6667);
117
+ });
118
+
119
+ it("updates top-level dm policy and allowlist", () => {
120
+ const cfg: CoreConfig = { channels: { irc: {} } };
121
+
122
+ expect(setIrcDmPolicy(cfg, "open")).toStrictEqual({
123
+ channels: {
124
+ irc: {
125
+ dmPolicy: "open",
126
+ allowFrom: ["*"],
127
+ },
128
+ },
129
+ });
130
+
131
+ expect(setIrcAllowFrom(cfg, ["alice", "bob"])).toStrictEqual({
132
+ channels: {
133
+ irc: {
134
+ allowFrom: ["alice", "bob"],
135
+ },
136
+ },
137
+ });
138
+ });
139
+
140
+ it("setup status honors the selected named account", async () => {
141
+ const status = await ircStatus({
142
+ cfg: {
143
+ channels: {
144
+ irc: {
145
+ accounts: {
146
+ ops: {
147
+ host: "irc.example.com",
148
+ nick: "ops-bot",
149
+ },
150
+ work: {
151
+ host: "irc.example.com",
152
+ },
153
+ },
154
+ },
155
+ },
156
+ } as CoreConfig,
157
+ accountOverrides: {
158
+ irc: "work",
159
+ },
160
+ });
161
+
162
+ expect(status.configured).toBe(false);
163
+ expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
164
+ });
165
+
166
+ it("setup status honors the configured default account", async () => {
167
+ const status = await ircStatus({
168
+ cfg: {
169
+ channels: {
170
+ irc: {
171
+ defaultAccount: "work",
172
+ accounts: {
173
+ ops: {
174
+ host: "irc.example.com",
175
+ nick: "ops-bot",
176
+ },
177
+ work: {
178
+ host: "irc.example.com",
179
+ nick: "",
180
+ },
181
+ },
182
+ },
183
+ },
184
+ } as CoreConfig,
185
+ accountOverrides: {},
186
+ });
187
+
188
+ expect(status.configured).toBe(false);
189
+ expect(status.statusLines).toEqual(["IRC: needs host + nick"]);
190
+ });
191
+
192
+ it("stores nickserv and account config patches on the scoped account", () => {
193
+ const cfg: CoreConfig = { channels: { irc: {} } };
194
+
195
+ expect(
196
+ setIrcNickServ(cfg, "work", {
197
+ enabled: true,
198
+ service: "NickServ",
199
+ }),
200
+ ).toStrictEqual({
201
+ channels: {
202
+ irc: {
203
+ accounts: {
204
+ work: {
205
+ nickserv: {
206
+ enabled: true,
207
+ service: "NickServ",
208
+ },
209
+ },
210
+ },
211
+ },
212
+ },
213
+ });
214
+
215
+ expect(
216
+ updateIrcAccountConfig(cfg, "work", {
217
+ host: "irc.libera.chat",
218
+ nick: "actagent-work",
219
+ }),
220
+ ).toStrictEqual({
221
+ channels: {
222
+ irc: {
223
+ accounts: {
224
+ work: {
225
+ host: "irc.libera.chat",
226
+ nick: "actagent-work",
227
+ },
228
+ },
229
+ },
230
+ },
231
+ });
232
+ });
233
+
234
+ it("normalizes allowlist groups and handles non-allowlist policies", () => {
235
+ const cfg: CoreConfig = { channels: { irc: {} } };
236
+
237
+ expect(
238
+ setIrcGroupAccess(
239
+ cfg,
240
+ "default",
241
+ "allowlist",
242
+ ["actagent", "#ops", "actagent", "*"],
243
+ (raw) => {
244
+ const trimmed = raw.trim();
245
+ if (!trimmed) {
246
+ return null;
247
+ }
248
+ if (trimmed === "*") {
249
+ return "*";
250
+ }
251
+ return trimmed.startsWith("#") ? trimmed : `#${trimmed}`;
252
+ },
253
+ ),
254
+ ).toStrictEqual({
255
+ channels: {
256
+ irc: {
257
+ enabled: true,
258
+ groupPolicy: "allowlist",
259
+ groups: {
260
+ "#actagent": {},
261
+ "#ops": {},
262
+ "*": {},
263
+ },
264
+ },
265
+ },
266
+ });
267
+
268
+ expect(setIrcGroupAccess(cfg, "default", "disabled", [], () => null)).toStrictEqual({
269
+ channels: {
270
+ irc: {
271
+ enabled: true,
272
+ groupPolicy: "disabled",
273
+ },
274
+ },
275
+ });
276
+ });
277
+
278
+ it("validates required input and applies normalized account config", () => {
279
+ const validateInput = ircSetupAdapter.validateInput;
280
+ const applyAccountConfig = ircSetupAdapter.applyAccountConfig;
281
+ expect(validateInput).toBeTypeOf("function");
282
+ expect(applyAccountConfig).toBeTypeOf("function");
283
+ if (!validateInput) {
284
+ throw new Error("Expected IRC setup validateInput");
285
+ }
286
+
287
+ expect(
288
+ validateInput({
289
+ input: { host: "", nick: "actagent" },
290
+ } as never),
291
+ ).toBe("IRC requires host.");
292
+
293
+ expect(
294
+ validateInput({
295
+ input: { host: "irc.libera.chat", nick: "" },
296
+ } as never),
297
+ ).toBe("IRC requires nick.");
298
+
299
+ expect(
300
+ validateInput({
301
+ input: { host: "irc.libera.chat", nick: "actagent" },
302
+ } as never),
303
+ ).toBeNull();
304
+
305
+ expect(
306
+ validateInput({
307
+ input: { host: "irc.libera.chat", nick: "actagent", port: "+07000" },
308
+ } as never),
309
+ ).toBeNull();
310
+
311
+ expect(
312
+ validateInput({
313
+ input: { host: "irc.libera.chat", nick: "actagent", port: "7000x" },
314
+ } as never),
315
+ ).toBe("IRC port must be between 1 and 65535.");
316
+
317
+ expect(
318
+ validateInput({
319
+ input: { host: "irc.libera.chat", nick: "actagent", port: "70000" },
320
+ } as never),
321
+ ).toBe("IRC port must be between 1 and 65535.");
322
+
323
+ expect(
324
+ applyAccountConfig({
325
+ cfg: { channels: { irc: {} } },
326
+ accountId: "default",
327
+ input: {
328
+ name: "Default",
329
+ host: " irc.libera.chat ",
330
+ port: "7000",
331
+ tls: true,
332
+ nick: " actagent ",
333
+ username: " actagent ",
334
+ realname: " ACTAgent Bot ",
335
+ password: " secret ",
336
+ channels: ["#actagent"],
337
+ },
338
+ } as never),
339
+ ).toEqual({
340
+ channels: {
341
+ irc: {
342
+ enabled: true,
343
+ name: "Default",
344
+ host: "irc.libera.chat",
345
+ port: 7000,
346
+ tls: true,
347
+ nick: "actagent",
348
+ username: "actagent",
349
+ realname: "ACTAgent Bot",
350
+ password: "secret",
351
+ channels: ["#actagent"],
352
+ },
353
+ },
354
+ });
355
+ });
356
+
357
+ it("configures host and nick via setup prompts", async () => {
358
+ const prompter = createTestWizardPrompter({
359
+ text: vi.fn(async ({ message }: { message: string }) => {
360
+ if (message === "IRC server host") {
361
+ return "irc.libera.chat";
362
+ }
363
+ if (message === "IRC server port") {
364
+ return "6697";
365
+ }
366
+ if (message === "IRC nick") {
367
+ return "actagent-bot";
368
+ }
369
+ if (message === "IRC username") {
370
+ return "actagent";
371
+ }
372
+ if (message === "IRC real name") {
373
+ return "ACTAgent Bot";
374
+ }
375
+ if (message.startsWith("Auto-join IRC channels")) {
376
+ return "#actagent, #ops";
377
+ }
378
+ if (message.startsWith("IRC channels allowlist")) {
379
+ return "#actagent, #ops";
380
+ }
381
+ throw new Error(`Unexpected prompt: ${message}`);
382
+ }) as WizardPrompter["text"],
383
+ confirm: vi.fn(async ({ message }: { message: string }) => {
384
+ if (message === "Use TLS for IRC?") {
385
+ return true;
386
+ }
387
+ if (message === "Configure IRC channels access?") {
388
+ return true;
389
+ }
390
+ return false;
391
+ }),
392
+ });
393
+
394
+ const result = await runSetupWizardConfigure({
395
+ configure: ircConfigureAdapter.configure,
396
+ cfg: {} as CoreConfig,
397
+ prompter,
398
+ options: {},
399
+ });
400
+
401
+ expect(result.accountId).toBe("default");
402
+ expect(result.cfg.channels?.irc?.enabled).toBe(true);
403
+ expect(result.cfg.channels?.irc?.host).toBe("irc.libera.chat");
404
+ expect(result.cfg.channels?.irc?.nick).toBe("actagent-bot");
405
+ expect(result.cfg.channels?.irc?.tls).toBe(true);
406
+ expect(result.cfg.channels?.irc?.channels).toEqual(["#actagent", "#ops"]);
407
+ expect(result.cfg.channels?.irc?.groupPolicy).toBe("allowlist");
408
+ expect(Object.keys(result.cfg.channels?.irc?.groups ?? {})).toEqual(["#actagent", "#ops"]);
409
+ });
410
+
411
+ it("rejects partial IRC setup wizard ports", async () => {
412
+ const portPrompt = ircSetupWizard.textInputs?.find((step) => step.inputKey === "httpPort");
413
+ if (!portPrompt?.validate) {
414
+ throw new Error("expected IRC port prompt validator");
415
+ }
416
+
417
+ expect(portPrompt.validate({ value: "7000x" } as never)).toBe("Use a port between 1 and 65535");
418
+ expect(portPrompt.validate({ value: "+07000" } as never)).toBeUndefined();
419
+ expect(portPrompt.validate({ value: "7000" } as never)).toBeUndefined();
420
+ });
421
+
422
+ it("writes DM allowFrom to top-level config for non-default account prompts", async () => {
423
+ const prompter = createTestWizardPrompter({
424
+ text: vi.fn(async ({ message }: { message: string }) => {
425
+ if (message === "IRC allowFrom (nick or nick!user@host)") {
426
+ return "Alice, Bob!ident@example.org";
427
+ }
428
+ throw new Error(`Unexpected prompt: ${message}`);
429
+ }) as WizardPrompter["text"],
430
+ confirm: vi.fn(async () => false),
431
+ });
432
+
433
+ const promptAllowFrom = ircConfigureAdapter.dmPolicy?.promptAllowFrom;
434
+ if (!promptAllowFrom) {
435
+ throw new Error("promptAllowFrom unavailable");
436
+ }
437
+
438
+ const cfg: CoreConfig = {
439
+ channels: {
440
+ irc: {
441
+ accounts: {
442
+ work: {
443
+ host: "irc.libera.chat",
444
+ nick: "actagent-work",
445
+ },
446
+ },
447
+ },
448
+ },
449
+ };
450
+
451
+ const updated = await promptSetupWizardAllowFrom({
452
+ promptAllowFrom,
453
+ cfg,
454
+ prompter,
455
+ accountId: "work",
456
+ });
457
+ if (!updated) {
458
+ throw new Error("expected IRC allowFrom setup to return updated config");
459
+ }
460
+
461
+ expect(updated.channels?.irc?.allowFrom).toEqual(["alice", "bob!ident@example.org"]);
462
+ expect(updated.channels?.irc?.accounts?.work?.allowFrom).toBeUndefined();
463
+ });
464
+
465
+ it("keeps startAccount pending until abort, then stops the monitor", async () => {
466
+ const stop = vi.fn();
467
+ hoisted.monitorIrcProvider.mockResolvedValue({ stop });
468
+ installIrcRuntime();
469
+
470
+ const { abort, task, isSettled } = startAccountAndTrackLifecycle({
471
+ startAccount: async (ctx) =>
472
+ await startIrcGatewayAccount({
473
+ ...ctx,
474
+ cfg: ctx.cfg as CoreConfig,
475
+ }),
476
+ account: buildAccount(),
477
+ });
478
+
479
+ await expectStopPendingUntilAbort({
480
+ waitForStarted: waitForStartedMocks(hoisted.monitorIrcProvider),
481
+ isSettled,
482
+ abort,
483
+ task,
484
+ stop,
485
+ });
486
+ });
487
+ });
package/src/types.ts ADDED
@@ -0,0 +1,101 @@
1
+ // Irc type declarations define plugin contracts.
2
+ import type {
3
+ BlockStreamingCoalesceConfig,
4
+ DmConfig,
5
+ DmPolicy,
6
+ GroupPolicy,
7
+ GroupToolPolicyBySenderConfig,
8
+ GroupToolPolicyConfig,
9
+ MarkdownConfig,
10
+ ACTAgentConfig,
11
+ BaseProbeResult,
12
+ } from "./runtime-api.js";
13
+
14
+ export type IrcChannelConfig = {
15
+ requireMention?: boolean;
16
+ tools?: GroupToolPolicyConfig;
17
+ toolsBySender?: GroupToolPolicyBySenderConfig;
18
+ skills?: string[];
19
+ enabled?: boolean;
20
+ allowFrom?: Array<string | number>;
21
+ systemPrompt?: string;
22
+ };
23
+
24
+ export type IrcNickServConfig = {
25
+ enabled?: boolean;
26
+ service?: string;
27
+ password?: string;
28
+ passwordFile?: string;
29
+ register?: boolean;
30
+ registerEmail?: string;
31
+ };
32
+
33
+ export type IrcAccountConfig = {
34
+ name?: string;
35
+ enabled?: boolean;
36
+ /**
37
+ * Break-glass override: allow nick-only allowlist matching.
38
+ * Default behavior requires host/user-qualified identities.
39
+ */
40
+ dangerouslyAllowNameMatching?: boolean;
41
+ host?: string;
42
+ port?: number;
43
+ tls?: boolean;
44
+ nick?: string;
45
+ username?: string;
46
+ realname?: string;
47
+ password?: string;
48
+ passwordFile?: string;
49
+ nickserv?: IrcNickServConfig;
50
+ dmPolicy?: DmPolicy;
51
+ allowFrom?: Array<string | number>;
52
+ defaultTo?: string;
53
+ groupPolicy?: GroupPolicy;
54
+ groupAllowFrom?: Array<string | number>;
55
+ groups?: Record<string, IrcChannelConfig>;
56
+ channels?: string[];
57
+ mentionPatterns?: string[];
58
+ markdown?: MarkdownConfig;
59
+ historyLimit?: number;
60
+ dmHistoryLimit?: number;
61
+ dms?: Record<string, DmConfig>;
62
+ textChunkLimit?: number;
63
+ chunkMode?: "length" | "newline";
64
+ blockStreaming?: boolean;
65
+ blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
66
+ responsePrefix?: string;
67
+ mediaMaxMb?: number;
68
+ };
69
+
70
+ type IrcConfig = IrcAccountConfig & {
71
+ accounts?: Record<string, IrcAccountConfig>;
72
+ defaultAccount?: string;
73
+ };
74
+
75
+ export type CoreConfig = ACTAgentConfig & {
76
+ channels?: ACTAgentConfig["channels"] & {
77
+ irc?: IrcConfig;
78
+ };
79
+ };
80
+
81
+ export type IrcInboundMessage = {
82
+ messageId: string;
83
+ /** Conversation peer id: channel name for groups, sender nick for DMs. */
84
+ target: string;
85
+ /** Raw IRC PRIVMSG target (bot nick for DMs, channel for groups). */
86
+ rawTarget?: string;
87
+ senderNick: string;
88
+ senderUser?: string;
89
+ senderHost?: string;
90
+ text: string;
91
+ timestamp: number;
92
+ isGroup: boolean;
93
+ };
94
+
95
+ export type IrcProbe = BaseProbeResult<string> & {
96
+ host: string;
97
+ port: number;
98
+ tls: boolean;
99
+ nick: string;
100
+ latencyMs?: number;
101
+ };
package/tsconfig.json ADDED
@@ -0,0 +1,16 @@
1
+ {
2
+ "extends": "../tsconfig.package-boundary.base.json",
3
+ "compilerOptions": {
4
+ "rootDir": "."
5
+ },
6
+ "include": ["./*.ts", "./src/**/*.ts"],
7
+ "exclude": [
8
+ "./**/*.test.ts",
9
+ "./dist/**",
10
+ "./node_modules/**",
11
+ "./src/test-support/**",
12
+ "./src/**/*test-helpers.ts",
13
+ "./src/**/*test-harness.ts",
14
+ "./src/**/*test-support.ts"
15
+ ]
16
+ }