@xmanrui/dsh-im 0.6.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 (42) hide show
  1. package/README.md +22 -4
  2. package/lib/client.js +649 -357
  3. package/lib/index.js +120 -111
  4. package/package.json +1 -1
  5. package/plugin-src/client/build.mjs +1 -1
  6. package/plugin-src/client/i18n.js +14 -0
  7. package/plugin-src/client/index.js +11 -3
  8. package/plugin-src/client/styles.js +49 -8
  9. package/plugin-src/client/workspace-directory-picker.js +230 -0
  10. package/plugin-src/client/workspace-editor.js +38 -65
  11. package/scripts/verify-package.mjs +5 -0
  12. package/src/channels/dingtalk/dingtalk-bridge.mjs +363 -9
  13. package/src/channels/dingtalk/harness-client.mjs +16 -310
  14. package/src/channels/discord/discord-api.mjs +1 -1
  15. package/src/channels/discord/discord-runtime.mjs +11 -1
  16. package/src/channels/discord/harness-client.mjs +10 -2
  17. package/src/channels/feishu/bridge.mjs +525 -51
  18. package/src/channels/feishu/feishu-runtime.mjs +41 -1
  19. package/src/channels/feishu/harness-client.mjs +16 -279
  20. package/src/channels/qq/harness-client.mjs +10 -2
  21. package/src/channels/qq/qq-bridge.mjs +383 -29
  22. package/src/channels/qq/qq-runtime.mjs +14 -3
  23. package/src/channels/shared/bot-workspace-store.mjs +185 -4
  24. package/src/channels/shared/harness-client.mjs +825 -0
  25. package/src/channels/shared/harness-question.mjs +85 -0
  26. package/src/channels/shared/harness-session-binding.mjs +110 -0
  27. package/src/channels/shared/text-harness-bridge.mjs +439 -25
  28. package/src/channels/shared/workspace-command.mjs +212 -16
  29. package/src/channels/shared/workspace-session.mjs +22 -9
  30. package/src/channels/slack/harness-client.mjs +10 -2
  31. package/src/channels/slack/slack-runtime.mjs +11 -1
  32. package/src/channels/telegram/harness-client.mjs +10 -2
  33. package/src/channels/telegram/telegram-runtime.mjs +15 -4
  34. package/src/channels/wecom/harness-client.mjs +10 -2
  35. package/src/channels/wecom/wecom-bridge.mjs +389 -15
  36. package/src/channels/wecom/wecom-runtime.mjs +6 -0
  37. package/src/channels/weixin/harness-client.mjs +16 -270
  38. package/src/channels/weixin/weixin-api.mjs +1 -1
  39. package/src/channels/weixin/weixin-bridge.mjs +406 -24
  40. package/src/channels/weixin/weixin-runtime.mjs +56 -7
  41. package/src/channels/whatsapp/harness-client.mjs +10 -2
  42. package/src/channels/whatsapp/whatsapp-runtime.mjs +1 -0
@@ -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
 
@@ -9,10 +14,17 @@ const HELP_TEXT = [
9
14
  '/new 开启一个全新会话',
10
15
  '/workspace 工作区绝对路径 切换工作区',
11
16
  '/workspacelist 列出工作区绝对路径',
17
+ '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
18
+ '/session Session ID 将当前聊天绑定到指定会话',
12
19
  '/status 检查连接状态',
13
20
  '/help 显示本帮助',
14
21
  ].join('\n');
15
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
+ }
16
28
 
17
29
  function bodyOf(frame) {
18
30
  return frame?.body && typeof frame.body === 'object' ? frame.body : {};
@@ -25,16 +37,23 @@ function conversationKey(frame) {
25
37
 
26
38
  function messageText(frame) {
27
39
  const body = bodyOf(frame);
28
- if (body.msgtype === 'text') return typeof body.text?.content === 'string' ? body.text.content.trim() : '';
29
- if (body.msgtype === 'voice') return typeof body.voice?.content === 'string' ? body.voice.content.trim() : '';
30
- if (body.msgtype === 'mixed' && Array.isArray(body.mixed?.msg_item)) {
31
- 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
32
47
  .filter((item) => item?.msgtype === 'text' && typeof item.text?.content === 'string')
33
48
  .map((item) => item.text.content)
34
49
  .join('\n')
35
50
  .trim();
36
51
  }
37
- 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;
38
57
  }
39
58
 
40
59
  function splitUtf8(text, maxBytes = MAX_REPLY_BYTES) {
@@ -64,6 +83,12 @@ function progressText(update) {
64
83
  return update?.text;
65
84
  }
66
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
+
67
92
  export function createWecomBridgeStatus() {
68
93
  return {
69
94
  messagesReceived: 0,
@@ -84,7 +109,11 @@ export class WecomHarnessBridge {
84
109
  #logger;
85
110
  #replyTimeoutMs;
86
111
  #generateReqId;
112
+ #signal;
87
113
  #queues = new Map();
114
+ #pendingInteractions = new Map();
115
+ #interactionKeys = new Map();
116
+ #acceptedMessageIds = new Set();
88
117
 
89
118
  constructor({
90
119
  client,
@@ -94,6 +123,7 @@ export class WecomHarnessBridge {
94
123
  logger = console,
95
124
  replyTimeoutMs = 600_000,
96
125
  generateStreamId = generateReqId,
126
+ signal,
97
127
  }) {
98
128
  if (!client || typeof client.replyStream !== 'function' || typeof client.sendMessage !== 'function') {
99
129
  throw new TypeError('Enterprise WeChat client is required');
@@ -106,6 +136,7 @@ export class WecomHarnessBridge {
106
136
  this.#logger = logger;
107
137
  this.#replyTimeoutMs = replyTimeoutMs;
108
138
  this.#generateReqId = generateStreamId;
139
+ this.#signal = signal;
109
140
  }
110
141
 
111
142
  get status() {
@@ -113,12 +144,65 @@ export class WecomHarnessBridge {
113
144
  }
114
145
 
115
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
+
116
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
+ } = {}) {
117
200
  const previous = this.#queues.get(key) ?? Promise.resolve();
118
201
  const current = previous
119
202
  .catch(() => undefined)
120
- .then(() => this.#process(frame))
203
+ .then(() => this.#process(frame, { alreadyRecorded }))
121
204
  .finally(() => {
205
+ if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
122
206
  if (this.#queues.get(key) === current) this.#queues.delete(key);
123
207
  });
124
208
  this.#queues.set(key, current);
@@ -126,16 +210,23 @@ export class WecomHarnessBridge {
126
210
  }
127
211
 
128
212
  async waitForIdle() {
129
- 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
+ ]);
130
219
  }
131
220
 
132
221
  async #sendActive(chatId, text) {
133
222
  for (const chunk of splitUtf8(text)) {
223
+ this.#signal?.throwIfAborted();
134
224
  await this.#client.sendMessage(chatId, { msgtype: 'markdown', markdown: { content: chunk } });
135
225
  }
136
226
  }
137
227
 
138
228
  async #sendImmediate(frame, chatId, text) {
229
+ this.#signal?.throwIfAborted();
139
230
  const chunks = splitUtf8(text);
140
231
  if (chunks.length === 0) return;
141
232
  try {
@@ -148,17 +239,20 @@ export class WecomHarnessBridge {
148
239
  }
149
240
  }
150
241
 
151
- async #process(frame) {
242
+ async #process(frame, { alreadyRecorded = false } = {}) {
243
+ if (this.#signal?.aborted) return;
152
244
  const body = bodyOf(frame);
153
245
  const messageId = typeof body.msgid === 'string' ? body.msgid : '';
154
246
  const senderId = typeof body.from?.userid === 'string' ? body.from.userid : '';
155
247
  const chatId = body.chattype === 'group' ? body.chatid : senderId;
156
248
  if (!messageId || !senderId || !chatId || !['single', 'group'].includes(body.chattype)) return;
157
- if (this.#state.hasSeen(messageId)) return;
158
-
159
- this.#status.messagesReceived += 1;
160
- 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
+ }
161
254
  const text = messageText(frame);
255
+ const key = conversationKey(frame);
162
256
  let streamId = null;
163
257
  let streamStarted = false;
164
258
  try {
@@ -174,19 +268,18 @@ export class WecomHarnessBridge {
174
268
  return;
175
269
  }
176
270
  if (command === '/status') {
177
- await this.#harness.ensureRunning();
271
+ await this.#harness.ensureRunning({ signal: this.#signal });
178
272
  await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
179
273
  await this.#state.markSeen(messageId);
180
274
  return;
181
275
  }
182
- const key = conversationKey(frame);
183
276
  if (command === '/new') {
184
277
  await this.#state.clearSession(key);
185
278
  await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
186
279
  await this.#state.markSeen(messageId);
187
280
  return;
188
281
  }
189
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness);
282
+ const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
190
283
  if (workspaceCommand) {
191
284
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
192
285
  await this.#sendImmediate(frame, chatId, reply);
@@ -208,14 +301,24 @@ export class WecomHarnessBridge {
208
301
  state: this.#state,
209
302
  key,
210
303
  text,
304
+ createOptions: { signal: this.#signal },
305
+ existsOptions: { signal: this.#signal },
211
306
  askOptions: {
212
307
  timeoutMs: this.#replyTimeoutMs,
308
+ signal: this.#signal,
213
309
  onUpdate: streamStarted && typeof this.#client.replyStreamNonBlocking === 'function'
214
310
  ? async (update) => {
215
311
  const progress = splitUtf8(progressText(update))[0];
216
312
  if (progress) await this.#client.replyStreamNonBlocking(frame, streamId, progress, false);
217
313
  }
218
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),
219
322
  },
220
323
  });
221
324
 
@@ -238,6 +341,7 @@ export class WecomHarnessBridge {
238
341
  this.#status.lastReplyAt = new Date().toISOString();
239
342
  this.#status.lastError = null;
240
343
  } catch (error) {
344
+ if (this.#signal?.aborted) return;
241
345
  this.#status.lastError = error?.message ?? String(error);
242
346
  this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
243
347
  try {
@@ -250,6 +354,276 @@ export class WecomHarnessBridge {
250
354
  } catch {
251
355
  this.#logger.error?.('[dsh-im:wecom] failed to send the safe error reply');
252
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
+ }
253
627
  }
254
628
  }
255
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;