@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/inbound.ts ADDED
@@ -0,0 +1,440 @@
1
+ // Irc plugin module implements inbound behavior.
2
+ import { logInboundDrop } from "actagent/plugin-sdk/channel-inbound";
3
+ import {
4
+ channelIngressRoutes,
5
+ createChannelIngressResolver,
6
+ defineStableChannelIngressIdentity,
7
+ } from "actagent/plugin-sdk/channel-ingress-runtime";
8
+ import { createChannelPairingController } from "actagent/plugin-sdk/channel-pairing";
9
+ import type { ACTAgentConfig } from "actagent/plugin-sdk/config-contracts";
10
+ import { isDangerousNameMatchingEnabled } from "actagent/plugin-sdk/dangerous-name-runtime";
11
+ import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "actagent/plugin-sdk/inbound-envelope";
12
+ import {
13
+ deliverFormattedTextWithAttachments,
14
+ type OutboundReplyPayload,
15
+ } from "actagent/plugin-sdk/reply-payload";
16
+ import type { RuntimeEnv } from "actagent/plugin-sdk/runtime";
17
+ import {
18
+ GROUP_POLICY_BLOCKED_LABEL,
19
+ resolveAllowlistProviderRuntimeGroupPolicy,
20
+ resolveDefaultGroupPolicy,
21
+ warnMissingProviderGroupPolicyFallbackOnce,
22
+ } from "actagent/plugin-sdk/runtime-group-policy";
23
+ import {
24
+ normalizeLowercaseStringOrEmpty,
25
+ normalizeOptionalString,
26
+ normalizeStringEntries,
27
+ } from "actagent/plugin-sdk/string-coerce-runtime";
28
+ import type { ResolvedIrcAccount } from "./accounts.js";
29
+ import { buildIrcAllowlistCandidates, normalizeIrcAllowEntry } from "./normalize.js";
30
+ import { resolveIrcGroupMatch, resolveIrcRequireMention } from "./policy.js";
31
+ import { getIrcRuntime } from "./runtime.js";
32
+ import { sendMessageIrc } from "./send.js";
33
+ import type { CoreConfig, IrcInboundMessage } from "./types.js";
34
+
35
+ const CHANNEL_ID = "irc" as const;
36
+ const IRC_NICK_KIND = "plugin:irc-nick" as const;
37
+ type IrcGroupPolicy = "open" | "allowlist" | "disabled";
38
+
39
+ const ircIngressIdentity = defineStableChannelIngressIdentity({
40
+ key: "irc-id",
41
+ normalizeEntry: normalizeIrcStableEntry,
42
+ normalizeSubject: normalizeLowercaseStringOrEmpty,
43
+ sensitivity: "pii",
44
+ aliases: [
45
+ ...["irc-id-nick-user", "irc-id-nick-host"].map((key) => ({
46
+ key,
47
+ kind: "stable-id" as const,
48
+ normalizeEntry: () => null,
49
+ normalizeSubject: normalizeLowercaseStringOrEmpty,
50
+ sensitivity: "pii" as const,
51
+ })),
52
+ {
53
+ key: "irc-nick",
54
+ kind: IRC_NICK_KIND,
55
+ normalizeEntry: normalizeIrcNickEntry,
56
+ normalizeSubject: normalizeLowercaseStringOrEmpty,
57
+ dangerous: true,
58
+ sensitivity: "pii",
59
+ },
60
+ ],
61
+ isWildcardEntry: (entry) => normalizeIrcAllowEntry(entry) === "*",
62
+ resolveEntryId: ({ entryIndex, fieldKey }) =>
63
+ `irc-entry-${entryIndex + 1}:${fieldKey === "irc-nick" ? "nick" : "id"}`,
64
+ });
65
+
66
+ const escapeIrcRegexLiteral = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
67
+
68
+ function isBareNick(value: string): boolean {
69
+ return !value.includes("!") && !value.includes("@");
70
+ }
71
+
72
+ function normalizeIrcStableEntry(value: string): string | null {
73
+ const normalized = normalizeIrcAllowEntry(value);
74
+ if (!normalized || normalized === "*" || isBareNick(normalized)) {
75
+ return null;
76
+ }
77
+ return normalized;
78
+ }
79
+
80
+ function normalizeIrcNickEntry(value: string): string | null {
81
+ const normalized = normalizeIrcAllowEntry(value);
82
+ if (!normalized || normalized === "*" || !isBareNick(normalized)) {
83
+ return null;
84
+ }
85
+ return normalized;
86
+ }
87
+
88
+ function hasEntries(entries: Array<string | number> | undefined): boolean {
89
+ return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry));
90
+ }
91
+
92
+ function createIrcIngressSubject(message: IrcInboundMessage) {
93
+ const candidates = buildIrcAllowlistCandidates(message, { allowNameMatching: true });
94
+ const stableCandidates = candidates.filter((candidate) => !isBareNick(candidate));
95
+ const nick = normalizeLowercaseStringOrEmpty(message.senderNick);
96
+ return {
97
+ stableId: stableCandidates[stableCandidates.length - 1] ?? nick,
98
+ aliases: {
99
+ "irc-id-nick-user": stableCandidates.find(
100
+ (candidate) => candidate.includes("!") && !candidate.includes("@"),
101
+ ),
102
+ "irc-id-nick-host": stableCandidates.find(
103
+ (candidate) => !candidate.includes("!") && candidate.includes("@"),
104
+ ),
105
+ "irc-nick": nick,
106
+ },
107
+ };
108
+ }
109
+
110
+ function routeDescriptorsForIrcGroup(params: {
111
+ isGroup: boolean;
112
+ groupPolicy: IrcGroupPolicy;
113
+ groupAllowed: boolean;
114
+ hasConfiguredGroups: boolean;
115
+ groupEnabled: boolean;
116
+ routeGroupAllowFrom: string[];
117
+ }) {
118
+ if (!params.isGroup) {
119
+ return [];
120
+ }
121
+ return channelIngressRoutes(
122
+ params.groupPolicy === "allowlist" && {
123
+ id: "irc:channel",
124
+ allowed: params.hasConfiguredGroups && params.groupAllowed,
125
+ precedence: 0,
126
+ matchId: "irc-channel",
127
+ blockReason: "channel_not_allowlisted",
128
+ },
129
+ !params.groupEnabled && {
130
+ id: "irc:channel-enabled",
131
+ enabled: false,
132
+ precedence: 10,
133
+ blockReason: "channel_disabled",
134
+ },
135
+ hasEntries(params.routeGroupAllowFrom) && {
136
+ id: "irc:channel-sender",
137
+ precedence: 20,
138
+ senderPolicy: "replace",
139
+ senderAllowFrom: params.routeGroupAllowFrom,
140
+ },
141
+ );
142
+ }
143
+
144
+ async function deliverIrcReply(params: {
145
+ payload: OutboundReplyPayload;
146
+ cfg: CoreConfig;
147
+ target: string;
148
+ accountId: string;
149
+ sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
150
+ statusSink?: (patch: { lastOutboundAt?: number }) => void;
151
+ }) {
152
+ await deliverFormattedTextWithAttachments({
153
+ payload: params.payload,
154
+ send: async ({ text, replyToId }) => {
155
+ if (params.sendReply) {
156
+ await params.sendReply(params.target, text, replyToId);
157
+ } else {
158
+ await sendMessageIrc(params.target, text, {
159
+ cfg: params.cfg,
160
+ accountId: params.accountId,
161
+ replyTo: replyToId,
162
+ });
163
+ }
164
+ params.statusSink?.({ lastOutboundAt: Date.now() });
165
+ },
166
+ });
167
+ }
168
+
169
+ export async function handleIrcInbound(params: {
170
+ message: IrcInboundMessage;
171
+ account: ResolvedIrcAccount;
172
+ config: CoreConfig;
173
+ runtime: RuntimeEnv;
174
+ connectedNick?: string;
175
+ sendReply?: (target: string, text: string, replyToId?: string) => Promise<void>;
176
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
177
+ }): Promise<void> {
178
+ const { message, account, config, runtime, connectedNick, statusSink } = params;
179
+ const core = getIrcRuntime();
180
+ const pairing = createChannelPairingController({
181
+ core,
182
+ channel: CHANNEL_ID,
183
+ accountId: account.accountId,
184
+ });
185
+
186
+ const rawBody = message.text?.trim() ?? "";
187
+ if (!rawBody) {
188
+ return;
189
+ }
190
+
191
+ statusSink?.({ lastInboundAt: message.timestamp });
192
+
193
+ const senderDisplay = message.senderHost
194
+ ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}`
195
+ : message.senderNick;
196
+ const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
197
+
198
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
199
+ const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
200
+ const { groupPolicy, providerMissingFallbackApplied } =
201
+ resolveAllowlistProviderRuntimeGroupPolicy({
202
+ providerConfigPresent: config.channels?.irc !== undefined,
203
+ groupPolicy: account.config.groupPolicy,
204
+ defaultGroupPolicy,
205
+ });
206
+ warnMissingProviderGroupPolicyFallbackOnce({
207
+ providerMissingFallbackApplied,
208
+ providerKey: "irc",
209
+ accountId: account.accountId,
210
+ blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
211
+ log: (messageLocal) => runtime.log?.(messageLocal),
212
+ });
213
+
214
+ const groupMatch = resolveIrcGroupMatch({
215
+ groups: account.config.groups,
216
+ target: message.target,
217
+ });
218
+
219
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
220
+ cfg: config as ACTAgentConfig,
221
+ surface: CHANNEL_ID,
222
+ });
223
+ const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as ACTAgentConfig);
224
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as ACTAgentConfig);
225
+ const mentionNick = connectedNick?.trim() || account.nick;
226
+ const explicitMentionRegex = mentionNick
227
+ ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i")
228
+ : null;
229
+ const wasMentioned =
230
+ core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) ||
231
+ (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
232
+ const requireMention = message.isGroup
233
+ ? resolveIrcRequireMention({
234
+ groupConfig: groupMatch.groupConfig,
235
+ wildcardConfig: groupMatch.wildcardConfig,
236
+ })
237
+ : false;
238
+ const routeGroupAllowFrom = normalizeStringEntries(
239
+ groupMatch.groupConfig?.allowFrom?.length
240
+ ? groupMatch.groupConfig.allowFrom
241
+ : groupMatch.wildcardConfig?.allowFrom,
242
+ );
243
+ const accessGroupPolicy: IrcGroupPolicy =
244
+ groupPolicy === "open" &&
245
+ (hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom))
246
+ ? "allowlist"
247
+ : groupPolicy;
248
+ const access = await createChannelIngressResolver({
249
+ channelId: CHANNEL_ID,
250
+ accountId: account.accountId,
251
+ identity: ircIngressIdentity,
252
+ cfg: config as ACTAgentConfig,
253
+ readStoreAllowFrom: async () => await pairing.readAllowFromStore(),
254
+ }).message({
255
+ subject: createIrcIngressSubject(message),
256
+ conversation: {
257
+ kind: message.isGroup ? "group" : "direct",
258
+ id: message.target,
259
+ },
260
+ route: routeDescriptorsForIrcGroup({
261
+ isGroup: message.isGroup,
262
+ groupPolicy,
263
+ groupAllowed: groupMatch.allowed,
264
+ hasConfiguredGroups: groupMatch.hasConfiguredGroups,
265
+ groupEnabled:
266
+ groupMatch.groupConfig?.enabled !== false && groupMatch.wildcardConfig?.enabled !== false,
267
+ routeGroupAllowFrom,
268
+ }),
269
+ mentionFacts: message.isGroup
270
+ ? {
271
+ canDetectMention: true,
272
+ wasMentioned,
273
+ hasAnyMention: wasMentioned,
274
+ }
275
+ : undefined,
276
+ dmPolicy,
277
+ groupPolicy: accessGroupPolicy,
278
+ policy: {
279
+ groupAllowFromFallbackToAllowFrom: false,
280
+ mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
281
+ activation: {
282
+ requireMention: message.isGroup && requireMention,
283
+ allowTextCommands,
284
+ },
285
+ },
286
+ allowFrom: account.config.allowFrom,
287
+ groupAllowFrom: account.config.groupAllowFrom,
288
+ command: {
289
+ allowTextCommands,
290
+ hasControlCommand,
291
+ },
292
+ });
293
+ const commandAuthorized = access.commandAccess.authorized;
294
+
295
+ if (access.ingress.admission === "pairing-required") {
296
+ await pairing.issueChallenge({
297
+ senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
298
+ senderIdLine: `Your IRC id: ${senderDisplay}`,
299
+ meta: { name: message.senderNick || undefined },
300
+ sendPairingReply: async (text) => {
301
+ await deliverIrcReply({
302
+ payload: { text },
303
+ cfg: config,
304
+ target: message.senderNick,
305
+ accountId: account.accountId,
306
+ sendReply: params.sendReply,
307
+ statusSink,
308
+ });
309
+ },
310
+ onReplyError: (err) => {
311
+ runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
312
+ },
313
+ });
314
+ runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
315
+ return;
316
+ }
317
+ if (access.ingress.admission === "skip") {
318
+ runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
319
+ return;
320
+ }
321
+ if (access.ingress.admission !== "dispatch") {
322
+ if (
323
+ message.isGroup &&
324
+ access.ingress.decisiveGateId === "command" &&
325
+ access.commandAccess.shouldBlockControlCommand
326
+ ) {
327
+ logInboundDrop({
328
+ log: (line) => runtime.log?.(line),
329
+ channel: CHANNEL_ID,
330
+ reason: "control command (unauthorized)",
331
+ target: senderDisplay,
332
+ });
333
+ return;
334
+ }
335
+ if (message.isGroup) {
336
+ if (access.routeAccess.reason === "channel_not_allowlisted") {
337
+ runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
338
+ } else if (access.routeAccess.reason === "channel_disabled") {
339
+ runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
340
+ } else {
341
+ runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
342
+ }
343
+ } else {
344
+ runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
345
+ }
346
+ return;
347
+ }
348
+
349
+ const channelTarget =
350
+ message.target.startsWith("#") || message.target.startsWith("&")
351
+ ? message.target
352
+ : `#${message.target}`;
353
+ const peerId = message.isGroup ? channelTarget : message.senderNick;
354
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
355
+ cfg: config as ACTAgentConfig,
356
+ channel: CHANNEL_ID,
357
+ accountId: account.accountId,
358
+ peer: {
359
+ kind: message.isGroup ? "group" : "direct",
360
+ id: peerId,
361
+ },
362
+ runtime: core.channel,
363
+ sessionStore: config.session?.store,
364
+ });
365
+
366
+ const fromLabel = message.isGroup ? message.target : senderDisplay;
367
+ const { storePath, body } = buildEnvelope({
368
+ channel: "IRC",
369
+ from: fromLabel,
370
+ timestamp: message.timestamp,
371
+ body: rawBody,
372
+ });
373
+
374
+ const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
375
+
376
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
377
+ Body: body,
378
+ RawBody: rawBody,
379
+ CommandBody: rawBody,
380
+ From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
381
+ To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
382
+ SessionKey: route.sessionKey,
383
+ AccountId: route.accountId,
384
+ ChatType: message.isGroup ? "group" : "direct",
385
+ ConversationLabel: fromLabel,
386
+ SenderName: message.senderNick || undefined,
387
+ SenderId: senderDisplay,
388
+ GroupSubject: message.isGroup ? message.target : undefined,
389
+ GroupSystemPrompt: message.isGroup ? groupSystemPrompt : undefined,
390
+ Provider: CHANNEL_ID,
391
+ Surface: CHANNEL_ID,
392
+ WasMentioned: message.isGroup ? wasMentioned : undefined,
393
+ MessageSid: message.messageId,
394
+ Timestamp: message.timestamp,
395
+ OriginatingChannel: CHANNEL_ID,
396
+ OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
397
+ CommandAuthorized: commandAuthorized,
398
+ });
399
+
400
+ await core.channel.inbound.dispatchReply({
401
+ cfg: config as ACTAgentConfig,
402
+ channel: CHANNEL_ID,
403
+ accountId: account.accountId,
404
+ agentId: route.agentId,
405
+ routeSessionKey: route.sessionKey,
406
+ storePath,
407
+ ctxPayload,
408
+ recordInboundSession: core.channel.session.recordInboundSession,
409
+ dispatchReplyWithBufferedBlockDispatcher:
410
+ core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
411
+ delivery: {
412
+ deliver: async (payload) => {
413
+ await deliverIrcReply({
414
+ payload,
415
+ cfg: config,
416
+ target: peerId,
417
+ accountId: account.accountId,
418
+ sendReply: params.sendReply,
419
+ statusSink,
420
+ });
421
+ },
422
+ onError: (err, info) => {
423
+ runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
424
+ },
425
+ },
426
+ replyPipeline: {},
427
+ replyOptions: {
428
+ skillFilter: groupMatch.groupConfig?.skills,
429
+ disableBlockStreaming:
430
+ typeof account.config.blockStreaming === "boolean"
431
+ ? !account.config.blockStreaming
432
+ : undefined,
433
+ },
434
+ record: {
435
+ onRecordError: (err) => {
436
+ runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
437
+ },
438
+ },
439
+ });
440
+ }
@@ -0,0 +1,29 @@
1
+ // Irc plugin module implements message adapter behavior.
2
+ import { defineChannelMessageAdapter } from "actagent/plugin-sdk/channel-outbound";
3
+ import { sendMessageIrc } from "./send.js";
4
+ import type { CoreConfig } from "./types.js";
5
+
6
+ export const ircMessageAdapter = defineChannelMessageAdapter({
7
+ id: "irc",
8
+ durableFinal: {
9
+ capabilities: {
10
+ text: true,
11
+ media: true,
12
+ replyTo: true,
13
+ },
14
+ },
15
+ send: {
16
+ text: async ({ cfg, to, text, accountId, replyToId }) =>
17
+ await sendMessageIrc(to, text, {
18
+ cfg: cfg as CoreConfig,
19
+ accountId: accountId ?? undefined,
20
+ replyTo: replyToId ?? undefined,
21
+ }),
22
+ media: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
23
+ await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
24
+ cfg: cfg as CoreConfig,
25
+ accountId: accountId ?? undefined,
26
+ replyTo: replyToId ?? undefined,
27
+ }),
28
+ },
29
+ });
@@ -0,0 +1,44 @@
1
+ // Irc tests cover monitor plugin behavior.
2
+ import { describe, expect, it } from "vitest";
3
+ import { resolveIrcInboundTarget } from "./monitor.js";
4
+
5
+ describe("irc monitor inbound target", () => {
6
+ it("keeps channel target for group messages", () => {
7
+ expect(
8
+ resolveIrcInboundTarget({
9
+ target: "#actagent",
10
+ senderNick: "alice",
11
+ }),
12
+ ).toEqual({
13
+ isGroup: true,
14
+ target: "#actagent",
15
+ rawTarget: "#actagent",
16
+ });
17
+ });
18
+
19
+ it("maps DM target to sender nick and preserves raw target", () => {
20
+ expect(
21
+ resolveIrcInboundTarget({
22
+ target: "actagent-bot",
23
+ senderNick: "alice",
24
+ }),
25
+ ).toEqual({
26
+ isGroup: false,
27
+ target: "alice",
28
+ rawTarget: "actagent-bot",
29
+ });
30
+ });
31
+
32
+ it("falls back to raw target when sender nick is empty", () => {
33
+ expect(
34
+ resolveIrcInboundTarget({
35
+ target: "actagent-bot",
36
+ senderNick: " ",
37
+ }),
38
+ ).toEqual({
39
+ isGroup: false,
40
+ target: "actagent-bot",
41
+ rawTarget: "actagent-bot",
42
+ });
43
+ });
44
+ });
package/src/monitor.ts ADDED
@@ -0,0 +1,150 @@
1
+ // Irc plugin module implements monitor behavior.
2
+ import { resolveLoggerBackedRuntime } from "actagent/plugin-sdk/extension-shared";
3
+ import { normalizeLowercaseStringOrEmpty } from "actagent/plugin-sdk/string-coerce-runtime";
4
+ import { resolveIrcAccount } from "./accounts.js";
5
+ import { connectIrcClient, type IrcClient } from "./client.js";
6
+ import { buildIrcConnectOptions } from "./connect-options.js";
7
+ import { handleIrcInbound } from "./inbound.js";
8
+ import { isChannelTarget } from "./normalize.js";
9
+ import { makeIrcMessageId } from "./protocol.js";
10
+ import type { RuntimeEnv } from "./runtime-api.js";
11
+ import { getIrcRuntime } from "./runtime.js";
12
+ import type { CoreConfig, IrcInboundMessage } from "./types.js";
13
+
14
+ type IrcMonitorOptions = {
15
+ accountId?: string;
16
+ config?: CoreConfig;
17
+ runtime?: RuntimeEnv;
18
+ abortSignal?: AbortSignal;
19
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
20
+ onMessage?: (message: IrcInboundMessage, client: IrcClient) => void | Promise<void>;
21
+ };
22
+
23
+ export function resolveIrcInboundTarget(params: { target: string; senderNick: string }): {
24
+ isGroup: boolean;
25
+ target: string;
26
+ rawTarget: string;
27
+ } {
28
+ const rawTarget = params.target;
29
+ const isGroup = isChannelTarget(rawTarget);
30
+ if (isGroup) {
31
+ return { isGroup: true, target: rawTarget, rawTarget };
32
+ }
33
+ const senderNick = params.senderNick.trim();
34
+ return { isGroup: false, target: senderNick || rawTarget, rawTarget };
35
+ }
36
+
37
+ export async function monitorIrcProvider(opts: IrcMonitorOptions): Promise<{ stop: () => void }> {
38
+ const core = getIrcRuntime();
39
+ const cfg = opts.config ?? (core.config.current() as CoreConfig);
40
+ const account = resolveIrcAccount({
41
+ cfg,
42
+ accountId: opts.accountId,
43
+ });
44
+
45
+ const runtime: RuntimeEnv = resolveLoggerBackedRuntime(
46
+ opts.runtime,
47
+ core.logging.getChildLogger(),
48
+ );
49
+
50
+ if (!account.configured) {
51
+ throw new Error(
52
+ `IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`,
53
+ );
54
+ }
55
+
56
+ const logger = core.logging.getChildLogger({
57
+ channel: "irc",
58
+ accountId: account.accountId,
59
+ });
60
+
61
+ let client: IrcClient | null = null;
62
+
63
+ client = await connectIrcClient(
64
+ buildIrcConnectOptions(account, {
65
+ channels: account.config.channels,
66
+ abortSignal: opts.abortSignal,
67
+ onLine: (line) => {
68
+ if (core.logging.shouldLogVerbose()) {
69
+ logger.debug?.(`[${account.accountId}] << ${line}`);
70
+ }
71
+ },
72
+ onNotice: (text, target) => {
73
+ if (core.logging.shouldLogVerbose()) {
74
+ logger.debug?.(`[${account.accountId}] notice ${target ?? ""}: ${text}`);
75
+ }
76
+ },
77
+ onError: (error) => {
78
+ logger.error(`[${account.accountId}] IRC error: ${error.message}`);
79
+ },
80
+ onPrivmsg: async (event) => {
81
+ if (!client) {
82
+ return;
83
+ }
84
+ if (
85
+ normalizeLowercaseStringOrEmpty(event.senderNick) ===
86
+ normalizeLowercaseStringOrEmpty(client.nick)
87
+ ) {
88
+ return;
89
+ }
90
+
91
+ const inboundTarget = resolveIrcInboundTarget({
92
+ target: event.target,
93
+ senderNick: event.senderNick,
94
+ });
95
+ const message: IrcInboundMessage = {
96
+ messageId: makeIrcMessageId(),
97
+ target: inboundTarget.target,
98
+ rawTarget: inboundTarget.rawTarget,
99
+ senderNick: event.senderNick,
100
+ senderUser: event.senderUser,
101
+ senderHost: event.senderHost,
102
+ text: event.text,
103
+ timestamp: Date.now(),
104
+ isGroup: inboundTarget.isGroup,
105
+ };
106
+
107
+ core.channel.activity.record({
108
+ channel: "irc",
109
+ accountId: account.accountId,
110
+ direction: "inbound",
111
+ at: message.timestamp,
112
+ });
113
+
114
+ if (opts.onMessage) {
115
+ await opts.onMessage(message, client);
116
+ return;
117
+ }
118
+
119
+ await handleIrcInbound({
120
+ message,
121
+ account,
122
+ config: cfg,
123
+ runtime,
124
+ connectedNick: client.nick,
125
+ sendReply: async (target, text) => {
126
+ client?.sendPrivmsg(target, text);
127
+ opts.statusSink?.({ lastOutboundAt: Date.now() });
128
+ core.channel.activity.record({
129
+ channel: "irc",
130
+ accountId: account.accountId,
131
+ direction: "outbound",
132
+ });
133
+ },
134
+ statusSink: opts.statusSink,
135
+ });
136
+ },
137
+ }),
138
+ );
139
+
140
+ logger.info(
141
+ `[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${client.nick}`,
142
+ );
143
+
144
+ return {
145
+ stop: () => {
146
+ client?.quit("shutdown");
147
+ client = null;
148
+ },
149
+ };
150
+ }