@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,56 @@
1
+ // Irc tests cover normalize plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ buildIrcAllowlistCandidates,
5
+ normalizeIrcAllowEntry,
6
+ normalizeIrcMessagingTarget,
7
+ resolveIrcAllowlistMatch,
8
+ } from "./normalize.js";
9
+
10
+ describe("irc normalize", () => {
11
+ it("normalizes targets", () => {
12
+ expect(normalizeIrcMessagingTarget("irc:channel:actagent")).toBe("#actagent");
13
+ expect(normalizeIrcMessagingTarget("user:alice")).toBe("alice");
14
+ expect(normalizeIrcMessagingTarget("\n")).toBeUndefined();
15
+ });
16
+
17
+ it("normalizes allowlist entries", () => {
18
+ expect(normalizeIrcAllowEntry("IRC:User:Alice!u@h")).toBe("alice!u@h");
19
+ });
20
+
21
+ it("matches senders by nick/user/host candidates", () => {
22
+ const message = {
23
+ messageId: "m1",
24
+ target: "#chan",
25
+ senderNick: "Alice",
26
+ senderUser: "ident",
27
+ senderHost: "example.org",
28
+ text: "hi",
29
+ timestamp: Date.now(),
30
+ isGroup: true,
31
+ };
32
+
33
+ expect(buildIrcAllowlistCandidates(message)).toContain("alice!ident@example.org");
34
+ expect(buildIrcAllowlistCandidates(message)).not.toContain("alice");
35
+ expect(buildIrcAllowlistCandidates(message, { allowNameMatching: true })).toContain("alice");
36
+ expect(
37
+ resolveIrcAllowlistMatch({
38
+ allowFrom: ["alice!ident@example.org"],
39
+ message,
40
+ }).allowed,
41
+ ).toBe(true);
42
+ expect(
43
+ resolveIrcAllowlistMatch({
44
+ allowFrom: ["alice"],
45
+ message,
46
+ }).allowed,
47
+ ).toBe(false);
48
+ expect(
49
+ resolveIrcAllowlistMatch({
50
+ allowFrom: ["alice"],
51
+ message,
52
+ allowNameMatching: true,
53
+ }).allowed,
54
+ ).toBe(true);
55
+ });
56
+ });
@@ -0,0 +1,111 @@
1
+ // Irc helper module supports normalize behavior.
2
+ import {
3
+ normalizeLowercaseStringOrEmpty,
4
+ normalizeOptionalLowercaseString,
5
+ normalizeStringEntriesLower,
6
+ } from "actagent/plugin-sdk/string-coerce-runtime";
7
+ import { hasIrcControlChars } from "./control-chars.js";
8
+ import type { IrcInboundMessage } from "./types.js";
9
+
10
+ const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
11
+
12
+ export function isChannelTarget(target: string): boolean {
13
+ return target.startsWith("#") || target.startsWith("&");
14
+ }
15
+
16
+ export function normalizeIrcMessagingTarget(raw: string): string | undefined {
17
+ const trimmed = raw.trim();
18
+ if (!trimmed) {
19
+ return undefined;
20
+ }
21
+ let target = trimmed;
22
+ const lowered = normalizeLowercaseStringOrEmpty(target);
23
+ if (lowered.startsWith("irc:")) {
24
+ target = target.slice("irc:".length).trim();
25
+ }
26
+ if (normalizeLowercaseStringOrEmpty(target).startsWith("channel:")) {
27
+ target = target.slice("channel:".length).trim();
28
+ if (!target.startsWith("#") && !target.startsWith("&")) {
29
+ target = `#${target}`;
30
+ }
31
+ }
32
+ if (normalizeLowercaseStringOrEmpty(target).startsWith("user:")) {
33
+ target = target.slice("user:".length).trim();
34
+ }
35
+ if (!target || !looksLikeIrcTargetId(target)) {
36
+ return undefined;
37
+ }
38
+ return target;
39
+ }
40
+
41
+ export function looksLikeIrcTargetId(raw: string): boolean {
42
+ const trimmed = raw.trim();
43
+ if (!trimmed) {
44
+ return false;
45
+ }
46
+ if (hasIrcControlChars(trimmed)) {
47
+ return false;
48
+ }
49
+ return IRC_TARGET_PATTERN.test(trimmed);
50
+ }
51
+
52
+ export function normalizeIrcAllowEntry(raw: string): string {
53
+ let value = normalizeLowercaseStringOrEmpty(raw);
54
+ if (!value) {
55
+ return "";
56
+ }
57
+ if (value.startsWith("irc:")) {
58
+ value = value.slice("irc:".length);
59
+ }
60
+ if (value.startsWith("user:")) {
61
+ value = value.slice("user:".length);
62
+ }
63
+ return value.trim();
64
+ }
65
+
66
+ export function normalizeIrcAllowlist(entries?: Array<string | number>): string[] {
67
+ return (entries ?? []).map((entry) => normalizeIrcAllowEntry(String(entry))).filter(Boolean);
68
+ }
69
+
70
+ export function buildIrcAllowlistCandidates(
71
+ message: IrcInboundMessage,
72
+ params?: { allowNameMatching?: boolean },
73
+ ): string[] {
74
+ const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
75
+ const user = normalizeOptionalLowercaseString(message.senderUser);
76
+ const host = normalizeOptionalLowercaseString(message.senderHost);
77
+ const candidates = new Set<string>();
78
+ if (nick && params?.allowNameMatching === true) {
79
+ candidates.add(nick);
80
+ }
81
+ if (nick && user) {
82
+ candidates.add(`${nick}!${user}`);
83
+ }
84
+ if (nick && host) {
85
+ candidates.add(`${nick}@${host}`);
86
+ }
87
+ if (nick && user && host) {
88
+ candidates.add(`${nick}!${user}@${host}`);
89
+ }
90
+ return [...candidates];
91
+ }
92
+
93
+ export function resolveIrcAllowlistMatch(params: {
94
+ allowFrom: string[];
95
+ message: IrcInboundMessage;
96
+ allowNameMatching?: boolean;
97
+ }): { allowed: boolean; source?: string } {
98
+ const allowFrom = new Set(normalizeStringEntriesLower(params.allowFrom));
99
+ if (allowFrom.has("*")) {
100
+ return { allowed: true, source: "wildcard" };
101
+ }
102
+ const candidates = buildIrcAllowlistCandidates(params.message, {
103
+ allowNameMatching: params.allowNameMatching,
104
+ });
105
+ for (const candidate of candidates) {
106
+ if (allowFrom.has(candidate)) {
107
+ return { allowed: true, source: candidate };
108
+ }
109
+ }
110
+ return { allowed: false };
111
+ }
@@ -0,0 +1,11 @@
1
+ // Irc plugin module implements outbound base behavior.
2
+ import { sanitizeForPlainText } from "actagent/plugin-sdk/channel-outbound";
3
+ import { chunkTextForOutbound } from "./channel-api.js";
4
+
5
+ export const ircOutboundBaseAdapter = {
6
+ deliveryMode: "direct" as const,
7
+ chunker: chunkTextForOutbound,
8
+ chunkerMode: "markdown" as const,
9
+ textChunkLimit: 350,
10
+ sanitizeText: ({ text }: { text: string }) => sanitizeForPlainText(text),
11
+ };
@@ -0,0 +1,56 @@
1
+ // Irc tests cover policy plugin behavior.
2
+ import { resolveChannelGroupPolicy } from "actagent/plugin-sdk/channel-policy";
3
+ import { describe, expect, it } from "vitest";
4
+ import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
5
+
6
+ describe("irc policy", () => {
7
+ it("matches direct and wildcard group entries", () => {
8
+ const direct = resolveIrcGroupMatch({
9
+ groups: {
10
+ "#ops": { requireMention: false },
11
+ },
12
+ target: "#ops",
13
+ });
14
+ expect(direct.allowed).toBe(true);
15
+ expect(resolveIrcRequireMention({ groupConfig: direct.groupConfig })).toBe(false);
16
+
17
+ const wildcard = resolveIrcGroupMatch({
18
+ groups: {
19
+ "*": { requireMention: true },
20
+ },
21
+ target: "#random",
22
+ });
23
+ expect(wildcard.allowed).toBe(true);
24
+ expect(resolveIrcRequireMention({ wildcardConfig: wildcard.wildcardConfig })).toBe(true);
25
+ });
26
+
27
+ it("keeps case-insensitive group matching aligned with shared channel policy resolution", () => {
28
+ const groups = {
29
+ "#Ops": { requireMention: false },
30
+ "#Hidden": { enabled: false },
31
+ "*": { requireMention: true },
32
+ };
33
+
34
+ const inboundDirect = resolveIrcGroupMatch({ groups, target: "#ops" });
35
+ const sharedDirect = resolveChannelGroupPolicy({
36
+ cfg: { channels: { irc: { groups } } },
37
+ channel: "irc",
38
+ groupId: "#ops",
39
+ groupIdCaseInsensitive: true,
40
+ });
41
+ expect(sharedDirect.allowed).toBe(inboundDirect.allowed);
42
+ expect(sharedDirect.groupConfig?.requireMention).toBe(
43
+ inboundDirect.groupConfig?.requireMention,
44
+ );
45
+
46
+ const inboundDisabled = resolveIrcGroupMatch({ groups, target: "#hidden" });
47
+ const sharedDisabled = resolveChannelGroupPolicy({
48
+ cfg: { channels: { irc: { groups } } },
49
+ channel: "irc",
50
+ groupId: "#hidden",
51
+ groupIdCaseInsensitive: true,
52
+ });
53
+ expect(sharedDisabled.allowed).toBe(inboundDisabled.allowed);
54
+ expect(inboundDisabled.groupConfig?.enabled).toBe(false);
55
+ });
56
+ });
package/src/policy.ts ADDED
@@ -0,0 +1,79 @@
1
+ // Irc plugin module implements policy behavior.
2
+ import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
3
+ import type { IrcChannelConfig } from "./types.js";
4
+
5
+ export type IrcGroupMatch = {
6
+ allowed: boolean;
7
+ groupConfig?: IrcChannelConfig;
8
+ wildcardConfig?: IrcChannelConfig;
9
+ hasConfiguredGroups: boolean;
10
+ };
11
+
12
+ export function resolveIrcGroupMatch(params: {
13
+ groups?: Record<string, IrcChannelConfig>;
14
+ target: string;
15
+ }): IrcGroupMatch {
16
+ const groups = params.groups ?? {};
17
+ const hasConfiguredGroups = Object.keys(groups).length > 0;
18
+
19
+ // IRC channel targets are case-insensitive, but config keys are plain strings.
20
+ // To avoid surprising drops (e.g. "#TUIRC-DEV" vs "#tuirc-dev"), match
21
+ // group config keys case-insensitively.
22
+ const direct = groups[params.target];
23
+ if (direct) {
24
+ return {
25
+ // "allowed" means the target matched an allowlisted key.
26
+ // Explicit disables are represented later as ingress route facts.
27
+ allowed: true,
28
+ groupConfig: direct,
29
+ wildcardConfig: groups["*"],
30
+ hasConfiguredGroups,
31
+ };
32
+ }
33
+
34
+ const targetLower = normalizeLowercaseStringOrEmpty(params.target);
35
+ const directKey = Object.keys(groups).find(
36
+ (key) => normalizeLowercaseStringOrEmpty(key) === targetLower,
37
+ );
38
+ if (directKey) {
39
+ const matched = groups[directKey];
40
+ if (matched) {
41
+ return {
42
+ // "allowed" means the target matched an allowlisted key.
43
+ // Explicit disables are represented later as ingress route facts.
44
+ allowed: true,
45
+ groupConfig: matched,
46
+ wildcardConfig: groups["*"],
47
+ hasConfiguredGroups,
48
+ };
49
+ }
50
+ }
51
+
52
+ const wildcard = groups["*"];
53
+ if (wildcard) {
54
+ return {
55
+ // "allowed" means the target matched an allowlisted key.
56
+ // Explicit disables are represented later as ingress route facts.
57
+ allowed: true,
58
+ wildcardConfig: wildcard,
59
+ hasConfiguredGroups,
60
+ };
61
+ }
62
+ return {
63
+ allowed: false,
64
+ hasConfiguredGroups,
65
+ };
66
+ }
67
+
68
+ export function resolveIrcRequireMention(params: {
69
+ groupConfig?: IrcChannelConfig;
70
+ wildcardConfig?: IrcChannelConfig;
71
+ }): boolean {
72
+ if (params.groupConfig?.requireMention !== undefined) {
73
+ return params.groupConfig.requireMention;
74
+ }
75
+ if (params.wildcardConfig?.requireMention !== undefined) {
76
+ return params.wildcardConfig.requireMention;
77
+ }
78
+ return true;
79
+ }
@@ -0,0 +1,111 @@
1
+ // Irc tests cover probe plugin behavior.
2
+ import { afterAll, afterEach, beforeEach, describe, expect, it, vi } from "vitest";
3
+ import { probeIrc } from "./probe.js";
4
+
5
+ const resolveIrcAccountMock = vi.hoisted(() => vi.fn());
6
+ const buildIrcConnectOptionsMock = vi.hoisted(() => vi.fn());
7
+ const connectIrcClientMock = vi.hoisted(() => vi.fn());
8
+
9
+ vi.mock("./accounts.js", () => ({
10
+ resolveIrcAccount: resolveIrcAccountMock,
11
+ }));
12
+
13
+ vi.mock("./connect-options.js", () => ({
14
+ buildIrcConnectOptions: buildIrcConnectOptionsMock,
15
+ }));
16
+
17
+ vi.mock("./client.js", () => ({
18
+ connectIrcClient: connectIrcClientMock,
19
+ }));
20
+
21
+ afterAll(() => {
22
+ vi.doUnmock("./accounts.js");
23
+ vi.doUnmock("./connect-options.js");
24
+ vi.doUnmock("./client.js");
25
+ vi.resetModules();
26
+ });
27
+
28
+ describe("probeIrc", () => {
29
+ beforeEach(() => {
30
+ resolveIrcAccountMock.mockReset();
31
+ buildIrcConnectOptionsMock.mockReset();
32
+ connectIrcClientMock.mockReset();
33
+ });
34
+
35
+ afterEach(() => {
36
+ vi.restoreAllMocks();
37
+ });
38
+
39
+ it("returns a configuration error when the IRC account is incomplete", async () => {
40
+ resolveIrcAccountMock.mockReturnValue({
41
+ configured: false,
42
+ host: "",
43
+ port: 6667,
44
+ tls: false,
45
+ nick: "",
46
+ });
47
+
48
+ await expect(probeIrc({} as never)).resolves.toEqual({
49
+ ok: false,
50
+ host: "",
51
+ port: 6667,
52
+ tls: false,
53
+ nick: "",
54
+ error: "missing host or nick",
55
+ });
56
+ expect(connectIrcClientMock).not.toHaveBeenCalled();
57
+ });
58
+
59
+ it("returns latency and quits the probe client on success", async () => {
60
+ const account = {
61
+ configured: true,
62
+ host: "irc.libera.chat",
63
+ port: 6697,
64
+ tls: true,
65
+ nick: "actagent",
66
+ };
67
+ resolveIrcAccountMock.mockReturnValue(account);
68
+ buildIrcConnectOptionsMock.mockReturnValue({ host: "irc.libera.chat" });
69
+ const quit = vi.fn();
70
+ connectIrcClientMock.mockResolvedValue({ quit });
71
+ const nowSpy = vi.spyOn(Date, "now").mockReturnValueOnce(100).mockReturnValueOnce(145);
72
+
73
+ try {
74
+ const result = await probeIrc({} as never, { timeoutMs: 5000 });
75
+
76
+ expect(buildIrcConnectOptionsMock).toHaveBeenCalledWith(account, { connectTimeoutMs: 5000 });
77
+ expect(result).toEqual({
78
+ ok: true,
79
+ host: "irc.libera.chat",
80
+ port: 6697,
81
+ tls: true,
82
+ nick: "actagent",
83
+ latencyMs: 45,
84
+ });
85
+ expect(quit).toHaveBeenCalledWith("probe");
86
+ } finally {
87
+ nowSpy.mockRestore();
88
+ }
89
+ });
90
+
91
+ it("formats non-Error probe failures into the returned error field", async () => {
92
+ resolveIrcAccountMock.mockReturnValue({
93
+ configured: true,
94
+ host: "irc.libera.chat",
95
+ port: 6667,
96
+ tls: false,
97
+ nick: "actagent",
98
+ });
99
+ buildIrcConnectOptionsMock.mockReturnValue({ host: "irc.libera.chat" });
100
+ connectIrcClientMock.mockRejectedValue({ code: "ECONNREFUSED" });
101
+
102
+ await expect(probeIrc({} as never)).resolves.toEqual({
103
+ ok: false,
104
+ host: "irc.libera.chat",
105
+ port: 6667,
106
+ tls: false,
107
+ nick: "actagent",
108
+ error: JSON.stringify({ code: "ECONNREFUSED" }),
109
+ });
110
+ });
111
+ });
package/src/probe.ts ADDED
@@ -0,0 +1,54 @@
1
+ // Irc plugin module implements probe behavior.
2
+ import { resolveIrcAccount } from "./accounts.js";
3
+ import { connectIrcClient } from "./client.js";
4
+ import { buildIrcConnectOptions } from "./connect-options.js";
5
+ import type { CoreConfig, IrcProbe } from "./types.js";
6
+
7
+ function formatError(err: unknown): string {
8
+ if (err instanceof Error) {
9
+ return err.message;
10
+ }
11
+ return typeof err === "string" ? err : JSON.stringify(err);
12
+ }
13
+
14
+ export async function probeIrc(
15
+ cfg: CoreConfig,
16
+ opts?: { accountId?: string; timeoutMs?: number },
17
+ ): Promise<IrcProbe> {
18
+ const account = resolveIrcAccount({ cfg, accountId: opts?.accountId });
19
+ const base: IrcProbe = {
20
+ ok: false,
21
+ host: account.host,
22
+ port: account.port,
23
+ tls: account.tls,
24
+ nick: account.nick,
25
+ };
26
+
27
+ if (!account.configured) {
28
+ return {
29
+ ...base,
30
+ error: "missing host or nick",
31
+ };
32
+ }
33
+
34
+ const started = Date.now();
35
+ try {
36
+ const client = await connectIrcClient(
37
+ buildIrcConnectOptions(account, {
38
+ connectTimeoutMs: opts?.timeoutMs ?? 8000,
39
+ }),
40
+ );
41
+ const elapsed = Date.now() - started;
42
+ client.quit("probe");
43
+ return {
44
+ ...base,
45
+ ok: true,
46
+ latencyMs: elapsed,
47
+ };
48
+ } catch (err) {
49
+ return {
50
+ ...base,
51
+ error: formatError(err),
52
+ };
53
+ }
54
+ }
@@ -0,0 +1,49 @@
1
+ // Irc tests cover protocol plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+ import {
4
+ parseIrcLine,
5
+ parseIrcPrefix,
6
+ sanitizeIrcOutboundText,
7
+ sanitizeIrcTarget,
8
+ splitIrcText,
9
+ } from "./protocol.js";
10
+
11
+ describe("irc protocol", () => {
12
+ it("parses PRIVMSG lines with prefix and trailing", () => {
13
+ const parsed = parseIrcLine(":alice!u@host PRIVMSG #room :hello world");
14
+ expect(parsed).toEqual({
15
+ raw: ":alice!u@host PRIVMSG #room :hello world",
16
+ prefix: "alice!u@host",
17
+ command: "PRIVMSG",
18
+ params: ["#room"],
19
+ trailing: "hello world",
20
+ });
21
+
22
+ expect(parseIrcPrefix(parsed?.prefix)).toEqual({
23
+ nick: "alice",
24
+ user: "u",
25
+ host: "host",
26
+ });
27
+ });
28
+
29
+ it("sanitizes outbound text to prevent command injection", () => {
30
+ expect(sanitizeIrcOutboundText("hello\\r\\nJOIN #oops")).toBe("hello JOIN #oops");
31
+ expect(sanitizeIrcOutboundText("\\u0001test\\u0000")).toBe("test");
32
+ });
33
+
34
+ it("validates targets and rejects control characters", () => {
35
+ expect(sanitizeIrcTarget("#actagent")).toBe("#actagent");
36
+ expect(() => sanitizeIrcTarget("#bad\\nPING")).toThrow(/Invalid IRC target/);
37
+ expect(() => sanitizeIrcTarget(" user")).toThrow(/Invalid IRC target/);
38
+ });
39
+
40
+ it("splits long text on boundaries", () => {
41
+ const chunks = splitIrcText("a ".repeat(300), 120);
42
+ expect(chunks.length).toBeGreaterThan(2);
43
+ expect(
44
+ chunks
45
+ .map((chunk, index) => ({ index, length: chunk.length }))
46
+ .filter((chunk) => chunk.length > 120),
47
+ ).toStrictEqual([]);
48
+ });
49
+ });
@@ -0,0 +1,170 @@
1
+ // Irc plugin module implements protocol behavior.
2
+ import { randomUUID } from "node:crypto";
3
+ import { hasIrcControlChars, stripIrcControlChars } from "./control-chars.js";
4
+
5
+ const IRC_TARGET_PATTERN = /^[^\s:]+$/u;
6
+
7
+ type ParsedIrcLine = {
8
+ raw: string;
9
+ prefix?: string;
10
+ command: string;
11
+ params: string[];
12
+ trailing?: string;
13
+ };
14
+
15
+ type ParsedIrcPrefix = {
16
+ nick?: string;
17
+ user?: string;
18
+ host?: string;
19
+ server?: string;
20
+ };
21
+
22
+ export function parseIrcLine(line: string): ParsedIrcLine | null {
23
+ const raw = line.replace(/[\r\n]+/g, "").trim();
24
+ if (!raw) {
25
+ return null;
26
+ }
27
+
28
+ let cursor = raw;
29
+ let prefix: string | undefined;
30
+ if (cursor.startsWith(":")) {
31
+ const idx = cursor.indexOf(" ");
32
+ if (idx <= 1) {
33
+ return null;
34
+ }
35
+ prefix = cursor.slice(1, idx);
36
+ cursor = cursor.slice(idx + 1).trimStart();
37
+ }
38
+
39
+ if (!cursor) {
40
+ return null;
41
+ }
42
+
43
+ const firstSpace = cursor.indexOf(" ");
44
+ const command = (firstSpace === -1 ? cursor : cursor.slice(0, firstSpace)).trim();
45
+ if (!command) {
46
+ return null;
47
+ }
48
+
49
+ cursor = firstSpace === -1 ? "" : cursor.slice(firstSpace + 1);
50
+ const params: string[] = [];
51
+ let trailing: string | undefined;
52
+
53
+ while (cursor.length > 0) {
54
+ cursor = cursor.trimStart();
55
+ if (!cursor) {
56
+ break;
57
+ }
58
+ if (cursor.startsWith(":")) {
59
+ trailing = cursor.slice(1);
60
+ break;
61
+ }
62
+ const spaceIdx = cursor.indexOf(" ");
63
+ if (spaceIdx === -1) {
64
+ params.push(cursor);
65
+ break;
66
+ }
67
+ params.push(cursor.slice(0, spaceIdx));
68
+ cursor = cursor.slice(spaceIdx + 1);
69
+ }
70
+
71
+ return {
72
+ raw,
73
+ prefix,
74
+ command: command.toUpperCase(),
75
+ params,
76
+ trailing,
77
+ };
78
+ }
79
+
80
+ export function parseIrcPrefix(prefix?: string): ParsedIrcPrefix {
81
+ if (!prefix) {
82
+ return {};
83
+ }
84
+ const nickPart = prefix.match(/^([^!@]+)!([^@]+)@(.+)$/);
85
+ if (nickPart) {
86
+ return {
87
+ nick: nickPart[1],
88
+ user: nickPart[2],
89
+ host: nickPart[3],
90
+ };
91
+ }
92
+ const nickHostPart = prefix.match(/^([^@]+)@(.+)$/);
93
+ if (nickHostPart) {
94
+ return {
95
+ nick: nickHostPart[1],
96
+ host: nickHostPart[2],
97
+ };
98
+ }
99
+ if (prefix.includes("!")) {
100
+ const [nick, user] = prefix.split("!", 2);
101
+ return { nick, user };
102
+ }
103
+ if (prefix.includes(".")) {
104
+ return { server: prefix };
105
+ }
106
+ return { nick: prefix };
107
+ }
108
+
109
+ function decodeLiteralEscapes(input: string): string {
110
+ // Defensive: this is not a full JS string unescaper.
111
+ // It's just enough to catch common "\r\n" / "\u0001" style payloads.
112
+ return input
113
+ .replace(/\\r/g, "\r")
114
+ .replace(/\\n/g, "\n")
115
+ .replace(/\\t/g, "\t")
116
+ .replace(/\\0/g, "\0")
117
+ .replace(/\\x([0-9a-fA-F]{2})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)))
118
+ .replace(/\\u([0-9a-fA-F]{4})/g, (_, hex) => String.fromCharCode(Number.parseInt(hex, 16)));
119
+ }
120
+
121
+ export function sanitizeIrcOutboundText(text: string): string {
122
+ const decoded = decodeLiteralEscapes(text);
123
+ return stripIrcControlChars(decoded.replace(/\r?\n/g, " ")).trim();
124
+ }
125
+
126
+ export function sanitizeIrcTarget(raw: string): string {
127
+ const decoded = decodeLiteralEscapes(raw);
128
+ if (!decoded) {
129
+ throw new Error("IRC target is required");
130
+ }
131
+ // Reject any surrounding whitespace instead of trimming it away.
132
+ if (decoded !== decoded.trim()) {
133
+ throw new Error(`Invalid IRC target: ${raw}`);
134
+ }
135
+ if (hasIrcControlChars(decoded)) {
136
+ throw new Error(`Invalid IRC target: ${raw}`);
137
+ }
138
+ if (!IRC_TARGET_PATTERN.test(decoded)) {
139
+ throw new Error(`Invalid IRC target: ${raw}`);
140
+ }
141
+ return decoded;
142
+ }
143
+
144
+ export function splitIrcText(text: string, maxChars = 350): string[] {
145
+ const cleaned = sanitizeIrcOutboundText(text);
146
+ if (!cleaned) {
147
+ return [];
148
+ }
149
+ if (cleaned.length <= maxChars) {
150
+ return [cleaned];
151
+ }
152
+ const chunks: string[] = [];
153
+ let remaining = cleaned;
154
+ while (remaining.length > maxChars) {
155
+ let splitAt = remaining.lastIndexOf(" ", maxChars);
156
+ if (splitAt < Math.floor(maxChars * 0.5)) {
157
+ splitAt = maxChars;
158
+ }
159
+ chunks.push(remaining.slice(0, splitAt).trim());
160
+ remaining = remaining.slice(splitAt).trimStart();
161
+ }
162
+ if (remaining) {
163
+ chunks.push(remaining);
164
+ }
165
+ return chunks.filter(Boolean);
166
+ }
167
+
168
+ export function makeIrcMessageId() {
169
+ return randomUUID();
170
+ }