@openclaw/irc 2026.7.2-beta.3 → 2026.7.2-beta.5

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.
package/dist/api.js CHANGED
@@ -1,3 +1,3 @@
1
- import { g as resolveIrcAccount, h as resolveDefaultIrcAccountId, m as listIrcAccountIds, n as ircSetupWizard, p as listEnabledIrcAccounts, r as ircSetupAdapter, t as ircPlugin } from "./channel-BC0q2_Tg.js";
1
+ import { _ as resolveIrcAccount, g as resolveDefaultIrcAccountId, h as listIrcAccountIds, m as listEnabledIrcAccounts, n as ircSetupWizard, r as ircSetupAdapter, t as ircPlugin } from "./channel-BZclM5wY.js";
2
2
  import { n as setIrcRuntime } from "./runtime-xy-FcjJC.js";
3
3
  export { ircPlugin, ircSetupAdapter, ircSetupWizard, listEnabledIrcAccounts, listIrcAccountIds, resolveDefaultIrcAccountId, resolveIrcAccount, setIrcRuntime };
@@ -9,22 +9,22 @@ import { buildMutableAllowEntryDetector, collectStandardAllowlistLists, composeA
9
9
  import { createChannelDirectoryAdapter, createResolvedDirectoryEntriesLister } from "openclaw/plugin-sdk/directory-runtime";
10
10
  import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
11
11
  import { buildBaseChannelStatusSummary, createComputedAccountStatusAdapter, createDefaultChannelRuntimeState } from "openclaw/plugin-sdk/status-helpers";
12
+ import { resolveAccountWithDefaultFallback } from "openclaw/plugin-sdk/account-core";
12
13
  import { DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$2, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
13
- import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
14
14
  import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
15
15
  import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
16
16
  import { normalizeResolvedSecretInputString } from "openclaw/plugin-sdk/secret-input";
17
17
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalLowercaseString, normalizeOptionalString, normalizeStringEntries, normalizeStringifiedOptionalString, uniqueStrings } from "openclaw/plugin-sdk/string-coerce-runtime";
18
- import { createAccountStatusSink, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
18
+ import { createAccountStatusSink, createMessageReceiptFromOutboundResults, defineChannelMessageAdapter, runPassiveAccountLifecycle, sanitizeForPlainText } from "openclaw/plugin-sdk/channel-outbound";
19
19
  import { PAIRING_APPROVED_MESSAGE } from "openclaw/plugin-sdk/channel-status";
20
20
  import { chunkTextForOutbound, convertMarkdownTables, sanitizeAssistantVisibleText } from "openclaw/plugin-sdk/text-chunking";
21
- import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
22
21
  import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
23
22
  import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
24
23
  import net from "node:net";
25
24
  import tls from "node:tls";
26
25
  import { withTimeout } from "openclaw/plugin-sdk/security-runtime";
27
26
  import { randomUUID } from "node:crypto";
27
+ import { defineChannelSetupContract } from "openclaw/plugin-sdk/channel-setup";
28
28
  import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, normalizeAccountId as normalizeAccountId$1 } from "openclaw/plugin-sdk/routing";
29
29
  import { applyAccountNameToChannelSection, createAllowFromSection, createPromptParsedAllowFromForAccount, createSetupInputPresenceValidator, createSetupTranslator, createStandardChannelSetupStatus, createTopLevelChannelAllowFromSetter, createTopLevelChannelDmPolicySetter, formatDocsLink, patchScopedAccountConfig, setSetupChannelEnabled } from "openclaw/plugin-sdk/setup";
30
30
  //#region extensions/irc/src/accounts.ts
@@ -44,20 +44,12 @@ function parseIntEnv(value) {
44
44
  if (parsed === void 0 || parsed > 65535) return;
45
45
  return parsed;
46
46
  }
47
- const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId } = createAccountListHelpers("irc", {
47
+ const { listAccountIds: listIrcAccountIds, resolveDefaultAccountId: resolveDefaultIrcAccountId, resolveAccountConfig: mergeIrcAccountConfig } = createAccountListHelpers("irc", {
48
48
  normalizeAccountId,
49
+ omitKeys: ["defaultAccount"],
50
+ nestedObjectKeys: ["nickserv"],
49
51
  hasImplicitDefaultAccount: (cfg) => Boolean((cfg.channels?.irc?.host?.trim() || process.env.IRC_HOST?.trim()) && (cfg.channels?.irc?.nick?.trim() || process.env.IRC_NICK?.trim()))
50
52
  });
51
- function mergeIrcAccountConfig(cfg, accountId) {
52
- return resolveMergedAccountConfig({
53
- channelConfig: cfg.channels?.irc,
54
- accounts: cfg.channels?.irc?.accounts,
55
- accountId,
56
- omitKeys: ["defaultAccount"],
57
- normalizeAccountId,
58
- nestedObjectKeys: ["nickserv"]
59
- });
60
- }
61
53
  function resolvePassword(accountId, merged) {
62
54
  if (accountId === DEFAULT_ACCOUNT_ID) {
63
55
  const envPassword = process.env.IRC_PASSWORD?.trim();
@@ -131,7 +123,6 @@ function resolveNickServConfig(accountId, nickserv) {
131
123
  };
132
124
  }
133
125
  function resolveIrcAccount(params) {
134
- const hasExplicitAccountId = Boolean(params.accountId?.trim());
135
126
  const baseEnabled = params.cfg.channels?.irc?.enabled !== false;
136
127
  const resolve = (accountId) => {
137
128
  const merged = mergeIrcAccountConfig(params.cfg, accountId);
@@ -177,14 +168,13 @@ function resolveIrcAccount(params) {
177
168
  config
178
169
  };
179
170
  };
180
- const primary = resolve(normalizeAccountId(params.accountId));
181
- if (hasExplicitAccountId) return primary;
182
- if (primary.configured) return primary;
183
- const fallbackId = resolveDefaultIrcAccountId(params.cfg);
184
- if (fallbackId === primary.accountId) return primary;
185
- const fallback = resolve(fallbackId);
186
- if (!fallback.configured) return primary;
187
- return fallback;
171
+ return resolveAccountWithDefaultFallback({
172
+ accountId: params.accountId,
173
+ normalizeAccountId,
174
+ resolvePrimary: resolve,
175
+ hasCredential: (account) => account.configured,
176
+ resolveDefaultAccountId: () => resolveDefaultIrcAccountId(params.cfg)
177
+ });
188
178
  }
189
179
  function listEnabledIrcAccounts(cfg) {
190
180
  return listIrcAccountIds(cfg).map((accountId) => resolveIrcAccount({
@@ -202,7 +192,7 @@ const collectIrcMutableAllowlistWarnings = createDangerousNameMatchingMutableAll
202
192
  });
203
193
  //#endregion
204
194
  //#region extensions/irc/src/gateway.ts
205
- const loadIrcChannelRuntime$1 = createLazyRuntimeModule(() => import("./channel-runtime-DBNGrI_Y.js"));
195
+ const loadIrcChannelRuntime$1 = createLazyRuntimeModule(() => import("./channel-runtime-CRzAwKGK.js"));
206
196
  async function startIrcGatewayAccount(ctx) {
207
197
  const account = ctx.account;
208
198
  const statusSink = createAccountStatusSink({
@@ -212,7 +202,7 @@ async function startIrcGatewayAccount(ctx) {
212
202
  if (!account.configured) throw new Error(`IRC is not configured for account "${account.accountId}" (need host and nick in channels.irc).`);
213
203
  ctx.log?.info?.(`[${account.accountId}] starting IRC provider (${account.host}:${account.port}${account.tls ? " tls" : ""})`);
214
204
  const { monitorIrcProvider } = await loadIrcChannelRuntime$1();
215
- await runStoppablePassiveMonitor({
205
+ await runPassiveAccountLifecycle({
216
206
  abortSignal: ctx.abortSignal,
217
207
  start: async () => await monitorIrcProvider({
218
208
  accountId: account.accountId,
@@ -220,7 +210,10 @@ async function startIrcGatewayAccount(ctx) {
220
210
  runtime: ctx.runtime,
221
211
  abortSignal: ctx.abortSignal,
222
212
  statusSink
223
- })
213
+ }),
214
+ stop: async (monitor) => {
215
+ await monitor.stop();
216
+ }
224
217
  });
225
218
  }
226
219
  //#endregion
@@ -554,7 +547,7 @@ async function connectIrcClient(options) {
554
547
  if (line.command === "PRIVMSG") {
555
548
  const targetParam = line.params[0];
556
549
  const target = targetParam ? targetParam.trim() : "";
557
- const text = line.trailing != null ? line.trailing : "";
550
+ const text = line.trailing ?? line.params[1] ?? "";
558
551
  const prefix = parseIrcPrefix(line.prefix);
559
552
  const senderNick = prefix.nick ? prefix.nick.trim() : "";
560
553
  if (!target || !senderNick || !text.trim()) continue;
@@ -562,6 +555,7 @@ async function connectIrcClient(options) {
562
555
  senderNick,
563
556
  senderUser: prefix.user ? prefix.user.trim() : void 0,
564
557
  senderHost: prefix.host ? prefix.host.trim() : void 0,
558
+ connectedNick: currentNick,
565
559
  target,
566
560
  text,
567
561
  rawLine
@@ -939,6 +933,7 @@ function setIrcGroupAccess(cfg, accountId, policy, entries, normalizeGroupEntry)
939
933
  });
940
934
  }
941
935
  const ircSetupAdapter = {
936
+ singleAccountKeysToMove: ["password"],
942
937
  resolveAccountId: ({ accountId }) => normalizeAccountId$1(accountId),
943
938
  applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({
944
939
  cfg,
@@ -975,6 +970,75 @@ const ircSetupAdapter = {
975
970
  });
976
971
  }
977
972
  };
973
+ const ircSetupContract = defineChannelSetupContract({
974
+ fields: {
975
+ host: {
976
+ kind: "string",
977
+ cli: {
978
+ flags: "--host <host>",
979
+ description: "IRC server host"
980
+ }
981
+ },
982
+ port: {
983
+ kind: "string",
984
+ cli: {
985
+ flags: "--port <port>",
986
+ description: "IRC server port"
987
+ }
988
+ },
989
+ tls: {
990
+ kind: "boolean",
991
+ cli: {
992
+ flags: "--tls",
993
+ description: "Use TLS for IRC"
994
+ }
995
+ },
996
+ nick: {
997
+ kind: "string",
998
+ cli: {
999
+ flags: "--nick <nick>",
1000
+ description: "IRC nickname"
1001
+ }
1002
+ },
1003
+ username: {
1004
+ kind: "string",
1005
+ cli: {
1006
+ flags: "--username <name>",
1007
+ description: "IRC username"
1008
+ }
1009
+ },
1010
+ realname: {
1011
+ kind: "string",
1012
+ cli: {
1013
+ flags: "--realname <name>",
1014
+ description: "IRC real name"
1015
+ }
1016
+ },
1017
+ channels: {
1018
+ kind: "string-list",
1019
+ cli: {
1020
+ flags: "--channels <names>",
1021
+ description: "IRC channels"
1022
+ }
1023
+ },
1024
+ password: {
1025
+ kind: "string",
1026
+ sensitive: true,
1027
+ cli: {
1028
+ flags: "--password <password>",
1029
+ description: "IRC server password"
1030
+ }
1031
+ },
1032
+ useEnv: {
1033
+ kind: "boolean",
1034
+ cli: {
1035
+ flags: "--use-env",
1036
+ description: "Use IRC environment configuration"
1037
+ }
1038
+ }
1039
+ },
1040
+ legacyAdapter: ircSetupAdapter
1041
+ });
978
1042
  //#endregion
979
1043
  //#region extensions/irc/src/setup-surface.ts
980
1044
  const t = createSetupTranslator();
@@ -1340,7 +1404,7 @@ const meta = {
1340
1404
  systemImage: "number",
1341
1405
  markdownCapable: true
1342
1406
  };
1343
- const loadIrcChannelRuntime = createLazyRuntimeModule(() => import("./channel-runtime-DBNGrI_Y.js"));
1407
+ const loadIrcChannelRuntime = createLazyRuntimeModule(() => import("./channel-runtime-CRzAwKGK.js"));
1344
1408
  function normalizePairingTarget(raw) {
1345
1409
  const normalized = normalizeIrcAllowEntry(raw);
1346
1410
  if (!normalized) return "";
@@ -1413,6 +1477,7 @@ const ircPlugin = createChatChannelPlugin({
1413
1477
  quickstartAllowFrom: true
1414
1478
  },
1415
1479
  setup: ircSetupAdapter,
1480
+ setupContract: ircSetupContract,
1416
1481
  setupWizard: ircSetupWizard,
1417
1482
  capabilities: {
1418
1483
  chatTypes: ["direct", "group"],
@@ -1570,24 +1635,13 @@ const ircPlugin = createChatChannelPlugin({
1570
1635
  base: ircOutboundBaseAdapter,
1571
1636
  attachedResults: {
1572
1637
  channel: "irc",
1573
- sendText: async ({ cfg, to, text, accountId, replyToId }) => {
1574
- const { sendMessageIrc } = await loadIrcChannelRuntime();
1575
- return await sendMessageIrc(to, text, {
1576
- cfg,
1577
- accountId: accountId ?? void 0,
1578
- replyTo: replyToId ?? void 0
1579
- });
1580
- },
1581
- sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
1582
- const { sendMessageIrc } = await loadIrcChannelRuntime();
1583
- return await sendMessageIrc(to, mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text, {
1584
- cfg,
1585
- accountId: accountId ?? void 0,
1586
- replyTo: replyToId ?? void 0
1587
- });
1588
- }
1638
+ sendText: ({ onDeliveryResult: _onDeliveryResult, ...ctx }) => ircMessageAdapter.send.text(ctx),
1639
+ sendMedia: ({ onDeliveryResult: _onDeliveryResult, mediaUrl, ...ctx }) => ircMessageAdapter.send.media({
1640
+ ...ctx,
1641
+ mediaUrl: mediaUrl ?? ""
1642
+ })
1589
1643
  }
1590
1644
  }
1591
1645
  });
1592
1646
  //#endregion
1593
- export { resolveIrcGroupRequireMention as a, isChannelTarget as c, connectIrcClient as d, makeIrcMessageId as f, resolveIrcAccount as g, resolveDefaultIrcAccountId as h, resolveIrcGroupMatch as i, normalizeIrcAllowEntry as l, listIrcAccountIds as m, ircSetupWizard as n, sendMessageIrc as o, listEnabledIrcAccounts as p, ircSetupAdapter as r, buildIrcAllowlistCandidates as s, ircPlugin as t, buildIrcConnectOptions as u };
1647
+ export { resolveIrcAccount as _, resolveIrcGroupRequireMention as a, isChannelTarget as c, connectIrcClient as d, parseIrcLine as f, resolveDefaultIrcAccountId as g, listIrcAccountIds as h, resolveIrcGroupMatch as i, normalizeIrcAllowEntry as l, listEnabledIrcAccounts as m, ircSetupWizard as n, sendMessageIrc as o, parseIrcPrefix as p, ircSetupAdapter as r, buildIrcAllowlistCandidates as s, ircPlugin as t, buildIrcConnectOptions as u };
@@ -1,2 +1,2 @@
1
- import { t as ircPlugin } from "./channel-BC0q2_Tg.js";
1
+ import { t as ircPlugin } from "./channel-BZclM5wY.js";
2
2
  export { ircPlugin };
@@ -1,7 +1,8 @@
1
- import { a as resolveIrcGroupRequireMention, c as isChannelTarget, d as connectIrcClient, f as makeIrcMessageId, g as resolveIrcAccount, i as resolveIrcGroupMatch, l as normalizeIrcAllowEntry, o as sendMessageIrc, s as buildIrcAllowlistCandidates, u as buildIrcConnectOptions } from "./channel-BC0q2_Tg.js";
1
+ import { _ as resolveIrcAccount, a as resolveIrcGroupRequireMention, c as isChannelTarget, d as connectIrcClient, f as parseIrcLine, i as resolveIrcGroupMatch, l as normalizeIrcAllowEntry, o as sendMessageIrc, p as parseIrcPrefix, s as buildIrcAllowlistCandidates, u as buildIrcConnectOptions } from "./channel-BZclM5wY.js";
2
2
  import { t as getIrcRuntime } from "./runtime-xy-FcjJC.js";
3
3
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
4
- import { resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
4
+ import { bindIngressLifecycleToReplyOptions, createChannelIngressMonitor, resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
5
+ import { randomUUID } from "node:crypto";
5
6
  import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
6
7
  import { buildChannelInboundEventContext, logInboundDrop, resolveChannelInboundRouteEnvelope } from "openclaw/plugin-sdk/channel-inbound";
7
8
  import { channelIngressRoutes, createChannelIngressResolver, defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
@@ -120,7 +121,7 @@ async function deliverIrcReply(params) {
120
121
  });
121
122
  }
122
123
  async function handleIrcInbound(params) {
123
- const { message, account, config, runtime, connectedNick, statusSink } = params;
124
+ const { message, account, config, runtime, connectedNick, statusSink, turnAdoptionLifecycle } = params;
124
125
  const core = getIrcRuntime();
125
126
  const pairing = createChannelPairingController({
126
127
  core,
@@ -128,7 +129,11 @@ async function handleIrcInbound(params) {
128
129
  accountId: account.accountId
129
130
  });
130
131
  const rawBody = message.text?.trim() ?? "";
131
- if (!rawBody) return;
132
+ if (!rawBody) return { kind: "completed" };
133
+ if (turnAdoptionLifecycle?.abortSignal.aborted) return {
134
+ kind: "failed-retryable",
135
+ error: turnAdoptionLifecycle.abortSignal.reason
136
+ };
132
137
  statusSink?.({ lastInboundAt: message.timestamp });
133
138
  const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick;
134
139
  const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
@@ -228,11 +233,11 @@ async function handleIrcInbound(params) {
228
233
  }
229
234
  });
230
235
  runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
231
- return;
236
+ return { kind: "completed" };
232
237
  }
233
238
  if (access.ingress.admission === "skip") {
234
239
  runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
235
- return;
240
+ return { kind: "completed" };
236
241
  }
237
242
  if (access.ingress.admission !== "dispatch") {
238
243
  if (message.isGroup && access.ingress.decisiveGateId === "command" && access.commandAccess.shouldBlockControlCommand) {
@@ -242,14 +247,18 @@ async function handleIrcInbound(params) {
242
247
  reason: "control command (unauthorized)",
243
248
  target: senderDisplay
244
249
  });
245
- return;
250
+ return { kind: "completed" };
246
251
  }
247
252
  if (message.isGroup) if (access.routeAccess.reason === "channel_not_allowlisted") runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
248
253
  else if (access.routeAccess.reason === "channel_disabled") runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
249
254
  else runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
250
255
  else runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
251
- return;
256
+ return { kind: "completed" };
252
257
  }
258
+ if (turnAdoptionLifecycle?.abortSignal.aborted) return {
259
+ kind: "failed-retryable",
260
+ error: turnAdoptionLifecycle.abortSignal.reason
261
+ };
253
262
  const channelTarget = message.target.startsWith("#") || message.target.startsWith("&") ? message.target : `#${message.target}`;
254
263
  const peerId = message.isGroup ? channelTarget : message.senderNick;
255
264
  const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
@@ -313,6 +322,22 @@ async function handleIrcInbound(params) {
313
322
  GroupSystemPrompt: message.isGroup ? groupSystemPrompt : void 0
314
323
  }
315
324
  });
325
+ const ingressState = { handoff: "none" };
326
+ const trackedIngressLifecycle = turnAdoptionLifecycle ? {
327
+ ...turnAdoptionLifecycle,
328
+ onAdopted: async () => {
329
+ ingressState.handoff = "adopted";
330
+ await turnAdoptionLifecycle.onAdopted();
331
+ },
332
+ onDeferred: () => {
333
+ ingressState.handoff = "deferred";
334
+ turnAdoptionLifecycle.onDeferred();
335
+ },
336
+ onAbandoned: async () => {
337
+ ingressState.handoff = "abandoned";
338
+ await turnAdoptionLifecycle.onAbandoned();
339
+ }
340
+ } : void 0;
316
341
  await core.channel.inbound.dispatch({
317
342
  cfg: config,
318
343
  channel: CHANNEL_ID,
@@ -339,6 +364,7 @@ async function handleIrcInbound(params) {
339
364
  },
340
365
  replyPipeline: {},
341
366
  replyOptions: {
367
+ ...trackedIngressLifecycle ? bindIngressLifecycleToReplyOptions(trackedIngressLifecycle) : {},
342
368
  skillFilter: groupMatch.groupConfig?.skills,
343
369
  disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
344
370
  },
@@ -346,23 +372,162 @@ async function handleIrcInbound(params) {
346
372
  runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
347
373
  } }
348
374
  });
375
+ if (turnAdoptionLifecycle?.abortSignal.aborted && ingressState.handoff === "none") return {
376
+ kind: "failed-retryable",
377
+ error: turnAdoptionLifecycle.abortSignal.reason
378
+ };
379
+ if (turnAdoptionLifecycle && ingressState.handoff === "none") {
380
+ await turnAdoptionLifecycle.onAdopted();
381
+ ingressState.handoff = "adopted";
382
+ }
383
+ return ingressState.handoff === "deferred" || ingressState.handoff === "abandoned" ? { kind: "deferred" } : { kind: "completed" };
349
384
  }
350
385
  //#endregion
351
- //#region extensions/irc/src/monitor.ts
352
- const IRC_MONITOR_RECONNECT_DELAY_MS = 1e3;
353
- function resolveIrcInboundTarget(params) {
354
- const rawTarget = params.target;
355
- if (isChannelTarget(rawTarget)) return {
356
- isGroup: true,
357
- target: rawTarget,
358
- rawTarget
386
+ //#region extensions/irc/src/irc-ingress.ts
387
+ const IRC_INGRESS_PAYLOAD_VERSION = 1;
388
+ const IRC_INGRESS_POLL_INTERVAL_MS = 1e3;
389
+ const IRC_INGRESS_PRUNE_INTERVAL_MS = 3600 * 1e3;
390
+ const IRC_INGRESS_TOMBSTONE_TTL_MS = 720 * 60 * 60 * 1e3;
391
+ const IRC_INGRESS_TOMBSTONE_MAX_ENTRIES = 1e3;
392
+ var IrcIngressPayloadError = class extends Error {
393
+ constructor(message) {
394
+ super(message);
395
+ this.name = "IrcIngressPayloadError";
396
+ }
397
+ };
398
+ function inspectRawPrivmsg(rawLine) {
399
+ const line = parseIrcLine(rawLine);
400
+ if (!line || line.command !== "PRIVMSG") throw new IrcIngressPayloadError("IRC ingress row is not a PRIVMSG line.");
401
+ const rawTarget = line.params[0]?.trim() ?? "";
402
+ const text = line.trailing ?? line.params[1] ?? "";
403
+ const prefix = parseIrcPrefix(line.prefix);
404
+ const senderNick = prefix.nick?.trim() ?? "";
405
+ if (!rawTarget || !senderNick || !text.trim()) throw new IrcIngressPayloadError("IRC PRIVMSG line is missing target, sender, or text.");
406
+ const isGroup = isChannelTarget(rawTarget);
407
+ const target = isGroup ? rawTarget : senderNick;
408
+ const lanePeer = normalizeLowercaseStringOrEmpty(target);
409
+ return {
410
+ laneKey: `${isGroup ? "channel" : "direct"}:${lanePeer}`,
411
+ message: {
412
+ target,
413
+ rawTarget,
414
+ senderNick,
415
+ senderUser: prefix.user?.trim() || void 0,
416
+ senderHost: prefix.host?.trim() || void 0,
417
+ text,
418
+ isGroup
419
+ }
420
+ };
421
+ }
422
+ function decodeIrcIngressPayload(payload, claimedId) {
423
+ 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.`);
424
+ const validPayload = payload;
425
+ return {
426
+ version: validPayload.version,
427
+ body: {
428
+ eventId: validPayload.eventId,
429
+ receivedAt: validPayload.receivedAt,
430
+ connectionEpoch: validPayload.connectionEpoch,
431
+ connectedNick: validPayload.connectedNick,
432
+ rawLine: validPayload.rawLine
433
+ }
359
434
  };
435
+ }
436
+ function inspectIrcIngress(raw, phase) {
437
+ try {
438
+ return {
439
+ eventId: raw.eventId,
440
+ laneKey: inspectRawPrivmsg(raw.rawLine).laneKey
441
+ };
442
+ } catch (error) {
443
+ if (phase === "admission" && error instanceof IrcIngressPayloadError) return {
444
+ eventId: raw.eventId,
445
+ laneKey: `invalid:${raw.eventId}`
446
+ };
447
+ throw error;
448
+ }
449
+ }
450
+ function resolveIrcIngressNonRetryableFailure(error) {
451
+ return error instanceof IrcIngressPayloadError ? {
452
+ reason: "invalid-event",
453
+ message: error.message
454
+ } : null;
455
+ }
456
+ function createIrcIngressMonitor(options) {
457
+ const monitor = createChannelIngressMonitor({
458
+ queue: options.queue ?? (() => getIrcRuntime().state.openChannelIngressQueue({ accountId: options.accountId })),
459
+ inspect: (raw, context) => inspectIrcIngress(raw, context.phase),
460
+ payload: {
461
+ version: IRC_INGRESS_PAYLOAD_VERSION,
462
+ serialize: (raw) => raw,
463
+ deserialize: (body) => body,
464
+ encode: ({ body }) => ({
465
+ version: IRC_INGRESS_PAYLOAD_VERSION,
466
+ ...body
467
+ }),
468
+ decode: (payload, { claim }) => decodeIrcIngressPayload(payload, claim.id),
469
+ createClaimError: (_kind, claim) => new IrcIngressPayloadError(`IRC ingress row ${claim.id} has invalid metadata.`)
470
+ },
471
+ deliver: (raw, lifecycle, claim) => {
472
+ const message = {
473
+ ...inspectRawPrivmsg(raw.rawLine).message,
474
+ messageId: claim.id,
475
+ timestamp: raw.receivedAt
476
+ };
477
+ return options.dispatch(message, lifecycle, {
478
+ connectedNick: raw.connectedNick.trim(),
479
+ connectionEpoch: raw.connectionEpoch.trim()
480
+ });
481
+ },
482
+ pollIntervalMs: options.pollIntervalMs ?? IRC_INGRESS_POLL_INTERVAL_MS,
483
+ retention: {
484
+ pruneIntervalMs: IRC_INGRESS_PRUNE_INTERVAL_MS,
485
+ completedTtlMs: IRC_INGRESS_TOMBSTONE_TTL_MS,
486
+ completedMaxEntries: IRC_INGRESS_TOMBSTONE_MAX_ENTRIES,
487
+ failedTtlMs: IRC_INGRESS_TOMBSTONE_TTL_MS,
488
+ failedMaxEntries: IRC_INGRESS_TOMBSTONE_MAX_ENTRIES
489
+ },
490
+ drain: {
491
+ resolveNonRetryableFailure: resolveIrcIngressNonRetryableFailure,
492
+ ...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
493
+ onLog: (message) => options.runtime.log?.(`irc ${message}`)
494
+ },
495
+ createStoppedError: () => /* @__PURE__ */ new Error("IRC ingress is stopped."),
496
+ onError: (error) => options.runtime.error?.(`irc ingress drain failed: ${String(error)}`)
497
+ });
360
498
  return {
361
- isGroup: false,
362
- target: params.senderNick.trim() || rawTarget,
363
- rawTarget
499
+ openConnection: (connectionEpoch = randomUUID()) => {
500
+ const epoch = connectionEpoch.trim();
501
+ if (!epoch) throw new Error("IRC ingress connection epoch is required.");
502
+ let sequence = 0;
503
+ return {
504
+ connectionEpoch: epoch,
505
+ accept: (rawLine, connectedNick) => {
506
+ if (monitor.isStopped()) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress is stopped."));
507
+ sequence += 1;
508
+ const eventId = `local:${epoch}:${String(sequence).padStart(12, "0")}`;
509
+ const receivedAt = Date.now();
510
+ const normalizedNick = connectedNick.trim();
511
+ if (!normalizedNick) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress connected nickname is required."));
512
+ return monitor.admit({
513
+ eventId,
514
+ rawLine,
515
+ receivedAt,
516
+ connectionEpoch: epoch,
517
+ connectedNick: normalizedNick
518
+ }, { receivedAt }).then(() => void 0);
519
+ }
520
+ };
521
+ },
522
+ start: monitor.start,
523
+ pause: monitor.pause,
524
+ stop: monitor.stop,
525
+ waitForIdle: monitor.waitForIdle
364
526
  };
365
527
  }
528
+ //#endregion
529
+ //#region extensions/irc/src/monitor.ts
530
+ const IRC_MONITOR_RECONNECT_DELAY_MS = 1e3;
366
531
  async function monitorIrcProvider(opts) {
367
532
  const core = getIrcRuntime();
368
533
  const cfg = opts.config ?? core.config.current();
@@ -377,6 +542,8 @@ async function monitorIrcProvider(opts) {
377
542
  accountId: account.accountId
378
543
  });
379
544
  let client = null;
545
+ let activeConnectionEpoch = null;
546
+ let ingressPause = Promise.resolve();
380
547
  let reconnectTimer = null;
381
548
  let stopped = false;
382
549
  const monitorAbort = new AbortController();
@@ -389,6 +556,48 @@ async function monitorIrcProvider(opts) {
389
556
  removeAbortListener = () => opts.abortSignal?.removeEventListener("abort", forwardAbort);
390
557
  }
391
558
  }
559
+ const ingress = createIrcIngressMonitor({
560
+ accountId: account.accountId,
561
+ runtime,
562
+ ...opts.ingressQueue ? { queue: opts.ingressQueue } : {},
563
+ dispatch: async (message, turnAdoptionLifecycle, context) => {
564
+ const activeClient = client;
565
+ if (!activeClient || stopped || monitorAbort.signal.aborted) return {
566
+ kind: "failed-retryable",
567
+ error: /* @__PURE__ */ new Error("IRC transport disconnected before ingress dispatch.")
568
+ };
569
+ if (normalizeLowercaseStringOrEmpty(message.senderNick) === normalizeLowercaseStringOrEmpty(context.connectedNick)) return { kind: "completed" };
570
+ if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) {
571
+ logger.warn?.(`[${account.accountId}] dropping replayed IRC DM after the connection changed`);
572
+ return { kind: "completed" };
573
+ }
574
+ if (opts.onMessage) {
575
+ await opts.onMessage(message, activeClient);
576
+ return { kind: "completed" };
577
+ }
578
+ return await handleIrcInbound({
579
+ message,
580
+ account,
581
+ config: cfg,
582
+ runtime,
583
+ connectedNick: context.connectedNick,
584
+ turnAdoptionLifecycle,
585
+ sendReply: async (target, text) => {
586
+ const replyClient = client;
587
+ if (!replyClient || !replyClient.isReady() || stopped || monitorAbort.signal.aborted) throw new Error("IRC transport disconnected before reply send.");
588
+ if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) throw new Error("IRC connection changed before private reply send.");
589
+ replyClient.sendPrivmsg(target, text);
590
+ opts.statusSink?.({ lastOutboundAt: Date.now() });
591
+ core.channel.activity.record({
592
+ channel: "irc",
593
+ accountId: account.accountId,
594
+ direction: "outbound"
595
+ });
596
+ },
597
+ statusSink: opts.statusSink
598
+ });
599
+ }
600
+ });
392
601
  function scheduleReconnect() {
393
602
  if (stopped || monitorAbort.signal.aborted || reconnectTimer) return;
394
603
  reconnectTimer = setTimeout(() => {
@@ -403,6 +612,7 @@ async function monitorIrcProvider(opts) {
403
612
  }
404
613
  async function connect() {
405
614
  if (stopped || monitorAbort.signal.aborted) return;
615
+ const ingressConnection = ingress.openConnection();
406
616
  const nextClient = await connectIrcClient(buildIrcConnectOptions(account, {
407
617
  channels: account.config.channels,
408
618
  abortSignal: monitorAbort.signal,
@@ -417,54 +627,20 @@ async function monitorIrcProvider(opts) {
417
627
  },
418
628
  onDisconnect: () => {
419
629
  if (stopped || monitorAbort.signal.aborted) return;
630
+ ingressPause = ingress.pause();
631
+ if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
420
632
  client = null;
421
633
  logger.warn?.(`[${account.accountId}] IRC connection closed; reconnecting in ${IRC_MONITOR_RECONNECT_DELAY_MS}ms`);
422
634
  scheduleReconnect();
423
635
  },
424
636
  onPrivmsg: async (event) => {
425
- if (!client) return;
426
- if (normalizeLowercaseStringOrEmpty(event.senderNick) === normalizeLowercaseStringOrEmpty(client.nick)) return;
427
- const inboundTarget = resolveIrcInboundTarget({
428
- target: event.target,
429
- senderNick: event.senderNick
430
- });
431
- const message = {
432
- messageId: makeIrcMessageId(),
433
- target: inboundTarget.target,
434
- rawTarget: inboundTarget.rawTarget,
435
- senderNick: event.senderNick,
436
- senderUser: event.senderUser,
437
- senderHost: event.senderHost,
438
- text: event.text,
439
- timestamp: Date.now(),
440
- isGroup: inboundTarget.isGroup
441
- };
637
+ await ingressConnection.accept(event.rawLine, event.connectedNick);
638
+ if (normalizeLowercaseStringOrEmpty(event.senderNick) === normalizeLowercaseStringOrEmpty(event.connectedNick)) return;
442
639
  core.channel.activity.record({
443
640
  channel: "irc",
444
641
  accountId: account.accountId,
445
642
  direction: "inbound",
446
- at: message.timestamp
447
- });
448
- if (opts.onMessage) {
449
- await opts.onMessage(message, client);
450
- return;
451
- }
452
- await handleIrcInbound({
453
- message,
454
- account,
455
- config: cfg,
456
- runtime,
457
- connectedNick: client.nick,
458
- sendReply: async (target, text) => {
459
- client?.sendPrivmsg(target, text);
460
- opts.statusSink?.({ lastOutboundAt: Date.now() });
461
- core.channel.activity.record({
462
- channel: "irc",
463
- accountId: account.accountId,
464
- direction: "outbound"
465
- });
466
- },
467
- statusSink: opts.statusSink
643
+ at: Date.now()
468
644
  });
469
645
  }
470
646
  }));
@@ -473,20 +649,40 @@ async function monitorIrcProvider(opts) {
473
649
  return;
474
650
  }
475
651
  client = nextClient;
652
+ activeConnectionEpoch = ingressConnection.connectionEpoch;
653
+ await ingressPause;
654
+ if (client !== nextClient || !nextClient.isReady()) {
655
+ if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
656
+ return;
657
+ }
658
+ ingress.start();
476
659
  logger.info(`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${nextClient.nick}`);
477
660
  }
478
- await connect();
479
- return { stop: () => {
480
- stopped = true;
661
+ try {
662
+ await connect();
663
+ } catch (error) {
481
664
  removeAbortListener?.();
482
665
  removeAbortListener = null;
483
- if (!monitorAbort.signal.aborted) monitorAbort.abort();
484
- if (reconnectTimer) {
485
- clearTimeout(reconnectTimer);
486
- reconnectTimer = null;
487
- }
488
- client?.quit("shutdown");
489
- client = null;
666
+ await ingress.stop();
667
+ throw error;
668
+ }
669
+ let stopTask;
670
+ return { stop: () => {
671
+ stopTask ??= (async () => {
672
+ stopped = true;
673
+ removeAbortListener?.();
674
+ removeAbortListener = null;
675
+ if (!monitorAbort.signal.aborted) monitorAbort.abort();
676
+ if (reconnectTimer) {
677
+ clearTimeout(reconnectTimer);
678
+ reconnectTimer = null;
679
+ }
680
+ client?.quit("shutdown");
681
+ client = null;
682
+ activeConnectionEpoch = null;
683
+ await ingress.stop();
684
+ })();
685
+ return stopTask;
490
686
  } };
491
687
  }
492
688
  //#endregion
@@ -6,20 +6,6 @@
6
6
  "channels": [
7
7
  "irc"
8
8
  ],
9
- "channelEnvVars": {
10
- "irc": [
11
- "IRC_HOST",
12
- "IRC_PORT",
13
- "IRC_TLS",
14
- "IRC_NICK",
15
- "IRC_USERNAME",
16
- "IRC_REALNAME",
17
- "IRC_PASSWORD",
18
- "IRC_CHANNELS",
19
- "IRC_NICKSERV_PASSWORD",
20
- "IRC_NICKSERV_REGISTER_EMAIL"
21
- ]
22
- },
23
9
  "configSchema": {
24
10
  "type": "object",
25
11
  "additionalProperties": false,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/irc",
3
- "version": "2026.7.2-beta.3",
3
+ "version": "2026.7.2-beta.5",
4
4
  "description": "OpenClaw IRC channel plugin",
5
5
  "type": "module",
6
6
  "openclaw": {
@@ -15,8 +15,19 @@
15
15
  "allowInvalidConfigRecovery": true
16
16
  },
17
17
  "setupEntry": "./dist/setup-entry.js",
18
+ "setupFeatures": {
19
+ "configPromotion": true
20
+ },
18
21
  "channel": {
19
22
  "id": "irc",
23
+ "configuredState": {
24
+ "env": {
25
+ "allOf": [
26
+ "IRC_HOST",
27
+ "IRC_NICK"
28
+ ]
29
+ }
30
+ },
20
31
  "label": "IRC",
21
32
  "selectionLabel": "IRC (Server + Nick)",
22
33
  "detailLabel": "IRC",
@@ -27,20 +38,89 @@
27
38
  "internet-relay-chat"
28
39
  ],
29
40
  "systemImage": "network",
30
- "configuredState": {
31
- "env": {
32
- "allOf": [
33
- "IRC_HOST",
34
- "IRC_NICK"
35
- ]
36
- }
41
+ "setup": {
42
+ "fields": [
43
+ {
44
+ "key": "host",
45
+ "kind": "string",
46
+ "cli": {
47
+ "flags": "--host <host>",
48
+ "description": "IRC server host"
49
+ }
50
+ },
51
+ {
52
+ "key": "port",
53
+ "kind": "string",
54
+ "cli": {
55
+ "flags": "--port <port>",
56
+ "description": "IRC server port"
57
+ }
58
+ },
59
+ {
60
+ "key": "tls",
61
+ "kind": "boolean",
62
+ "cli": {
63
+ "flags": "--tls",
64
+ "description": "Use TLS for IRC"
65
+ }
66
+ },
67
+ {
68
+ "key": "nick",
69
+ "kind": "string",
70
+ "cli": {
71
+ "flags": "--nick <nick>",
72
+ "description": "IRC nickname"
73
+ }
74
+ },
75
+ {
76
+ "key": "username",
77
+ "kind": "string",
78
+ "cli": {
79
+ "flags": "--username <name>",
80
+ "description": "IRC username"
81
+ }
82
+ },
83
+ {
84
+ "key": "realname",
85
+ "kind": "string",
86
+ "cli": {
87
+ "flags": "--realname <name>",
88
+ "description": "IRC real name"
89
+ }
90
+ },
91
+ {
92
+ "key": "channels",
93
+ "kind": "string-list",
94
+ "cli": {
95
+ "flags": "--channels <names>",
96
+ "description": "IRC channels"
97
+ }
98
+ },
99
+ {
100
+ "key": "password",
101
+ "kind": "string",
102
+ "sensitive": true,
103
+ "cli": {
104
+ "flags": "--password <password>",
105
+ "description": "IRC server password"
106
+ }
107
+ },
108
+ {
109
+ "key": "useEnv",
110
+ "kind": "boolean",
111
+ "cli": {
112
+ "flags": "--use-env",
113
+ "description": "Use IRC environment configuration"
114
+ }
115
+ }
116
+ ]
37
117
  }
38
118
  },
39
119
  "compat": {
40
- "pluginApi": ">=2026.7.2-beta.3"
120
+ "pluginApi": ">=2026.7.2-beta.5"
41
121
  },
42
122
  "build": {
43
- "openclawVersion": "2026.7.2-beta.3",
123
+ "openclawVersion": "2026.7.2-beta.5",
44
124
  "bundledDist": false
45
125
  },
46
126
  "release": {
@@ -62,11 +142,10 @@
62
142
  "files": [
63
143
  "dist/**",
64
144
  "openclaw.plugin.json",
65
- "npm-shrinkwrap.json",
66
145
  "README.md"
67
146
  ],
68
147
  "peerDependencies": {
69
- "openclaw": ">=2026.7.2-beta.3"
148
+ "openclaw": ">=2026.7.2-beta.5"
70
149
  },
71
150
  "peerDependenciesMeta": {
72
151
  "openclaw": {
@@ -1,24 +0,0 @@
1
- {
2
- "name": "@openclaw/irc",
3
- "version": "2026.7.2-beta.3",
4
- "lockfileVersion": 3,
5
- "requires": true,
6
- "packages": {
7
- "": {
8
- "name": "@openclaw/irc",
9
- "version": "2026.7.2-beta.3",
10
- "dependencies": {
11
- "zod": "4.4.3"
12
- }
13
- },
14
- "node_modules/zod": {
15
- "version": "4.4.3",
16
- "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz",
17
- "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==",
18
- "license": "MIT",
19
- "funding": {
20
- "url": "https://github.com/sponsors/colinhacks"
21
- }
22
- }
23
- }
24
- }