@xmanrui/dsh-im 0.7.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (30) hide show
  1. package/lib/index.js +119 -121
  2. package/package.json +1 -1
  3. package/src/channels/dingtalk/dingtalk-bridge.mjs +423 -8
  4. package/src/channels/dingtalk/harness-client.mjs +16 -366
  5. package/src/channels/discord/discord-api.mjs +1 -1
  6. package/src/channels/discord/discord-runtime.mjs +11 -1
  7. package/src/channels/discord/harness-client.mjs +10 -2
  8. package/src/channels/feishu/bridge.mjs +571 -50
  9. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  10. package/src/channels/feishu/harness-client.mjs +16 -335
  11. package/src/channels/qq/harness-client.mjs +10 -2
  12. package/src/channels/qq/qq-bridge.mjs +428 -28
  13. package/src/channels/qq/qq-runtime.mjs +14 -3
  14. package/src/channels/shared/harness-approval.mjs +472 -0
  15. package/src/channels/shared/harness-client.mjs +858 -0
  16. package/src/channels/shared/harness-question.mjs +85 -0
  17. package/src/channels/shared/text-harness-bridge.mjs +486 -24
  18. package/src/channels/slack/harness-client.mjs +10 -2
  19. package/src/channels/slack/slack-runtime.mjs +11 -1
  20. package/src/channels/telegram/harness-client.mjs +10 -2
  21. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  22. package/src/channels/wecom/harness-client.mjs +10 -2
  23. package/src/channels/wecom/wecom-bridge.mjs +434 -14
  24. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  25. package/src/channels/weixin/harness-client.mjs +16 -326
  26. package/src/channels/weixin/weixin-api.mjs +1 -1
  27. package/src/channels/weixin/weixin-bridge.mjs +451 -22
  28. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  29. package/src/channels/whatsapp/harness-client.mjs +10 -2
  30. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -34,19 +34,21 @@ export function normalizeTelegramUpdate(update, { botId, username }) {
34
34
  const addressed = direct
35
35
  || String(message.reply_to_message?.from?.id ?? '') === String(botId)
36
36
  || mentionedUsername(message, username);
37
+ const messageThreadId = Number.isSafeInteger(message.message_thread_id)
38
+ ? message.message_thread_id : undefined;
37
39
  return {
38
40
  messageId: String(update.update_id),
39
41
  senderId: String(senderId),
40
42
  senderIsBot: message.from?.is_bot === true,
41
43
  kind: direct ? 'direct' : 'group',
42
- conversationId: String(chatId),
44
+ conversationId: messageThreadId === undefined
45
+ ? String(chatId) : `${chatId}:${messageThreadId}`,
43
46
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
44
47
  addressed,
45
48
  replyTarget: {
46
49
  chatId,
47
50
  replyToMessageId: messageId,
48
- messageThreadId: Number.isSafeInteger(message.message_thread_id)
49
- ? message.message_thread_id : undefined,
51
+ messageThreadId,
50
52
  },
51
53
  };
52
54
  }
@@ -206,6 +208,7 @@ export class TelegramRuntime {
206
208
  status: this.#status,
207
209
  logger: this.#logger,
208
210
  replyTimeoutMs: this.#replyTimeoutMs,
211
+ signal: controller.signal,
209
212
  });
210
213
 
211
214
  let cursor = this.#state.cursor();
@@ -248,7 +251,15 @@ export class TelegramRuntime {
248
251
  botId: this.#config.platformId,
249
252
  username: this.#config.username,
250
253
  });
251
- if (message) await this.#bridge.accept(message);
254
+ if (message) {
255
+ void this.#bridge.accept(message).catch((error) => {
256
+ if (signal.aborted) return;
257
+ this.#logger.error?.(
258
+ `[dsh-im:telegram] bot ${this.#config.botId} message handling failed:`,
259
+ error,
260
+ );
261
+ });
262
+ }
252
263
  cursor = update.update_id + 1;
253
264
  await this.#state.setCursor(cursor);
254
265
  }
@@ -1,3 +1,11 @@
1
- import { HarnessClient } from '../weixin/harness-client.mjs';
1
+ import { HarnessClient } from '../shared/harness-client.mjs';
2
2
 
3
- export class WecomHarnessClient extends HarnessClient {}
3
+ export class WecomHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'wecom',
8
+ logPrefix: 'dsh-wecom',
9
+ });
10
+ }
11
+ }
@@ -1,4 +1,10 @@
1
1
  import { generateReqId } from '@wecom/aibot-node-sdk';
2
+ import {
3
+ harnessAnswerForQuestion,
4
+ harnessQuestionText,
5
+ validHarnessQuestion,
6
+ } from '../shared/harness-question.mjs';
7
+ import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
2
8
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
3
9
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
4
10
 
@@ -15,6 +21,11 @@ const HELP_TEXT = [
15
21
  '/help 显示本帮助',
16
22
  ].join('\n');
17
23
  const MAX_REPLY_BYTES = 18_000;
24
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
25
+
26
+ function nonEmptyString(value) {
27
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
28
+ }
18
29
 
19
30
  function bodyOf(frame) {
20
31
  return frame?.body && typeof frame.body === 'object' ? frame.body : {};
@@ -27,16 +38,23 @@ function conversationKey(frame) {
27
38
 
28
39
  function messageText(frame) {
29
40
  const body = bodyOf(frame);
30
- if (body.msgtype === 'text') return typeof body.text?.content === 'string' ? body.text.content.trim() : '';
31
- if (body.msgtype === 'voice') return typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
32
- if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
33
- return body.mixed.msg_item
41
+ let text = '';
42
+ if (body.msgtype === 'text') {
43
+ text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
44
+ } else if (body.msgtype === 'voice') {
45
+ text = typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
46
+ } else if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
47
+ text = body.mixed.msg_item
34
48
  .filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string')
35
49
  .map((item) => item.text.content)
36
50
  .join('\n')
37
51
  .trim();
38
52
  }
39
- return '';
53
+ // Group callbacks retain the leading @bot mention that caused delivery.
54
+ // It is routing metadata rather than part of the user's prompt or answer.
55
+ return body.chattype === 'group'
56
+ ? text.replace(/^\s*@\S+(?:\s+|$)/u, '').trim()
57
+ : text;
40
58
  }
41
59
 
42
60
  function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
@@ -66,6 +84,12 @@ function progressText(update) {
66
84
  return update?.text;
67
85
  }
68
86
 
87
+ function canClaimInteractionReply(frame, pending) {
88
+ return pending.questions[pending.index]
89
+ && nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
90
+ && nonEmptyString(messageText(frame));
91
+ }
92
+
69
93
  export function createWecomBridgeStatus() {
70
94
  return {
71
95
  messagesReceived: 0,
@@ -86,7 +110,13 @@ export class WecomHarnessBridge {
86
110
  #logger;
87
111
  #replyTimeoutMs;
88
112
  #generateReqId;
113
+ #signal;
89
114
  #queues = new Map();
115
+ #pendingInteractions = new Map();
116
+ #interactionKeys = new Map();
117
+ #acceptedMessageIds = new Set();
118
+ #approvalTasks = new Set();
119
+ #approvals;
90
120
 
91
121
  constructor({
92
122
  client,
@@ -96,6 +126,7 @@ export class WecomHarnessBridge {
96
126
  logger = console,
97
127
  replyTimeoutMs = 600_000,
98
128
  generateStreamId = generateReqId,
129
+ signal,
99
130
  }) {
100
131
  if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
101
132
  throw new TypeError('Enterprise WeChat client is required');
@@ -108,6 +139,8 @@ export class WecomHarnessBridge {
108
139
  this.#logger = logger;
109
140
  this.#replyTimeoutMs = replyTimeoutMs;
110
141
  this.#generateReqId = generateStreamId;
142
+ this.#signal = signal;
143
+ this.#approvals = new HarnessApprovalQueue({ label: 'wecom', logger });
111
144
  }
112
145
 
113
146
  get status() {
@@ -115,12 +148,94 @@ export class WecomHarnessBridge {
115
148
  }
116
149
 
117
150
  accept(frame) {
151
+ if (this.#signal?.aborted) return Promise.resolve();
152
+ const body = bodyOf(frame);
153
+ const messageId = nonEmptyString(body.msgid);
154
+ const senderId = nonEmptyString(body.from?.userid);
155
+ const chatId = body.chattype === 'group'
156
+ ? nonEmptyString(body.chatid)
157
+ : senderId;
158
+ if (!messageId || !senderId || !chatId
159
+ || !['single', 'group'].includes(body.chattype)
160
+ || this.#state.hasSeen(messageId)
161
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
162
+
118
163
  const key = conversationKey(frame);
164
+ this.#acceptedMessageIds.add(messageId);
165
+ const pending = this.#pendingInteractions.get(key);
166
+ const approval = this.#approvals.claimReply({
167
+ key,
168
+ actor: senderId,
169
+ messageId,
170
+ text: messageText(frame),
171
+ addressed: true,
172
+ hasPendingQuestion: Boolean(pending),
173
+ questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
174
+ ? pending.queue
175
+ : null,
176
+ isQuestionPending: () => this.#pendingInteractions.has(key),
177
+ send: (text) => this.#sendImmediate(frame, chatId, text),
178
+ });
179
+ if (approval) {
180
+ let task;
181
+ task = approval.process(async () => {
182
+ if (this.#state.hasSeen(messageId)) return false;
183
+ await this.#state.markSeen(messageId);
184
+ this.#status.messagesReceived += 1;
185
+ this.#status.lastMessageAt = new Date().toISOString();
186
+ return true;
187
+ })
188
+ .finally(() => {
189
+ this.#acceptedMessageIds.delete(messageId);
190
+ this.#approvalTasks.delete(task);
191
+ });
192
+ this.#approvalTasks.add(task);
193
+ return task;
194
+ }
195
+ if (pending && pending.actor !== senderId) {
196
+ return this.#enqueueMessage(frame, messageId, key);
197
+ }
198
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
199
+ return this.#enqueueMessage(frame, messageId, key);
200
+ }
201
+ if (pending) {
202
+ if (canClaimInteractionReply(frame, pending)) {
203
+ pending.claimedReplyMessageId = messageId;
204
+ }
205
+ const previous = pending.queue ?? Promise.resolve();
206
+ const current = previous
207
+ .catch(() => undefined)
208
+ .then(() => this.#processInteractionReply(
209
+ frame,
210
+ messageId,
211
+ senderId,
212
+ chatId,
213
+ key,
214
+ pending,
215
+ ))
216
+ .finally(() => {
217
+ this.#acceptedMessageIds.delete(messageId);
218
+ if (pending.claimedReplyMessageId === messageId) {
219
+ pending.claimedReplyMessageId = null;
220
+ }
221
+ if (pending.queue === current) pending.queue = null;
222
+ });
223
+ pending.queue = current;
224
+ return current;
225
+ }
226
+ return this.#enqueueMessage(frame, messageId, key);
227
+ }
228
+
229
+ #enqueueMessage(frame, messageId, key, {
230
+ releaseMessageId = true,
231
+ alreadyRecorded = false,
232
+ } = {}) {
119
233
  const previous = this.#queues.get(key) ?? Promise.resolve();
120
234
  const current = previous
121
235
  .catch(() => undefined)
122
- .then(() => this.#process(frame))
236
+ .then(() => this.#process(frame, { alreadyRecorded }))
123
237
  .finally(() => {
238
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
124
239
  if (this.#queues.get(key) === current) this.#queues.delete(key);
125
240
  });
126
241
  this.#queues.set(key, current);
@@ -128,16 +243,24 @@ export class WecomHarnessBridge {
128
243
  }
129
244
 
130
245
  async waitForIdle() {
131
- await Promise.allSettled([...this.#queues.values()]);
246
+ await Promise.allSettled([
247
+ ...this.#queues.values(),
248
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
249
+ pending.queue ? [pending.queue] : []
250
+ )),
251
+ ...this.#approvalTasks,
252
+ ]);
132
253
  }
133
254
 
134
255
  async #sendActive(chatId, text) {
135
256
  for (const chunk of splitUtf8(text)) {
257
+ this.#signal?.throwIfAborted();
136
258
  await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
137
259
  }
138
260
  }
139
261
 
140
262
  async #sendImmediate(frame, chatId, text) {
263
+ this.#signal?.throwIfAborted();
141
264
  const chunks = splitUtf8(text);
142
265
  if (chunks.length === 0) return;
143
266
  try {
@@ -150,17 +273,20 @@ export class WecomHarnessBridge {
150
273
  }
151
274
  }
152
275
 
153
- async #process(frame) {
276
+ async #process(frame, { alreadyRecorded = false } = {}) {
277
+ if (this.#signal?.aborted) return;
154
278
  const body = bodyOf(frame);
155
279
  const messageId = typeof body.msgid === 'string' ? body.msgid : '';
156
280
  const senderId = typeof body.from?.userid === 'string' ? body.from.userid : '';
157
281
  const chatId = body.chattype === 'group' ? body.chatid : senderId;
158
282
  if (!messageId || !senderId || !chatId || !['single', 'group'].includes(body.chattype)) return;
159
- if (this.#state.hasSeen(messageId)) return;
160
-
161
- this.#status.messagesReceived += 1;
162
- this.#status.lastMessageAt = new Date().toISOString();
283
+ if (!alreadyRecorded) {
284
+ if (this.#state.hasSeen(messageId)) return;
285
+ this.#status.messagesReceived += 1;
286
+ this.#status.lastMessageAt = new Date().toISOString();
287
+ }
163
288
  const text = messageText(frame);
289
+ const key = conversationKey(frame);
164
290
  let streamId = null;
165
291
  let streamStarted = false;
166
292
  try {
@@ -176,12 +302,11 @@ export class WecomHarnessBridge {
176
302
  return;
177
303
  }
178
304
  if (command === '/status') {
179
- await this.#harness.ensureRunning();
305
+ await this.#harness.ensureRunning({ signal: this.#signal });
180
306
  await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
181
307
  await this.#state.markSeen(messageId);
182
308
  return;
183
309
  }
184
- const key = conversationKey(frame);
185
310
  if (command === '/new') {
186
311
  await this.#state.clearSession(key);
187
312
  await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
@@ -210,14 +335,24 @@ export class WecomHarnessBridge {
210
335
  state: this.#state,
211
336
  key,
212
337
  text,
338
+ createOptions: { signal: this.#signal },
339
+ existsOptions: { signal: this.#signal },
213
340
  askOptions: {
214
341
  timeoutMs: this.#replyTimeoutMs,
342
+ signal: this.#signal,
215
343
  onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
216
344
  ? async (update) => {
217
345
  const progress = splitUtf8(progressText(update))[0];
218
346
  if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
219
347
  }
220
348
  : undefined,
349
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
350
+ key,
351
+ actor: senderId,
352
+ chatId,
353
+ requiresMention: body.chattype === 'group',
354
+ }),
355
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
221
356
  },
222
357
  });
223
358
 
@@ -240,6 +375,7 @@ export class WecomHarnessBridge {
240
375
  this.#status.lastReplyAt = new Date().toISOString();
241
376
  this.#status.lastError = null;
242
377
  } catch (error) {
378
+ if (this.#signal?.aborted) return;
243
379
  this.#status.lastError = error?.message ?? String(error);
244
380
  this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
245
381
  try {
@@ -252,6 +388,290 @@ export class WecomHarnessBridge {
252
388
  } catch {
253
389
  this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
254
390
  }
391
+ } finally {
392
+ await Promise.allSettled([
393
+ this.#cancelPendingInteraction(key),
394
+ this.#approvals.closeRoute(key),
395
+ ]);
396
+ }
397
+ }
398
+
399
+ async #processInteractionReply(frame, messageId, senderId, chatId, key, expected) {
400
+ if (this.#signal?.aborted) return;
401
+ const current = this.#pendingInteractions.get(key);
402
+ const claimed = expected.claimedReplyMessageId === messageId;
403
+ if (!current || current !== expected || current.submitting) {
404
+ if (claimed && (!current || current !== expected)) {
405
+ return this.#discardResolvedInteractionReply(frame, messageId, chatId);
406
+ }
407
+ return this.#enqueueMessage(frame, messageId, key, { releaseMessageId: false });
408
+ }
409
+ if (this.#state.hasSeen(messageId)) return;
410
+ await this.#state.markSeen(messageId);
411
+ this.#status.messagesReceived += 1;
412
+ this.#status.lastMessageAt = new Date().toISOString();
413
+
414
+ const text = nonEmptyString(messageText(frame));
415
+ if (!text) {
416
+ await this.#sendImmediate(frame, chatId, '请用文字或语音回答当前问题。')
417
+ .catch(() => undefined);
418
+ return;
419
+ }
420
+
421
+ const pending = this.#pendingInteractions.get(key);
422
+ if (!pending || pending !== expected || pending.submitting) {
423
+ if (claimed && (!pending || pending !== expected)) {
424
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
425
+ .catch(() => undefined);
426
+ return;
427
+ }
428
+ return this.#enqueueMessage(frame, messageId, key, {
429
+ releaseMessageId: false,
430
+ alreadyRecorded: true,
431
+ });
432
+ }
433
+ if (pending.actor !== senderId) {
434
+ return this.#enqueueMessage(frame, messageId, key, {
435
+ releaseMessageId: false,
436
+ alreadyRecorded: true,
437
+ });
438
+ }
439
+
440
+ pending.chatId = chatId;
441
+ if (pending.needsPresentation) {
442
+ try {
443
+ await this.#presentInteraction(pending);
444
+ } catch {
445
+ this.#status.lastError = '企业微信交互问题发送失败。';
446
+ this.#logger.error?.('[dsh-im:wecom] failed to retry an interaction question');
447
+ pending.interaction.reconnect?.();
448
+ return;
449
+ }
450
+ const presentedPending = this.#pendingInteractions.get(key);
451
+ if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
452
+ if (claimed && (!presentedPending || presentedPending !== expected)) {
453
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
454
+ .catch(() => undefined);
455
+ return;
456
+ }
457
+ return this.#enqueueMessage(frame, messageId, key, {
458
+ releaseMessageId: false,
459
+ alreadyRecorded: true,
460
+ });
461
+ }
462
+ }
463
+
464
+ const question = pending.questions[pending.index];
465
+ if (!question) return;
466
+ pending.answers.push(harnessAnswerForQuestion(question, text));
467
+ pending.index += 1;
468
+ if (pending.index < pending.questions.length) {
469
+ if (pending.claimedReplyMessageId === messageId) {
470
+ pending.claimedReplyMessageId = null;
471
+ }
472
+ pending.needsPresentation = true;
473
+ try {
474
+ await this.#presentInteraction(pending);
475
+ } catch {
476
+ this.#status.lastError = '企业微信交互问题发送失败。';
477
+ this.#logger.error?.('[dsh-im:wecom] failed to send the next interaction question');
478
+ pending.interaction.reconnect?.();
479
+ }
480
+ return;
481
+ }
482
+
483
+ pending.submitting = true;
484
+ try {
485
+ await pending.interaction.respond({
486
+ ok: true,
487
+ value: {
488
+ sessionId: pending.sessionId,
489
+ answer: { answers: pending.answers },
490
+ },
491
+ });
492
+ this.#clearPendingInteraction(key, pending.interactionId);
493
+ this.#status.lastError = null;
494
+ } catch (error) {
495
+ if (this.#signal?.aborted) return;
496
+ if (error?.code === 'interaction-not-pending') {
497
+ if (this.#pendingInteractions.get(key) === pending) {
498
+ this.#clearPendingInteraction(key, pending.interactionId);
499
+ }
500
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
501
+ .catch(() => undefined);
502
+ return;
503
+ }
504
+ if (this.#pendingInteractions.get(key) !== pending) return;
505
+ pending.submitting = false;
506
+ pending.answers.pop();
507
+ pending.index -= 1;
508
+ this.#status.lastError = '回答提交失败。';
509
+ this.#logger.error?.('[dsh-im:wecom] failed to answer a Harness interaction');
510
+ await this.#sendImmediate(frame, chatId, '回答提交失败,请重新发送当前问题的答案。')
511
+ .catch(() => undefined);
512
+ }
513
+ }
514
+
515
+ async #handleInteraction(interaction, {
516
+ key,
517
+ actor,
518
+ chatId,
519
+ requiresMention,
520
+ }) {
521
+ if (interaction?.kind === 'approval') {
522
+ return this.#approvals.handleRequested(interaction, {
523
+ key,
524
+ actor,
525
+ requiresMention,
526
+ send: (text) => this.#sendActive(chatId, text),
527
+ });
528
+ }
529
+ if (interaction?.kind !== 'question') return;
530
+ const questions = interaction?.payload?.questions;
531
+ const interactionId = typeof interaction?.interactionId === 'string'
532
+ ? interaction.interactionId
533
+ : interaction?.rpcId;
534
+ if (typeof interaction?.rpcId !== 'string'
535
+ || typeof interactionId !== 'string'
536
+ || typeof interaction.sessionId !== 'string'
537
+ || !Array.isArray(questions)
538
+ || questions.length === 0
539
+ || questions.some((question) => !validHarnessQuestion(question))) {
540
+ this.#logger.warn?.('[dsh-im:wecom] ignored an invalid Harness question interaction');
541
+ return;
542
+ }
543
+
544
+ if (interaction.recovered === true) {
545
+ await interaction.respond({
546
+ ok: false,
547
+ error: {
548
+ code: 'cancelled',
549
+ message: 'Enterprise WeChat safely cancelled an interaction left by an earlier client.',
550
+ details: {},
551
+ },
552
+ });
553
+ await this.#sendActive(
554
+ chatId,
555
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
556
+ ).catch(() => undefined);
557
+ return;
558
+ }
559
+
560
+ const existing = this.#pendingInteractions.get(key);
561
+ if (existing?.interactionId === interactionId) {
562
+ existing.interaction = interaction;
563
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
564
+ return;
565
+ }
566
+ if (this.#interactionKeys.has(interactionId)) return;
567
+ if (existing) {
568
+ this.#logger.warn?.('[dsh-im:wecom] cancelled a second pending Harness question');
569
+ await interaction.respond({
570
+ ok: false,
571
+ error: {
572
+ code: 'cancelled',
573
+ message: 'Enterprise WeChat is already handling another user interaction.',
574
+ details: {},
575
+ },
576
+ });
577
+ return;
578
+ }
579
+
580
+ const pending = {
581
+ kind: 'question',
582
+ interactionId,
583
+ sessionId: interaction.sessionId,
584
+ interaction,
585
+ actor,
586
+ requiresMention,
587
+ questions,
588
+ answers: [],
589
+ index: 0,
590
+ chatId,
591
+ queue: null,
592
+ claimedReplyMessageId: null,
593
+ submitting: false,
594
+ needsPresentation: true,
595
+ presentationPromise: null,
596
+ };
597
+ this.#pendingInteractions.set(key, pending);
598
+ this.#interactionKeys.set(pending.interactionId, key);
599
+ await this.#presentInteraction(pending);
600
+ }
601
+
602
+ async #handleInteractionResolved(resolution) {
603
+ if (resolution?.kind === 'approval') {
604
+ await this.#approvals.handleResolved(resolution);
605
+ return;
606
+ }
607
+ const interactionId = resolution?.interactionId;
608
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
609
+ const key = this.#interactionKeys.get(interactionId);
610
+ if (!key) return;
611
+ this.#clearPendingInteraction(key, interactionId);
612
+ }
613
+
614
+ #presentInteraction(pending) {
615
+ if (!pending.needsPresentation) return Promise.resolve();
616
+ if (pending.presentationPromise) return pending.presentationPromise;
617
+ const question = pending.questions[pending.index];
618
+ if (!question) return Promise.resolve();
619
+ const presentation = this.#sendActive(
620
+ pending.chatId,
621
+ harnessQuestionText(
622
+ question,
623
+ pending.index,
624
+ pending.questions.length,
625
+ { requiresMention: pending.requiresMention },
626
+ ),
627
+ ).then(() => {
628
+ pending.needsPresentation = false;
629
+ }).finally(() => {
630
+ if (pending.presentationPromise === presentation) {
631
+ pending.presentationPromise = null;
632
+ }
633
+ });
634
+ pending.presentationPromise = presentation;
635
+ return presentation;
636
+ }
637
+
638
+ async #discardResolvedInteractionReply(frame, messageId, chatId) {
639
+ if (this.#state.hasSeen(messageId)) return;
640
+ await this.#state.markSeen(messageId);
641
+ this.#status.messagesReceived += 1;
642
+ this.#status.lastMessageAt = new Date().toISOString();
643
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
644
+ }
645
+
646
+ #takePendingInteraction(key, interactionId) {
647
+ const pending = this.#pendingInteractions.get(key);
648
+ if (!pending
649
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
650
+ this.#pendingInteractions.delete(key);
651
+ this.#interactionKeys.delete(pending.interactionId);
652
+ return pending;
653
+ }
654
+
655
+ #clearPendingInteraction(key, interactionId) {
656
+ return this.#takePendingInteraction(key, interactionId) !== null;
657
+ }
658
+
659
+ async #cancelPendingInteraction(key) {
660
+ const pending = this.#takePendingInteraction(key);
661
+ if (!pending || pending.kind !== 'question') return;
662
+ try {
663
+ await pending.interaction.respond({
664
+ ok: false,
665
+ error: {
666
+ code: 'cancelled',
667
+ message: 'The Enterprise WeChat interaction ended before the user answered.',
668
+ details: {},
669
+ },
670
+ }, { signal: AbortSignal.timeout(5_000) });
671
+ } catch (error) {
672
+ if (error?.code !== 'interaction-not-pending') {
673
+ this.#logger.warn?.('[dsh-im:wecom] failed to cancel a pending Harness interaction');
674
+ }
255
675
  }
256
676
  }
257
677
  }
@@ -36,6 +36,7 @@ export class WecomRuntime {
36
36
  #bridge = null;
37
37
  #starting = null;
38
38
  #startController = null;
39
+ #runtimeController = null;
39
40
 
40
41
  constructor({
41
42
  config,
@@ -69,8 +70,10 @@ export class WecomRuntime {
69
70
  async start() {
70
71
  if (this.#status.ready && this.#client) return this.status;
71
72
  if (this.#starting) return this.#starting;
73
+ this.#runtimeController?.abort(new DOMException('Enterprise WeChat runtime replaced', 'AbortError'));
72
74
  const controller = new AbortController();
73
75
  this.#startController = controller;
76
+ this.#runtimeController = controller;
74
77
  this.#starting = this.#start(controller.signal).finally(() => {
75
78
  if (this.#startController === controller) this.#startController = null;
76
79
  this.#starting = null;
@@ -105,6 +108,7 @@ export class WecomRuntime {
105
108
  status: this.#status,
106
109
  logger: this.#logger,
107
110
  replyTimeoutMs: this.#replyTimeoutMs,
111
+ signal,
108
112
  });
109
113
 
110
114
  let readyResolve;
@@ -186,6 +190,8 @@ export class WecomRuntime {
186
190
  async stop() {
187
191
  const starting = this.#starting;
188
192
  this.#startController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
193
+ this.#runtimeController?.abort(new DOMException('Enterprise WeChat runtime stopped', 'AbortError'));
194
+ this.#runtimeController = null;
189
195
  await this.#stopActive();
190
196
  await starting?.catch(() => undefined);
191
197
  return this.status;