@openclaw/irc 2026.7.2-beta.7 → 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 { a as resolveIrcGroupRequireMention, c as buildIrcAllowlistCandidates, d as buildIrcConnectOptions, f as connectIrcClient, i as resolveIrcGroupMatch, l as isChannelTarget, m as parseIrcPrefix, o as sanitizeIrcAssistantText, p as parseIrcLine, s as sendMessageIrc, u as normalizeIrcAllowEntry, v as resolveIrcAccount } from "./channel-DoavT7vs.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, createChannelIngressError, 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
@@ -108,10 +107,7 @@ function routeDescriptorsForIrcGroup(params) {
108
107
  }
109
108
  async function deliverIrcReply(params) {
110
109
  await deliverFormattedTextWithAttachments({
111
- payload: {
112
- ...params.payload,
113
- text: sanitizeIrcAssistantText(params.payload.text ?? "")
114
- },
110
+ payload: params.payload,
115
111
  send: async ({ text, replyToId }) => {
116
112
  if (params.sendReply) await params.sendReply(params.target, text, replyToId);
117
113
  else await sendMessageIrc(params.target, text, {
@@ -124,7 +120,7 @@ async function deliverIrcReply(params) {
124
120
  });
125
121
  }
126
122
  async function handleIrcInbound(params) {
127
- const { message, account, config, runtime, connectedNick, statusSink, turnAdoptionLifecycle } = params;
123
+ const { message, account, config, runtime, connectedNick, statusSink } = params;
128
124
  const core = getIrcRuntime();
129
125
  const pairing = createChannelPairingController({
130
126
  core,
@@ -132,11 +128,7 @@ async function handleIrcInbound(params) {
132
128
  accountId: account.accountId
133
129
  });
134
130
  const rawBody = message.text?.trim() ?? "";
135
- if (!rawBody) return { kind: "completed" };
136
- if (turnAdoptionLifecycle?.abortSignal.aborted) return {
137
- kind: "failed-retryable",
138
- error: turnAdoptionLifecycle.abortSignal.reason
139
- };
131
+ if (!rawBody) return;
140
132
  statusSink?.({ lastInboundAt: message.timestamp });
141
133
  const senderDisplay = message.senderHost ? `${message.senderNick}!${message.senderUser ?? "?"}@${message.senderHost}` : message.senderNick;
142
134
  const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
@@ -167,9 +159,9 @@ async function handleIrcInbound(params) {
167
159
  const mentionNick = connectedNick?.trim() || account.nick;
168
160
  const explicitMentionRegex = mentionNick ? new RegExp(`\\b${escapeIrcRegexLiteral(mentionNick)}\\b[:,]?`, "i") : null;
169
161
  const wasMentioned = core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) || (explicitMentionRegex ? explicitMentionRegex.test(rawBody) : false);
170
- const requireMention = message.isGroup ? resolveIrcGroupRequireMention({
171
- groups: account.config.groups,
172
- target: message.target
162
+ const requireMention = message.isGroup ? resolveIrcRequireMention({
163
+ groupConfig: groupMatch.groupConfig,
164
+ wildcardConfig: groupMatch.wildcardConfig
173
165
  }) : false;
174
166
  const routeGroupAllowFrom = normalizeStringEntries(groupMatch.groupConfig?.allowFrom?.length ? groupMatch.groupConfig.allowFrom : groupMatch.wildcardConfig?.allowFrom);
175
167
  const accessGroupPolicy = groupPolicy === "open" && (hasEntries(account.config.groupAllowFrom) || hasEntries(routeGroupAllowFrom)) ? "allowlist" : groupPolicy;
@@ -236,11 +228,11 @@ async function handleIrcInbound(params) {
236
228
  }
237
229
  });
238
230
  runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
239
- return { kind: "completed" };
231
+ return;
240
232
  }
241
233
  if (access.ingress.admission === "skip") {
242
234
  runtime.log?.(`irc: drop channel ${message.target} (missing-mention)`);
243
- return { kind: "completed" };
235
+ return;
244
236
  }
245
237
  if (access.ingress.admission !== "dispatch") {
246
238
  if (message.isGroup && access.ingress.decisiveGateId === "command" && access.commandAccess.shouldBlockControlCommand) {
@@ -250,106 +242,69 @@ async function handleIrcInbound(params) {
250
242
  reason: "control command (unauthorized)",
251
243
  target: senderDisplay
252
244
  });
253
- return { kind: "completed" };
245
+ return;
254
246
  }
255
- if (message.isGroup) if (access.routeAccess.reason === "channel_not_allowlisted") runtime.log?.(`irc: drop channel ${message.target} (not allowlisted)`);
256
- else if (access.routeAccess.reason === "channel_disabled") runtime.log?.(`irc: drop channel ${message.target} (disabled)`);
257
- else runtime.log?.(`irc: drop group sender ${senderDisplay} (policy=${groupPolicy})`);
258
- else runtime.log?.(`irc: drop DM sender ${senderDisplay} (dmPolicy=${dmPolicy})`);
259
- 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;
260
253
  }
261
- if (turnAdoptionLifecycle?.abortSignal.aborted) return {
262
- kind: "failed-retryable",
263
- error: turnAdoptionLifecycle.abortSignal.reason
264
- };
265
254
  const channelTarget = message.target.startsWith("#") || message.target.startsWith("&") ? message.target : `#${message.target}`;
266
255
  const peerId = message.isGroup ? channelTarget : message.senderNick;
267
- const { route, buildEnvelope } = resolveChannelInboundRouteEnvelope({
256
+ const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
268
257
  cfg: config,
269
258
  channel: CHANNEL_ID,
270
259
  accountId: account.accountId,
271
260
  peer: {
272
261
  kind: message.isGroup ? "group" : "direct",
273
262
  id: peerId
274
- }
263
+ },
264
+ runtime: core.channel,
265
+ sessionStore: config.session?.store
275
266
  });
276
267
  const fromLabel = message.isGroup ? message.target : senderDisplay;
277
- const body = buildEnvelope({
268
+ const { storePath, body } = buildEnvelope({
278
269
  channel: "IRC",
279
270
  from: fromLabel,
280
271
  timestamp: message.timestamp,
281
272
  body: rawBody
282
273
  });
283
274
  const groupSystemPrompt = normalizeOptionalString(groupMatch.groupConfig?.systemPrompt);
284
- const blockStreamingEnabled = resolveChannelStreamingBlockEnabled(account.config);
285
- const ctxPayload = buildChannelInboundEventContext({
286
- channel: CHANNEL_ID,
287
- accountId: route.accountId,
288
- messageId: message.messageId,
289
- timestamp: message.timestamp,
290
- from: message.isGroup ? `channel:${channelTarget}` : `irc:${senderDisplay}`,
291
- sender: {
292
- id: senderDisplay,
293
- name: message.senderNick || void 0
294
- },
295
- conversation: {
296
- kind: message.isGroup ? "group" : "direct",
297
- id: peerId,
298
- label: fromLabel
299
- },
300
- route: {
301
- agentId: route.agentId,
302
- dmScope: route.dmScope,
303
- accountId: route.accountId,
304
- routeSessionKey: route.sessionKey
305
- },
306
- reply: {
307
- to: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`,
308
- originatingTo: message.isGroup ? `channel:${channelTarget}` : `irc:${peerId}`
309
- },
310
- message: {
311
- body,
312
- bodyForAgent: rawBody,
313
- rawBody,
314
- commandBody: rawBody
315
- },
316
- access: {
317
- commands: { authorized: commandAuthorized },
318
- mentions: {
319
- canDetectMention: message.isGroup,
320
- wasMentioned
321
- }
322
- },
323
- extra: {
324
- GroupSubject: message.isGroup ? message.target : void 0,
325
- GroupSystemPrompt: message.isGroup ? groupSystemPrompt : void 0
326
- }
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
327
297
  });
328
- const ingressState = { handoff: "none" };
329
- const trackedIngressLifecycle = turnAdoptionLifecycle ? {
330
- ...turnAdoptionLifecycle,
331
- onAdopted: async () => {
332
- ingressState.handoff = "adopted";
333
- await turnAdoptionLifecycle.onAdopted();
334
- },
335
- onDeferred: () => {
336
- ingressState.handoff = "deferred";
337
- turnAdoptionLifecycle.onDeferred();
338
- },
339
- onAbandoned: async () => {
340
- ingressState.handoff = "abandoned";
341
- await turnAdoptionLifecycle.onAbandoned();
342
- }
343
- } : void 0;
344
- await core.channel.inbound.dispatch({
298
+ await core.channel.inbound.dispatchReply({
345
299
  cfg: config,
346
300
  channel: CHANNEL_ID,
347
301
  accountId: account.accountId,
348
- route: {
349
- agentId: route.agentId,
350
- sessionKey: route.sessionKey
351
- },
302
+ agentId: route.agentId,
303
+ routeSessionKey: route.sessionKey,
304
+ storePath,
352
305
  ctxPayload,
306
+ recordInboundSession: core.channel.session.recordInboundSession,
307
+ dispatchReplyWithBufferedBlockDispatcher: core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
353
308
  delivery: {
354
309
  deliver: async (payload) => {
355
310
  await deliverIrcReply({
@@ -367,159 +322,30 @@ async function handleIrcInbound(params) {
367
322
  },
368
323
  replyPipeline: {},
369
324
  replyOptions: {
370
- ...trackedIngressLifecycle ? bindIngressLifecycleToReplyOptions(trackedIngressLifecycle) : {},
371
325
  skillFilter: groupMatch.groupConfig?.skills,
372
- disableBlockStreaming: typeof blockStreamingEnabled === "boolean" ? !blockStreamingEnabled : void 0
326
+ disableBlockStreaming: typeof account.config.blockStreaming === "boolean" ? !account.config.blockStreaming : void 0
373
327
  },
374
328
  record: { onRecordError: (err) => {
375
329
  runtime.error?.(`irc: failed updating session meta: ${String(err)}`);
376
330
  } }
377
331
  });
378
- if (turnAdoptionLifecycle?.abortSignal.aborted && ingressState.handoff === "none") return {
379
- kind: "failed-retryable",
380
- error: turnAdoptionLifecycle.abortSignal.reason
381
- };
382
- if (turnAdoptionLifecycle && ingressState.handoff === "none") {
383
- await turnAdoptionLifecycle.onAdopted();
384
- ingressState.handoff = "adopted";
385
- }
386
- return ingressState.handoff === "deferred" || ingressState.handoff === "abandoned" ? { kind: "deferred" } : { kind: "completed" };
387
332
  }
388
333
  //#endregion
389
- //#region extensions/irc/src/irc-ingress.ts
390
- const IRC_INGRESS_PAYLOAD_VERSION = 1;
391
- const IRC_INGRESS_POLL_INTERVAL_MS = 1e3;
392
- const IrcIngressPayloadError = createChannelIngressError("IrcIngressPayloadError");
393
- function inspectRawPrivmsg(rawLine) {
394
- const line = parseIrcLine(rawLine);
395
- if (!line || line.command !== "PRIVMSG") throw new IrcIngressPayloadError("IRC ingress row is not a PRIVMSG line.");
396
- const rawTarget = line.params[0]?.trim() ?? "";
397
- const text = line.trailing ?? line.params[1] ?? "";
398
- const prefix = parseIrcPrefix(line.prefix);
399
- const senderNick = prefix.nick?.trim() ?? "";
400
- if (!rawTarget || !senderNick || !text.trim()) throw new IrcIngressPayloadError("IRC PRIVMSG line is missing target, sender, or text.");
401
- const isGroup = isChannelTarget(rawTarget);
402
- const target = isGroup ? rawTarget : senderNick;
403
- const lanePeer = normalizeLowercaseStringOrEmpty(target);
404
- return {
405
- laneKey: `${isGroup ? "channel" : "direct"}:${lanePeer}`,
406
- message: {
407
- target,
408
- rawTarget,
409
- senderNick,
410
- senderUser: prefix.user?.trim() || void 0,
411
- senderHost: prefix.host?.trim() || void 0,
412
- text,
413
- isGroup
414
- }
415
- };
416
- }
417
- function decodeIrcIngressPayload(payload, claimedId) {
418
- 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.`);
419
- const validPayload = payload;
420
- return {
421
- version: validPayload.version,
422
- body: {
423
- eventId: validPayload.eventId,
424
- receivedAt: validPayload.receivedAt,
425
- connectionEpoch: validPayload.connectionEpoch,
426
- connectedNick: validPayload.connectedNick,
427
- rawLine: validPayload.rawLine
428
- }
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
429
342
  };
430
- }
431
- function inspectIrcIngress(raw, phase) {
432
- try {
433
- return {
434
- eventId: raw.eventId,
435
- laneKey: inspectRawPrivmsg(raw.rawLine).laneKey
436
- };
437
- } catch (error) {
438
- if (phase === "admission" && error instanceof IrcIngressPayloadError) return {
439
- eventId: raw.eventId,
440
- laneKey: `invalid:${raw.eventId}`
441
- };
442
- throw error;
443
- }
444
- }
445
- function resolveIrcIngressNonRetryableFailure(error) {
446
- return error instanceof IrcIngressPayloadError ? {
447
- reason: "invalid-event",
448
- message: error.message
449
- } : null;
450
- }
451
- function createIrcIngressMonitor(options) {
452
- const monitor = createChannelIngressMonitor({
453
- queue: options.queue ?? (() => getIrcRuntime().state.openChannelIngressQueue({ accountId: options.accountId })),
454
- inspect: (raw, context) => inspectIrcIngress(raw, context.phase),
455
- payload: {
456
- version: IRC_INGRESS_PAYLOAD_VERSION,
457
- serialize: (raw) => raw,
458
- deserialize: (body) => body,
459
- encode: ({ body }) => ({
460
- version: IRC_INGRESS_PAYLOAD_VERSION,
461
- ...body
462
- }),
463
- decode: (payload, { claim }) => decodeIrcIngressPayload(payload, claim.id),
464
- createClaimError: (_kind, claim) => new IrcIngressPayloadError(`IRC ingress row ${claim.id} has invalid metadata.`)
465
- },
466
- deliver: (raw, lifecycle, claim) => {
467
- const message = {
468
- ...inspectRawPrivmsg(raw.rawLine).message,
469
- messageId: claim.id,
470
- timestamp: raw.receivedAt
471
- };
472
- return options.dispatch(message, lifecycle, {
473
- connectedNick: raw.connectedNick.trim(),
474
- connectionEpoch: raw.connectionEpoch.trim()
475
- });
476
- },
477
- pollIntervalMs: options.pollIntervalMs ?? IRC_INGRESS_POLL_INTERVAL_MS,
478
- retention: {
479
- completedMaxEntries: 1e3,
480
- failedMaxEntries: 1e3
481
- },
482
- drain: {
483
- resolveNonRetryableFailure: resolveIrcIngressNonRetryableFailure,
484
- ...options.adoptionStallTimeoutMs === void 0 ? {} : { adoptionStallTimeoutMs: options.adoptionStallTimeoutMs },
485
- onLog: (message) => options.runtime.log?.(`irc ${message}`)
486
- },
487
- createStoppedError: () => /* @__PURE__ */ new Error("IRC ingress is stopped."),
488
- onError: (error) => options.runtime.error?.(`irc ingress drain failed: ${String(error)}`)
489
- });
490
343
  return {
491
- openConnection: (connectionEpoch = randomUUID()) => {
492
- const epoch = connectionEpoch.trim();
493
- if (!epoch) throw new Error("IRC ingress connection epoch is required.");
494
- let sequence = 0;
495
- return {
496
- connectionEpoch: epoch,
497
- accept: (rawLine, connectedNick) => {
498
- if (monitor.isStopped()) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress is stopped."));
499
- sequence += 1;
500
- const eventId = `local:${epoch}:${String(sequence).padStart(12, "0")}`;
501
- const receivedAt = Date.now();
502
- const normalizedNick = connectedNick.trim();
503
- if (!normalizedNick) return Promise.reject(/* @__PURE__ */ new Error("IRC ingress connected nickname is required."));
504
- return monitor.admit({
505
- eventId,
506
- rawLine,
507
- receivedAt,
508
- connectionEpoch: epoch,
509
- connectedNick: normalizedNick
510
- }, { receivedAt }).then(() => void 0);
511
- }
512
- };
513
- },
514
- start: monitor.start,
515
- pause: monitor.pause,
516
- stop: monitor.stop,
517
- waitForIdle: monitor.waitForIdle
344
+ isGroup: false,
345
+ target: params.senderNick.trim() || rawTarget,
346
+ rawTarget
518
347
  };
519
348
  }
520
- //#endregion
521
- //#region extensions/irc/src/monitor.ts
522
- const IRC_MONITOR_RECONNECT_DELAY_MS = 1e3;
523
349
  async function monitorIrcProvider(opts) {
524
350
  const core = getIrcRuntime();
525
351
  const cfg = opts.config ?? core.config.current();
@@ -534,8 +360,6 @@ async function monitorIrcProvider(opts) {
534
360
  accountId: account.accountId
535
361
  });
536
362
  let client = null;
537
- let activeConnectionEpoch = null;
538
- let ingressPause = Promise.resolve();
539
363
  let reconnectTimer = null;
540
364
  let stopped = false;
541
365
  const monitorAbort = new AbortController();
@@ -548,48 +372,6 @@ async function monitorIrcProvider(opts) {
548
372
  removeAbortListener = () => opts.abortSignal?.removeEventListener("abort", forwardAbort);
549
373
  }
550
374
  }
551
- const ingress = createIrcIngressMonitor({
552
- accountId: account.accountId,
553
- runtime,
554
- ...opts.ingressQueue ? { queue: opts.ingressQueue } : {},
555
- dispatch: async (message, turnAdoptionLifecycle, context) => {
556
- const activeClient = client;
557
- if (!activeClient || stopped || monitorAbort.signal.aborted) return {
558
- kind: "failed-retryable",
559
- error: /* @__PURE__ */ new Error("IRC transport disconnected before ingress dispatch.")
560
- };
561
- if (normalizeLowercaseStringOrEmpty(message.senderNick) === normalizeLowercaseStringOrEmpty(context.connectedNick)) return { kind: "completed" };
562
- if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) {
563
- logger.warn?.(`[${account.accountId}] dropping replayed IRC DM after the connection changed`);
564
- return { kind: "completed" };
565
- }
566
- if (opts.onMessage) {
567
- await opts.onMessage(message, activeClient);
568
- return { kind: "completed" };
569
- }
570
- return await handleIrcInbound({
571
- message,
572
- account,
573
- config: cfg,
574
- runtime,
575
- connectedNick: context.connectedNick,
576
- turnAdoptionLifecycle,
577
- sendReply: async (target, text) => {
578
- const replyClient = client;
579
- if (!replyClient || !replyClient.isReady() || stopped || monitorAbort.signal.aborted) throw new Error("IRC transport disconnected before reply send.");
580
- if (!message.isGroup && context.connectionEpoch !== activeConnectionEpoch) throw new Error("IRC connection changed before private reply send.");
581
- replyClient.sendPrivmsg(target, text);
582
- opts.statusSink?.({ lastOutboundAt: Date.now() });
583
- core.channel.activity.record({
584
- channel: "irc",
585
- accountId: account.accountId,
586
- direction: "outbound"
587
- });
588
- },
589
- statusSink: opts.statusSink
590
- });
591
- }
592
- });
593
375
  function scheduleReconnect() {
594
376
  if (stopped || monitorAbort.signal.aborted || reconnectTimer) return;
595
377
  reconnectTimer = setTimeout(() => {
@@ -604,7 +386,6 @@ async function monitorIrcProvider(opts) {
604
386
  }
605
387
  async function connect() {
606
388
  if (stopped || monitorAbort.signal.aborted) return;
607
- const ingressConnection = ingress.openConnection();
608
389
  const nextClient = await connectIrcClient(buildIrcConnectOptions(account, {
609
390
  channels: account.config.channels,
610
391
  abortSignal: monitorAbort.signal,
@@ -619,20 +400,54 @@ async function monitorIrcProvider(opts) {
619
400
  },
620
401
  onDisconnect: () => {
621
402
  if (stopped || monitorAbort.signal.aborted) return;
622
- ingressPause = ingress.pause();
623
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
624
403
  client = null;
625
404
  logger.warn?.(`[${account.accountId}] IRC connection closed; reconnecting in ${IRC_MONITOR_RECONNECT_DELAY_MS}ms`);
626
405
  scheduleReconnect();
627
406
  },
628
407
  onPrivmsg: async (event) => {
629
- await ingressConnection.accept(event.rawLine, event.connectedNick);
630
- 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
+ };
631
425
  core.channel.activity.record({
632
426
  channel: "irc",
633
427
  accountId: account.accountId,
634
428
  direction: "inbound",
635
- 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
636
451
  });
637
452
  }
638
453
  }));
@@ -641,40 +456,20 @@ async function monitorIrcProvider(opts) {
641
456
  return;
642
457
  }
643
458
  client = nextClient;
644
- activeConnectionEpoch = ingressConnection.connectionEpoch;
645
- await ingressPause;
646
- if (client !== nextClient || !nextClient.isReady()) {
647
- if (activeConnectionEpoch === ingressConnection.connectionEpoch) activeConnectionEpoch = null;
648
- return;
649
- }
650
- ingress.start();
651
459
  logger.info(`[${account.accountId}] connected to ${account.host}:${account.port}${account.tls ? " (tls)" : ""} as ${nextClient.nick}`);
652
460
  }
653
- try {
654
- await connect();
655
- } catch (error) {
461
+ await connect();
462
+ return { stop: () => {
463
+ stopped = true;
656
464
  removeAbortListener?.();
657
465
  removeAbortListener = null;
658
- await ingress.stop();
659
- throw error;
660
- }
661
- let stopTask;
662
- return { stop: () => {
663
- stopTask ??= (async () => {
664
- stopped = true;
665
- removeAbortListener?.();
666
- removeAbortListener = null;
667
- if (!monitorAbort.signal.aborted) monitorAbort.abort();
668
- if (reconnectTimer) {
669
- clearTimeout(reconnectTimer);
670
- reconnectTimer = null;
671
- }
672
- client?.quit("shutdown");
673
- client = null;
674
- activeConnectionEpoch = null;
675
- await ingress.stop();
676
- })();
677
- 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;
678
473
  } };
679
474
  }
680
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 };