@xmanrui/dsh-im 2.3.0 → 2.5.0

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 (57) hide show
  1. package/README.en.md +9 -4
  2. package/README.md +9 -4
  3. package/lib/client.js +411 -156
  4. package/lib/index.js +218 -210
  5. package/package.json +1 -1
  6. package/plugin-src/client/channel-card-meta.js +36 -1
  7. package/plugin-src/client/channels/dingtalk/api.js +2 -0
  8. package/plugin-src/client/channels/dingtalk/index.js +9 -1
  9. package/plugin-src/client/channels/feishu/api.js +3 -0
  10. package/plugin-src/client/channels/feishu/index.js +46 -28
  11. package/plugin-src/client/channels/feishu/styles.js +26 -1
  12. package/plugin-src/client/channels/qq/api.js +2 -0
  13. package/plugin-src/client/channels/qq/index.js +9 -1
  14. package/plugin-src/client/channels/shared/token-api.js +2 -0
  15. package/plugin-src/client/channels/shared/token-channel.js +9 -1
  16. package/plugin-src/client/channels/wecom/api.js +2 -0
  17. package/plugin-src/client/channels/wecom/index.js +9 -1
  18. package/plugin-src/client/channels/weixin/api.js +2 -10
  19. package/plugin-src/client/channels/weixin/index.js +9 -5
  20. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  21. package/plugin-src/client/channels/whatsapp/index.js +9 -1
  22. package/plugin-src/client/i18n.js +31 -25
  23. package/plugin-src/client/index.js +16 -2
  24. package/plugin-src/client/last-message-error.js +17 -0
  25. package/plugin-src/client/styles.js +5 -1
  26. package/plugin-src/host/channels/feishu/rpc.mjs +3 -0
  27. package/src/channels/dingtalk/dingtalk-api.mjs +1 -1
  28. package/src/channels/dingtalk/dingtalk-bridge.mjs +50 -16
  29. package/src/channels/dingtalk/dingtalk-card-stream.mjs +2 -2
  30. package/src/channels/dingtalk/dingtalk-controller.mjs +2 -0
  31. package/src/channels/feishu/bridge.mjs +148 -70
  32. package/src/channels/feishu/feishu-cards.mjs +10 -6
  33. package/src/channels/feishu/feishu-runtime.mjs +0 -1
  34. package/src/channels/feishu/message-utils.mjs +1 -1
  35. package/src/channels/feishu/multi-bot-controller.mjs +3 -17
  36. package/src/channels/feishu/repair-manager.mjs +7 -2
  37. package/src/channels/qq/qq-bridge.mjs +84 -28
  38. package/src/channels/qq/qq-controller.mjs +2 -0
  39. package/src/channels/shared/control-command.mjs +11 -1
  40. package/src/channels/shared/harness-client.mjs +40 -4
  41. package/src/channels/shared/i18n-en/feishu.mjs +27 -25
  42. package/src/channels/shared/i18n-en/shared-a.mjs +78 -0
  43. package/src/channels/shared/i18n-en/shared-b.mjs +1 -0
  44. package/src/channels/shared/i18n-en/telegram.mjs +1 -0
  45. package/src/channels/shared/message-failure.mjs +244 -0
  46. package/src/channels/shared/semantic/artifact-delivery.mjs +9 -2
  47. package/src/channels/shared/text-harness-bridge.mjs +66 -60
  48. package/src/channels/shared/token-bot-controller.mjs +2 -0
  49. package/src/channels/slack/slack-controller.mjs +2 -0
  50. package/src/channels/telegram/telegram-runtime.mjs +1 -0
  51. package/src/channels/wecom/wecom-bridge.mjs +50 -15
  52. package/src/channels/wecom/wecom-controller.mjs +2 -0
  53. package/src/channels/weixin/weixin-api.mjs +47 -0
  54. package/src/channels/weixin/weixin-bridge.mjs +262 -43
  55. package/src/channels/weixin/weixin-controller.mjs +2 -15
  56. package/src/channels/weixin/weixin-runtime.mjs +1 -0
  57. package/src/channels/whatsapp/whatsapp-controller.mjs +2 -0
@@ -49,10 +49,17 @@ import {
49
49
  createDeliveryReceipt,
50
50
  providerMessageIdsFor,
51
51
  } from '../shared/semantic/delivery.mjs';
52
+ import {
53
+ channelDeliveryFailure,
54
+ clearLastMessageFailure,
55
+ messageFailureText,
56
+ setLastMessageFailure,
57
+ } from '../shared/message-failure.mjs';
52
58
  import { t } from '../shared/i18n.mjs';
53
59
 
54
60
  const INTERACTION_RESOLVED_TEXT = () => t('这个问题已在其他客户端处理,无需再次回答。');
55
- const GENERIC_PROCESSING_ERROR = () => t('消息处理失败,请稍后重试。');
61
+ const DEFAULT_TYPING_KEEPALIVE_MS = 5_000;
62
+ const TYPING_RETRY_DELAY_MS = 60_000;
56
63
 
57
64
  const HELP_TEXT = () => [
58
65
  t('微信已连接 DeepSeek Harness。'),
@@ -79,6 +86,7 @@ const HELP_TEXT = () => [
79
86
  t('/send 提交当前批次'),
80
87
  t('/cancel 取消当前批次'),
81
88
  t('/status 检查连接状态'),
89
+ t('/version 查看插件版本'),
82
90
  t('/help 显示本帮助'),
83
91
  ].join('\n');
84
92
 
@@ -129,16 +137,6 @@ function canClaimInteractionReply(message, pending) {
129
137
  && nonEmptyString(extractWeixinText(message));
130
138
  }
131
139
 
132
- function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR()) {
133
- const diagnostic = imagePromptDiagnostic(error);
134
- return {
135
- code: diagnostic?.code ?? 'message-processing-failed',
136
- reason: diagnostic?.reason ?? 'UNKNOWN',
137
- message: diagnostic?.userMessage ?? userMessage,
138
- at: Date.now(),
139
- };
140
- }
141
-
142
140
  function artifactFailureText(fileName, error) {
143
141
  const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
144
142
  switch (error?.code) {
@@ -185,6 +183,7 @@ export class WeixinHarnessBridge {
185
183
  #logger;
186
184
  #replyTimeoutMs;
187
185
  #maxMessageChars;
186
+ #typingKeepaliveMs;
188
187
  #signal;
189
188
  #queues = new Map();
190
189
  #pendingInteractions = new Map();
@@ -194,6 +193,14 @@ export class WeixinHarnessBridge {
194
193
  #commandTasks = new Set();
195
194
  #approvals;
196
195
  #batchInputs = new BatchInputManager();
196
+ #typingTicket = null;
197
+ #typingTarget = null;
198
+ #typingTimer = null;
199
+ #typingGeneration = 0;
200
+ #typingTail = Promise.resolve();
201
+ #typingClosed = false;
202
+ #typingRetryAt = 0;
203
+ #typingTicketStale = false;
197
204
 
198
205
  constructor({
199
206
  api,
@@ -206,11 +213,15 @@ export class WeixinHarnessBridge {
206
213
  logger = console,
207
214
  replyTimeoutMs = 600_000,
208
215
  maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
216
+ typingKeepaliveMs = DEFAULT_TYPING_KEEPALIVE_MS,
209
217
  signal,
210
218
  }) {
211
219
  if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
212
220
  if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
213
221
  if (!harness || !state) throw new TypeError('Harness client and state store are required');
222
+ if (!Number.isFinite(typingKeepaliveMs) || typingKeepaliveMs <= 0) {
223
+ throw new TypeError('typingKeepaliveMs must be a positive number');
224
+ }
214
225
  this.#api = api;
215
226
  this.#baseUrl = baseUrl;
216
227
  this.#token = token;
@@ -221,6 +232,7 @@ export class WeixinHarnessBridge {
221
232
  this.#logger = logger;
222
233
  this.#replyTimeoutMs = replyTimeoutMs;
223
234
  this.#maxMessageChars = maxMessageChars;
235
+ this.#typingKeepaliveMs = typingKeepaliveMs;
224
236
  this.#signal = signal;
225
237
  this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
226
238
  }
@@ -267,6 +279,7 @@ export class WeixinHarnessBridge {
267
279
  return this.#finishBatchResult(
268
280
  message,
269
281
  messageId,
282
+ key,
270
283
  sender,
271
284
  contextToken,
272
285
  runId,
@@ -293,9 +306,12 @@ export class WeixinHarnessBridge {
293
306
  ).catch((error) => {
294
307
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
295
308
  this.#status.lastError = error?.message ?? String(error);
296
- this.#status.lastMessageError = safeMessageError(error);
297
- this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
298
- return this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
309
+ const failure = setLastMessageFailure(this.#status, error);
310
+ this.#logger.error?.(
311
+ `[dsh-weixin] failed to process a command [${failure.referenceId}]:`,
312
+ error,
313
+ );
314
+ return this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
299
315
  .catch(() => undefined);
300
316
  }).finally(() => {
301
317
  this.#acceptedMessageIds.delete(messageId);
@@ -385,7 +401,7 @@ export class WeixinHarnessBridge {
385
401
  return current;
386
402
  }
387
403
 
388
- #finishBatchResult(message, messageId, sender, contextToken, runId, result) {
404
+ #finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
389
405
  let task;
390
406
  task = Promise.resolve().then(async () => {
391
407
  if (this.#state.hasSeen(messageId)) return;
@@ -393,16 +409,18 @@ export class WeixinHarnessBridge {
393
409
  this.#status.messagesReceived += 1;
394
410
  this.#status.lastMessageAt = new Date().toISOString();
395
411
  if (result.message) {
396
- await this.#send(sender, result.message, contextToken, runId);
412
+ await this.#sendOutOfBand(key, sender, result.message, contextToken, runId);
397
413
  }
398
414
  this.#status.lastError = null;
399
- this.#status.lastMessageError = null;
400
415
  }).catch(async (error) => {
401
416
  if (this.#signal?.aborted) return;
402
417
  this.#status.lastError = error?.message ?? String(error);
403
- this.#status.lastMessageError = safeMessageError(error);
404
- this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
405
- await this.#send(sender, GENERIC_PROCESSING_ERROR(), contextToken, runId)
418
+ const failure = setLastMessageFailure(this.#status, error);
419
+ this.#logger.error?.(
420
+ `[dsh-weixin] failed to process a batch input message [${failure.referenceId}]:`,
421
+ error,
422
+ );
423
+ await this.#sendOutOfBand(key, sender, messageFailureText(failure), contextToken, runId)
406
424
  .catch(() => undefined);
407
425
  }).finally(() => {
408
426
  this.#acceptedMessageIds.delete(messageId);
@@ -420,9 +438,15 @@ export class WeixinHarnessBridge {
420
438
  )),
421
439
  ...this.#approvalTasks,
422
440
  ...this.#commandTasks,
441
+ this.#typingTail,
423
442
  ]);
424
443
  }
425
444
 
445
+ async close() {
446
+ this.#typingClosed = true;
447
+ await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
448
+ }
449
+
426
450
  async #processFastCommand(
427
451
  message,
428
452
  messageId,
@@ -453,10 +477,14 @@ export class WeixinHarnessBridge {
453
477
  ]);
454
478
  }
455
479
  for (const reply of result?.messages ?? [result?.message]) {
456
- if (reply) await this.#send(sender, reply, contextToken, runId);
480
+ if (!reply) continue;
481
+ if (result?.stopped) {
482
+ await this.#send(sender, reply, contextToken, runId);
483
+ } else {
484
+ await this.#sendOutOfBand(key, sender, reply, contextToken, runId);
485
+ }
457
486
  }
458
487
  this.#status.lastError = null;
459
- this.#status.lastMessageError = null;
460
488
  }
461
489
 
462
490
  async #process(message, key, {
@@ -482,6 +510,7 @@ export class WeixinHarnessBridge {
482
510
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
483
511
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
484
512
  let batchSettled = batchSubmission === null;
513
+ let promptRecorded = false;
485
514
  try {
486
515
  const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
487
516
  const text = promptMessage.content;
@@ -536,12 +565,15 @@ export class WeixinHarnessBridge {
536
565
  return;
537
566
  }
538
567
 
539
- const content = hasImages
540
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
541
- : undefined;
542
568
  let answer;
543
569
  let artifacts = [];
570
+ await this.#startTyping(sender, contextToken);
544
571
  try {
572
+ const content = hasImages
573
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
574
+ : undefined;
575
+ await this.#state.markSeen(messageId);
576
+ promptRecorded = true;
545
577
  ({ answer, artifacts = [] } = await askInWorkspaceSession({
546
578
  harness: this.#harness,
547
579
  state: this.#state,
@@ -553,13 +585,17 @@ export class WeixinHarnessBridge {
553
585
  timeoutMs: this.#replyTimeoutMs,
554
586
  signal: this.#signal,
555
587
  control: { owner: this, key },
588
+ onUpdate: () => this.#resumeTyping(key, sender, contextToken),
556
589
  onInteraction: (interaction) => this.#handleInteraction(interaction, {
557
590
  key,
558
591
  actor: sender,
559
592
  contextToken,
560
593
  runId,
561
594
  }),
562
- onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
595
+ onInteractionResolved: async (resolution) => {
596
+ await this.#handleInteractionResolved(resolution);
597
+ await this.#resumeTyping(key, sender, contextToken);
598
+ },
563
599
  files: promptMessage.files,
564
600
  },
565
601
  }));
@@ -569,6 +605,7 @@ export class WeixinHarnessBridge {
569
605
  }
570
606
  } finally {
571
607
  await Promise.allSettled([
608
+ this.#stopTyping(),
572
609
  this.#cancelPendingInteraction(key),
573
610
  this.#approvals.closeRoute(key),
574
611
  ]);
@@ -585,7 +622,7 @@ export class WeixinHarnessBridge {
585
622
  providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
586
623
  });
587
624
  } catch (error) {
588
- textDeliveryError = error;
625
+ textDeliveryError = channelDeliveryFailure(error);
589
626
  }
590
627
  const delivery = await this.#deliverArtifacts(
591
628
  sender,
@@ -596,11 +633,16 @@ export class WeixinHarnessBridge {
596
633
  textReceipt,
597
634
  );
598
635
  if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
599
- await this.#state.markSeen(messageId);
636
+ if (textDeliveryError && delivery.artifactSendErrors === 0) {
637
+ setLastMessageFailure(this.#status, textDeliveryError);
638
+ }
639
+ if (!promptRecorded) await this.#state.markSeen(messageId);
600
640
  this.#status.messagesReplied += 1;
601
641
  this.#status.lastReplyAt = new Date().toISOString();
602
642
  this.#status.lastError = null;
603
- this.#status.lastMessageError = null;
643
+ if (!textDeliveryError && delivery.artifactSendErrors === 0) {
644
+ clearLastMessageFailure(this.#status);
645
+ }
604
646
  return delivery.receipt;
605
647
  } catch (error) {
606
648
  let batchFailureMessage = null;
@@ -613,24 +655,32 @@ export class WeixinHarnessBridge {
613
655
  batchSettled = true;
614
656
  }
615
657
  if (error?.code === 'turn-stopped') {
616
- await this.#state.markSeen(messageId);
658
+ if (!promptRecorded) await this.#state.markSeen(messageId);
617
659
  return;
618
660
  }
619
661
  if (this.#signal?.aborted) return;
620
662
  this.#status.lastError = error?.message ?? String(error);
621
663
  const userMessage = inboundFileUserMessage(error)
622
- ?? imagePromptUserMessage(error)
623
- ?? GENERIC_PROCESSING_ERROR();
624
- this.#status.lastMessageError = safeMessageError(error, userMessage);
625
- this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
664
+ ?? imagePromptUserMessage(error);
665
+ const imageDiagnostic = imagePromptDiagnostic(error);
666
+ const failure = setLastMessageFailure(this.#status, error, {
667
+ userMessage,
668
+ reason: imageDiagnostic?.reason,
669
+ });
670
+ this.#logger.error?.(
671
+ `[dsh-weixin] failed to process an inbound message [${failure.referenceId}]:`,
672
+ error,
673
+ );
626
674
  try {
627
675
  await this.#send(
628
676
  sender,
629
- batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
677
+ batchFailureMessage
678
+ ? `${messageFailureText(failure)}\n\n${batchFailureMessage}`
679
+ : messageFailureText(failure),
630
680
  contextToken,
631
681
  runId,
632
682
  );
633
- await this.#state.markSeen(messageId);
683
+ if (!promptRecorded) await this.#state.markSeen(messageId);
634
684
  } catch (sendError) {
635
685
  this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
636
686
  }
@@ -740,7 +790,6 @@ export class WeixinHarnessBridge {
740
790
  });
741
791
  this.#clearPendingInteraction(key, pending.interactionId);
742
792
  this.#status.lastError = null;
743
- this.#status.lastMessageError = null;
744
793
  } catch (error) {
745
794
  if (this.#signal?.aborted) return;
746
795
  if (error?.code === 'interaction-not-pending') {
@@ -934,20 +983,24 @@ export class WeixinHarnessBridge {
934
983
  async #handleInteractionFailure(message, messageId, error) {
935
984
  if (this.#signal?.aborted) return;
936
985
  this.#status.lastError = error?.message ?? String(error);
937
- this.#status.lastMessageError = safeMessageError(error);
938
- this.#logger.error?.('[dsh-weixin] failed to process an interaction reply:', error);
986
+ const failure = setLastMessageFailure(this.#status, error);
987
+ this.#logger.error?.(
988
+ `[dsh-weixin] failed to process an interaction reply [${failure.referenceId}]:`,
989
+ error,
990
+ );
939
991
  if (!this.#state.hasSeen(messageId)) {
940
992
  await this.#state.markSeen(messageId).catch(() => undefined);
941
993
  }
942
994
  await this.#send(
943
995
  nonEmptyString(message?.from_user_id),
944
- GENERIC_PROCESSING_ERROR(),
996
+ messageFailureText(failure),
945
997
  nonEmptyString(message?.context_token) ?? undefined,
946
998
  nonEmptyString(message?.run_id) ?? undefined,
947
999
  ).catch(() => undefined);
948
1000
  }
949
1001
 
950
1002
  async #send(toUserId, text, contextToken, runId) {
1003
+ await this.#stopTyping();
951
1004
  const providerMessageIds = [];
952
1005
  for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
953
1006
  const result = await this.#api.sendText({
@@ -964,6 +1017,164 @@ export class WeixinHarnessBridge {
964
1017
  return providerMessageIds;
965
1018
  }
966
1019
 
1020
+ async #sendOutOfBand(key, toUserId, text, contextToken, runId) {
1021
+ try {
1022
+ return await this.#send(toUserId, text, contextToken, runId);
1023
+ } finally {
1024
+ if (this.#queues.has(key)) {
1025
+ await this.#resumeTyping(key, toUserId, contextToken);
1026
+ }
1027
+ }
1028
+ }
1029
+
1030
+ #queueTyping(operation) {
1031
+ const task = this.#typingTail
1032
+ .catch(() => undefined)
1033
+ .then(operation);
1034
+ this.#typingTail = task.catch(() => undefined);
1035
+ return task;
1036
+ }
1037
+
1038
+ async #startTyping(toUserId, contextToken) {
1039
+ const target = nonEmptyString(toUserId);
1040
+ if (!target
1041
+ || this.#typingClosed
1042
+ || this.#signal?.aborted
1043
+ || Date.now() < this.#typingRetryAt
1044
+ || typeof this.#api.getConfig !== 'function'
1045
+ || typeof this.#api.sendTyping !== 'function') return false;
1046
+ if (this.#typingTarget === target) return true;
1047
+
1048
+ const generation = ++this.#typingGeneration;
1049
+ try {
1050
+ return await this.#queueTyping(async () => {
1051
+ if (generation !== this.#typingGeneration || this.#typingClosed) return false;
1052
+ let ticket = this.#typingTicket;
1053
+ if (!ticket) {
1054
+ const config = await this.#api.getConfig({
1055
+ baseUrl: this.#baseUrl,
1056
+ token: this.#token,
1057
+ toUserId: target,
1058
+ contextToken,
1059
+ signal: this.#signal,
1060
+ });
1061
+ ticket = nonEmptyString(config?.typingTicket);
1062
+ if (!ticket) {
1063
+ this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
1064
+ return false;
1065
+ }
1066
+ if (generation !== this.#typingGeneration || this.#typingClosed) return false;
1067
+ this.#typingTicket = ticket;
1068
+ this.#typingRetryAt = 0;
1069
+ }
1070
+
1071
+ this.#typingTarget = target;
1072
+ await this.#api.sendTyping({
1073
+ baseUrl: this.#baseUrl,
1074
+ token: this.#token,
1075
+ toUserId: target,
1076
+ typingTicket: ticket,
1077
+ status: 1,
1078
+ signal: this.#signal,
1079
+ });
1080
+ if (generation !== this.#typingGeneration || this.#typingClosed) return false;
1081
+ this.#typingTicketStale = false;
1082
+ this.#typingRetryAt = 0;
1083
+ this.#scheduleTyping(generation);
1084
+ return true;
1085
+ });
1086
+ } catch (error) {
1087
+ if (generation === this.#typingGeneration) {
1088
+ this.#clearTypingTimer();
1089
+ this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
1090
+ this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
1091
+ }
1092
+ if (!this.#signal?.aborted && !this.#typingClosed) {
1093
+ this.#logger.warn?.('[dsh-weixin] typing indicator failed:', error);
1094
+ }
1095
+ return false;
1096
+ }
1097
+ }
1098
+
1099
+ async #stopTyping({ signal = AbortSignal.timeout(5_000) } = {}) {
1100
+ ++this.#typingGeneration;
1101
+ this.#clearTypingTimer();
1102
+ const target = this.#typingTarget;
1103
+ const ticket = this.#typingTicket;
1104
+ const stale = this.#typingTicketStale;
1105
+ this.#typingTarget = null;
1106
+ if (typeof this.#api.sendTyping !== 'function') return false;
1107
+ try {
1108
+ return await this.#queueTyping(async () => {
1109
+ if (!target || !ticket) return false;
1110
+ try {
1111
+ await this.#api.sendTyping({
1112
+ baseUrl: this.#baseUrl,
1113
+ token: this.#token,
1114
+ toUserId: target,
1115
+ typingTicket: ticket,
1116
+ status: 2,
1117
+ signal,
1118
+ });
1119
+ } finally {
1120
+ if (stale && this.#typingTicket === ticket) this.#typingTicket = null;
1121
+ this.#typingTicketStale = false;
1122
+ }
1123
+ return true;
1124
+ });
1125
+ } catch (error) {
1126
+ if (!signal?.aborted) {
1127
+ this.#logger.warn?.('[dsh-weixin] typing cancellation failed:', error);
1128
+ }
1129
+ return false;
1130
+ }
1131
+ }
1132
+
1133
+ #scheduleTyping(generation) {
1134
+ this.#clearTypingTimer();
1135
+ if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) return;
1136
+ const timer = setTimeout(() => {
1137
+ if (this.#typingTimer === timer) this.#typingTimer = null;
1138
+ void this.#queueTyping(async () => {
1139
+ if (generation !== this.#typingGeneration || !this.#typingTarget || this.#typingClosed) {
1140
+ return false;
1141
+ }
1142
+ await this.#api.sendTyping({
1143
+ baseUrl: this.#baseUrl,
1144
+ token: this.#token,
1145
+ toUserId: this.#typingTarget,
1146
+ typingTicket: this.#typingTicket,
1147
+ status: 1,
1148
+ signal: this.#signal,
1149
+ });
1150
+ return true;
1151
+ }).then((sent) => {
1152
+ if (sent) this.#scheduleTyping(generation);
1153
+ }).catch((error) => {
1154
+ if (generation !== this.#typingGeneration) return;
1155
+ this.#typingTicketStale = Boolean(this.#typingTarget && this.#typingTicket);
1156
+ this.#typingRetryAt = Date.now() + TYPING_RETRY_DELAY_MS;
1157
+ if (!this.#signal?.aborted && !this.#typingClosed) {
1158
+ this.#logger.warn?.('[dsh-weixin] typing keepalive failed:', error);
1159
+ }
1160
+ });
1161
+ }, this.#typingKeepaliveMs);
1162
+ timer.unref?.();
1163
+ this.#typingTimer = timer;
1164
+ }
1165
+
1166
+ #clearTypingTimer() {
1167
+ if (this.#typingTimer) clearTimeout(this.#typingTimer);
1168
+ this.#typingTimer = null;
1169
+ }
1170
+
1171
+ #resumeTyping(key, toUserId, contextToken) {
1172
+ if (this.#pendingInteractions.has(key) || this.#approvals.hasPending(key)) {
1173
+ return Promise.resolve(false);
1174
+ }
1175
+ return this.#startTyping(toUserId, contextToken);
1176
+ }
1177
+
967
1178
  async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
968
1179
  const sendArtifact = (method, file) => this.#api[method]({
969
1180
  baseUrl: this.#baseUrl,
@@ -987,9 +1198,13 @@ export class WeixinHarnessBridge {
987
1198
  sendFile: typeof this.#api.sendFile === 'function'
988
1199
  ? (file) => sendArtifact('sendFile', file)
989
1200
  : undefined,
990
- sendFailureNotice: (artifact, error) => this.#send(
1201
+ onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
1202
+ userMessage: artifactFailureText(artifact?.fileName, error),
1203
+ reason: error?.code,
1204
+ }),
1205
+ sendFailureNotice: (_artifact, _error, failure) => this.#send(
991
1206
  toUserId,
992
- artifactFailureText(artifact?.fileName, error),
1207
+ messageFailureText(failure),
993
1208
  contextToken,
994
1209
  runId,
995
1210
  ),
@@ -999,6 +1214,10 @@ export class WeixinHarnessBridge {
999
1214
  + delivery.artifactsSent;
1000
1215
  this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
1001
1216
  + delivery.artifactSendErrors;
1002
- return { receipt: delivery.receipt, userVisible: delivery.userVisible };
1217
+ return {
1218
+ receipt: delivery.receipt,
1219
+ userVisible: delivery.userVisible,
1220
+ artifactSendErrors: delivery.artifactSendErrors,
1221
+ };
1003
1222
  }
1004
1223
  }
@@ -10,6 +10,7 @@ import {
10
10
  connectionTestMessage,
11
11
  connectionTestTargetUnavailable,
12
12
  } from '../shared/connection-test.mjs';
13
+ import { publicMessageFailure } from '../shared/message-failure.mjs';
13
14
  import { t } from '../shared/i18n.mjs';
14
15
 
15
16
  const ACTIVE_ATTEMPT_STATES = new Set([
@@ -74,20 +75,6 @@ function safeAccountError(code, message) {
74
75
  return Object.freeze({ code, message });
75
76
  }
76
77
 
77
- function publicMessageError(value) {
78
- if (!value || typeof value !== 'object'
79
- || typeof value.code !== 'string' || !value.code
80
- || typeof value.reason !== 'string' || !value.reason
81
- || typeof value.message !== 'string' || !value.message
82
- || !Number.isFinite(value.at)) return null;
83
- return {
84
- code: value.code.slice(0, 64),
85
- reason: value.reason.slice(0, 128),
86
- message: value.message.slice(0, 500),
87
- at: value.at,
88
- };
89
- }
90
-
91
78
  function activationStageError(code, cause) {
92
79
  const error = new Error(`Weixin activation failed during ${code}`, { cause });
93
80
  error.name = 'WeixinActivationStageError';
@@ -377,7 +364,7 @@ export class WeixinController {
377
364
  messagesReceived: runtimeStatus?.messagesReceived ?? 0,
378
365
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
379
366
  },
380
- lastMessageError: publicMessageError(runtimeStatus?.lastMessageError),
367
+ lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
381
368
  error: error ? structuredClone(error) : null,
382
369
  };
383
370
  });
@@ -285,6 +285,7 @@ export class WeixinRuntime {
285
285
  this.#abortController?.abort();
286
286
  this.#abortController = null;
287
287
  this.#monitor = null;
288
+ await bridge?.close?.();
288
289
  await monitor?.catch(() => undefined);
289
290
  await bridge?.waitForIdle();
290
291
  this.#bridge = null;
@@ -2,6 +2,7 @@ import { randomUUID } from 'node:crypto';
2
2
 
3
3
  import { connectionTestMessage } from '../shared/connection-test.mjs';
4
4
  import { t } from '../shared/i18n.mjs';
5
+ import { publicMessageFailure } from '../shared/message-failure.mjs';
5
6
  import {
6
7
  deriveWhatsappBotId,
7
8
  maskWhatsappAccount,
@@ -285,6 +286,7 @@ export class WhatsappController {
285
286
  messagesReceived: runtimeStatus?.messagesReceived ?? 0,
286
287
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
287
288
  },
289
+ lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
288
290
  accessPolicy: normalizeWhatsappAccessPolicy(config),
289
291
  error: structuredClone(this.#errors.get(config.botId) ?? null),
290
292
  };