@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
package/src/client.ts ADDED
@@ -0,0 +1,443 @@
1
+ // Irc plugin module implements client behavior.
2
+ import net from "node:net";
3
+ import tls from "node:tls";
4
+ import { withTimeout } from "actagent/plugin-sdk/security-runtime";
5
+ import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
6
+ import {
7
+ parseIrcLine,
8
+ parseIrcPrefix,
9
+ sanitizeIrcOutboundText,
10
+ sanitizeIrcTarget,
11
+ } from "./protocol.js";
12
+
13
+ const IRC_ERROR_CODES = new Set(["432", "464", "465"]);
14
+ const IRC_NICK_COLLISION_CODES = new Set(["433", "436"]);
15
+
16
+ type IrcPrivmsgEvent = {
17
+ senderNick: string;
18
+ senderUser?: string;
19
+ senderHost?: string;
20
+ target: string;
21
+ text: string;
22
+ rawLine: string;
23
+ };
24
+
25
+ export type IrcClientOptions = {
26
+ host: string;
27
+ port: number;
28
+ tls: boolean;
29
+ nick: string;
30
+ username: string;
31
+ realname: string;
32
+ password?: string;
33
+ nickserv?: IrcNickServOptions;
34
+ channels?: string[];
35
+ connectTimeoutMs?: number;
36
+ messageChunkMaxChars?: number;
37
+ abortSignal?: AbortSignal;
38
+ onPrivmsg?: (event: IrcPrivmsgEvent) => void | Promise<void>;
39
+ onNotice?: (text: string, target?: string) => void;
40
+ onError?: (error: Error) => void;
41
+ onLine?: (line: string) => void;
42
+ };
43
+
44
+ type IrcNickServOptions = {
45
+ enabled?: boolean;
46
+ service?: string;
47
+ password?: string;
48
+ register?: boolean;
49
+ registerEmail?: string;
50
+ };
51
+
52
+ export type IrcClient = {
53
+ nick: string;
54
+ isReady: () => boolean;
55
+ sendRaw: (line: string) => void;
56
+ join: (channel: string) => void;
57
+ sendPrivmsg: (target: string, text: string) => void;
58
+ quit: (reason?: string) => void;
59
+ close: () => void;
60
+ };
61
+
62
+ function toError(err: unknown): Error {
63
+ if (err instanceof Error) {
64
+ return err;
65
+ }
66
+ return new Error(typeof err === "string" ? err : JSON.stringify(err));
67
+ }
68
+
69
+ function buildFallbackNick(nick: string): string {
70
+ const normalized = nick.replace(/\s+/g, "");
71
+ const safe = normalized.replace(/[^A-Za-z0-9_\-[\]\\`^{}|]/g, "");
72
+ const base = safe || "actagent";
73
+ const suffix = "_";
74
+ const maxNickLen = 30;
75
+ if (base.length >= maxNickLen) {
76
+ return `${base.slice(0, maxNickLen - suffix.length)}${suffix}`;
77
+ }
78
+ return `${base}${suffix}`;
79
+ }
80
+
81
+ function normalizeIrcNick(value: string): string {
82
+ return normalizeLowercaseStringOrEmpty(value);
83
+ }
84
+
85
+ export function buildIrcNickServCommands(options?: IrcNickServOptions): string[] {
86
+ if (!options || options.enabled === false) {
87
+ return [];
88
+ }
89
+ const password = sanitizeIrcOutboundText(options.password ?? "");
90
+ if (!password) {
91
+ return [];
92
+ }
93
+ const service = sanitizeIrcTarget(options.service?.trim() || "NickServ");
94
+ const commands = [`PRIVMSG ${service} :IDENTIFY ${password}`];
95
+ if (options.register) {
96
+ const registerEmail = sanitizeIrcOutboundText(options.registerEmail ?? "");
97
+ if (!registerEmail) {
98
+ throw new Error("IRC NickServ register requires registerEmail");
99
+ }
100
+ commands.push(`PRIVMSG ${service} :REGISTER ${password} ${registerEmail}`);
101
+ }
102
+ return commands;
103
+ }
104
+
105
+ export async function connectIrcClient(options: IrcClientOptions): Promise<IrcClient> {
106
+ const timeoutMs = options.connectTimeoutMs != null ? options.connectTimeoutMs : 15000;
107
+ const messageChunkMaxChars =
108
+ options.messageChunkMaxChars != null ? options.messageChunkMaxChars : 350;
109
+
110
+ if (!options.host.trim()) {
111
+ throw new Error("IRC host is required");
112
+ }
113
+ if (!options.nick.trim()) {
114
+ throw new Error("IRC nick is required");
115
+ }
116
+
117
+ const desiredNick = options.nick.trim();
118
+ let currentNick = desiredNick;
119
+ let ready = false;
120
+ let closed = false;
121
+ let nickServRecoverAttempted = false;
122
+ let fallbackNickAttempted = false;
123
+ let removeAbortListener: (() => void) | null = null;
124
+
125
+ const socket = options.tls
126
+ ? tls.connect({
127
+ host: options.host,
128
+ port: options.port,
129
+ servername: options.host,
130
+ })
131
+ : net.connect({ host: options.host, port: options.port });
132
+
133
+ socket.setEncoding("utf8");
134
+
135
+ let resolveReady: (() => void) | null = null;
136
+ let rejectReady: ((error: Error) => void) | null = null;
137
+ const readyPromise = new Promise<void>((resolve, reject) => {
138
+ resolveReady = resolve;
139
+ rejectReady = reject;
140
+ });
141
+
142
+ const fail = (err: unknown) => {
143
+ const error = toError(err);
144
+ if (options.onError) {
145
+ options.onError(error);
146
+ }
147
+ if (!ready && rejectReady) {
148
+ rejectReady(error);
149
+ rejectReady = null;
150
+ resolveReady = null;
151
+ }
152
+ };
153
+
154
+ const failAndClose = (err: unknown) => {
155
+ fail(err);
156
+ close();
157
+ };
158
+
159
+ const sendRaw = (line: string) => {
160
+ const cleaned = line.replace(/[\r\n]+/g, "").trim();
161
+ if (!cleaned) {
162
+ throw new Error("IRC command cannot be empty");
163
+ }
164
+ socket.write(`${cleaned}\r\n`);
165
+ };
166
+
167
+ const tryRecoverNickCollision = (): boolean => {
168
+ const nickServEnabled = options.nickserv?.enabled !== false;
169
+ const nickservPassword = sanitizeIrcOutboundText(options.nickserv?.password ?? "");
170
+ if (nickServEnabled && !nickServRecoverAttempted && nickservPassword) {
171
+ nickServRecoverAttempted = true;
172
+ try {
173
+ const service = sanitizeIrcTarget(options.nickserv?.service?.trim() || "NickServ");
174
+ sendRaw(`PRIVMSG ${service} :GHOST ${desiredNick} ${nickservPassword}`);
175
+ sendRaw(`NICK ${desiredNick}`);
176
+ return true;
177
+ } catch (err) {
178
+ fail(err);
179
+ }
180
+ }
181
+
182
+ if (!fallbackNickAttempted) {
183
+ fallbackNickAttempted = true;
184
+ const fallbackNick = buildFallbackNick(desiredNick);
185
+ if (normalizeIrcNick(fallbackNick) !== normalizeIrcNick(currentNick)) {
186
+ try {
187
+ sendRaw(`NICK ${fallbackNick}`);
188
+ currentNick = fallbackNick;
189
+ return true;
190
+ } catch (err) {
191
+ fail(err);
192
+ }
193
+ }
194
+ }
195
+ return false;
196
+ };
197
+
198
+ const join = (channel: string) => {
199
+ const target = sanitizeIrcTarget(channel);
200
+ if (!target.startsWith("#") && !target.startsWith("&")) {
201
+ throw new Error(`IRC JOIN target must be a channel: ${channel}`);
202
+ }
203
+ sendRaw(`JOIN ${target}`);
204
+ };
205
+
206
+ const sendPrivmsg = (target: string, text: string) => {
207
+ const normalizedTarget = sanitizeIrcTarget(target);
208
+ const cleaned = sanitizeIrcOutboundText(text);
209
+ if (!cleaned) {
210
+ return;
211
+ }
212
+ let remaining = cleaned;
213
+ while (remaining.length > 0) {
214
+ let chunk = remaining;
215
+ if (chunk.length > messageChunkMaxChars) {
216
+ let splitAt = chunk.lastIndexOf(" ", messageChunkMaxChars);
217
+ if (splitAt < Math.floor(messageChunkMaxChars / 2)) {
218
+ splitAt = messageChunkMaxChars;
219
+ }
220
+ chunk = chunk.slice(0, splitAt).trim();
221
+ }
222
+ if (!chunk) {
223
+ break;
224
+ }
225
+ sendRaw(`PRIVMSG ${normalizedTarget} :${chunk}`);
226
+ remaining = remaining.slice(chunk.length).trimStart();
227
+ }
228
+ };
229
+
230
+ const quit = (reason?: string) => {
231
+ if (closed) {
232
+ return;
233
+ }
234
+ closed = true;
235
+ removeAbortListener?.();
236
+ removeAbortListener = null;
237
+ const safeReason = sanitizeIrcOutboundText(reason != null ? reason : "bye");
238
+ try {
239
+ if (safeReason) {
240
+ sendRaw(`QUIT :${safeReason}`);
241
+ } else {
242
+ sendRaw("QUIT");
243
+ }
244
+ } catch {
245
+ // Ignore quit failures while shutting down.
246
+ }
247
+ socket.end();
248
+ };
249
+
250
+ const close = () => {
251
+ if (closed) {
252
+ return;
253
+ }
254
+ closed = true;
255
+ removeAbortListener?.();
256
+ removeAbortListener = null;
257
+ socket.destroy();
258
+ };
259
+
260
+ let buffer = "";
261
+ socket.on("data", (chunk: string) => {
262
+ buffer += chunk;
263
+ let idx = buffer.indexOf("\n");
264
+ while (idx !== -1) {
265
+ const rawLine = buffer.slice(0, idx).replace(/\r$/, "");
266
+ buffer = buffer.slice(idx + 1);
267
+ idx = buffer.indexOf("\n");
268
+
269
+ if (!rawLine) {
270
+ continue;
271
+ }
272
+ if (options.onLine) {
273
+ options.onLine(rawLine);
274
+ }
275
+
276
+ const line = parseIrcLine(rawLine);
277
+ if (!line) {
278
+ continue;
279
+ }
280
+
281
+ if (line.command === "PING") {
282
+ const payload =
283
+ line.trailing != null ? line.trailing : line.params[0] != null ? line.params[0] : "";
284
+ sendRaw(`PONG :${payload}`);
285
+ continue;
286
+ }
287
+
288
+ if (line.command === "NICK") {
289
+ const prefix = parseIrcPrefix(line.prefix);
290
+ if (prefix.nick && normalizeIrcNick(prefix.nick) === normalizeIrcNick(currentNick)) {
291
+ const next =
292
+ line.trailing != null
293
+ ? line.trailing
294
+ : line.params[0] != null
295
+ ? line.params[0]
296
+ : currentNick;
297
+ currentNick = next.trim();
298
+ }
299
+ continue;
300
+ }
301
+
302
+ if (!ready && IRC_NICK_COLLISION_CODES.has(line.command)) {
303
+ if (tryRecoverNickCollision()) {
304
+ continue;
305
+ }
306
+ const detail =
307
+ line.trailing != null ? line.trailing : line.params.join(" ") || "nickname in use";
308
+ fail(new Error(`IRC login failed (${line.command}): ${detail}`));
309
+ close();
310
+ return;
311
+ }
312
+
313
+ if (!ready && IRC_ERROR_CODES.has(line.command)) {
314
+ const detail =
315
+ line.trailing != null ? line.trailing : line.params.join(" ") || "login rejected";
316
+ fail(new Error(`IRC login failed (${line.command}): ${detail}`));
317
+ close();
318
+ return;
319
+ }
320
+
321
+ if (line.command === "001") {
322
+ ready = true;
323
+ const nickParam = line.params[0];
324
+ if (nickParam && nickParam.trim()) {
325
+ currentNick = nickParam.trim();
326
+ }
327
+ try {
328
+ const nickServCommands = buildIrcNickServCommands(options.nickserv);
329
+ for (const command of nickServCommands) {
330
+ sendRaw(command);
331
+ }
332
+ } catch (err) {
333
+ fail(err);
334
+ }
335
+ for (const channel of options.channels || []) {
336
+ const trimmed = channel.trim();
337
+ if (!trimmed) {
338
+ continue;
339
+ }
340
+ try {
341
+ join(trimmed);
342
+ } catch (err) {
343
+ fail(err);
344
+ }
345
+ }
346
+ if (resolveReady) {
347
+ resolveReady();
348
+ }
349
+ resolveReady = null;
350
+ rejectReady = null;
351
+ continue;
352
+ }
353
+
354
+ if (line.command === "NOTICE") {
355
+ if (options.onNotice) {
356
+ options.onNotice(line.trailing != null ? line.trailing : "", line.params[0]);
357
+ }
358
+ continue;
359
+ }
360
+
361
+ if (line.command === "PRIVMSG") {
362
+ const targetParam = line.params[0];
363
+ const target = targetParam ? targetParam.trim() : "";
364
+ const text = line.trailing != null ? line.trailing : "";
365
+ const prefix = parseIrcPrefix(line.prefix);
366
+ const senderNick = prefix.nick ? prefix.nick.trim() : "";
367
+ if (!target || !senderNick || !text.trim()) {
368
+ continue;
369
+ }
370
+ if (options.onPrivmsg) {
371
+ void Promise.resolve(
372
+ options.onPrivmsg({
373
+ senderNick,
374
+ senderUser: prefix.user ? prefix.user.trim() : undefined,
375
+ senderHost: prefix.host ? prefix.host.trim() : undefined,
376
+ target,
377
+ text,
378
+ rawLine,
379
+ }),
380
+ ).catch((error: unknown) => {
381
+ fail(error);
382
+ });
383
+ }
384
+ }
385
+ }
386
+ });
387
+
388
+ socket.once("connect", () => {
389
+ try {
390
+ if (options.password && options.password.trim()) {
391
+ sendRaw(`PASS ${options.password.trim()}`);
392
+ }
393
+ sendRaw(`NICK ${options.nick.trim()}`);
394
+ sendRaw(`USER ${options.username.trim()} 0 * :${sanitizeIrcOutboundText(options.realname)}`);
395
+ } catch (err) {
396
+ fail(err);
397
+ close();
398
+ }
399
+ });
400
+
401
+ socket.once("error", (err: unknown) => {
402
+ fail(err);
403
+ });
404
+
405
+ socket.once("close", () => {
406
+ if (!closed) {
407
+ closed = true;
408
+ if (!ready) {
409
+ fail(new Error("IRC connection closed before ready"));
410
+ }
411
+ }
412
+ });
413
+
414
+ if (options.abortSignal) {
415
+ const abort = () => {
416
+ if (!ready) {
417
+ failAndClose(new Error("IRC connect aborted"));
418
+ return;
419
+ }
420
+ quit("shutdown");
421
+ };
422
+ if (options.abortSignal.aborted) {
423
+ abort();
424
+ } else {
425
+ options.abortSignal.addEventListener("abort", abort, { once: true });
426
+ removeAbortListener = () => options.abortSignal?.removeEventListener("abort", abort);
427
+ }
428
+ }
429
+
430
+ await withTimeout(readyPromise, timeoutMs, "IRC connect");
431
+
432
+ return {
433
+ get nick() {
434
+ return currentNick;
435
+ },
436
+ isReady: () => ready && !closed,
437
+ sendRaw,
438
+ join,
439
+ sendPrivmsg,
440
+ quit,
441
+ close,
442
+ };
443
+ }
@@ -0,0 +1,117 @@
1
+ // Irc tests cover config schema plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+ import { IrcConfigSchema } from "./config-schema.js";
4
+
5
+ function expectValidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
6
+ expect(result.success).toBe(true);
7
+ if (!result.success) {
8
+ throw new Error("expected config to be valid");
9
+ }
10
+ return result.data;
11
+ }
12
+
13
+ function expectInvalidConfig(result: ReturnType<typeof IrcConfigSchema.safeParse>) {
14
+ expect(result.success).toBe(false);
15
+ if (result.success) {
16
+ throw new Error("expected config to be invalid");
17
+ }
18
+ return result.error.issues;
19
+ }
20
+
21
+ describe("irc config schema", () => {
22
+ it("accepts basic config", () => {
23
+ const config = expectValidConfig(
24
+ IrcConfigSchema.safeParse({
25
+ host: "irc.libera.chat",
26
+ nick: "actagent-bot",
27
+ channels: ["#actagent"],
28
+ }),
29
+ );
30
+
31
+ expect(config.host).toBe("irc.libera.chat");
32
+ expect(config.nick).toBe("actagent-bot");
33
+ });
34
+
35
+ it('rejects dmPolicy="open" without allowFrom "*"', () => {
36
+ const issues = expectInvalidConfig(
37
+ IrcConfigSchema.safeParse({
38
+ dmPolicy: "open",
39
+ allowFrom: ["alice"],
40
+ }),
41
+ );
42
+
43
+ expect(issues[0]?.path.join(".")).toBe("allowFrom");
44
+ });
45
+
46
+ it('accepts dmPolicy="open" with allowFrom "*"', () => {
47
+ const config = expectValidConfig(
48
+ IrcConfigSchema.safeParse({
49
+ dmPolicy: "open",
50
+ allowFrom: ["*"],
51
+ }),
52
+ );
53
+
54
+ expect(config.dmPolicy).toBe("open");
55
+ });
56
+
57
+ it("accepts numeric allowFrom and groupAllowFrom entries", () => {
58
+ const parsed = IrcConfigSchema.parse({
59
+ dmPolicy: "allowlist",
60
+ allowFrom: [12345, "alice"],
61
+ groupAllowFrom: [67890, "alice!ident@example.org"],
62
+ });
63
+
64
+ expect(parsed.allowFrom).toEqual([12345, "alice"]);
65
+ expect(parsed.groupAllowFrom).toEqual([67890, "alice!ident@example.org"]);
66
+ });
67
+
68
+ it("accepts numeric per-channel allowFrom entries", () => {
69
+ const parsed = IrcConfigSchema.parse({
70
+ groups: {
71
+ "#ops": {
72
+ allowFrom: [42, "alice"],
73
+ },
74
+ },
75
+ });
76
+
77
+ expect(parsed.groups?.["#ops"]?.allowFrom).toEqual([42, "alice"]);
78
+ });
79
+
80
+ it("rejects nickserv register without registerEmail", () => {
81
+ const issues = expectInvalidConfig(
82
+ IrcConfigSchema.safeParse({
83
+ nickserv: {
84
+ register: true,
85
+ password: "secret",
86
+ },
87
+ }),
88
+ );
89
+
90
+ expect(issues[0]?.path.join(".")).toBe("nickserv.registerEmail");
91
+ });
92
+
93
+ it("accepts nickserv register with password and registerEmail", () => {
94
+ const config = expectValidConfig(
95
+ IrcConfigSchema.safeParse({
96
+ nickserv: {
97
+ register: true,
98
+ password: "secret",
99
+ registerEmail: "bot@example.com",
100
+ },
101
+ }),
102
+ );
103
+
104
+ expect(config.nickserv?.register).toBe(true);
105
+ });
106
+
107
+ it("accepts nickserv register with registerEmail only", () => {
108
+ expectValidConfig(
109
+ IrcConfigSchema.safeParse({
110
+ nickserv: {
111
+ register: true,
112
+ registerEmail: "bot@example.com",
113
+ },
114
+ }),
115
+ );
116
+ });
117
+ });
@@ -0,0 +1,97 @@
1
+ // Irc helper module supports config schema behavior.
2
+ import {
3
+ DmPolicySchema,
4
+ GroupPolicySchema,
5
+ MarkdownConfigSchema,
6
+ ReplyRuntimeConfigSchemaShape,
7
+ ToolPolicySchema,
8
+ buildChannelConfigSchema,
9
+ requireOpenAllowFrom,
10
+ } from "actagent/plugin-sdk/channel-config-schema";
11
+ import { z } from "zod";
12
+ import { ircChannelConfigUiHints } from "./config-ui-hints.js";
13
+
14
+ const IrcGroupSchema = z
15
+ .object({
16
+ requireMention: z.boolean().optional(),
17
+ tools: ToolPolicySchema,
18
+ toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
19
+ skills: z.array(z.string()).optional(),
20
+ enabled: z.boolean().optional(),
21
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
22
+ systemPrompt: z.string().optional(),
23
+ })
24
+ .strict();
25
+
26
+ const IrcNickServSchema = z
27
+ .object({
28
+ enabled: z.boolean().optional(),
29
+ service: z.string().optional(),
30
+ password: z.string().optional(),
31
+ passwordFile: z.string().optional(),
32
+ register: z.boolean().optional(),
33
+ registerEmail: z.string().optional(),
34
+ })
35
+ .strict()
36
+ .superRefine((value, ctx) => {
37
+ if (value.register && !value.registerEmail?.trim()) {
38
+ ctx.addIssue({
39
+ code: z.ZodIssueCode.custom,
40
+ path: ["registerEmail"],
41
+ message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail",
42
+ });
43
+ }
44
+ });
45
+
46
+ const IrcAccountSchemaBase = z
47
+ .object({
48
+ name: z.string().optional(),
49
+ enabled: z.boolean().optional(),
50
+ dangerouslyAllowNameMatching: z.boolean().optional(),
51
+ host: z.string().optional(),
52
+ port: z.number().int().min(1).max(65535).optional(),
53
+ tls: z.boolean().optional(),
54
+ nick: z.string().optional(),
55
+ username: z.string().optional(),
56
+ realname: z.string().optional(),
57
+ password: z.string().optional(),
58
+ passwordFile: z.string().optional(),
59
+ nickserv: IrcNickServSchema.optional(),
60
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
61
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
62
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
63
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
64
+ groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
65
+ channels: z.array(z.string()).optional(),
66
+ mentionPatterns: z.array(z.string()).optional(),
67
+ markdown: MarkdownConfigSchema,
68
+ ...ReplyRuntimeConfigSchemaShape,
69
+ })
70
+ .strict();
71
+
72
+ const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
73
+ requireOpenAllowFrom({
74
+ policy: value.dmPolicy,
75
+ allowFrom: value.allowFrom,
76
+ ctx,
77
+ path: ["allowFrom"],
78
+ message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
79
+ });
80
+ });
81
+
82
+ export const IrcConfigSchema = IrcAccountSchemaBase.extend({
83
+ accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
84
+ defaultAccount: z.string().optional(),
85
+ }).superRefine((value, ctx) => {
86
+ requireOpenAllowFrom({
87
+ policy: value.dmPolicy,
88
+ allowFrom: value.allowFrom,
89
+ ctx,
90
+ path: ["allowFrom"],
91
+ message: 'channels.irc.dmPolicy="open" requires channels.irc.allowFrom to include "*"',
92
+ });
93
+ });
94
+
95
+ export const IrcChannelConfigSchema = buildChannelConfigSchema(IrcConfigSchema, {
96
+ uiHints: ircChannelConfigUiHints,
97
+ });
@@ -0,0 +1,41 @@
1
+ // Irc helper module supports config ui hints behavior.
2
+ import type { ChannelConfigUiHint } from "actagent/plugin-sdk/core";
3
+
4
+ export const ircChannelConfigUiHints = {
5
+ "": {
6
+ label: "IRC",
7
+ help: "IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into ACTAgent.",
8
+ },
9
+ dmPolicy: {
10
+ label: "IRC DM Policy",
11
+ help: 'Direct message access control ("pairing" recommended). "open" requires channels.irc.allowFrom=["*"].',
12
+ },
13
+ "nickserv.enabled": {
14
+ label: "IRC NickServ Enabled",
15
+ help: "Enable NickServ identify/register after connect (defaults to enabled when password is configured).",
16
+ },
17
+ "nickserv.service": {
18
+ label: "IRC NickServ Service",
19
+ help: "NickServ service nick (default: NickServ).",
20
+ },
21
+ "nickserv.password": {
22
+ label: "IRC NickServ Password",
23
+ help: "NickServ password used for IDENTIFY/REGISTER (sensitive).",
24
+ },
25
+ "nickserv.passwordFile": {
26
+ label: "IRC NickServ Password File",
27
+ help: "Optional file path containing NickServ password.",
28
+ },
29
+ "nickserv.register": {
30
+ label: "IRC NickServ Register",
31
+ help: "If true, send NickServ REGISTER on every connect. Use once for initial registration, then disable.",
32
+ },
33
+ "nickserv.registerEmail": {
34
+ label: "IRC NickServ Register Email",
35
+ help: "Email used with NickServ REGISTER (required when register=true).",
36
+ },
37
+ configWrites: {
38
+ label: "IRC Config Writes",
39
+ help: "Allow IRC to write config in response to channel events/commands (default: true).",
40
+ },
41
+ } satisfies Record<string, ChannelConfigUiHint>;