@xmanrui/dsh-im 0.7.0 → 0.7.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.
Files changed (29) hide show
  1. package/lib/index.js +118 -121
  2. package/package.json +1 -1
  3. package/src/channels/dingtalk/dingtalk-bridge.mjs +360 -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 +522 -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 +380 -28
  13. package/src/channels/qq/qq-runtime.mjs +14 -3
  14. package/src/channels/shared/harness-client.mjs +825 -0
  15. package/src/channels/shared/harness-question.mjs +85 -0
  16. package/src/channels/shared/text-harness-bridge.mjs +436 -24
  17. package/src/channels/slack/harness-client.mjs +10 -2
  18. package/src/channels/slack/slack-runtime.mjs +11 -1
  19. package/src/channels/telegram/harness-client.mjs +10 -2
  20. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  21. package/src/channels/wecom/harness-client.mjs +10 -2
  22. package/src/channels/wecom/wecom-bridge.mjs +386 -14
  23. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  24. package/src/channels/weixin/harness-client.mjs +16 -326
  25. package/src/channels/weixin/weixin-api.mjs +1 -1
  26. package/src/channels/weixin/weixin-bridge.mjs +402 -22
  27. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  28. package/src/channels/whatsapp/harness-client.mjs +10 -2
  29. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -309,6 +309,7 @@ export class SlackRuntime {
309
309
  status: this.#status,
310
310
  logger: this.#logger,
311
311
  replyTimeoutMs: this.#replyTimeoutMs,
312
+ signal: controller.signal,
312
313
  });
313
314
  let timer;
314
315
  try {
@@ -389,7 +390,16 @@ export class SlackRuntime {
389
390
  if (this.#appId && packet.payload.api_app_id
390
391
  && packet.payload.api_app_id !== this.#appId) return;
391
392
  const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]);
392
- if (message) void this.#bridge?.accept(message);
393
+ const bridge = this.#bridge;
394
+ if (message && bridge) {
395
+ void bridge.accept(message).catch((error) => {
396
+ if (generation !== this.#generation || this.#stopped) return;
397
+ this.#logger.error?.(
398
+ `[dsh-im:slack] bot ${this.#config.botId} message handling failed:`,
399
+ error,
400
+ );
401
+ });
402
+ }
393
403
  });
394
404
 
395
405
  addSocketListener(socket, 'close', (event = {}) => {
@@ -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 TelegramHarnessClient extends HarnessClient {}
3
+ export class TelegramHarnessClient extends HarnessClient {
4
+ constructor(options) {
5
+ super({
6
+ ...options,
7
+ rpcIdPrefix: 'telegram',
8
+ logPrefix: 'dsh-telegram',
9
+ });
10
+ }
11
+ }
@@ -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,9 @@
1
1
  import { generateReqId } from '@wecom/aibot-node-sdk';
2
+ import {
3
+ harnessAnswerForQuestion,
4
+ harnessQuestionText,
5
+ validHarnessQuestion,
6
+ } from '../shared/harness-question.mjs';
2
7
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
3
8
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
4
9
 
@@ -15,6 +20,11 @@ const HELP_TEXT = [
15
20
  '/help 显示本帮助',
16
21
  ].join('\n');
17
22
  const MAX_REPLY_BYTES = 18_000;
23
+ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
24
+
25
+ function nonEmptyString(value) {
26
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
27
+ }
18
28
 
19
29
  function bodyOf(frame) {
20
30
  return frame?.body && typeof frame.body === 'object' ? frame.body : {};
@@ -27,16 +37,23 @@ function conversationKey(frame) {
27
37
 
28
38
  function messageText(frame) {
29
39
  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
40
+ let text = '';
41
+ if (body.msgtype === 'text') {
42
+ text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
43
+ } else if (body.msgtype === 'voice') {
44
+ text = typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
45
+ } else if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
46
+ text = body.mixed.msg_item
34
47
  .filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string')
35
48
  .map((item) => item.text.content)
36
49
  .join('\n')
37
50
  .trim();
38
51
  }
39
- return '';
52
+ // Group callbacks retain the leading @bot mention that caused delivery.
53
+ // It is routing metadata rather than part of the user's prompt or answer.
54
+ return body.chattype === 'group'
55
+ ? text.replace(/^\s*@\S+(?:\s+|$)/u, '').trim()
56
+ : text;
40
57
  }
41
58
 
42
59
  function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
@@ -66,6 +83,12 @@ function progressText(update) {
66
83
  return update?.text;
67
84
  }
68
85
 
86
+ function canClaimInteractionReply(frame, pending) {
87
+ return pending.questions[pending.index]
88
+ && nonEmptyString(bodyOf(frame).from?.userid) === pending.actor
89
+ && nonEmptyString(messageText(frame));
90
+ }
91
+
69
92
  export function createWecomBridgeStatus() {
70
93
  return {
71
94
  messagesReceived: 0,
@@ -86,7 +109,11 @@ export class WecomHarnessBridge {
86
109
  #logger;
87
110
  #replyTimeoutMs;
88
111
  #generateReqId;
112
+ #signal;
89
113
  #queues = new Map();
114
+ #pendingInteractions = new Map();
115
+ #interactionKeys = new Map();
116
+ #acceptedMessageIds = new Set();
90
117
 
91
118
  constructor({
92
119
  client,
@@ -96,6 +123,7 @@ export class WecomHarnessBridge {
96
123
  logger = console,
97
124
  replyTimeoutMs = 600_000,
98
125
  generateStreamId = generateReqId,
126
+ signal,
99
127
  }) {
100
128
  if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
101
129
  throw new TypeError('Enterprise WeChat client is required');
@@ -108,6 +136,7 @@ export class WecomHarnessBridge {
108
136
  this.#logger = logger;
109
137
  this.#replyTimeoutMs = replyTimeoutMs;
110
138
  this.#generateReqId = generateStreamId;
139
+ this.#signal = signal;
111
140
  }
112
141
 
113
142
  get status() {
@@ -115,12 +144,65 @@ export class WecomHarnessBridge {
115
144
  }
116
145
 
117
146
  accept(frame) {
147
+ if (this.#signal?.aborted) return Promise.resolve();
148
+ const body = bodyOf(frame);
149
+ const messageId = nonEmptyString(body.msgid);
150
+ const senderId = nonEmptyString(body.from?.userid);
151
+ const chatId = body.chattype === 'group'
152
+ ? nonEmptyString(body.chatid)
153
+ : senderId;
154
+ if (!messageId || !senderId || !chatId
155
+ || !['single', 'group'].includes(body.chattype)
156
+ || this.#state.hasSeen(messageId)
157
+ || this.#acceptedMessageIds.has(messageId)) return Promise.resolve();
158
+
118
159
  const key = conversationKey(frame);
160
+ this.#acceptedMessageIds.add(messageId);
161
+ const pending = this.#pendingInteractions.get(key);
162
+ if (pending && pending.actor !== senderId) {
163
+ return this.#enqueueMessage(frame, messageId, key);
164
+ }
165
+ if (pending?.submitting || pending?.claimedReplyMessageId) {
166
+ return this.#enqueueMessage(frame, messageId, key);
167
+ }
168
+ if (pending) {
169
+ if (canClaimInteractionReply(frame, pending)) {
170
+ pending.claimedReplyMessageId = messageId;
171
+ }
172
+ const previous = pending.queue ?? Promise.resolve();
173
+ const current = previous
174
+ .catch(() => undefined)
175
+ .then(() => this.#processInteractionReply(
176
+ frame,
177
+ messageId,
178
+ senderId,
179
+ chatId,
180
+ key,
181
+ pending,
182
+ ))
183
+ .finally(() => {
184
+ this.#acceptedMessageIds.delete(messageId);
185
+ if (pending.claimedReplyMessageId === messageId) {
186
+ pending.claimedReplyMessageId = null;
187
+ }
188
+ if (pending.queue === current) pending.queue = null;
189
+ });
190
+ pending.queue = current;
191
+ return current;
192
+ }
193
+ return this.#enqueueMessage(frame, messageId, key);
194
+ }
195
+
196
+ #enqueueMessage(frame, messageId, key, {
197
+ releaseMessageId = true,
198
+ alreadyRecorded = false,
199
+ } = {}) {
119
200
  const previous = this.#queues.get(key) ?? Promise.resolve();
120
201
  const current = previous
121
202
  .catch(() => undefined)
122
- .then(() => this.#process(frame))
203
+ .then(() => this.#process(frame, { alreadyRecorded }))
123
204
  .finally(() => {
205
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
124
206
  if (this.#queues.get(key) === current) this.#queues.delete(key);
125
207
  });
126
208
  this.#queues.set(key, current);
@@ -128,16 +210,23 @@ export class WecomHarnessBridge {
128
210
  }
129
211
 
130
212
  async waitForIdle() {
131
- await Promise.allSettled([...this.#queues.values()]);
213
+ await Promise.allSettled([
214
+ ...this.#queues.values(),
215
+ ...[...this.#pendingInteractions.values()].flatMap((pending) => (
216
+ pending.queue ? [pending.queue] : []
217
+ )),
218
+ ]);
132
219
  }
133
220
 
134
221
  async #sendActive(chatId, text) {
135
222
  for (const chunk of splitUtf8(text)) {
223
+ this.#signal?.throwIfAborted();
136
224
  await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
137
225
  }
138
226
  }
139
227
 
140
228
  async #sendImmediate(frame, chatId, text) {
229
+ this.#signal?.throwIfAborted();
141
230
  const chunks = splitUtf8(text);
142
231
  if (chunks.length === 0) return;
143
232
  try {
@@ -150,17 +239,20 @@ export class WecomHarnessBridge {
150
239
  }
151
240
  }
152
241
 
153
- async #process(frame) {
242
+ async #process(frame, { alreadyRecorded = false } = {}) {
243
+ if (this.#signal?.aborted) return;
154
244
  const body = bodyOf(frame);
155
245
  const messageId = typeof body.msgid === 'string' ? body.msgid : '';
156
246
  const senderId = typeof body.from?.userid === 'string' ? body.from.userid : '';
157
247
  const chatId = body.chattype === 'group' ? body.chatid : senderId;
158
248
  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();
249
+ if (!alreadyRecorded) {
250
+ if (this.#state.hasSeen(messageId)) return;
251
+ this.#status.messagesReceived += 1;
252
+ this.#status.lastMessageAt = new Date().toISOString();
253
+ }
163
254
  const text = messageText(frame);
255
+ const key = conversationKey(frame);
164
256
  let streamId = null;
165
257
  let streamStarted = false;
166
258
  try {
@@ -176,12 +268,11 @@ export class WecomHarnessBridge {
176
268
  return;
177
269
  }
178
270
  if (command === '/status') {
179
- await this.#harness.ensureRunning();
271
+ await this.#harness.ensureRunning({ signal: this.#signal });
180
272
  await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
181
273
  await this.#state.markSeen(messageId);
182
274
  return;
183
275
  }
184
- const key = conversationKey(frame);
185
276
  if (command === '/new') {
186
277
  await this.#state.clearSession(key);
187
278
  await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
@@ -210,14 +301,24 @@ export class WecomHarnessBridge {
210
301
  state: this.#state,
211
302
  key,
212
303
  text,
304
+ createOptions: { signal: this.#signal },
305
+ existsOptions: { signal: this.#signal },
213
306
  askOptions: {
214
307
  timeoutMs: this.#replyTimeoutMs,
308
+ signal: this.#signal,
215
309
  onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
216
310
  ? async (update) => {
217
311
  const progress = splitUtf8(progressText(update))[0];
218
312
  if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
219
313
  }
220
314
  : undefined,
315
+ onInteraction: (interaction) => this.#handleInteraction(interaction, {
316
+ key,
317
+ actor: senderId,
318
+ chatId,
319
+ requiresMention: body.chattype === 'group',
320
+ }),
321
+ onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
221
322
  },
222
323
  });
223
324
 
@@ -240,6 +341,7 @@ export class WecomHarnessBridge {
240
341
  this.#status.lastReplyAt = new Date().toISOString();
241
342
  this.#status.lastError = null;
242
343
  } catch (error) {
344
+ if (this.#signal?.aborted) return;
243
345
  this.#status.lastError = error?.message ?? String(error);
244
346
  this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
245
347
  try {
@@ -252,6 +354,276 @@ export class WecomHarnessBridge {
252
354
  } catch {
253
355
  this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
254
356
  }
357
+ } finally {
358
+ await this.#cancelPendingInteraction(key);
359
+ }
360
+ }
361
+
362
+ async #processInteractionReply(frame, messageId, senderId, chatId, key, expected) {
363
+ if (this.#signal?.aborted) return;
364
+ const current = this.#pendingInteractions.get(key);
365
+ const claimed = expected.claimedReplyMessageId === messageId;
366
+ if (!current || current !== expected || current.submitting) {
367
+ if (claimed && (!current || current !== expected)) {
368
+ return this.#discardResolvedInteractionReply(frame, messageId, chatId);
369
+ }
370
+ return this.#enqueueMessage(frame, messageId, key, { releaseMessageId: false });
371
+ }
372
+ if (this.#state.hasSeen(messageId)) return;
373
+ await this.#state.markSeen(messageId);
374
+ this.#status.messagesReceived += 1;
375
+ this.#status.lastMessageAt = new Date().toISOString();
376
+
377
+ const text = nonEmptyString(messageText(frame));
378
+ if (!text) {
379
+ await this.#sendImmediate(frame, chatId, '请用文字或语音回答当前问题。')
380
+ .catch(() => undefined);
381
+ return;
382
+ }
383
+
384
+ const pending = this.#pendingInteractions.get(key);
385
+ if (!pending || pending !== expected || pending.submitting) {
386
+ if (claimed && (!pending || pending !== expected)) {
387
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
388
+ .catch(() => undefined);
389
+ return;
390
+ }
391
+ return this.#enqueueMessage(frame, messageId, key, {
392
+ releaseMessageId: false,
393
+ alreadyRecorded: true,
394
+ });
395
+ }
396
+ if (pending.actor !== senderId) {
397
+ return this.#enqueueMessage(frame, messageId, key, {
398
+ releaseMessageId: false,
399
+ alreadyRecorded: true,
400
+ });
401
+ }
402
+
403
+ pending.chatId = chatId;
404
+ if (pending.needsPresentation) {
405
+ try {
406
+ await this.#presentInteraction(pending);
407
+ } catch {
408
+ this.#status.lastError = '企业微信交互问题发送失败。';
409
+ this.#logger.error?.('[dsh-im:wecom] failed to retry an interaction question');
410
+ pending.interaction.reconnect?.();
411
+ return;
412
+ }
413
+ const presentedPending = this.#pendingInteractions.get(key);
414
+ if (!presentedPending || presentedPending !== expected || presentedPending.submitting) {
415
+ if (claimed && (!presentedPending || presentedPending !== expected)) {
416
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
417
+ .catch(() => undefined);
418
+ return;
419
+ }
420
+ return this.#enqueueMessage(frame, messageId, key, {
421
+ releaseMessageId: false,
422
+ alreadyRecorded: true,
423
+ });
424
+ }
425
+ }
426
+
427
+ const question = pending.questions[pending.index];
428
+ if (!question) return;
429
+ pending.answers.push(harnessAnswerForQuestion(question, text));
430
+ pending.index += 1;
431
+ if (pending.index < pending.questions.length) {
432
+ if (pending.claimedReplyMessageId === messageId) {
433
+ pending.claimedReplyMessageId = null;
434
+ }
435
+ pending.needsPresentation = true;
436
+ try {
437
+ await this.#presentInteraction(pending);
438
+ } catch {
439
+ this.#status.lastError = '企业微信交互问题发送失败。';
440
+ this.#logger.error?.('[dsh-im:wecom] failed to send the next interaction question');
441
+ pending.interaction.reconnect?.();
442
+ }
443
+ return;
444
+ }
445
+
446
+ pending.submitting = true;
447
+ try {
448
+ await pending.interaction.respond({
449
+ ok: true,
450
+ value: {
451
+ sessionId: pending.sessionId,
452
+ answer: { answers: pending.answers },
453
+ },
454
+ });
455
+ this.#clearPendingInteraction(key, pending.interactionId);
456
+ this.#status.lastError = null;
457
+ } catch (error) {
458
+ if (this.#signal?.aborted) return;
459
+ if (error?.code === 'interaction-not-pending') {
460
+ if (this.#pendingInteractions.get(key) === pending) {
461
+ this.#clearPendingInteraction(key, pending.interactionId);
462
+ }
463
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT)
464
+ .catch(() => undefined);
465
+ return;
466
+ }
467
+ if (this.#pendingInteractions.get(key) !== pending) return;
468
+ pending.submitting = false;
469
+ pending.answers.pop();
470
+ pending.index -= 1;
471
+ this.#status.lastError = '回答提交失败。';
472
+ this.#logger.error?.('[dsh-im:wecom] failed to answer a Harness interaction');
473
+ await this.#sendImmediate(frame, chatId, '回答提交失败,请重新发送当前问题的答案。')
474
+ .catch(() => undefined);
475
+ }
476
+ }
477
+
478
+ async #handleInteraction(interaction, {
479
+ key,
480
+ actor,
481
+ chatId,
482
+ requiresMention,
483
+ }) {
484
+ // Approval remains unanswered until #5 supplies an authenticated policy.
485
+ if (interaction?.kind !== 'question') return;
486
+ const questions = interaction?.payload?.questions;
487
+ const interactionId = typeof interaction?.interactionId === 'string'
488
+ ? interaction.interactionId
489
+ : interaction?.rpcId;
490
+ if (typeof interaction?.rpcId !== 'string'
491
+ || typeof interactionId !== 'string'
492
+ || typeof interaction.sessionId !== 'string'
493
+ || !Array.isArray(questions)
494
+ || questions.length === 0
495
+ || questions.some((question) => !validHarnessQuestion(question))) {
496
+ this.#logger.warn?.('[dsh-im:wecom] ignored an invalid Harness question interaction');
497
+ return;
498
+ }
499
+
500
+ if (interaction.recovered === true) {
501
+ await interaction.respond({
502
+ ok: false,
503
+ error: {
504
+ code: 'cancelled',
505
+ message: 'Enterprise WeChat safely cancelled an interaction left by an earlier client.',
506
+ details: {},
507
+ },
508
+ });
509
+ await this.#sendActive(
510
+ chatId,
511
+ '检测到这个 Session 中遗留的待回答问题,已安全取消并继续处理你刚才的消息。',
512
+ ).catch(() => undefined);
513
+ return;
514
+ }
515
+
516
+ const existing = this.#pendingInteractions.get(key);
517
+ if (existing?.interactionId === interactionId) {
518
+ existing.interaction = interaction;
519
+ if (existing.needsPresentation) await this.#presentInteraction(existing);
520
+ return;
521
+ }
522
+ if (this.#interactionKeys.has(interactionId)) return;
523
+ if (existing) {
524
+ this.#logger.warn?.('[dsh-im:wecom] cancelled a second pending Harness question');
525
+ await interaction.respond({
526
+ ok: false,
527
+ error: {
528
+ code: 'cancelled',
529
+ message: 'Enterprise WeChat is already handling another user interaction.',
530
+ details: {},
531
+ },
532
+ });
533
+ return;
534
+ }
535
+
536
+ const pending = {
537
+ kind: 'question',
538
+ interactionId,
539
+ sessionId: interaction.sessionId,
540
+ interaction,
541
+ actor,
542
+ requiresMention,
543
+ questions,
544
+ answers: [],
545
+ index: 0,
546
+ chatId,
547
+ queue: null,
548
+ claimedReplyMessageId: null,
549
+ submitting: false,
550
+ needsPresentation: true,
551
+ presentationPromise: null,
552
+ };
553
+ this.#pendingInteractions.set(key, pending);
554
+ this.#interactionKeys.set(pending.interactionId, key);
555
+ await this.#presentInteraction(pending);
556
+ }
557
+
558
+ #handleInteractionResolved(resolution) {
559
+ const interactionId = resolution?.interactionId;
560
+ if (resolution?.kind !== 'question' || typeof interactionId !== 'string') return;
561
+ const key = this.#interactionKeys.get(interactionId);
562
+ if (!key) return;
563
+ this.#clearPendingInteraction(key, interactionId);
564
+ }
565
+
566
+ #presentInteraction(pending) {
567
+ if (!pending.needsPresentation) return Promise.resolve();
568
+ if (pending.presentationPromise) return pending.presentationPromise;
569
+ const question = pending.questions[pending.index];
570
+ if (!question) return Promise.resolve();
571
+ const presentation = this.#sendActive(
572
+ pending.chatId,
573
+ harnessQuestionText(
574
+ question,
575
+ pending.index,
576
+ pending.questions.length,
577
+ { requiresMention: pending.requiresMention },
578
+ ),
579
+ ).then(() => {
580
+ pending.needsPresentation = false;
581
+ }).finally(() => {
582
+ if (pending.presentationPromise === presentation) {
583
+ pending.presentationPromise = null;
584
+ }
585
+ });
586
+ pending.presentationPromise = presentation;
587
+ return presentation;
588
+ }
589
+
590
+ async #discardResolvedInteractionReply(frame, messageId, chatId) {
591
+ if (this.#state.hasSeen(messageId)) return;
592
+ await this.#state.markSeen(messageId);
593
+ this.#status.messagesReceived += 1;
594
+ this.#status.lastMessageAt = new Date().toISOString();
595
+ await this.#sendImmediate(frame, chatId, INTERACTION_RESOLVED_TEXT).catch(() => undefined);
596
+ }
597
+
598
+ #takePendingInteraction(key, interactionId) {
599
+ const pending = this.#pendingInteractions.get(key);
600
+ if (!pending
601
+ || (interactionId !== undefined && pending.interactionId !== interactionId)) return null;
602
+ this.#pendingInteractions.delete(key);
603
+ this.#interactionKeys.delete(pending.interactionId);
604
+ return pending;
605
+ }
606
+
607
+ #clearPendingInteraction(key, interactionId) {
608
+ return this.#takePendingInteraction(key, interactionId) !== null;
609
+ }
610
+
611
+ async #cancelPendingInteraction(key) {
612
+ const pending = this.#takePendingInteraction(key);
613
+ if (!pending || pending.kind !== 'question') return;
614
+ try {
615
+ await pending.interaction.respond({
616
+ ok: false,
617
+ error: {
618
+ code: 'cancelled',
619
+ message: 'The Enterprise WeChat interaction ended before the user answered.',
620
+ details: {},
621
+ },
622
+ }, { signal: AbortSignal.timeout(5_000) });
623
+ } catch (error) {
624
+ if (error?.code !== 'interaction-not-pending') {
625
+ this.#logger.warn?.('[dsh-im:wecom] failed to cancel a pending Harness interaction');
626
+ }
255
627
  }
256
628
  }
257
629
  }
@@ -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;