@openclaw/irc 2026.7.2-beta.5 → 2026.7.33

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.
@@ -1,13 +1,12 @@
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
- import { t as getIrcRuntime } from "./runtime-xy-FcjJC.js";
1
+ import { a as resolveIrcRequireMention, 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-SaGThQQW.mjs";
2
+ import { t as getIrcRuntime } from "./runtime-DZfYm3vY.mjs";
3
3
  import { normalizeLowercaseStringOrEmpty, normalizeOptionalString, normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
4
- import { bindIngressLifecycleToReplyOptions, createChannelIngressMonitor, resolveChannelStreamingBlockEnabled } from "openclaw/plugin-sdk/channel-outbound";
5
- import { randomUUID } from "node:crypto";
6
4
  import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
7
- import { buildChannelInboundEventContext, logInboundDrop, resolveChannelInboundRouteEnvelope } from "openclaw/plugin-sdk/channel-inbound";
5
+ import { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
8
6
  import { channelIngressRoutes, createChannelIngressResolver, defineStableChannelIngressIdentity } from "openclaw/plugin-sdk/channel-ingress-runtime";
9
7
  import { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
10
8
  import { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
9
+ import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
11
10
  import { deliverFormattedTextWithAttachments } from "openclaw/plugin-sdk/reply-payload";
12
11
  import { GROUP_POLICY_BLOCKED_LABEL, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, warnMissingProviderGroupPolicyFallbackOnce } from "openclaw/plugin-sdk/runtime-group-policy";
13
12
  //#region extensions/irc/src/inbound.ts
@@ -121,7 +120,7 @@ async function deliverIrcReply(params) {
121
120
  });
122
121
  }
123
122
  async function handleIrcInbound(params) {
124
- const { message, account, config, runtime, connectedNick, statusSink, turnAdoptionLifecycle } = params;
123
+ const { message, account, config, runtime, connectedNick, statusSink } = params;
125
124
  const core = getIrcRuntime();
126
125
  const pairing = createChannelPairingController({
127
126
  core,
@@ -129,11 +128,7 @@ async function handleIrcInbound(params) {
129
128
  accountId: account.accountId
130
129
  });
131
130
  const rawBody = message.text?.trim() ?? "";
132
- if (!rawBody) return { kind: "completed" };
133
- if (turnAdoptionLifecycle?.abortSignal.aborted) return {
134
- kind: "failed-retryable",
135
- error: turnAdoptionLifecycle.abortSignal.reason
136
- };
131
+ if (!rawBody) return;
137
132
  statusSink?.({ lastInboundAt: message.timestamp });
138
133
  const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick;
139
134
  const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
@@ -164,9 +159,9 @@ async function handleIrcInbound(params) {
164
159
  const mentionNick = connectedNick?.trim() || account.nick;
165
160
  const explicitMentionRegex = mentionNick ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") : null;
166
161
  const wasMentioned = core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
167
- const requireMention = message.isGroup ? resolveIrcGroupRequireMention({
168
- groups: account.config.groups,
169
- target: message.target
162
+ const requireMention = message.isGroup ? resolveIrcRequireMention({
163
+ groupConfig: groupMatch.groupConfig,
164
+ wildcardConfig: groupMatch.wildcardConfig
170
165
  }) : false;
171
166
  const routeGroupAllowFrom = normalizeStringEntries(groupMatch.groupConfig?.allowFrom?.length ? groupMatch.groupConfig.allowFrom : groupMatch.wildcardConfig?.allowFrom);
172
167
  const accessGroupPolicy = groupPolicy === "open" && (hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom)) ? "allowlist" : groupPolicy;
@@ -233,11 +228,11 @@ async function handleIrcInbound(params) {
233
228
  }
234
229
  });
235
230
  runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
236
- return { kind: "completed" };
231
+ return;
237
232
  }
238
233
  if (access.ingress.admission === "skip") {
239
234
  runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
240
- return { kind: "completed" };
235
+ return;
241
236
  }
242
237
  if (access.ingress.admission !== "dispatch") {
243
238
  if (message.isGroup && access.ingress.decisiveGateId === "command" && access.commandAccess.shouldBlockControlCommand) {
@@ -247,106 +242,69 @@ async function handleIrcInbound(params) {
247
242
  reason: "control command (unauthorized)",
248
243
  target: senderDisplay
249
244
  });
250
- return { kind: "completed" };
245
+ return;
251
246
  }
252
- if (message.isGroup) if (access.routeAccess.reason === "channel_not_allowlisted") runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
253
- else if (access.routeAccess.reason === "channel_disabled") runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
254
- else runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
255
- else runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
256
- return { kind: "completed" };
247
+ if (message.isGroup) {
248
+ if (access.routeAccess.reason === "channel_not_allowlisted") runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
249
+ else if (access.routeAccess.reason === "channel_disabled") runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
250
+ else runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
251
+ } else runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
252
+ return;
257
253
  }
258
- if (turnAdoptionLifecycle?.abortSignal.aborted) return {
259
- kind: "failed-retryable",
260
- error: turnAdoptionLifecycle.abortSignal.reason
261
- };
262
254
  const channelTarget = message.target.startsWith("#") || message.target.startsWith("&") ? message.target : `#${message.target}`;
263
255
  const peerId = message.isGroup ? channelTarget : message.senderNick;
264
- const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
256
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
265
257
  cfg: config,
266
258
  channel: CHANNEL_ID,
267
259
  accountId: account.accountId,
268
260
  peer: {
269
261
  kind: message.isGroup ? "group" : "direct",
270
262
  id: peerId
271
- }
263
+ },
264
+ runtime: core.channel,
265
+ sessionStore: config.session?.store
272
266
  });
273
267
  const fromLabel = message.isGroup ? message.target : senderDisplay;
274
- const body = buildEnvelope({
268
+ const { storePath, body } = buildEnvelope({
275
269
  channel: "IRC",
276
270
  from: fromLabel,
277
271
  timestamp: message.timestamp,
278
272
  body: rawBody
279
273
  });
280
274
  const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
281
- const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
282
- const ctxPayload = buildChannelInboundEventContext({
283
- channel: CHANNEL_ID,
284
- accountId: route.accountId,
285
- messageId: message.messageId,
286
- timestamp: message.timestamp,
287
- from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
288
- sender: {
289
- id: senderDisplay,
290
- name: message.senderNick || void 0
291
- },
292
- conversation: {
293
- kind: message.isGroup ? "group" : "direct",
294
- id: peerId,
295
- label: fromLabel
296
- },
297
- route: {
298
- agentId: route.agentId,
299
- dmScope: route.dmScope,
300
- accountId: route.accountId,
301
- routeSessionKey: route.sessionKey
302
- },
303
- reply: {
304
- to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
305
- originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`
306
- },
307
- message: {
308
- body,
309
- bodyForAgent: rawBody,
310
- rawBody,
311
- commandBody: rawBody
312
- },
313
- access: {
314
- commands: { authorized: commandAuthorized },
315
- mentions: {
316
- canDetectMention: message.isGroup,
317
- wasMentioned
318
- }
319
- },
320
- extra: {
321
- GroupSubject: message.isGroup ? message.target : void 0,
322
- GroupSystemPrompt: message.isGroup ? groupSystemPrompt : void 0
323
- }
275
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
276
+ Body: body,
277
+ RawBody: rawBody,
278
+ CommandBody: rawBody,
279
+ From: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
280
+ To: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
281
+ SessionKey: route.sessionKey,
282
+ AccountId: route.accountId,
283
+ ChatType: message.isGroup ? "group" : "direct",
284
+ ConversationLabel: fromLabel,
285
+ SenderName: message.senderNick || void 0,
286
+ SenderId: senderDisplay,
287
+ GroupSubject: message.isGroup ? message.target : void 0,
288
+ GroupSystemPrompt: message.isGroup ? groupSystemPrompt : void 0,
289
+ Provider: CHANNEL_ID,
290
+ Surface: CHANNEL_ID,
291
+ WasMentioned: message.isGroup ? wasMentioned : void 0,
292
+ MessageSid: message.messageId,
293
+ Timestamp: message.timestamp,
294
+ OriginatingChannel: CHANNEL_ID,
295
+ OriginatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
296
+ CommandAuthorized: commandAuthorized
324
297
  });
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;
341
- await core.channel.inbound.dispatch({
298
+ await core.channel.inbound.dispatchReply({
342
299
  cfg: config,
343
300
  channel: CHANNEL_ID,
344
301
  accountId: account.accountId,
345
- route: {
346
- agentId: route.agentId,
347
- sessionKey: route.sessionKey
348
- },
302
+ agentId: route.agentId,
303
+ routeSessionKey: route.sessionKey,
304
+ storePath,
349
305
  ctxPayload,
306
+ recordInboundSession: core.channel.session.recordInboundSession,
307
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
350
308
  delivery: {
351
309
  deliver: async (payload) => {
352
310
  await deliverIrcReply({
@@ -364,170 +322,30 @@ async function handleIrcInbound(params) {
364
322
  },
365
323
  replyPipeline: {},
366
324
  replyOptions: {
367
- ...trackedIngressLifecycle ? bindIngressLifecycleToReplyOptions(trackedIngressLifecycle) : {},
368
325
  skillFilter: groupMatch.groupConfig?.skills,
369
- disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
326
+ disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming : void 0
370
327
  },
371
328
  record: { onRecordError: (err) => {
372
329
  runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
373
330
  } }
374
331
  });
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" };
384
332
  }
385
333
  //#endregion
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
- }
334
+ //#region extensions/irc/src/monitor.ts
335
+ const IRC_MONITOR_RECONNECT_DELAY_MS = 1e3;
336
+ function resolveIrcInboundTarget(params) {
337
+ const rawTarget = params.target;
338
+ if (isChannelTarget(rawTarget)) return {
339
+ isGroup: true,
340
+ target: rawTarget,
341
+ rawTarget
434
342
  };
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
- });
498
343
  return {
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
344
+ isGroup: false,
345
+ target: params.senderNick.trim() || rawTarget,
346
+ rawTarget
526
347
  };
527
348
  }
528
- //#endregion
529
- //#region extensions/irc/src/monitor.ts
530
- const IRC_MONITOR_RECONNECT_DELAY_MS = 1e3;
531
349
  async function monitorIrcProvider(opts) {
532
350
  const core = getIrcRuntime();
533
351
  const cfg = opts.config ?? core.config.current();
@@ -542,8 +360,6 @@ async function monitorIrcProvider(opts) {
542
360
  accountId: account.accountId
543
361
  });
544
362
  let client = null;
545
- let activeConnectionEpoch = null;
546
- let ingressPause = Promise.resolve();
547
363
  let reconnectTimer = null;
548
364
  let stopped = false;
549
365
  const monitorAbort = new AbortController();
@@ -556,48 +372,6 @@ async function monitorIrcProvider(opts) {
556
372
  removeAbortListener = () => opts.abortSignal?.removeEventListener("abort", forwardAbort);
557
373
  }
558
374
  }
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
- });
601
375
  function scheduleReconnect() {
602
376
  if (stopped || monitorAbort.signal.aborted || reconnectTimer) return;
603
377
  reconnectTimer = setTimeout(() => {
@@ -612,7 +386,6 @@ async function monitorIrcProvider(opts) {
612
386
  }
613
387
  async function connect() {
614
388
  if (stopped || monitorAbort.signal.aborted) return;
615
- const ingressConnection = ingress.openConnection();
616
389
  const nextClient = await connectIrcClient(buildIrcConnectOptions(account, {
617
390
  channels: account.config.channels,
618
391
  abortSignal: monitorAbort.signal,
@@ -627,20 +400,54 @@ async function monitorIrcProvider(opts) {
627
400
  },
628
401
  onDisconnect: () => {
629
402
  if (stopped || monitorAbort.signal.aborted) return;
630
- ingressPause = ingress.pause();
631
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
632
403
  client = null;
633
404
  logger.warn?.(`[${account.accountId}] IRC connection closed; reconnecting in ${IRC_MONITOR_RECONNECT_DELAY_MS}ms`);
634
405
  scheduleReconnect();
635
406
  },
636
407
  onPrivmsg: async (event) => {
637
- await ingressConnection.accept(event.rawLine, event.connectedNick);
638
- if (normalizeLowercaseStringOrEmpty(event.senderNick) === normalizeLowercaseStringOrEmpty(event.connectedNick)) return;
408
+ if (!client) return;
409
+ if (normalizeLowercaseStringOrEmpty(event.senderNick) === normalizeLowercaseStringOrEmpty(client.nick)) return;
410
+ const inboundTarget = resolveIrcInboundTarget({
411
+ target: event.target,
412
+ senderNick: event.senderNick
413
+ });
414
+ const message = {
415
+ messageId: makeIrcMessageId(),
416
+ target: inboundTarget.target,
417
+ rawTarget: inboundTarget.rawTarget,
418
+ senderNick: event.senderNick,
419
+ senderUser: event.senderUser,
420
+ senderHost: event.senderHost,
421
+ text: event.text,
422
+ timestamp: Date.now(),
423
+ isGroup: inboundTarget.isGroup
424
+ };
639
425
  core.channel.activity.record({
640
426
  channel: "irc",
641
427
  accountId: account.accountId,
642
428
  direction: "inbound",
643
- at: Date.now()
429
+ at: message.timestamp
430
+ });
431
+ if (opts.onMessage) {
432
+ await opts.onMessage(message, client);
433
+ return;
434
+ }
435
+ await handleIrcInbound({
436
+ message,
437
+ account,
438
+ config: cfg,
439
+ runtime,
440
+ connectedNick: client.nick,
441
+ sendReply: async (target, text) => {
442
+ client?.sendPrivmsg(target, text);
443
+ opts.statusSink?.({ lastOutboundAt: Date.now() });
444
+ core.channel.activity.record({
445
+ channel: "irc",
446
+ accountId: account.accountId,
447
+ direction: "outbound"
448
+ });
449
+ },
450
+ statusSink: opts.statusSink
644
451
  });
645
452
  }
646
453
  }));
@@ -649,40 +456,20 @@ async function monitorIrcProvider(opts) {
649
456
  return;
650
457
  }
651
458
  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();
659
459
  logger.info(`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${nextClient.nick}`);
660
460
  }
661
- try {
662
- await connect();
663
- } catch (error) {
461
+ await connect();
462
+ return { stop: () => {
463
+ stopped = true;
664
464
  removeAbortListener?.();
665
465
  removeAbortListener = 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;
466
+ if (!monitorAbort.signal.aborted) monitorAbort.abort();
467
+ if (reconnectTimer) {
468
+ clearTimeout(reconnectTimer);
469
+ reconnectTimer = null;
470
+ }
471
+ client?.quit("shutdown");
472
+ client = null;
686
473
  } };
687
474
  }
688
475
  //#endregion
@@ -1,5 +1,4 @@
1
- import { createChannelConfigUiHints } from "openclaw/plugin-sdk/channel-core";
2
- import { ChannelGroupEntrySchema, DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, ReplyRuntimeConfigSchemaShape, buildChannelConfigSchema, buildMultiAccountChannelSchema, requireOpenAllowFrom } from "openclaw/plugin-sdk/channel-config-schema";
1
+ import { DmPolicySchema, GroupPolicySchema, MarkdownConfigSchema, ReplyRuntimeConfigSchemaShape, ToolPolicySchema, buildChannelConfigSchema, requireOpenAllowFrom } from "openclaw/plugin-sdk/channel-config-schema";
3
2
  import { z } from "zod";
4
3
  //#region extensions/irc/src/config-ui-hints.ts
5
4
  const ircChannelConfigUiHints = {
@@ -7,10 +6,10 @@ const ircChannelConfigUiHints = {
7
6
  label: "IRC",
8
7
  help: "IRC channel provider configuration and compatibility settings for classic IRC transport workflows. Use this section when bridging legacy chat infrastructure into OpenClaw."
9
8
  },
10
- ...createChannelConfigUiHints({
11
- channelLabel: "IRC",
12
- dmPolicy: { channelKey: "irc" }
13
- }),
9
+ dmPolicy: {
10
+ label: "IRC DM Policy",
11
+ help: "Direct message access control (\"pairing\" recommended). \"open\" requires channels.irc.allowFrom=[\"*\"]."
12
+ },
14
13
  "nickserv.enabled": {
15
14
  label: "IRC NickServ Enabled",
16
15
  help: "Enable NickServ identify/register after connect (defaults to enabled when password is configured)."
@@ -35,13 +34,22 @@ const ircChannelConfigUiHints = {
35
34
  label: "IRC NickServ Register Email",
36
35
  help: "Email used with NickServ REGISTER (required when register=true)."
37
36
  },
38
- ...createChannelConfigUiHints({
39
- channelLabel: "IRC",
40
- configWrites: true
41
- })
37
+ configWrites: {
38
+ label: "IRC Config Writes",
39
+ help: "Allow IRC to write config in response to channel events/commands (default: true)."
40
+ }
42
41
  };
43
42
  //#endregion
44
43
  //#region extensions/irc/src/config-schema.ts
44
+ const IrcGroupSchema = z.object({
45
+ requireMention: z.boolean().optional(),
46
+ tools: ToolPolicySchema,
47
+ toolsBySender: z.record(z.string(), ToolPolicySchema).optional(),
48
+ skills: z.array(z.string()).optional(),
49
+ enabled: z.boolean().optional(),
50
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
51
+ systemPrompt: z.string().optional()
52
+ }).strict();
45
53
  const IrcNickServSchema = z.object({
46
54
  enabled: z.boolean().optional(),
47
55
  service: z.string().optional(),
@@ -56,7 +64,7 @@ const IrcNickServSchema = z.object({
56
64
  message: "channels.irc.nickserv.register=true requires channels.irc.nickserv.registerEmail"
57
65
  });
58
66
  });
59
- const IrcChannelConfigSchema = buildChannelConfigSchema(buildMultiAccountChannelSchema(z.object({
67
+ const IrcAccountSchemaBase = z.object({
60
68
  name: z.string().optional(),
61
69
  enabled: z.boolean().optional(),
62
70
  dangerouslyAllowNameMatching: z.boolean().optional(),
@@ -73,22 +81,33 @@ const IrcChannelConfigSchema = buildChannelConfigSchema(buildMultiAccountChannel
73
81
  allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
74
82
  groupPolicy: GroupPolicySchema.optional().default("allowlist"),
75
83
  groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
76
- groups: z.record(z.string(), ChannelGroupEntrySchema.optional()).optional(),
84
+ groups: z.record(z.string(), IrcGroupSchema.optional()).optional(),
77
85
  channels: z.array(z.string()).optional(),
78
86
  mentionPatterns: z.array(z.string()).optional(),
79
87
  markdown: MarkdownConfigSchema,
80
88
  ...ReplyRuntimeConfigSchemaShape
81
- }).strict(), {
82
- optionalAccount: true,
83
- refine: (value, ctx) => {
84
- requireOpenAllowFrom({
85
- policy: value.dmPolicy,
86
- allowFrom: value.allowFrom,
87
- ctx,
88
- path: ["allowFrom"],
89
- message: "channels.irc.dmPolicy=\"open\" requires channels.irc.allowFrom to include \"*\""
90
- });
91
- }
92
- }), { uiHints: ircChannelConfigUiHints });
89
+ }).strict();
90
+ const IrcAccountSchema = IrcAccountSchemaBase.superRefine((value, ctx) => {
91
+ requireOpenAllowFrom({
92
+ policy: value.dmPolicy,
93
+ allowFrom: value.allowFrom,
94
+ ctx,
95
+ path: ["allowFrom"],
96
+ message: "channels.irc.dmPolicy=\"open\" requires channels.irc.allowFrom to include \"*\""
97
+ });
98
+ });
99
+ const IrcConfigSchema = IrcAccountSchemaBase.extend({
100
+ accounts: z.record(z.string(), IrcAccountSchema.optional()).optional(),
101
+ defaultAccount: z.string().optional()
102
+ }).superRefine((value, ctx) => {
103
+ requireOpenAllowFrom({
104
+ policy: value.dmPolicy,
105
+ allowFrom: value.allowFrom,
106
+ ctx,
107
+ path: ["allowFrom"],
108
+ message: "channels.irc.dmPolicy=\"open\" requires channels.irc.allowFrom to include \"*\""
109
+ });
110
+ });
111
+ const IrcChannelConfigSchema = buildChannelConfigSchema(IrcConfigSchema, { uiHints: ircChannelConfigUiHints });
93
112
  //#endregion
94
113
  export { IrcChannelConfigSchema as t };