@openclaw/irc 2026.10.10 → 2026.11.5-beta.1

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 (32) hide show
  1. package/dist/.setup/{channel-t-k-A5n4.mjs → channel-DRdPa321.mjs} +252 -294
  2. package/dist/.setup/channel-plugin-api-DFjxQCOi.mjs +2 -0
  3. package/dist/.setup/channel-runtime-D2sqTm8e.mjs +579 -0
  4. package/dist/.setup/{config-schema-DFqPW9AB.mjs → config-schema-CQuAQW7O.mjs} +1 -2
  5. package/dist/.source-module-urls/4751df78f545ae971ceb22b6e4bff6086f63313240899a1d9884b970424a305e.json +1 -1
  6. package/dist/api.js +1 -1
  7. package/dist/channel-config-api.js +1 -1
  8. package/dist/channel-plugin-api.js +1 -1
  9. package/dist/setup-entry.js +1 -1
  10. package/node_modules/zod/package.json +1 -1
  11. package/node_modules/zod/src/v4/classic/tests/array.test.ts +27 -0
  12. package/node_modules/zod/src/v4/classic/tests/map.test.ts +7 -0
  13. package/node_modules/zod/src/v4/classic/tests/set.test.ts +6 -0
  14. package/node_modules/zod/src/v4/core/compile.ts +9 -0
  15. package/node_modules/zod/src/v4/core/schemas.ts +35 -1
  16. package/node_modules/zod/src/v4/core/util.ts +13 -0
  17. package/node_modules/zod/src/v4/core/versions.ts +1 -1
  18. package/node_modules/zod/v4/core/compile.cjs +8 -0
  19. package/node_modules/zod/v4/core/compile.js +8 -0
  20. package/node_modules/zod/v4/core/schemas.cjs +30 -0
  21. package/node_modules/zod/v4/core/schemas.d.cts +3 -1
  22. package/node_modules/zod/v4/core/schemas.d.ts +3 -1
  23. package/node_modules/zod/v4/core/schemas.js +30 -0
  24. package/node_modules/zod/v4/core/util.cjs +15 -0
  25. package/node_modules/zod/v4/core/util.d.cts +2 -0
  26. package/node_modules/zod/v4/core/util.d.ts +2 -0
  27. package/node_modules/zod/v4/core/util.js +14 -0
  28. package/node_modules/zod/v4/core/versions.cjs +1 -1
  29. package/node_modules/zod/v4/core/versions.js +1 -1
  30. package/package.json +5 -5
  31. package/dist/.setup/channel-plugin-api-DRR9Fg5C.mjs +0 -2
  32. package/dist/.setup/channel-runtime-exb5AWhn.mjs +0 -648
@@ -1,648 +0,0 @@
1
- import { a as resolveIrcGroupRequireMention, c as connectIrcClient, d as sanitizeIrcAssistantText, f as createIrcIngressSubject, h as normalizeIrcAllowEntry, i as resolveIrcGroupMatch, l as parseIrcLine, m as isChannelTarget, o as sendMessageIrc, p as ircIngressIdentity, s as buildIrcConnectOptions, u as parseIrcPrefix, y as resolveIrcAccount } from "./channel-t-k-A5n4.mjs";
2
- import { t as getIrcRuntime } from "./runtime-CsdAoZex.mjs";
3
- import { channelIngressRoutes } from "openclaw/plugin-sdk/channel-ingress-runtime";
4
- import { computeBackoff } from "openclaw/plugin-sdk/runtime-env";
5
- import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
6
- import { bindIngressLifecycleToReplyOptions, createChannelIngressError, createChannelIngressMonitor, resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
7
- import { randomUUID } from "node:crypto";
8
- import { createChannelInboundEnvelopeBuilderAsync, logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
9
- import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
10
- import { resolveAgentRoute } from "openclaw/plugin-sdk/routing";
11
- import { channelReadyPatch } from "openclaw/plugin-sdk/gateway-runtime";
12
- import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
13
- import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
14
- import { deliverFormattedTextWithAttachments } from "openclaw/plugin-sdk/reply-payload";
15
- import { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
16
- import { escapeRegExp } from "openclaw/plugin-sdk/text-utility-runtime";
17
- // extensions/irc/src/inbound.ts
18
- const CHANNEL_ID = "irc";
19
- const IRC_NICK_CHARACTER = String.raw`[A-Za-z0-9_\-\[\]\\\x60^{}|~]`;
20
- const IRC_RFC1459_CASE_EQUIVALENTS = /* @__PURE__ */ new Map([
21
- ["[", "{"],
22
- ["{", "["],
23
- ["]", "}"],
24
- ["}", "]"],
25
- ["\\", "|"],
26
- ["|", "\\"],
27
- ["^", "~"],
28
- ["~", "^"]
29
- ]);
30
- function buildIrcNickMentionPattern(value) {
31
- return Array.from(value, (character) => {
32
- const equivalent = IRC_RFC1459_CASE_EQUIVALENTS.get(character);
33
- return equivalent ? `[${escapeRegExp(character)}${escapeRegExp(equivalent)}]` : escapeRegExp(character);
34
- }).join("");
35
- }
36
- function hasEntries(entries) {
37
- return normalizeStringEntries(entries).some((entry) => normalizeIrcAllowEntry(entry));
38
- }
39
- async function deliverIrcReply(params) {
40
- await deliverFormattedTextWithAttachments({
41
- payload: {
42
- ...params.payload,
43
- text: sanitizeIrcAssistantText(params.payload.text ?? "")
44
- },
45
- send: async ({ text, replyToId }) => {
46
- if (params.sendReply) await params.sendReply(params.target, text, replyToId);
47
- else await sendMessageIrc(params.target, text, {
48
- cfg: params.cfg,
49
- accountId: params.accountId,
50
- replyTo: replyToId
51
- });
52
- params.statusSink?.({ lastOutboundAt: Date.now() });
53
- }
54
- });
55
- }
56
- async function handleIrcInbound(params) {
57
- const { message, account, config, runtime, connectedNick, statusSink, turnAdoptionLifecycle } = params;
58
- const core = getIrcRuntime();
59
- const pairing = createChannelPairingController({
60
- core,
61
- channel: CHANNEL_ID,
62
- accountId: account.accountId
63
- });
64
- const rawBody = message.text?.trim() ?? "";
65
- if (!rawBody) return { kind: "completed" };
66
- if (turnAdoptionLifecycle?.abortSignal.aborted) return {
67
- kind: "failed-retryable",
68
- error: turnAdoptionLifecycle.abortSignal.reason
69
- };
70
- statusSink?.({ lastInboundAt: message.timestamp });
71
- const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick;
72
- const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
73
- const dmPolicy = account.config.dmPolicy ?? "pairing";
74
- const defaultGroupPolicy = resolveDefaultGroupPolicy(config);
75
- const { groupPolicy, providerMissingFallbackApplied } = resolveAllowlistProviderRuntimeGroupPolicy({
76
- providerConfigPresent: config.channels?.irc !== void 0,
77
- groupPolicy: account.config.groupPolicy,
78
- defaultGroupPolicy
79
- });
80
- warnMissingProviderGroupPolicyFallbackOnce({
81
- providerMissingFallbackApplied,
82
- providerKey: "irc",
83
- accountId: account.accountId,
84
- blockedLabel: GROUP_POLICY_BLOCKED_LABEL.channel,
85
- log: (messageLocal) => runtime.log?.(messageLocal)
86
- });
87
- const groupMatch = resolveIrcGroupMatch({
88
- groups: account.config.groups,
89
- target: message.target
90
- });
91
- const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
92
- cfg: config,
93
- surface: CHANNEL_ID
94
- });
95
- const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config);
96
- const mentionRegexes = core.channel.mentions.buildMentionRegexes(config);
97
- const mentionNick = connectedNick?.trim() || account.nick;
98
- const explicitMentionRegex = mentionNick ? new RegExp(`(?<!${IRC_NICK_CHARACTER})${buildIrcNickMentionPattern(mentionNick)}(?!${IRC_NICK_CHARACTER})[:,]?`, "i") : null;
99
- const wasMentioned = core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
100
- const requireMention = message.isGroup ? resolveIrcGroupRequireMention({
101
- groups: account.config.groups,
102
- target: message.target
103
- }) : false;
104
- const routeGroupAllowFrom = normalizeStringEntries(groupMatch.groupConfig?.allowFrom?.length ? groupMatch.groupConfig.allowFrom : groupMatch.wildcardConfig?.allowFrom);
105
- const accessGroupPolicy = groupPolicy === "open" && (hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom)) ? "allowlist" : groupPolicy;
106
- const channelTarget = message.target.startsWith("#") || message.target.startsWith("&") ? message.target : `#${message.target}`;
107
- const peerId = message.isGroup ? channelTarget : message.senderNick;
108
- const route = resolveAgentRoute({
109
- cfg: config,
110
- channel: CHANNEL_ID,
111
- accountId: account.accountId,
112
- peer: {
113
- kind: message.isGroup ? "group" : "direct",
114
- id: peerId
115
- }
116
- });
117
- const access = await core.channel.inbound.ingress.createResolver({
118
- channelId: CHANNEL_ID,
119
- accountId: account.accountId,
120
- identity: ircIngressIdentity,
121
- cfg: config,
122
- readStoreAllowFrom: async () => await pairing.readAllowFromStore()
123
- }).message({
124
- subject: createIrcIngressSubject(message),
125
- conversation: {
126
- kind: message.isGroup ? "group" : "direct",
127
- id: message.target
128
- },
129
- contextBinding: {
130
- agentId: route.agentId,
131
- sessionKey: route.sessionKey,
132
- ...message.messageId ? { messageId: message.messageId } : {},
133
- inboundEventKind: "user_request"
134
- },
135
- route: message.isGroup ? channelIngressRoutes(groupPolicy === "allowlist" && {
136
- id: "irc:channel",
137
- allowed: groupMatch.hasConfiguredGroups && groupMatch.allowed,
138
- precedence: 0,
139
- matchId: "irc-channel",
140
- blockReason: "channel_not_allowlisted"
141
- }, (groupMatch.groupConfig?.enabled === false || groupMatch.wildcardConfig?.enabled === false) && {
142
- id: "irc:channel-enabled",
143
- enabled: false,
144
- precedence: 10,
145
- blockReason: "channel_disabled"
146
- }, hasEntries(routeGroupAllowFrom) && {
147
- id: "irc:channel-sender",
148
- precedence: 20,
149
- senderPolicy: "replace",
150
- senderAllowFrom: routeGroupAllowFrom
151
- }) : [],
152
- mentionFacts: message.isGroup ? {
153
- canDetectMention: true,
154
- wasMentioned,
155
- hasAnyMention: wasMentioned
156
- } : void 0,
157
- dmPolicy,
158
- groupPolicy: accessGroupPolicy,
159
- policy: {
160
- groupAllowFromFallbackToAllowFrom: false,
161
- mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
162
- activation: {
163
- requireMention: message.isGroup && requireMention,
164
- allowTextCommands
165
- }
166
- },
167
- allowFrom: account.config.allowFrom,
168
- groupAllowFrom: account.config.groupAllowFrom,
169
- command: {
170
- allowTextCommands,
171
- hasControlCommand
172
- }
173
- });
174
- const commandAuthorized = access.commandAccess.authorized;
175
- if (access.ingress.admission === "pairing-required") {
176
- await pairing.issueChallenge({
177
- senderId: normalizeLowercaseStringOrEmpty(senderDisplay),
178
- senderIdLine: `Your IRC id: ${senderDisplay}`,
179
- meta: { name: message.senderNick || void 0 },
180
- sendPairingReply: async (text) => {
181
- await deliverIrcReply({
182
- payload: { text },
183
- cfg: config,
184
- target: message.senderNick,
185
- accountId: account.accountId,
186
- sendReply: params.sendReply,
187
- statusSink
188
- });
189
- },
190
- onReplyError: (err) => {
191
- runtime.error?.(`irc: pairing reply failed for ${senderDisplay}: ${String(err)}`);
192
- }
193
- });
194
- runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
195
- return { kind: "completed" };
196
- }
197
- if (access.ingress.admission === "skip") {
198
- runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
199
- return { kind: "completed" };
200
- }
201
- if (access.ingress.admission !== "dispatch") {
202
- if (message.isGroup && access.ingress.decisiveGateId === "command" && access.commandAccess.shouldBlockControlCommand) {
203
- logInboundDrop({
204
- log: (line) => runtime.log?.(line),
205
- channel: CHANNEL_ID,
206
- reason: "control command (unauthorized)",
207
- target: senderDisplay
208
- });
209
- return { kind: "completed" };
210
- }
211
- if (message.isGroup) {
212
- if (access.routeAccess.reason === "channel_not_allowlisted") runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
213
- else if (access.routeAccess.reason === "channel_disabled") runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
214
- else runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
215
- } else runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
216
- return { kind: "completed" };
217
- }
218
- if (turnAdoptionLifecycle?.abortSignal.aborted) return {
219
- kind: "failed-retryable",
220
- error: turnAdoptionLifecycle.abortSignal.reason
221
- };
222
- const fromLabel = message.isGroup ? message.target : senderDisplay;
223
- const body = (await createChannelInboundEnvelopeBuilderAsync({
224
- cfg: config,
225
- route
226
- }))({
227
- channel: "IRC",
228
- from: fromLabel,
229
- timestamp: message.timestamp,
230
- body: rawBody
231
- });
232
- const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
233
- const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
234
- const ctxPayload = core.channel.inbound.buildContext({
235
- channelIngress: access,
236
- channel: CHANNEL_ID,
237
- accountId: route.accountId,
238
- messageId: message.messageId,
239
- timestamp: message.timestamp,
240
- from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
241
- sender: {
242
- id: senderDisplay,
243
- name: message.senderNick || void 0
244
- },
245
- conversation: {
246
- kind: message.isGroup ? "group" : "direct",
247
- id: peerId,
248
- label: fromLabel
249
- },
250
- route: {
251
- agentId: route.agentId,
252
- dmScope: route.dmScope,
253
- accountId: route.accountId,
254
- routeSessionKey: route.sessionKey
255
- },
256
- reply: {
257
- to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
258
- originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`
259
- },
260
- message: {
261
- body,
262
- bodyForAgent: rawBody,
263
- rawBody,
264
- commandBody: rawBody
265
- },
266
- access: {
267
- commands: { authorized: commandAuthorized },
268
- mentions: {
269
- canDetectMention: message.isGroup,
270
- wasMentioned
271
- }
272
- },
273
- extra: {
274
- GroupSubject: message.isGroup ? message.target : void 0,
275
- GroupSystemPrompt: message.isGroup ? groupSystemPrompt : void 0
276
- }
277
- });
278
- const ingressState = { handoff: "none" };
279
- const trackedIngressLifecycle = turnAdoptionLifecycle ? {
280
- ...turnAdoptionLifecycle,
281
- onAdopted: async () => {
282
- ingressState.handoff = "adopted";
283
- await turnAdoptionLifecycle.onAdopted();
284
- },
285
- onDeferred: () => {
286
- ingressState.handoff = "deferred";
287
- turnAdoptionLifecycle.onDeferred();
288
- },
289
- onAbandoned: async () => {
290
- ingressState.handoff = "abandoned";
291
- await turnAdoptionLifecycle.onAbandoned();
292
- }
293
- } : void 0;
294
- await core.channel.inbound.dispatch({
295
- cfg: config,
296
- channel: CHANNEL_ID,
297
- accountId: account.accountId,
298
- route: {
299
- agentId: route.agentId,
300
- sessionKey: route.sessionKey
301
- },
302
- ctxPayload,
303
- delivery: {
304
- deliver: async (payload) => {
305
- await deliverIrcReply({
306
- payload,
307
- cfg: config,
308
- target: peerId,
309
- accountId: account.accountId,
310
- sendReply: params.sendReply,
311
- statusSink
312
- });
313
- },
314
- onError: (err, info) => {
315
- runtime.error?.(`irc ${info.kind} reply failed: ${String(err)}`);
316
- }
317
- },
318
- replyPipeline: {},
319
- replyOptions: {
320
- ...trackedIngressLifecycle ? bindIngressLifecycleToReplyOptions(trackedIngressLifecycle) : {},
321
- skillFilter: groupMatch.groupConfig?.skills,
322
- disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
323
- },
324
- record: { onRecordError: (err) => {
325
- runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
326
- } }
327
- });
328
- if (turnAdoptionLifecycle?.abortSignal.aborted && ingressState.handoff === "none") return {
329
- kind: "failed-retryable",
330
- error: turnAdoptionLifecycle.abortSignal.reason
331
- };
332
- if (turnAdoptionLifecycle && ingressState.handoff === "none") {
333
- await turnAdoptionLifecycle.onAdopted();
334
- ingressState.handoff = "adopted";
335
- }
336
- return ingressState.handoff === "deferred" || ingressState.handoff === "abandoned" ? { kind: "deferred" } : { kind: "completed" };
337
- }
338
- // extensions/irc/src/irc-ingress.ts
339
- const IRC_INGRESS_PAYLOAD_VERSION = 1;
340
- const IRC_INGRESS_POLL_INTERVAL_MS = 1e3;
341
- const IrcIngressPayloadError = createChannelIngressError("IrcIngressPayloadError");
342
- function inspectRawPrivmsg(rawLine) {
343
- const line = parseIrcLine(rawLine);
344
- if (!line || line.command !== "PRIVMSG") throw new IrcIngressPayloadError("IRC ingress row is not a PRIVMSG line.");
345
- const rawTarget = line.params[0]?.trim() ?? "";
346
- const text = line.trailing ?? line.params[1] ?? "";
347
- const prefix = parseIrcPrefix(line.prefix);
348
- const senderNick = prefix.nick?.trim() ?? "";
349
- if (!rawTarget || !senderNick || !text.trim()) throw new IrcIngressPayloadError("IRC PRIVMSG line is missing target, sender, or text.");
350
- const isGroup = isChannelTarget(rawTarget);
351
- const target = isGroup ? rawTarget : senderNick;
352
- const lanePeer = normalizeLowercaseStringOrEmpty(target);
353
- return {
354
- laneKey: `${isGroup ? "channel" : "direct"}:${lanePeer}`,
355
- message: {
356
- target,
357
- rawTarget,
358
- senderNick,
359
- senderUser: prefix.user?.trim() || void 0,
360
- senderHost: prefix.host?.trim() || void 0,
361
- text,
362
- isGroup
363
- }
364
- };
365
- }
366
- function decodeIrcIngressPayload(payload, claimedId) {
367
- if (!payload || typeof payload !== "object" || Array.isArray(payload) || typeof payload.eventId !== "string" || !Number.isSafeInteger(payload.receivedAt) || (payload.receivedAt ?? 0) <= 0 || typeof payload.connectionEpoch !== "string" || !payload.connectionEpoch?.trim() || typeof payload.connectedNick !== "string" || !payload.connectedNick?.trim() || typeof payload.rawLine !== "string") throw new IrcIngressPayloadError(`IRC ingress row ${claimedId} has invalid metadata.`);
368
- const validPayload = payload;
369
- return {
370
- version: validPayload.version,
371
- body: {
372
- eventId: validPayload.eventId,
373
- receivedAt: validPayload.receivedAt,
374
- connectionEpoch: validPayload.connectionEpoch,
375
- connectedNick: validPayload.connectedNick,
376
- rawLine: validPayload.rawLine
377
- }
378
- };
379
- }
380
- function inspectIrcIngress(raw, phase) {
381
- try {
382
- const projection = inspectRawPrivmsg(raw.body.rawLine);
383
- if (phase === "claim") raw.claimedProjection = projection;
384
- return {
385
- eventId: raw.body.eventId,
386
- laneKey: projection.laneKey
387
- };
388
- } catch (error) {
389
- if (phase === "admission" && error instanceof IrcIngressPayloadError) return {
390
- eventId: raw.body.eventId,
391
- laneKey: `invalid:${raw.body.eventId}`
392
- };
393
- throw error;
394
- }
395
- }
396
- function resolveIrcIngressNonRetryableFailure(error) {
397
- return error instanceof IrcIngressPayloadError ? {
398
- reason: "invalid-event",
399
- message: error.message
400
- } : null;
401
- }
402
- function createIrcIngressMonitor(options) {
403
- const monitor = createChannelIngressMonitor({
404
- queue: options.queue ?? (() => getIrcRuntime().state.openChannelIngressQueue({ accountId: options.accountId })),
405
- inspect: (raw, context) => inspectIrcIngress(raw, context.phase),
406
- payload: {
407
- version: IRC_INGRESS_PAYLOAD_VERSION,
408
- serialize: (raw) => raw.body,
409
- deserialize: (body) => ({ body }),
410
- encode: ({ body }) => ({
411
- version: IRC_INGRESS_PAYLOAD_VERSION,
412
- ...body
413
- }),
414
- decode: (payload, { claim }) => decodeIrcIngressPayload(payload, claim.id),
415
- createClaimError: (_kind, claim) => new IrcIngressPayloadError(`IRC ingress row ${claim.id} has invalid metadata.`)
416
- },
417
- deliver: (raw, lifecycle, claim) => {
418
- const message = {
419
- ...raw.claimedProjection.message,
420
- messageId: claim.id,
421
- timestamp: raw.body.receivedAt
422
- };
423
- return options.dispatch(message, lifecycle, {
424
- connectedNick: raw.body.connectedNick.trim(),
425
- connectionEpoch: raw.body.connectionEpoch.trim()
426
- });
427
- },
428
- pollIntervalMs: options.pollIntervalMs ?? IRC_INGRESS_POLL_INTERVAL_MS,
429
- retention: {
430
- completedMaxEntries: 1e3,
431
- failedMaxEntries: 1e3
432
- },
433
- drain: {
434
- resolveNonRetryableFailure: resolveIrcIngressNonRetryableFailure,
435
- ...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
436
- onLog: (message) => options.runtime.log?.(`irc ${message}`)
437
- },
438
- createStoppedError: () => /* @__PURE__ */ new Error("IRC ingress is stopped."),
439
- onError: (error) => options.runtime.error?.(`irc ingress drain failed: ${String(error)}`)
440
- });
441
- return {
442
- openConnection: (connectionEpoch = randomUUID()) => {
443
- const epoch = connectionEpoch.trim();
444
- if (!epoch) throw new Error("IRC ingress connection epoch is required.");
445
- let sequence = 0;
446
- return {
447
- connectionEpoch: epoch,
448
- accept: (rawLine, connectedNick) => {
449
- if (monitor.isStopped()) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress is stopped."));
450
- sequence += 1;
451
- const eventId = `local:${epoch}:${String(sequence).padStart(12, "0")}`;
452
- const receivedAt = Date.now();
453
- const normalizedNick = connectedNick.trim();
454
- if (!normalizedNick) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress connected nickname is required."));
455
- return monitor.admit({ body: {
456
- eventId,
457
- rawLine,
458
- receivedAt,
459
- connectionEpoch: epoch,
460
- connectedNick: normalizedNick
461
- } }, { receivedAt }).then(() => void 0);
462
- }
463
- };
464
- },
465
- start: monitor.start,
466
- pause: monitor.pause,
467
- stop: monitor.stop,
468
- waitForIdle: monitor.waitForIdle
469
- };
470
- }
471
- // extensions/irc/src/reconnect-backoff.ts
472
- const IRC_RECONNECT_BACKOFF = {
473
- initialMs: 1e3,
474
- maxMs: 3e4,
475
- factor: 2,
476
- jitter: .2
477
- };
478
- const IRC_RECONNECT_STABLE_MS = 6e4;
479
- function createIrcReconnectBackoff(now = Date.now) {
480
- let attempt = 0;
481
- let connectedAt;
482
- return {
483
- markConnected: () => {
484
- connectedAt = now();
485
- },
486
- nextDelayMs: () => {
487
- if (connectedAt !== void 0 && now() - connectedAt >= IRC_RECONNECT_STABLE_MS) attempt = 0;
488
- connectedAt = void 0;
489
- attempt += 1;
490
- return computeBackoff(IRC_RECONNECT_BACKOFF, attempt);
491
- }
492
- };
493
- }
494
- // extensions/irc/src/monitor.ts
495
- async function monitorIrcProvider(opts) {
496
- const core = getIrcRuntime();
497
- const cfg = opts.config ?? core.config.current();
498
- const account = resolveIrcAccount({
499
- cfg,
500
- accountId: opts.accountId
501
- });
502
- const runtime = resolveLoggerBackedRuntime(opts.runtime, core.logging.getChildLogger());
503
- if (!account.configured) throw new Error(`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`);
504
- const logger = core.logging.getChildLogger({
505
- channel: "irc",
506
- accountId: account.accountId
507
- });
508
- let client = null;
509
- let activeConnectionEpoch = null;
510
- let ingressPause = Promise.resolve();
511
- let reconnectTimer = null;
512
- const reconnectBackoff = createIrcReconnectBackoff();
513
- let stopped = false;
514
- const monitorAbort = new AbortController();
515
- const abortSignal = opts.abortSignal ? AbortSignal.any([opts.abortSignal, monitorAbort.signal]) : monitorAbort.signal;
516
- const ingress = createIrcIngressMonitor({
517
- accountId: account.accountId,
518
- runtime,
519
- ...opts.ingressQueue ? { queue: opts.ingressQueue } : {},
520
- dispatch: async (message, turnAdoptionLifecycle, context) => {
521
- const activeClient = client;
522
- if (!activeClient || stopped || abortSignal.aborted) return {
523
- kind: "failed-retryable",
524
- error: /* @__PURE__ */ new Error("IRC transport disconnected before ingress dispatch.")
525
- };
526
- if (normalizeLowercaseStringOrEmpty(message.senderNick) === normalizeLowercaseStringOrEmpty(context.connectedNick)) return { kind: "completed" };
527
- if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) {
528
- logger.warn?.(`[${account.accountId}] dropping replayed IRC DM after the connection changed`);
529
- return { kind: "completed" };
530
- }
531
- if (opts.onMessage) {
532
- await opts.onMessage(message, activeClient);
533
- return { kind: "completed" };
534
- }
535
- return await handleIrcInbound({
536
- message,
537
- account,
538
- config: cfg,
539
- runtime,
540
- connectedNick: context.connectedNick,
541
- turnAdoptionLifecycle,
542
- sendReply: async (target, text) => {
543
- const replyClient = client;
544
- if (!replyClient || !replyClient.isReady() || stopped || abortSignal.aborted) throw new Error("IRC transport disconnected before reply send.");
545
- if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) throw new Error("IRC connection changed before private reply send.");
546
- await replyClient.sendPrivmsg(target, text);
547
- opts.statusSink?.({ lastOutboundAt: Date.now() });
548
- core.channel.activity.record({
549
- channel: "irc",
550
- accountId: account.accountId,
551
- direction: "outbound"
552
- });
553
- },
554
- statusSink: opts.statusSink
555
- });
556
- }
557
- });
558
- function scheduleReconnect() {
559
- if (stopped || abortSignal.aborted || reconnectTimer) return;
560
- opts.statusSink?.({
561
- connected: false,
562
- lifecycle: "recovering"
563
- });
564
- const delayMs = reconnectBackoff.nextDelayMs();
565
- reconnectTimer = setTimeout(() => {
566
- reconnectTimer = null;
567
- connect().catch((error) => {
568
- if (stopped || abortSignal.aborted) return;
569
- const message = error instanceof Error ? error.message : String(error);
570
- logger.error(`[${account.accountId}] IRC reconnect failed: ${message}`);
571
- opts.statusSink?.({ lastError: message });
572
- scheduleReconnect();
573
- });
574
- }, delayMs);
575
- return delayMs;
576
- }
577
- async function connect() {
578
- if (stopped || abortSignal.aborted) return;
579
- const ingressConnection = ingress.openConnection();
580
- const nextClient = await connectIrcClient(buildIrcConnectOptions(account, {
581
- channels: account.config.channels,
582
- abortSignal,
583
- onNotice: (text, target) => {
584
- if (core.logging.shouldLogVerbose()) logger.debug?.(`[${account.accountId}] notice ${target ?? ""} len=${text.length}`);
585
- },
586
- onError: (error) => {
587
- logger.error(`[${account.accountId}] IRC error: ${error.message}`);
588
- },
589
- onDisconnect: () => {
590
- if (stopped || abortSignal.aborted) return;
591
- ingressPause = ingress.pause();
592
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
593
- client = null;
594
- opts.statusSink?.({ lastDisconnect: { at: Date.now() } });
595
- const delayMs = scheduleReconnect();
596
- if (delayMs !== void 0) logger.warn?.(`[${account.accountId}] IRC connection closed; reconnecting in ${delayMs}ms`);
597
- },
598
- onPrivmsg: async (event) => {
599
- await ingressConnection.accept(event.rawLine, event.connectedNick);
600
- if (normalizeLowercaseStringOrEmpty(event.senderNick) === normalizeLowercaseStringOrEmpty(event.connectedNick)) return;
601
- core.channel.activity.record({
602
- channel: "irc",
603
- accountId: account.accountId,
604
- direction: "inbound",
605
- at: Date.now()
606
- });
607
- }
608
- }));
609
- if (stopped || abortSignal.aborted) {
610
- nextClient.quit("shutdown");
611
- return;
612
- }
613
- client = nextClient;
614
- activeConnectionEpoch = ingressConnection.connectionEpoch;
615
- await ingressPause;
616
- if (client !== nextClient || !nextClient.isReady()) {
617
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
618
- return;
619
- }
620
- ingress.start();
621
- reconnectBackoff.markConnected();
622
- opts.statusSink?.(channelReadyPatch());
623
- logger.info(`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${nextClient.nick}`);
624
- }
625
- try {
626
- await connect();
627
- } catch (error) {
628
- await ingress.stop();
629
- throw error;
630
- }
631
- let stopTask;
632
- return { stop: () => {
633
- stopTask ??= (async () => {
634
- stopped = true;
635
- if (!abortSignal.aborted) monitorAbort.abort();
636
- if (reconnectTimer) {
637
- clearTimeout(reconnectTimer);
638
- reconnectTimer = null;
639
- }
640
- client?.quit("shutdown");
641
- client = null;
642
- activeConnectionEpoch = null;
643
- await ingress.stop();
644
- })();
645
- return stopTask;
646
- } };
647
- }
648
- export { monitorIrcProvider, sendMessageIrc };