@qwen-code/channel-dws 0.23.0 → 0.23.1-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -5,14 +5,14 @@
5
5
  */
6
6
  import { createHash, randomUUID } from 'node:crypto';
7
7
  import process from 'node:process';
8
- import { isTerminalTaskLifecycleType, PollingChannelBase, sanitizeLogText, truncateCodePoints, } from '@qwen-code/channel-base';
8
+ import { isTerminalTaskLifecycleType, PollingChannelBase, sanitizeLogText, stripMessagePrefix, truncateCodePoints, } from '@qwen-code/channel-base';
9
9
  import { DwsClient, DwsCommandError, } from './dws-client.js';
10
10
  import { DwsEventProcessError, } from './dws-event-stream.js';
11
11
  const MAX_DOCUMENT_CONTEXT_CHARS = 12_000;
12
12
  const MAX_TODO_CONTEXT_CHARS = 12_000;
13
13
  const MAX_COMMENT_CHARS = 4_000;
14
14
  const MAX_PROCESSED_ITEMS = 5_000;
15
- const MAX_DIRECT_REPLAY_DISPATCHES = 16;
15
+ const MAX_REPLAY_CONVERSATIONS = 16;
16
16
  /**
17
17
  * How many times one inbound message may fail its turn before it is dropped.
18
18
  *
@@ -164,7 +164,8 @@ function isPendingMessage(value) {
164
164
  return false;
165
165
  }
166
166
  const pending = value;
167
- const sourceValid = pending.source?.kind === 'direct' ||
167
+ const sourceValid = pending.source?.kind === 'at' ||
168
+ pending.source?.kind === 'direct' ||
168
169
  pending.source?.kind === 'group-all' ||
169
170
  (pending.source?.kind === 'group' &&
170
171
  typeof pending.source.conversationId === 'string' &&
@@ -173,7 +174,8 @@ function isPendingMessage(value) {
173
174
  if (!sourceValid || typeof message !== 'object' || message === null) {
174
175
  return false;
175
176
  }
176
- return ((message.type === 'user_im_message_receive_o2o' ||
177
+ return ((message.type === 'user_im_message_receive_at' ||
178
+ message.type === 'user_im_message_receive_o2o' ||
177
179
  message.type === 'user_im_message_receive_o2o_all' ||
178
180
  message.type === 'user_im_message_receive_group' ||
179
181
  message.type === 'user_im_message_receive_group_all') &&
@@ -262,6 +264,13 @@ function sourceLabel(source) {
262
264
  return 'all group messages';
263
265
  return 'group messages';
264
266
  }
267
+ function sameImSource(left, right) {
268
+ if (left.kind !== right.kind)
269
+ return false;
270
+ if (left.kind !== 'group')
271
+ return true;
272
+ return right.kind === 'group' && left.conversationId === right.conversationId;
273
+ }
265
274
  function retryLimit(error) {
266
275
  if (!(error instanceof DwsEventProcessError))
267
276
  return 1;
@@ -315,6 +324,7 @@ export class DwsChannel extends PollingChannelBase {
315
324
  userInstructions;
316
325
  client;
317
326
  imStates;
327
+ dwsMessagePrefix;
318
328
  startReactionName;
319
329
  endReactionName;
320
330
  watchTodos;
@@ -325,13 +335,17 @@ export class DwsChannel extends PollingChannelBase {
325
335
  endReactionKeys = new Set();
326
336
  notifiedSenderPairingNotifications = new Set();
327
337
  processingMessages = new Map();
328
- queuedDirectMessages = new Map();
329
- // Replay-started direct dispatches only. The cap must not be consumed by
338
+ queuedMessages = new Map();
339
+ // Replay-started dispatches only. The cap must not be consumed by
330
340
  // live or followup traffic, whose queue entries outlive their turn.
331
- replayDirectDispatches = new Map();
332
- directConversationTails = new Map();
333
- directMessageStartResolvers = new Map();
341
+ replayDispatches = new Map();
342
+ attemptedPendingMessages = new Set();
343
+ conversationTails = new Map();
344
+ messageStartResolvers = new Map();
345
+ processingMessageGenerations = new Map();
334
346
  pendingMessageCapacityWaiters = new Set();
347
+ drainingImSubscriptions = new Set();
348
+ pendingImStartups = new Set();
335
349
  pollAbortController = new AbortController();
336
350
  lifecycleGeneration = 0;
337
351
  connectionStartedAt = 0;
@@ -340,6 +354,7 @@ export class DwsChannel extends PollingChannelBase {
340
354
  notificationWatermarkPulledBack = false;
341
355
  constructor(name, config, bridge, options, client) {
342
356
  const profile = configuredString(config.profile, 'profile');
357
+ const messagePrefix = configuredString(config.messagePrefix, 'messagePrefix');
343
358
  const startReactionName = configuredString(config.startReaction, 'startReaction') ??
344
359
  DEFAULT_START_REACTION;
345
360
  const endReactionName = configuredString(config.endReaction, 'endReaction');
@@ -383,6 +398,7 @@ export class DwsChannel extends PollingChannelBase {
383
398
  }));
384
399
  this.startReactionName = startReactionName;
385
400
  this.endReactionName = endReactionName;
401
+ this.dwsMessagePrefix = messagePrefix;
386
402
  this.watchTodos = watchTodos;
387
403
  }
388
404
  createInitialCursor() {
@@ -464,7 +480,7 @@ export class DwsChannel extends PollingChannelBase {
464
480
  notificationCheckpoint: cursor.notificationCheckpoint,
465
481
  mentionCheckpoint: cursor.mentionCheckpoint,
466
482
  pendingDocumentNotifications: (cursor.pendingDocumentNotifications ?? []).slice(-MAX_PROCESSED_ITEMS),
467
- pendingMessages: (cursor.pendingMessages ?? []).slice(-MAX_PROCESSED_ITEMS),
483
+ pendingMessages: (cursor.pendingMessages ?? []).slice(),
468
484
  processedMessages: cursor.processedMessages.slice(-MAX_PROCESSED_ITEMS),
469
485
  imTargets: cursor.imTargets.slice(-MAX_IM_TARGETS),
470
486
  todosInitialized: cursor.todosInitialized ?? false,
@@ -477,9 +493,13 @@ export class DwsChannel extends PollingChannelBase {
477
493
  if (this.connected)
478
494
  return;
479
495
  const generation = ++this.lifecycleGeneration;
480
- this.connectionStartedAt = Date.now();
481
496
  this.pollAbortController.abort();
482
497
  this.pollAbortController = new AbortController();
498
+ await this.waitForDisconnect();
499
+ if (generation !== this.lifecycleGeneration) {
500
+ throw new Error('DWS channel connection was cancelled.');
501
+ }
502
+ this.connectionStartedAt = Date.now();
483
503
  await this.client.assertCompatible?.(this.pollAbortController.signal);
484
504
  if (generation !== this.lifecycleGeneration) {
485
505
  throw new Error('DWS channel connection was cancelled.');
@@ -539,7 +559,10 @@ export class DwsChannel extends PollingChannelBase {
539
559
  }
540
560
  this.connected = true;
541
561
  try {
542
- await Promise.all(this.imStates.map((state) => this.startImSourceWithRetry(state, generation)));
562
+ const startups = this.imStates.map((state) => this.startImSourceWithRetry(state, generation));
563
+ for (const startup of startups)
564
+ this.trackImStartup(startup);
565
+ await Promise.all(startups);
543
566
  if (generation !== this.lifecycleGeneration || !this.connected) {
544
567
  throw new Error('DWS channel connection was cancelled.');
545
568
  }
@@ -573,12 +596,13 @@ export class DwsChannel extends PollingChannelBase {
573
596
  this.cleanupReaction(key, 'disconnect reaction removal');
574
597
  }
575
598
  this.sessionReactionKeys.clear();
576
- this.queuedDirectMessages.clear();
577
- this.replayDirectDispatches.clear();
578
- for (const resolve of this.directMessageStartResolvers.values())
599
+ this.queuedMessages.clear();
600
+ this.replayDispatches.clear();
601
+ this.attemptedPendingMessages.clear();
602
+ for (const { resolve } of this.messageStartResolvers.values())
579
603
  resolve();
580
- this.directMessageStartResolvers.clear();
581
- this.directConversationTails.clear();
604
+ this.messageStartResolvers.clear();
605
+ this.conversationTails.clear();
582
606
  for (const resolve of this.pendingMessageCapacityWaiters)
583
607
  resolve();
584
608
  this.pendingMessageCapacityWaiters.clear();
@@ -587,11 +611,35 @@ export class DwsChannel extends PollingChannelBase {
587
611
  if (state.retryTimer)
588
612
  clearTimeout(state.retryTimer);
589
613
  state.retryTimer = undefined;
590
- state.subscription?.stop();
614
+ const subscription = state.subscription;
615
+ if (subscription) {
616
+ void this.drainImSubscription(subscription);
617
+ }
591
618
  state.subscription = undefined;
592
619
  state.restartAttempts = 0;
593
620
  }
594
621
  }
622
+ async waitForDisconnect() {
623
+ while (this.pendingImStartups.size > 0 ||
624
+ this.drainingImSubscriptions.size > 0) {
625
+ await Promise.all([
626
+ ...this.pendingImStartups,
627
+ ...this.drainingImSubscriptions,
628
+ ]);
629
+ }
630
+ }
631
+ trackImStartup(startup) {
632
+ const settled = startup.then(() => undefined, () => undefined);
633
+ this.pendingImStartups.add(settled);
634
+ void settled.then(() => this.pendingImStartups.delete(settled));
635
+ }
636
+ drainImSubscription(subscription) {
637
+ const drain = subscription.closed.then(() => undefined, () => undefined);
638
+ this.drainingImSubscriptions.add(drain);
639
+ void drain.then(() => this.drainingImSubscriptions.delete(drain));
640
+ subscription.stop();
641
+ return drain;
642
+ }
595
643
  supportsProactiveSend() {
596
644
  return true;
597
645
  }
@@ -774,6 +822,7 @@ export class DwsChannel extends PollingChannelBase {
774
822
  const signal = this.pollAbortController.signal;
775
823
  if (!this.connected || signal.aborted)
776
824
  return;
825
+ this.attemptedPendingMessages.clear();
777
826
  const endTime = Date.now();
778
827
  await this.replayPendingMessages(signal);
779
828
  if (signal.aborted || !this.connected)
@@ -793,8 +842,16 @@ export class DwsChannel extends PollingChannelBase {
793
842
  for (const message of mentions.messages) {
794
843
  if (signal.aborted || !this.connected)
795
844
  return;
796
- await this.receiveImMessage({ kind: 'at' }, message, true);
845
+ const key = messageKey(message);
846
+ if (this.cursor.processedMessages.includes(key))
847
+ continue;
848
+ if (this.hasPendingMessage(key))
849
+ continue;
850
+ this.enqueuePendingConversation(message.conversationId);
851
+ await this.admitHistoryMessage({ kind: 'at' }, message);
797
852
  }
853
+ if (signal.aborted || !this.connected)
854
+ return;
798
855
  if (mentions.nextCursor) {
799
856
  this.cursor.mentionCheckpoint = {
800
857
  ...mentionCheckpoint,
@@ -833,13 +890,14 @@ export class DwsChannel extends PollingChannelBase {
833
890
  // shared retry budget twice per poll.
834
891
  if (this.hasPendingMessage(key))
835
892
  continue;
836
- await this.receiveDirectMessage(message, true).admitted;
893
+ this.enqueuePendingConversation(message.conversationId);
894
+ await this.admitHistoryMessage({ kind: 'direct' }, message);
837
895
  }
838
896
  if (signal.aborted || !this.connected)
839
897
  return;
840
898
  if (this.notificationWatermarkPulledBack) {
841
899
  // R4-4: a stale direct message replayed while this window's
842
- // fetch was in flight, and `admitReceivedDirectMessage` pulled the
900
+ // fetch was in flight, and `admitReceivedMessage` pulled the
843
901
  // watermark back
844
902
  // to cover it. That replay was left UNMARKED on purpose for history
845
903
  // polling, so finishing this window normally would undo the rescue:
@@ -958,6 +1016,7 @@ export class DwsChannel extends PollingChannelBase {
958
1016
  messageId: `todo-${fingerprint}`,
959
1017
  text: `Process this DingTalk todo:\n${truncateCodePoints(title, MAX_COMMENT_CHARS)}`,
960
1018
  displayText: title,
1019
+ bypassMessagePrefix: true,
961
1020
  isGroup: true,
962
1021
  isMentioned: true,
963
1022
  isReplyToBot: false,
@@ -1001,19 +1060,21 @@ export class DwsChannel extends PollingChannelBase {
1001
1060
  this.saveCursor();
1002
1061
  }
1003
1062
  async startImSource(state, generation) {
1063
+ const connectionStartedAt = this.connectionStartedAt;
1004
1064
  const subscription = await this.client.subscribeToIm(state.source, (message) => {
1005
1065
  state.lastError = undefined;
1006
1066
  state.restartAttempts = 0;
1007
- return state.source.kind === 'direct'
1008
- ? this.receiveDirectMessage(message)
1009
- : this.receiveImMessage(state.source, message);
1067
+ return this.receiveImMessage(state.source, message, false, false, {
1068
+ generation,
1069
+ connectionStartedAt,
1070
+ });
1010
1071
  }, (error) => {
1011
1072
  if (error instanceof DwsEventProcessError)
1012
1073
  state.lastError = error;
1013
1074
  this.logImError(state.source, error);
1014
1075
  });
1015
1076
  if (!this.connected || generation !== this.lifecycleGeneration) {
1016
- subscription.stop();
1077
+ await this.drainImSubscription(subscription);
1017
1078
  return;
1018
1079
  }
1019
1080
  state.lastError = undefined;
@@ -1092,45 +1153,27 @@ export class DwsChannel extends PollingChannelBase {
1092
1153
  if (!this.connected)
1093
1154
  return;
1094
1155
  state.lastError = undefined;
1095
- void this.startImSource(state, this.lifecycleGeneration).catch((error) => {
1156
+ const startup = this.startImSource(state, this.lifecycleGeneration).catch((error) => {
1096
1157
  const resolvedError = error instanceof Error ? error : new Error(String(error));
1097
1158
  this.logImError(state.source, resolvedError);
1098
1159
  this.scheduleImRestart(state, resolvedError instanceof DwsEventProcessError
1099
1160
  ? resolvedError
1100
1161
  : undefined);
1101
1162
  });
1163
+ this.trackImStartup(startup);
1102
1164
  }, delay);
1103
1165
  state.retryTimer.unref?.();
1104
1166
  }
1105
1167
  logImError(source, error) {
1106
1168
  process.stderr.write(`[Channel:${this.name}] DWS ${sanitizeLogText(sourceLabel(source), 120)} stream error: ${sanitizeLogText(error.message, 300)}\n`);
1107
1169
  }
1108
- async receiveImMessage(source, message, fromHistory = false) {
1109
- if (!this.connected)
1110
- return;
1111
- if (this.markSelfMessageProcessed(message))
1112
- return;
1113
- if (this.isStaleLiveMessage(message, fromHistory) &&
1114
- message.eventTime !== undefined) {
1115
- this.markProcessedMessage(messageKey(message));
1116
- this.saveCursor();
1117
- return;
1118
- }
1119
- if (source.kind === 'group-all' &&
1120
- (this.config.groups[message.conversationId]?.requireMention ??
1121
- this.config.groups['*']?.requireMention ??
1122
- true)) {
1123
- return;
1124
- }
1125
- if ((source.kind === 'group' || source.kind === 'group-all') &&
1126
- this.config.groupPolicy === 'pairing' &&
1127
- !this.groupGate.isGroupApproved(message.conversationId)) {
1128
- return;
1129
- }
1130
- await this.dispatchImMessage(source, message, messageKey(message));
1170
+ requiresMention(conversationId) {
1171
+ return (this.config.groups[conversationId]?.requireMention ??
1172
+ this.config.groups['*']?.requireMention ??
1173
+ true);
1131
1174
  }
1132
- receiveDirectMessage(message, fromHistory = false, reportFailure = fromHistory) {
1133
- const admission = this.admitReceivedDirectMessage(message, fromHistory, reportFailure);
1175
+ receiveImMessage(source, message, fromHistory = false, reportFailure = fromHistory, admissionContext) {
1176
+ const admission = this.admitReceivedMessage(source, message, fromHistory, reportFailure, admissionContext);
1134
1177
  const completed = admission.then(({ completion }) => completion);
1135
1178
  void completed.catch(() => undefined);
1136
1179
  return {
@@ -1138,68 +1181,145 @@ export class DwsChannel extends PollingChannelBase {
1138
1181
  completed,
1139
1182
  };
1140
1183
  }
1141
- async admitReceivedDirectMessage(message, fromHistory, reportFailure) {
1142
- if (!this.connected)
1143
- return { completion: Promise.resolve() };
1184
+ async admitReceivedMessage(source, message, fromHistory, reportFailure, admissionContext) {
1185
+ const isAmbientSource = source.kind === 'group' || source.kind === 'group-all';
1186
+ const isCurrentLifecycle = this.connected &&
1187
+ (admissionContext === undefined ||
1188
+ admissionContext.generation === this.lifecycleGeneration);
1189
+ if (!isCurrentLifecycle && (!admissionContext || !isAmbientSource)) {
1190
+ return { completion: Promise.resolve(), remembered: true };
1191
+ }
1144
1192
  const key = messageKey(message);
1145
1193
  if (this.markSelfMessageProcessed(message)) {
1146
- return { completion: Promise.resolve() };
1194
+ return { completion: Promise.resolve(), remembered: true };
1147
1195
  }
1148
- if (this.isStaleLiveMessage(message, fromHistory) &&
1196
+ if (this.isStaleLiveMessage(message, fromHistory, admissionContext?.connectionStartedAt) &&
1149
1197
  message.eventTime !== undefined) {
1150
- // A replayed direct message is left UNMARKED on purpose, for history
1151
- // polling to pick up. That only works if polling will ever look
1152
- // that far back: on a fresh cursor `notificationWatermark` starts at
1153
- // `connectionStartedAt` and the query window opens at `watermark − 5s`
1154
- // — exactly this branch's drop boundary — so every message this branch
1155
- // parks was strictly outside every window the watermark would ever
1156
- // produce, now and forever, since the window only moves forward. Pull
1157
- // the watermark back to cover what we just declined to handle.
1158
- this.cursor.notificationWatermark = Math.min(this.cursor.notificationWatermark ?? this.connectionStartedAt, message.eventTime);
1159
- this.notificationWatermarkPulledBack = true;
1160
- // R6-1: the flag alone only covers a replay that lands while a poll's
1161
- // fetch is in flight — `pollOnce` clears it before every fetch, so a
1162
- // pullback arriving BETWEEN two polls is reset before it is ever read.
1163
- // A persisted multi-page checkpoint would then resume its own window,
1164
- // whose `startTime` is already past this replay, and finish by setting
1165
- // the watermark to `checkpoint.endTime` — pulling it forward again over
1166
- // a replay no window will ever cover. Drop the checkpoint here too, so
1167
- // the pullback survives regardless of when it arrived.
1168
- this.cursor.notificationCheckpoint = undefined;
1169
- process.stderr.write(`[Channel:${this.name}] parked a stale direct message for history polling and pulled the watermark back to ${message.eventTime}: ${sanitizeLogText(message.messageId, 120)}\n`);
1198
+ if (source.kind === 'direct') {
1199
+ this.parkStaleDirectMessage(message);
1200
+ return { completion: Promise.resolve(), remembered: true };
1201
+ }
1202
+ this.markProcessedMessage(key);
1170
1203
  this.saveCursor();
1171
- return { completion: Promise.resolve() };
1204
+ return { completion: Promise.resolve(), remembered: true };
1205
+ }
1206
+ if (this.shouldFilterImMessage(source, message)) {
1207
+ if (source.kind === 'group' || source.kind === 'group-all') {
1208
+ const text = stripMessagePrefix(message.content.trim(), this.dwsMessagePrefix);
1209
+ if (!this.dwsMessagePrefix || text) {
1210
+ const envelope = this.createImEnvelope(source, message, text);
1211
+ if (this.groupGate.check(envelope, { createPairingRequest: false })
1212
+ .reason === 'mention_required' &&
1213
+ !this.queuedMessages.has(key) &&
1214
+ !this.cursor.processedMessages.includes(key) &&
1215
+ !this.hasPendingMessage(key)) {
1216
+ this.recordPendingGroupHistory(envelope);
1217
+ }
1218
+ }
1219
+ }
1220
+ this.removePersistedPendingMessageForSource(key, source);
1221
+ return { completion: Promise.resolve(), remembered: true };
1222
+ }
1223
+ if (!isCurrentLifecycle) {
1224
+ if (this.cursor.processedMessages.includes(key)) {
1225
+ this.removePersistedPendingMessage(key);
1226
+ return { completion: Promise.resolve(), remembered: true };
1227
+ }
1228
+ this.rememberDrainingPendingMessage(source, message);
1229
+ return { completion: Promise.resolve(), remembered: true };
1230
+ }
1231
+ return this.admitMessage(source, message, key, reportFailure);
1232
+ }
1233
+ async admitHistoryMessage(source, message) {
1234
+ const { completion, remembered } = await this.admitReceivedMessage(source, message, true, true);
1235
+ if (remembered) {
1236
+ void completion.catch(() => undefined);
1237
+ return;
1172
1238
  }
1173
- return this.admitDirectMessage({ kind: 'direct' }, message, key, reportFailure);
1239
+ await completion;
1240
+ }
1241
+ shouldFilterImMessage(source, message) {
1242
+ const groupConfig = this.config.groups[message.conversationId] ?? this.config.groups['*'];
1243
+ if ((source.kind === 'group' || source.kind === 'group-all') &&
1244
+ (groupConfig?.requireMention ?? true)) {
1245
+ return true;
1246
+ }
1247
+ return ((source.kind === 'group' || source.kind === 'group-all') &&
1248
+ this.config.groupPolicy === 'pairing' &&
1249
+ !this.groupGate.isGroupApproved(message.conversationId));
1250
+ }
1251
+ parkStaleDirectMessage(message) {
1252
+ // A replayed direct message is left UNMARKED on purpose, for history
1253
+ // polling to pick up. Pull the watermark back because the normal window
1254
+ // starts after this message and only moves forward.
1255
+ this.cursor.notificationWatermark = Math.min(this.cursor.notificationWatermark ?? this.connectionStartedAt, message.eventTime);
1256
+ this.notificationWatermarkPulledBack = true;
1257
+ // R6-1: also drop a persisted multi-page checkpoint so a replay that
1258
+ // arrives between polls cannot be skipped when that checkpoint resumes.
1259
+ this.cursor.notificationCheckpoint = undefined;
1260
+ process.stderr.write(`[Channel:${this.name}] parked a stale direct message for history polling and pulled the watermark back to ${message.eventTime}: ${sanitizeLogText(message.messageId, 120)}\n`);
1261
+ this.saveCursor();
1174
1262
  }
1175
1263
  markSelfMessageProcessed(message) {
1176
1264
  if (!this.isSelfMessage(message))
1177
1265
  return false;
1178
- this.markProcessedMessage(messageKey(message));
1266
+ const key = messageKey(message);
1267
+ this.markProcessedMessage(key);
1268
+ this.removePendingMessage(key);
1179
1269
  this.saveCursor();
1180
1270
  return true;
1181
1271
  }
1182
- isStaleLiveMessage(message, fromHistory) {
1272
+ isStaleLiveMessage(message, fromHistory, connectionStartedAt = this.connectionStartedAt) {
1183
1273
  return (!fromHistory &&
1184
1274
  message.eventTime !== undefined &&
1185
- message.eventTime < this.connectionStartedAt - 5_000);
1275
+ message.eventTime < connectionStartedAt - 5_000);
1186
1276
  }
1187
- async admitDirectMessage(source, message, key, reportFailure) {
1277
+ async admitMessage(source, message, key, reportFailure) {
1188
1278
  if (this.cursor.processedMessages.includes(key)) {
1189
1279
  this.removePersistedPendingMessage(key);
1190
- return { completion: Promise.resolve() };
1280
+ return { completion: Promise.resolve(), remembered: true };
1191
1281
  }
1282
+ if (!reportFailure) {
1283
+ this.enqueuePendingConversation(message.conversationId);
1284
+ }
1285
+ let remembered = true;
1192
1286
  if (!this.hasPendingMessage(key)) {
1193
- if (!(await this.rememberPendingMessage(source, message))) {
1194
- return { completion: Promise.resolve() };
1287
+ const dispatchUnparkedAtCapacity = (source.kind === 'at' || source.kind === 'direct') &&
1288
+ this.connected &&
1289
+ (this.cursor.pendingMessages?.length ?? 0) >= MAX_PROCESSED_ITEMS;
1290
+ remembered = dispatchUnparkedAtCapacity
1291
+ ? false
1292
+ : source.kind === 'group' || source.kind === 'group-all'
1293
+ ? await this.rememberPendingMessageWhenAvailable(source, message)
1294
+ : await this.rememberPendingMessage(source, message);
1295
+ if (!remembered) {
1296
+ if (!dispatchUnparkedAtCapacity) {
1297
+ return { completion: Promise.resolve(), remembered };
1298
+ }
1195
1299
  }
1196
1300
  }
1197
1301
  return {
1198
- completion: this.scheduleDirectMessage(source, message, key, reportFailure),
1302
+ completion: this.scheduleMessage(source, message, key, reportFailure),
1303
+ remembered,
1199
1304
  };
1200
1305
  }
1201
- scheduleDirectMessage(source, message, key, reportFailure) {
1202
- const queued = this.queuedDirectMessages.get(key);
1306
+ enqueuePendingConversation(conversationId) {
1307
+ for (const pending of [...(this.cursor.pendingMessages ?? [])]) {
1308
+ if (pending.message.conversationId !== conversationId)
1309
+ continue;
1310
+ const key = messageKey(pending.message);
1311
+ if (this.attemptedPendingMessages.has(key))
1312
+ continue;
1313
+ if (this.queuedMessages.has(key))
1314
+ continue;
1315
+ const dispatch = this.receiveImMessage(pending.source, pending.message, true, true);
1316
+ void dispatch.admitted.catch((error) => {
1317
+ process.stderr.write(`[Channel:${this.name}] pending DWS message remains degraded: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
1318
+ });
1319
+ }
1320
+ }
1321
+ scheduleMessage(source, message, key, reportFailure) {
1322
+ const queued = this.queuedMessages.get(key);
1203
1323
  if (queued)
1204
1324
  return queued;
1205
1325
  if (this.cursor.processedMessages.includes(key)) {
@@ -1211,11 +1331,11 @@ export class DwsChannel extends PollingChannelBase {
1211
1331
  try {
1212
1332
  if (!this.connected || generation !== this.lifecycleGeneration)
1213
1333
  return;
1214
- await this.dispatchImMessage(source, message, key);
1334
+ await this.dispatchImMessage(source, message, key, generation);
1215
1335
  }
1216
1336
  finally {
1217
- if (this.queuedDirectMessages.get(key) === task) {
1218
- this.queuedDirectMessages.delete(key);
1337
+ if (this.queuedMessages.get(key) === task) {
1338
+ this.queuedMessages.delete(key);
1219
1339
  }
1220
1340
  }
1221
1341
  };
@@ -1223,24 +1343,30 @@ export class DwsChannel extends PollingChannelBase {
1223
1343
  const started = new Promise((resolve) => {
1224
1344
  resolveStarted = resolve;
1225
1345
  });
1226
- const previous = this.directConversationTails.get(message.conversationId);
1346
+ const previous = this.conversationTails.get(message.conversationId);
1347
+ const groupConfig = source.kind === 'direct'
1348
+ ? undefined
1349
+ : (this.config.groups[message.conversationId] ??
1350
+ this.config.groups['*']);
1227
1351
  // Live turns only need FIFO through ChannelBase registration; replay and
1228
1352
  // followup turns must wait for the prior turn to finish.
1229
- const predecessor = reportFailure || this.config.dispatchMode === 'followup'
1353
+ const predecessor = reportFailure ||
1354
+ (groupConfig?.dispatchMode ?? this.config.dispatchMode) === 'followup'
1230
1355
  ? previous?.completed
1231
1356
  : previous?.started;
1232
1357
  const task = predecessor
1233
1358
  ? predecessor.catch(() => undefined).then(dispatch)
1234
1359
  : Promise.resolve().then(dispatch);
1235
1360
  const tail = { started, completed: task };
1236
- this.queuedDirectMessages.set(key, task);
1237
- this.directMessageStartResolvers.set(key, resolveStarted);
1238
- this.directConversationTails.set(message.conversationId, tail);
1361
+ this.queuedMessages.set(key, task);
1362
+ const startResolver = { generation, resolve: resolveStarted };
1363
+ this.messageStartResolvers.set(key, startResolver);
1364
+ this.conversationTails.set(message.conversationId, tail);
1239
1365
  void task
1240
1366
  .finally(() => {
1241
- this.releaseDirectMessageStart(message.conversationId, message.messageId);
1242
- if (this.directConversationTails.get(message.conversationId) === tail) {
1243
- this.directConversationTails.delete(message.conversationId);
1367
+ this.releaseMessageStart(message.conversationId, message.messageId, startResolver);
1368
+ if (this.conversationTails.get(message.conversationId) === tail) {
1369
+ this.conversationTails.delete(message.conversationId);
1244
1370
  }
1245
1371
  })
1246
1372
  .catch(() => undefined);
@@ -1251,25 +1377,33 @@ export class DwsChannel extends PollingChannelBase {
1251
1377
  }
1252
1378
  return task;
1253
1379
  }
1254
- async dispatchImMessage(source, message, key) {
1380
+ async dispatchImMessage(source, message, key, generation) {
1255
1381
  let waitedOnInFlight = false;
1382
+ let waitedOnCurrentGeneration = false;
1256
1383
  let inFlightError;
1257
1384
  while (true) {
1258
1385
  const existing = this.processingMessages.get(key);
1259
1386
  if (!existing)
1260
1387
  break;
1261
1388
  waitedOnInFlight = true;
1389
+ if (this.processingMessageGenerations.get(key) === generation) {
1390
+ waitedOnCurrentGeneration = true;
1391
+ }
1262
1392
  inFlightError = await existing.then(() => undefined, (error) => error);
1263
1393
  }
1394
+ if (!this.connected || generation !== this.lifecycleGeneration)
1395
+ return;
1264
1396
  if (this.cursor.processedMessages.includes(key))
1265
1397
  return;
1266
- // The in-flight turn failed and parked the message while this caller
1267
- // waited; replay already re-drives parked entries every poll, so a new
1268
- // turn here would spend the retry budget twice in one poll. Rethrow
1269
- // instead of returning so `replayPendingMessages` keeps the entry — a
1270
- // normal return there deletes it.
1271
- if (waitedOnInFlight && this.hasPendingMessage(key))
1398
+ // A duplicate in the same lifecycle must not spend the retry budget twice
1399
+ // in one poll. A replay from a later reconnect generation is different:
1400
+ // it owns the persisted retry and must run before newer conversation work.
1401
+ if (waitedOnInFlight &&
1402
+ waitedOnCurrentGeneration &&
1403
+ this.hasPendingMessage(key)) {
1272
1404
  throw inFlightError;
1405
+ }
1406
+ this.processingMessageGenerations.set(key, generation);
1273
1407
  const task = this.processImMessage(source, message, key);
1274
1408
  this.processingMessages.set(key, task);
1275
1409
  try {
@@ -1282,29 +1416,83 @@ export class DwsChannel extends PollingChannelBase {
1282
1416
  finally {
1283
1417
  if (this.processingMessages.get(key) === task) {
1284
1418
  this.processingMessages.delete(key);
1419
+ this.processingMessageGenerations.delete(key);
1285
1420
  }
1286
1421
  }
1287
1422
  }
1288
1423
  async processImMessage(source, message, key) {
1424
+ // DWS reports only that the bot was mentioned somewhere, not which token is
1425
+ // the bot, so a leading mention goes only when what follows is unambiguously
1426
+ // a slash command and no later mention could be the one that caused the
1427
+ // at-event. That scan shares the document-mention parser's boundary
1428
+ // heuristic: an `@` glued to a word is an address like `bob@example.com`,
1429
+ // one behind punctuation is a rival mention. The command lookahead must stay
1430
+ // ahead of the whole-remainder scan; swapping them re-runs it at every
1431
+ // backtracked space, quadratic on attacker-controlled content.
1432
+ //
1433
+ // Mention-required conversations only: an ambient group delivers one message
1434
+ // on both the at stream and its group stream under a single dedup key, and
1435
+ // only when a mention is required is the at stream the sole deliverer, so
1436
+ // the normalized text cannot depend on which copy won the race.
1437
+ const rawText = source.kind === 'at' && this.requiresMention(message.conversationId)
1438
+ ? message.content
1439
+ .replace(/^\s*@[^\s\p{Cf}]+\s+(?=\/[a-zA-Z0-9_:-]+(?:\s|$))(?![\s\S]*(?<![A-Za-z0-9_])@)/u, '')
1440
+ .trim()
1441
+ : message.content.trim();
1442
+ const providerDocumentNotification = source.kind === 'direct'
1443
+ ? parseDocumentMentionNotification(rawText)
1444
+ : undefined;
1445
+ const text = providerDocumentNotification
1446
+ ? rawText
1447
+ : stripMessagePrefix(rawText, this.dwsMessagePrefix);
1448
+ if (this.dwsMessagePrefix && !text) {
1449
+ this.markProcessedMessage(key);
1450
+ this.saveCursor();
1451
+ return;
1452
+ }
1289
1453
  const target = source.kind === 'direct'
1290
1454
  ? { kind: 'direct', openDingTalkId: message.senderId }
1291
1455
  : { kind: 'group', conversationId: message.conversationId };
1292
1456
  this.rememberImTarget(message.conversationId, target);
1293
- const text = message.content.trim();
1294
1457
  if (!text) {
1295
1458
  this.markProcessedMessage(key);
1296
1459
  this.saveCursor();
1297
1460
  return;
1298
1461
  }
1299
- const documentNotification = source.kind === 'direct'
1300
- ? parseDocumentMentionNotification(text)
1301
- : undefined;
1462
+ const documentNotification = providerDocumentNotification ??
1463
+ (source.kind === 'direct'
1464
+ ? parseDocumentMentionNotification(text)
1465
+ : undefined);
1302
1466
  if (documentNotification) {
1303
1467
  await this.processDocumentNotification(message, key, documentNotification);
1304
1468
  return;
1305
1469
  }
1306
- const isGroup = source.kind !== 'direct';
1307
- const envelope = {
1470
+ const envelope = this.createImEnvelope(source, message, text);
1471
+ this.rememberInboundReactionTarget(message.conversationId, message.messageId);
1472
+ try {
1473
+ await this.handleInbound(envelope);
1474
+ }
1475
+ catch (error) {
1476
+ this.attemptedPendingMessages.add(key);
1477
+ // Under budget the throw propagates so redelivery observes the failure
1478
+ // while persisted replay can retry it. Once the budget is spent the
1479
+ // message is marked processed and the error is swallowed, which is the
1480
+ // only thing that lets the watermark move past it.
1481
+ if (this.recordInboundFailure(key, error, () => {
1482
+ this.markProcessedMessage(key);
1483
+ this.removePendingMessage(key);
1484
+ })) {
1485
+ throw error;
1486
+ }
1487
+ return;
1488
+ }
1489
+ this.clearInboundFailure(key);
1490
+ this.removePendingMessage(key);
1491
+ this.markProcessedMessage(key);
1492
+ this.saveCursor();
1493
+ }
1494
+ createImEnvelope(source, message, text = message.content.trim()) {
1495
+ return {
1308
1496
  channelName: this.name,
1309
1497
  senderId: message.senderId,
1310
1498
  senderName: message.senderName,
@@ -1312,10 +1500,11 @@ export class DwsChannel extends PollingChannelBase {
1312
1500
  chatName: message.conversationId,
1313
1501
  messageId: message.messageId,
1314
1502
  text,
1503
+ bypassMessagePrefix: true,
1315
1504
  ...(message.referencedText
1316
1505
  ? { referencedText: message.referencedText }
1317
1506
  : {}),
1318
- isGroup,
1507
+ isGroup: source.kind !== 'direct',
1319
1508
  isMentioned: source.kind === 'at',
1320
1509
  isReplyToBot: false,
1321
1510
  metadata: [
@@ -1324,34 +1513,6 @@ export class DwsChannel extends PollingChannelBase {
1324
1513
  `DWS event ID: ${message.eventId}`,
1325
1514
  ].join('\n'),
1326
1515
  };
1327
- this.rememberInboundReactionTarget(message.conversationId, message.messageId);
1328
- try {
1329
- await this.handleInbound(envelope);
1330
- }
1331
- catch (error) {
1332
- if (source.kind !== 'at') {
1333
- // R12-1: park failed direct messages next to ambient group ones.
1334
- // Direct-message history may be unavailable, and ambient group
1335
- // messages have no history fallback. `at` messages need no parking:
1336
- // the pinned mention checkpoint re-fetches them.
1337
- await this.rememberFailedMessage(source, message);
1338
- }
1339
- // Under budget the throw propagates exactly as before, so redelivery
1340
- // and concurrent-duplicate retry keep their contracts. Once the budget
1341
- // is spent the message is marked processed and the error is swallowed,
1342
- // which is the only thing that lets the watermark move past it.
1343
- if (this.recordInboundFailure(key, error, () => {
1344
- this.markProcessedMessage(key);
1345
- this.removePendingMessage(key);
1346
- })) {
1347
- throw error;
1348
- }
1349
- return;
1350
- }
1351
- this.clearInboundFailure(key);
1352
- this.removePendingMessage(key);
1353
- this.markProcessedMessage(key);
1354
- this.saveCursor();
1355
1516
  }
1356
1517
  /**
1357
1518
  * Account one failed inbound turn, and drop the message once its budget is
@@ -1416,15 +1577,18 @@ export class DwsChannel extends PollingChannelBase {
1416
1577
  this.saveCursor();
1417
1578
  }
1418
1579
  catch (error) {
1580
+ if (source.kind === 'group' || source.kind === 'group-all') {
1581
+ return true;
1582
+ }
1419
1583
  this.removePendingMessage(key);
1420
1584
  throw error;
1421
1585
  }
1422
1586
  return true;
1423
1587
  }
1424
- async rememberFailedMessage(source, message) {
1588
+ async rememberPendingMessageWhenAvailable(source, message) {
1425
1589
  const key = messageKey(message);
1426
1590
  if (this.hasPendingMessage(key))
1427
- return;
1591
+ return true;
1428
1592
  const generation = this.lifecycleGeneration;
1429
1593
  while (this.connected &&
1430
1594
  generation === this.lifecycleGeneration &&
@@ -1433,6 +1597,19 @@ export class DwsChannel extends PollingChannelBase {
1433
1597
  this.pendingMessageCapacityWaiters.add(resolve);
1434
1598
  });
1435
1599
  }
1600
+ if (this.hasPendingMessage(key))
1601
+ return true;
1602
+ if (!this.connected || generation !== this.lifecycleGeneration) {
1603
+ // Ambient group streams have no history fallback. A disconnect wakes
1604
+ // capacity waiters, so persist their already-read message before the
1605
+ // event process exits even when that temporarily exceeds the soft cap.
1606
+ this.rememberDrainingPendingMessage(source, message);
1607
+ return true;
1608
+ }
1609
+ return this.rememberPendingMessage(source, message);
1610
+ }
1611
+ rememberDrainingPendingMessage(source, message) {
1612
+ const key = messageKey(message);
1436
1613
  if (this.hasPendingMessage(key))
1437
1614
  return;
1438
1615
  const pending = this.cursor.pendingMessages ?? [];
@@ -1441,14 +1618,20 @@ export class DwsChannel extends PollingChannelBase {
1441
1618
  try {
1442
1619
  this.saveCursor();
1443
1620
  }
1444
- catch {
1445
- return;
1621
+ catch (error) {
1622
+ process.stderr.write(`[Channel:${this.name}] could not persist a draining DWS message; keeping it in memory: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
1446
1623
  }
1447
1624
  }
1448
1625
  removePersistedPendingMessage(key) {
1449
1626
  if (this.removePendingMessage(key))
1450
1627
  this.saveCursor();
1451
1628
  }
1629
+ removePersistedPendingMessageForSource(key, source) {
1630
+ const ownsPendingMessage = (this.cursor.pendingMessages ?? []).some((pending) => messageKey(pending.message) === key &&
1631
+ sameImSource(pending.source, source));
1632
+ if (ownsPendingMessage)
1633
+ this.removePersistedPendingMessage(key);
1634
+ }
1452
1635
  removePendingMessage(key) {
1453
1636
  const pending = this.cursor.pendingMessages ?? [];
1454
1637
  const remaining = pending.filter((item) => messageKey(item.message) !== key);
@@ -1519,6 +1702,7 @@ export class DwsChannel extends PollingChannelBase {
1519
1702
  threadId: notification.commentKey,
1520
1703
  messageId: message.messageId,
1521
1704
  text: truncateCodePoints(notification.request, MAX_COMMENT_CHARS),
1705
+ bypassMessagePrefix: true,
1522
1706
  isGroup: true,
1523
1707
  isMentioned: true,
1524
1708
  isReplyToBot: false,
@@ -1573,37 +1757,71 @@ export class DwsChannel extends PollingChannelBase {
1573
1757
  }
1574
1758
  }
1575
1759
  async replayPendingMessages(signal) {
1760
+ const activeBySource = new Map();
1761
+ const activeConversations = new Set();
1762
+ for (const { sourceKind, conversationId, } of this.replayDispatches.values()) {
1763
+ if (!activeConversations.has(conversationId)) {
1764
+ activeBySource.set(sourceKind, (activeBySource.get(sourceKind) ?? 0) + 1);
1765
+ activeConversations.add(conversationId);
1766
+ }
1767
+ }
1768
+ const selectedConversations = new Set();
1769
+ const blockedConversations = new Set();
1576
1770
  for (const pending of [...(this.cursor.pendingMessages ?? [])]) {
1577
1771
  if (signal.aborted || !this.connected)
1578
1772
  return;
1579
1773
  const key = messageKey(pending.message);
1580
- if (pending.source.kind === 'direct') {
1581
- if (this.queuedDirectMessages.has(key))
1582
- continue;
1583
- if (this.replayDirectDispatches.size >= MAX_DIRECT_REPLAY_DISPATCHES) {
1584
- continue;
1585
- }
1586
- const dispatched = this.scheduleDirectMessage(pending.source, pending.message, key, true);
1587
- this.replayDirectDispatches.set(key, dispatched);
1588
- void dispatched
1589
- .finally(() => {
1590
- if (this.replayDirectDispatches.get(key) === dispatched) {
1591
- this.replayDirectDispatches.delete(key);
1774
+ if (this.queuedMessages.has(key))
1775
+ continue;
1776
+ if (this.markSelfMessageProcessed(pending.message))
1777
+ continue;
1778
+ if (this.shouldFilterImMessage(pending.source, pending.message)) {
1779
+ if (pending.source.kind === 'group' ||
1780
+ pending.source.kind === 'group-all') {
1781
+ const text = stripMessagePrefix(pending.message.content.trim(), this.dwsMessagePrefix);
1782
+ if (!this.dwsMessagePrefix || text) {
1783
+ const envelope = this.createImEnvelope(pending.source, pending.message, text);
1784
+ if (this.groupGate.check(envelope, { createPairingRequest: false })
1785
+ .reason === 'mention_required') {
1786
+ this.recordPendingGroupHistory(envelope);
1787
+ }
1592
1788
  }
1593
- })
1594
- .catch(() => undefined);
1789
+ }
1790
+ this.removePersistedPendingMessageForSource(key, pending.source);
1595
1791
  continue;
1596
1792
  }
1597
- try {
1598
- await this.receiveImMessage(pending.source, pending.message, true);
1599
- this.removePendingMessage(key);
1600
- this.saveCursor();
1793
+ if (activeConversations.has(pending.message.conversationId))
1794
+ continue;
1795
+ if (blockedConversations.has(pending.message.conversationId))
1796
+ continue;
1797
+ if (!selectedConversations.has(pending.message.conversationId)) {
1798
+ const activeForSource = activeBySource.get(pending.source.kind) ?? 0;
1799
+ if (activeForSource >= MAX_REPLAY_CONVERSATIONS) {
1800
+ blockedConversations.add(pending.message.conversationId);
1801
+ continue;
1802
+ }
1803
+ activeBySource.set(pending.source.kind, activeForSource + 1);
1804
+ selectedConversations.add(pending.message.conversationId);
1601
1805
  }
1602
- catch (error) {
1806
+ const dispatch = this.receiveImMessage(pending.source, pending.message, true, true);
1807
+ const completed = dispatch.completed;
1808
+ const replay = {
1809
+ sourceKind: pending.source.kind,
1810
+ conversationId: pending.message.conversationId,
1811
+ };
1812
+ this.replayDispatches.set(key, replay);
1813
+ void dispatch.admitted.catch((error) => {
1603
1814
  if (signal.aborted || !this.connected)
1604
1815
  return;
1605
1816
  process.stderr.write(`[Channel:${this.name}] pending DWS message remains degraded: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 300)}\n`);
1606
- }
1817
+ });
1818
+ void completed
1819
+ .finally(() => {
1820
+ if (this.replayDispatches.get(key) === replay) {
1821
+ this.replayDispatches.delete(key);
1822
+ }
1823
+ })
1824
+ .catch(() => undefined);
1607
1825
  }
1608
1826
  }
1609
1827
  async replayPendingDocumentNotifications(signal) {
@@ -1700,13 +1918,17 @@ export class DwsChannel extends PollingChannelBase {
1700
1918
  reactionKey(conversationId, messageId) {
1701
1919
  return `${conversationId}\0${messageId}`;
1702
1920
  }
1703
- releaseDirectMessageStart(conversationId, messageId) {
1921
+ releaseMessageStart(conversationId, messageId, expected) {
1704
1922
  const key = `${conversationId}\0${messageId}`;
1705
- const resolve = this.directMessageStartResolvers.get(key);
1706
- if (!resolve)
1923
+ const resolver = this.messageStartResolvers.get(key);
1924
+ if (!resolver || (expected && resolver !== expected))
1707
1925
  return;
1708
- this.directMessageStartResolvers.delete(key);
1709
- resolve();
1926
+ if (!expected &&
1927
+ this.processingMessageGenerations.get(key) !== resolver.generation) {
1928
+ return;
1929
+ }
1930
+ this.messageStartResolvers.delete(key);
1931
+ resolver.resolve();
1710
1932
  }
1711
1933
  rememberInboundReactionTarget(chatId, messageId, conversationId = chatId) {
1712
1934
  const key = this.reactionKey(chatId, messageId);
@@ -1866,7 +2088,7 @@ export class DwsChannel extends PollingChannelBase {
1866
2088
  onTaskLifecycle(event) {
1867
2089
  if (event.type === 'started') {
1868
2090
  if (event.messageId) {
1869
- this.releaseDirectMessageStart(event.chatId, event.messageId);
2091
+ this.releaseMessageStart(event.chatId, event.messageId);
1870
2092
  }
1871
2093
  this.startReaction(event.chatId, event.messageId, event.sessionId);
1872
2094
  return;