@openclaw/irc 2026.10.10-beta.5 → 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 (39) hide show
  1. package/dist/.setup/{channel-Bi4lj8N2.mjs → channel-DRdPa321.mjs} +266 -326
  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-Cy9tItsu.mjs → config-schema-CQuAQW7O.mjs} +1 -4
  5. package/dist/.setup/{runtime-C_V9tmpk.mjs → runtime-CsdAoZex.mjs} +1 -2
  6. package/dist/.setup/{secret-contract-C5i-29Hz.mjs → secret-contract-0Ccz8X9F.mjs} +1 -2
  7. package/dist/.setup/{secret-contract-api-4IT9_EK6.mjs → secret-contract-api-BabNUhhB.mjs} +1 -1
  8. package/dist/.source-module-urls/4751df78f545ae971ceb22b6e4bff6086f63313240899a1d9884b970424a305e.json +1 -1
  9. package/dist/api.js +2 -2
  10. package/dist/channel-config-api.js +1 -1
  11. package/dist/channel-plugin-api.js +1 -1
  12. package/dist/doctor-contract-api.js +6 -12
  13. package/dist/index.js +1 -2
  14. package/dist/runtime-api.js +1 -1
  15. package/dist/secret-contract-api.js +1 -1
  16. package/dist/setup-entry.js +3 -4
  17. package/node_modules/zod/package.json +1 -1
  18. package/node_modules/zod/src/v4/classic/tests/array.test.ts +27 -0
  19. package/node_modules/zod/src/v4/classic/tests/map.test.ts +7 -0
  20. package/node_modules/zod/src/v4/classic/tests/set.test.ts +6 -0
  21. package/node_modules/zod/src/v4/core/compile.ts +9 -0
  22. package/node_modules/zod/src/v4/core/schemas.ts +35 -1
  23. package/node_modules/zod/src/v4/core/util.ts +13 -0
  24. package/node_modules/zod/src/v4/core/versions.ts +1 -1
  25. package/node_modules/zod/v4/core/compile.cjs +8 -0
  26. package/node_modules/zod/v4/core/compile.js +8 -0
  27. package/node_modules/zod/v4/core/schemas.cjs +30 -0
  28. package/node_modules/zod/v4/core/schemas.d.cts +3 -1
  29. package/node_modules/zod/v4/core/schemas.d.ts +3 -1
  30. package/node_modules/zod/v4/core/schemas.js +30 -0
  31. package/node_modules/zod/v4/core/util.cjs +15 -0
  32. package/node_modules/zod/v4/core/util.d.cts +2 -0
  33. package/node_modules/zod/v4/core/util.d.ts +2 -0
  34. package/node_modules/zod/v4/core/util.js +14 -0
  35. package/node_modules/zod/v4/core/versions.cjs +1 -1
  36. package/node_modules/zod/v4/core/versions.js +1 -1
  37. package/package.json +5 -5
  38. package/dist/.setup/channel-plugin-api-1I8kWfv8.mjs +0 -2
  39. package/dist/.setup/channel-runtime-B4OMtNnX.mjs +0 -652
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/irc",
3
- "version": "2026.10.10-beta.5",
3
+ "version": "2026.11.5-beta.1",
4
4
  "description": "OpenClaw IRC channel plugin",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -121,10 +121,10 @@
121
121
  }
122
122
  },
123
123
  "compat": {
124
- "pluginApi": ">=2026.10.10-beta.5"
124
+ "pluginApi": ">=2026.11.5-beta.1"
125
125
  },
126
126
  "build": {
127
- "openclawVersion": "2026.10.10-beta.5",
127
+ "openclawVersion": "2026.11.5-beta.1",
128
128
  "bundledDist": false
129
129
  },
130
130
  "release": {
@@ -137,7 +137,7 @@
137
137
  "runtimeSetupEntry": "./dist/setup-entry.js"
138
138
  },
139
139
  "dependencies": {
140
- "zod": "4.6.5"
140
+ "zod": "4.6.7"
141
141
  },
142
142
  "repository": {
143
143
  "type": "git",
@@ -151,7 +151,7 @@
151
151
  "assets/activity.svg"
152
152
  ],
153
153
  "peerDependencies": {
154
- "openclaw": ">=2026.10.10-beta.5"
154
+ "openclaw": ">=2026.11.5-beta.1"
155
155
  },
156
156
  "peerDependenciesMeta": {
157
157
  "openclaw": {
@@ -1,2 +0,0 @@
1
- export { t as ircPlugin } from "./channel-Bi4lj8N2.mjs";
2
- import "../channel-plugin-api.js";
@@ -1,652 +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-Bi4lj8N2.mjs";
2
- import { t as getIrcRuntime } from "./runtime-C_V9tmpk.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
- //#region 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
- //#endregion
339
- //#region extensions/irc/src/irc-ingress.ts
340
- const IRC_INGRESS_PAYLOAD_VERSION = 1;
341
- const IRC_INGRESS_POLL_INTERVAL_MS = 1e3;
342
- const IrcIngressPayloadError = createChannelIngressError("IrcIngressPayloadError");
343
- function inspectRawPrivmsg(rawLine) {
344
- const line = parseIrcLine(rawLine);
345
- if (!line || line.command !== "PRIVMSG") throw new IrcIngressPayloadError("IRC ingress row is not a PRIVMSG line.");
346
- const rawTarget = line.params[0]?.trim() ?? "";
347
- const text = line.trailing ?? line.params[1] ?? "";
348
- const prefix = parseIrcPrefix(line.prefix);
349
- const senderNick = prefix.nick?.trim() ?? "";
350
- if (!rawTarget || !senderNick || !text.trim()) throw new IrcIngressPayloadError("IRC PRIVMSG line is missing target, sender, or text.");
351
- const isGroup = isChannelTarget(rawTarget);
352
- const target = isGroup ? rawTarget : senderNick;
353
- const lanePeer = normalizeLowercaseStringOrEmpty(target);
354
- return {
355
- laneKey: `${isGroup ? "channel" : "direct"}:${lanePeer}`,
356
- message: {
357
- target,
358
- rawTarget,
359
- senderNick,
360
- senderUser: prefix.user?.trim() || void 0,
361
- senderHost: prefix.host?.trim() || void 0,
362
- text,
363
- isGroup
364
- }
365
- };
366
- }
367
- function decodeIrcIngressPayload(payload, claimedId) {
368
- 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.`);
369
- const validPayload = payload;
370
- return {
371
- version: validPayload.version,
372
- body: {
373
- eventId: validPayload.eventId,
374
- receivedAt: validPayload.receivedAt,
375
- connectionEpoch: validPayload.connectionEpoch,
376
- connectedNick: validPayload.connectedNick,
377
- rawLine: validPayload.rawLine
378
- }
379
- };
380
- }
381
- function inspectIrcIngress(raw, phase) {
382
- try {
383
- const projection = inspectRawPrivmsg(raw.body.rawLine);
384
- if (phase === "claim") raw.claimedProjection = projection;
385
- return {
386
- eventId: raw.body.eventId,
387
- laneKey: projection.laneKey
388
- };
389
- } catch (error) {
390
- if (phase === "admission" && error instanceof IrcIngressPayloadError) return {
391
- eventId: raw.body.eventId,
392
- laneKey: `invalid:${raw.body.eventId}`
393
- };
394
- throw error;
395
- }
396
- }
397
- function resolveIrcIngressNonRetryableFailure(error) {
398
- return error instanceof IrcIngressPayloadError ? {
399
- reason: "invalid-event",
400
- message: error.message
401
- } : null;
402
- }
403
- function createIrcIngressMonitor(options) {
404
- const monitor = createChannelIngressMonitor({
405
- queue: options.queue ?? (() => getIrcRuntime().state.openChannelIngressQueue({ accountId: options.accountId })),
406
- inspect: (raw, context) => inspectIrcIngress(raw, context.phase),
407
- payload: {
408
- version: IRC_INGRESS_PAYLOAD_VERSION,
409
- serialize: (raw) => raw.body,
410
- deserialize: (body) => ({ body }),
411
- encode: ({ body }) => ({
412
- version: IRC_INGRESS_PAYLOAD_VERSION,
413
- ...body
414
- }),
415
- decode: (payload, { claim }) => decodeIrcIngressPayload(payload, claim.id),
416
- createClaimError: (_kind, claim) => new IrcIngressPayloadError(`IRC ingress row ${claim.id} has invalid metadata.`)
417
- },
418
- deliver: (raw, lifecycle, claim) => {
419
- const message = {
420
- ...raw.claimedProjection.message,
421
- messageId: claim.id,
422
- timestamp: raw.body.receivedAt
423
- };
424
- return options.dispatch(message, lifecycle, {
425
- connectedNick: raw.body.connectedNick.trim(),
426
- connectionEpoch: raw.body.connectionEpoch.trim()
427
- });
428
- },
429
- pollIntervalMs: options.pollIntervalMs ?? IRC_INGRESS_POLL_INTERVAL_MS,
430
- retention: {
431
- completedMaxEntries: 1e3,
432
- failedMaxEntries: 1e3
433
- },
434
- drain: {
435
- resolveNonRetryableFailure: resolveIrcIngressNonRetryableFailure,
436
- ...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
437
- onLog: (message) => options.runtime.log?.(`irc ${message}`)
438
- },
439
- createStoppedError: () => /* @__PURE__ */ new Error("IRC ingress is stopped."),
440
- onError: (error) => options.runtime.error?.(`irc ingress drain failed: ${String(error)}`)
441
- });
442
- return {
443
- openConnection: (connectionEpoch = randomUUID()) => {
444
- const epoch = connectionEpoch.trim();
445
- if (!epoch) throw new Error("IRC ingress connection epoch is required.");
446
- let sequence = 0;
447
- return {
448
- connectionEpoch: epoch,
449
- accept: (rawLine, connectedNick) => {
450
- if (monitor.isStopped()) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress is stopped."));
451
- sequence += 1;
452
- const eventId = `local:${epoch}:${String(sequence).padStart(12, "0")}`;
453
- const receivedAt = Date.now();
454
- const normalizedNick = connectedNick.trim();
455
- if (!normalizedNick) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress connected nickname is required."));
456
- return monitor.admit({ body: {
457
- eventId,
458
- rawLine,
459
- receivedAt,
460
- connectionEpoch: epoch,
461
- connectedNick: normalizedNick
462
- } }, { receivedAt }).then(() => void 0);
463
- }
464
- };
465
- },
466
- start: monitor.start,
467
- pause: monitor.pause,
468
- stop: monitor.stop,
469
- waitForIdle: monitor.waitForIdle
470
- };
471
- }
472
- //#endregion
473
- //#region extensions/irc/src/reconnect-backoff.ts
474
- const IRC_RECONNECT_BACKOFF = {
475
- initialMs: 1e3,
476
- maxMs: 3e4,
477
- factor: 2,
478
- jitter: .2
479
- };
480
- const IRC_RECONNECT_STABLE_MS = 6e4;
481
- function createIrcReconnectBackoff(now = Date.now) {
482
- let attempt = 0;
483
- let connectedAt;
484
- return {
485
- markConnected: () => {
486
- connectedAt = now();
487
- },
488
- nextDelayMs: () => {
489
- if (connectedAt !== void 0 && now() - connectedAt >= IRC_RECONNECT_STABLE_MS) attempt = 0;
490
- connectedAt = void 0;
491
- attempt += 1;
492
- return computeBackoff(IRC_RECONNECT_BACKOFF, attempt);
493
- }
494
- };
495
- }
496
- //#endregion
497
- //#region extensions/irc/src/monitor.ts
498
- async function monitorIrcProvider(opts) {
499
- const core = getIrcRuntime();
500
- const cfg = opts.config ?? core.config.current();
501
- const account = resolveIrcAccount({
502
- cfg,
503
- accountId: opts.accountId
504
- });
505
- const runtime = resolveLoggerBackedRuntime(opts.runtime, core.logging.getChildLogger());
506
- if (!account.configured) throw new Error(`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`);
507
- const logger = core.logging.getChildLogger({
508
- channel: "irc",
509
- accountId: account.accountId
510
- });
511
- let client = null;
512
- let activeConnectionEpoch = null;
513
- let ingressPause = Promise.resolve();
514
- let reconnectTimer = null;
515
- const reconnectBackoff = createIrcReconnectBackoff();
516
- let stopped = false;
517
- const monitorAbort = new AbortController();
518
- const abortSignal = opts.abortSignal ? AbortSignal.any([opts.abortSignal, monitorAbort.signal]) : monitorAbort.signal;
519
- const ingress = createIrcIngressMonitor({
520
- accountId: account.accountId,
521
- runtime,
522
- ...opts.ingressQueue ? { queue: opts.ingressQueue } : {},
523
- dispatch: async (message, turnAdoptionLifecycle, context) => {
524
- const activeClient = client;
525
- if (!activeClient || stopped || abortSignal.aborted) return {
526
- kind: "failed-retryable",
527
- error: /* @__PURE__ */ new Error("IRC transport disconnected before ingress dispatch.")
528
- };
529
- if (normalizeLowercaseStringOrEmpty(message.senderNick) === normalizeLowercaseStringOrEmpty(context.connectedNick)) return { kind: "completed" };
530
- if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) {
531
- logger.warn?.(`[${account.accountId}] dropping replayed IRC DM after the connection changed`);
532
- return { kind: "completed" };
533
- }
534
- if (opts.onMessage) {
535
- await opts.onMessage(message, activeClient);
536
- return { kind: "completed" };
537
- }
538
- return await handleIrcInbound({
539
- message,
540
- account,
541
- config: cfg,
542
- runtime,
543
- connectedNick: context.connectedNick,
544
- turnAdoptionLifecycle,
545
- sendReply: async (target, text) => {
546
- const replyClient = client;
547
- if (!replyClient || !replyClient.isReady() || stopped || abortSignal.aborted) throw new Error("IRC transport disconnected before reply send.");
548
- if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) throw new Error("IRC connection changed before private reply send.");
549
- await replyClient.sendPrivmsg(target, text);
550
- opts.statusSink?.({ lastOutboundAt: Date.now() });
551
- core.channel.activity.record({
552
- channel: "irc",
553
- accountId: account.accountId,
554
- direction: "outbound"
555
- });
556
- },
557
- statusSink: opts.statusSink
558
- });
559
- }
560
- });
561
- function scheduleReconnect() {
562
- if (stopped || abortSignal.aborted || reconnectTimer) return;
563
- opts.statusSink?.({
564
- connected: false,
565
- lifecycle: "recovering"
566
- });
567
- const delayMs = reconnectBackoff.nextDelayMs();
568
- reconnectTimer = setTimeout(() => {
569
- reconnectTimer = null;
570
- connect().catch((error) => {
571
- if (stopped || abortSignal.aborted) return;
572
- const message = error instanceof Error ? error.message : String(error);
573
- logger.error(`[${account.accountId}] IRC reconnect failed: ${message}`);
574
- opts.statusSink?.({ lastError: message });
575
- scheduleReconnect();
576
- });
577
- }, delayMs);
578
- return delayMs;
579
- }
580
- async function connect() {
581
- if (stopped || abortSignal.aborted) return;
582
- const ingressConnection = ingress.openConnection();
583
- const nextClient = await connectIrcClient(buildIrcConnectOptions(account, {
584
- channels: account.config.channels,
585
- abortSignal,
586
- onNotice: (text, target) => {
587
- if (core.logging.shouldLogVerbose()) logger.debug?.(`[${account.accountId}] notice ${target ?? ""} len=${text.length}`);
588
- },
589
- onError: (error) => {
590
- logger.error(`[${account.accountId}] IRC error: ${error.message}`);
591
- },
592
- onDisconnect: () => {
593
- if (stopped || abortSignal.aborted) return;
594
- ingressPause = ingress.pause();
595
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
596
- client = null;
597
- opts.statusSink?.({ lastDisconnect: { at: Date.now() } });
598
- const delayMs = scheduleReconnect();
599
- if (delayMs !== void 0) logger.warn?.(`[${account.accountId}] IRC connection closed; reconnecting in ${delayMs}ms`);
600
- },
601
- onPrivmsg: async (event) => {
602
- await ingressConnection.accept(event.rawLine, event.connectedNick);
603
- if (normalizeLowercaseStringOrEmpty(event.senderNick) === normalizeLowercaseStringOrEmpty(event.connectedNick)) return;
604
- core.channel.activity.record({
605
- channel: "irc",
606
- accountId: account.accountId,
607
- direction: "inbound",
608
- at: Date.now()
609
- });
610
- }
611
- }));
612
- if (stopped || abortSignal.aborted) {
613
- nextClient.quit("shutdown");
614
- return;
615
- }
616
- client = nextClient;
617
- activeConnectionEpoch = ingressConnection.connectionEpoch;
618
- await ingressPause;
619
- if (client !== nextClient || !nextClient.isReady()) {
620
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
621
- return;
622
- }
623
- ingress.start();
624
- reconnectBackoff.markConnected();
625
- opts.statusSink?.(channelReadyPatch());
626
- logger.info(`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${nextClient.nick}`);
627
- }
628
- try {
629
- await connect();
630
- } catch (error) {
631
- await ingress.stop();
632
- throw error;
633
- }
634
- let stopTask;
635
- return { stop: () => {
636
- stopTask ??= (async () => {
637
- stopped = true;
638
- if (!abortSignal.aborted) monitorAbort.abort();
639
- if (reconnectTimer) {
640
- clearTimeout(reconnectTimer);
641
- reconnectTimer = null;
642
- }
643
- client?.quit("shutdown");
644
- client = null;
645
- activeConnectionEpoch = null;
646
- await ingress.stop();
647
- })();
648
- return stopTask;
649
- } };
650
- }
651
- //#endregion
652
- export { monitorIrcProvider, sendMessageIrc };