@xmanrui/dsh-im 2.4.0 → 2.6.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 (60) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +322 -109
  4. package/lib/index.js +218 -211
  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 +9 -1
  11. package/plugin-src/client/channels/qq/api.js +2 -0
  12. package/plugin-src/client/channels/qq/index.js +9 -1
  13. package/plugin-src/client/channels/shared/token-api.js +2 -0
  14. package/plugin-src/client/channels/shared/token-channel.js +9 -1
  15. package/plugin-src/client/channels/wecom/api.js +2 -0
  16. package/plugin-src/client/channels/wecom/index.js +9 -1
  17. package/plugin-src/client/channels/weixin/api.js +2 -10
  18. package/plugin-src/client/channels/weixin/index.js +9 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +2 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +9 -1
  21. package/plugin-src/client/i18n.js +4 -0
  22. package/plugin-src/client/index.js +16 -2
  23. package/plugin-src/client/last-message-error.js +17 -0
  24. package/plugin-src/client/styles.js +5 -1
  25. package/plugin-src/host/channels/feishu/rpc.mjs +3 -0
  26. package/src/channels/dingtalk/dingtalk-api.mjs +102 -6
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +247 -38
  28. package/src/channels/dingtalk/dingtalk-card-stream.mjs +2 -2
  29. package/src/channels/dingtalk/dingtalk-controller.mjs +2 -0
  30. package/src/channels/discord/discord-api.mjs +35 -0
  31. package/src/channels/discord/discord-bridge.mjs +1 -0
  32. package/src/channels/discord/discord-runtime.mjs +26 -0
  33. package/src/channels/feishu/bridge.mjs +115 -69
  34. package/src/channels/feishu/multi-bot-controller.mjs +2 -0
  35. package/src/channels/qq/qq-bridge.mjs +83 -28
  36. package/src/channels/qq/qq-controller.mjs +2 -0
  37. package/src/channels/shared/harness-client.mjs +40 -4
  38. package/src/channels/shared/i18n-en/shared-a.mjs +77 -0
  39. package/src/channels/shared/message-failure.mjs +244 -0
  40. package/src/channels/shared/semantic/artifact-delivery.mjs +9 -2
  41. package/src/channels/shared/status-reaction.mjs +107 -0
  42. package/src/channels/shared/text-harness-bridge.mjs +123 -66
  43. package/src/channels/shared/token-bot-controller.mjs +2 -0
  44. package/src/channels/slack/manifest.mjs +1 -0
  45. package/src/channels/slack/slack-api.mjs +28 -0
  46. package/src/channels/slack/slack-bridge.mjs +5 -0
  47. package/src/channels/slack/slack-controller.mjs +2 -0
  48. package/src/channels/slack/slack-runtime.mjs +24 -0
  49. package/src/channels/telegram/telegram-api.mjs +9 -0
  50. package/src/channels/telegram/telegram-bridge.mjs +1 -0
  51. package/src/channels/telegram/telegram-runtime.mjs +20 -0
  52. package/src/channels/wecom/wecom-bridge.mjs +49 -15
  53. package/src/channels/wecom/wecom-controller.mjs +2 -0
  54. package/src/channels/weixin/weixin-api.mjs +47 -0
  55. package/src/channels/weixin/weixin-bridge.mjs +261 -43
  56. package/src/channels/weixin/weixin-controller.mjs +2 -15
  57. package/src/channels/weixin/weixin-runtime.mjs +1 -0
  58. package/src/channels/whatsapp/whatsapp-bridge.mjs +1 -0
  59. package/src/channels/whatsapp/whatsapp-controller.mjs +2 -0
  60. package/src/channels/whatsapp/whatsapp-runtime.mjs +36 -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。'),
@@ -130,16 +137,6 @@ function canClaimInteractionReply(message, pending) {
130
137
  && nonEmptyString(extractWeixinText(message));
131
138
  }
132
139
 
133
- function safeMessageError(error, userMessage = GENERIC_PROCESSING_ERROR()) {
134
- const diagnostic = imagePromptDiagnostic(error);
135
- return {
136
- code: diagnostic?.code ?? 'message-processing-failed',
137
- reason: diagnostic?.reason ?? 'UNKNOWN',
138
- message: diagnostic?.userMessage ?? userMessage,
139
- at: Date.now(),
140
- };
141
- }
142
-
143
140
  function artifactFailureText(fileName, error) {
144
141
  const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
145
142
  switch (error?.code) {
@@ -186,6 +183,7 @@ export class WeixinHarnessBridge {
186
183
  #logger;
187
184
  #replyTimeoutMs;
188
185
  #maxMessageChars;
186
+ #typingKeepaliveMs;
189
187
  #signal;
190
188
  #queues = new Map();
191
189
  #pendingInteractions = new Map();
@@ -195,6 +193,14 @@ export class WeixinHarnessBridge {
195
193
  #commandTasks = new Set();
196
194
  #approvals;
197
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;
198
204
 
199
205
  constructor({
200
206
  api,
@@ -207,11 +213,15 @@ export class WeixinHarnessBridge {
207
213
  logger = console,
208
214
  replyTimeoutMs = 600_000,
209
215
  maxMessageChars = DEFAULT_WEIXIN_MAX_MESSAGE_CHARS,
216
+ typingKeepaliveMs = DEFAULT_TYPING_KEEPALIVE_MS,
210
217
  signal,
211
218
  }) {
212
219
  if (!api || typeof api.sendText !== 'function') throw new TypeError('Weixin API is required');
213
220
  if (!baseUrl || !token || !ownerUserId) throw new TypeError('Weixin account credentials are required');
214
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
+ }
215
225
  this.#api = api;
216
226
  this.#baseUrl = baseUrl;
217
227
  this.#token = token;
@@ -222,6 +232,7 @@ export class WeixinHarnessBridge {
222
232
  this.#logger = logger;
223
233
  this.#replyTimeoutMs = replyTimeoutMs;
224
234
  this.#maxMessageChars = maxMessageChars;
235
+ this.#typingKeepaliveMs = typingKeepaliveMs;
225
236
  this.#signal = signal;
226
237
  this.#approvals = new HarnessApprovalQueue({ label: 'weixin', logger });
227
238
  }
@@ -268,6 +279,7 @@ export class WeixinHarnessBridge {
268
279
  return this.#finishBatchResult(
269
280
  message,
270
281
  messageId,
282
+ key,
271
283
  sender,
272
284
  contextToken,
273
285
  runId,
@@ -294,9 +306,12 @@ export class WeixinHarnessBridge {
294
306
  ).catch((error) => {
295
307
  if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
296
308
  this.#status.lastError = error?.message ?? String(error);
297
- this.#status.lastMessageError = safeMessageError(error);
298
- this.#logger.error?.('[dsh-weixin] failed to process a command:', error);
299
- 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)
300
315
  .catch(() => undefined);
301
316
  }).finally(() => {
302
317
  this.#acceptedMessageIds.delete(messageId);
@@ -386,7 +401,7 @@ export class WeixinHarnessBridge {
386
401
  return current;
387
402
  }
388
403
 
389
- #finishBatchResult(message, messageId, sender, contextToken, runId, result) {
404
+ #finishBatchResult(message, messageId, key, sender, contextToken, runId, result) {
390
405
  let task;
391
406
  task = Promise.resolve().then(async () => {
392
407
  if (this.#state.hasSeen(messageId)) return;
@@ -394,16 +409,18 @@ export class WeixinHarnessBridge {
394
409
  this.#status.messagesReceived += 1;
395
410
  this.#status.lastMessageAt = new Date().toISOString();
396
411
  if (result.message) {
397
- await this.#send(sender, result.message, contextToken, runId);
412
+ await this.#sendOutOfBand(key, sender, result.message, contextToken, runId);
398
413
  }
399
414
  this.#status.lastError = null;
400
- this.#status.lastMessageError = null;
401
415
  }).catch(async (error) => {
402
416
  if (this.#signal?.aborted) return;
403
417
  this.#status.lastError = error?.message ?? String(error);
404
- this.#status.lastMessageError = safeMessageError(error);
405
- this.#logger.error?.('[dsh-weixin] failed to process a batch input message:', error);
406
- 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)
407
424
  .catch(() => undefined);
408
425
  }).finally(() => {
409
426
  this.#acceptedMessageIds.delete(messageId);
@@ -421,9 +438,15 @@ export class WeixinHarnessBridge {
421
438
  )),
422
439
  ...this.#approvalTasks,
423
440
  ...this.#commandTasks,
441
+ this.#typingTail,
424
442
  ]);
425
443
  }
426
444
 
445
+ async close() {
446
+ this.#typingClosed = true;
447
+ await this.#stopTyping({ signal: AbortSignal.timeout(5_000) });
448
+ }
449
+
427
450
  async #processFastCommand(
428
451
  message,
429
452
  messageId,
@@ -454,10 +477,14 @@ export class WeixinHarnessBridge {
454
477
  ]);
455
478
  }
456
479
  for (const reply of result?.messages ?? [result?.message]) {
457
- 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
+ }
458
486
  }
459
487
  this.#status.lastError = null;
460
- this.#status.lastMessageError = null;
461
488
  }
462
489
 
463
490
  async #process(message, key, {
@@ -483,6 +510,7 @@ export class WeixinHarnessBridge {
483
510
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
484
511
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
485
512
  let batchSettled = batchSubmission === null;
513
+ let promptRecorded = false;
486
514
  try {
487
515
  const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
488
516
  const text = promptMessage.content;
@@ -537,12 +565,15 @@ export class WeixinHarnessBridge {
537
565
  return;
538
566
  }
539
567
 
540
- const content = hasImages
541
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
542
- : undefined;
543
568
  let answer;
544
569
  let artifacts = [];
570
+ await this.#startTyping(sender, contextToken);
545
571
  try {
572
+ const content = hasImages
573
+ ? await promptContentForMessage(promptMessage, { signal: this.#signal })
574
+ : undefined;
575
+ await this.#state.markSeen(messageId);
576
+ promptRecorded = true;
546
577
  ({ answer, artifacts = [] } = await askInWorkspaceSession({
547
578
  harness: this.#harness,
548
579
  state: this.#state,
@@ -554,13 +585,17 @@ export class WeixinHarnessBridge {
554
585
  timeoutMs: this.#replyTimeoutMs,
555
586
  signal: this.#signal,
556
587
  control: { owner: this, key },
588
+ onUpdate: () => this.#resumeTyping(key, sender, contextToken),
557
589
  onInteraction: (interaction) => this.#handleInteraction(interaction, {
558
590
  key,
559
591
  actor: sender,
560
592
  contextToken,
561
593
  runId,
562
594
  }),
563
- onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
595
+ onInteractionResolved: async (resolution) => {
596
+ await this.#handleInteractionResolved(resolution);
597
+ await this.#resumeTyping(key, sender, contextToken);
598
+ },
564
599
  files: promptMessage.files,
565
600
  },
566
601
  }));
@@ -570,6 +605,7 @@ export class WeixinHarnessBridge {
570
605
  }
571
606
  } finally {
572
607
  await Promise.allSettled([
608
+ this.#stopTyping(),
573
609
  this.#cancelPendingInteraction(key),
574
610
  this.#approvals.closeRoute(key),
575
611
  ]);
@@ -586,7 +622,7 @@ export class WeixinHarnessBridge {
586
622
  providerMessageIds: await this.#send(sender, answerText, contextToken, runId),
587
623
  });
588
624
  } catch (error) {
589
- textDeliveryError = error;
625
+ textDeliveryError = channelDeliveryFailure(error);
590
626
  }
591
627
  const delivery = await this.#deliverArtifacts(
592
628
  sender,
@@ -597,11 +633,16 @@ export class WeixinHarnessBridge {
597
633
  textReceipt,
598
634
  );
599
635
  if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
600
- 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);
601
640
  this.#status.messagesReplied += 1;
602
641
  this.#status.lastReplyAt = new Date().toISOString();
603
642
  this.#status.lastError = null;
604
- this.#status.lastMessageError = null;
643
+ if (!textDeliveryError && delivery.artifactSendErrors === 0) {
644
+ clearLastMessageFailure(this.#status);
645
+ }
605
646
  return delivery.receipt;
606
647
  } catch (error) {
607
648
  let batchFailureMessage = null;
@@ -614,24 +655,32 @@ export class WeixinHarnessBridge {
614
655
  batchSettled = true;
615
656
  }
616
657
  if (error?.code === 'turn-stopped') {
617
- await this.#state.markSeen(messageId);
658
+ if (!promptRecorded) await this.#state.markSeen(messageId);
618
659
  return;
619
660
  }
620
661
  if (this.#signal?.aborted) return;
621
662
  this.#status.lastError = error?.message ?? String(error);
622
663
  const userMessage = inboundFileUserMessage(error)
623
- ?? imagePromptUserMessage(error)
624
- ?? GENERIC_PROCESSING_ERROR();
625
- this.#status.lastMessageError = safeMessageError(error, userMessage);
626
- 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
+ );
627
674
  try {
628
675
  await this.#send(
629
676
  sender,
630
- batchFailureMessage ? `${userMessage}\n\n${batchFailureMessage}` : userMessage,
677
+ batchFailureMessage
678
+ ? `${messageFailureText(failure)}\n\n${batchFailureMessage}`
679
+ : messageFailureText(failure),
631
680
  contextToken,
632
681
  runId,
633
682
  );
634
- await this.#state.markSeen(messageId);
683
+ if (!promptRecorded) await this.#state.markSeen(messageId);
635
684
  } catch (sendError) {
636
685
  this.#logger.error?.('[dsh-weixin] failed to send the safe error reply:', sendError);
637
686
  }
@@ -741,7 +790,6 @@ export class WeixinHarnessBridge {
741
790
  });
742
791
  this.#clearPendingInteraction(key, pending.interactionId);
743
792
  this.#status.lastError = null;
744
- this.#status.lastMessageError = null;
745
793
  } catch (error) {
746
794
  if (this.#signal?.aborted) return;
747
795
  if (error?.code === 'interaction-not-pending') {
@@ -935,20 +983,24 @@ export class WeixinHarnessBridge {
935
983
  async #handleInteractionFailure(message, messageId, error) {
936
984
  if (this.#signal?.aborted) return;
937
985
  this.#status.lastError = error?.message ?? String(error);
938
- this.#status.lastMessageError = safeMessageError(error);
939
- 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
+ );
940
991
  if (!this.#state.hasSeen(messageId)) {
941
992
  await this.#state.markSeen(messageId).catch(() => undefined);
942
993
  }
943
994
  await this.#send(
944
995
  nonEmptyString(message?.from_user_id),
945
- GENERIC_PROCESSING_ERROR(),
996
+ messageFailureText(failure),
946
997
  nonEmptyString(message?.context_token) ?? undefined,
947
998
  nonEmptyString(message?.run_id) ?? undefined,
948
999
  ).catch(() => undefined);
949
1000
  }
950
1001
 
951
1002
  async #send(toUserId, text, contextToken, runId) {
1003
+ await this.#stopTyping();
952
1004
  const providerMessageIds = [];
953
1005
  for (const chunk of splitWeixinText(text, this.#maxMessageChars)) {
954
1006
  const result = await this.#api.sendText({
@@ -965,6 +1017,164 @@ export class WeixinHarnessBridge {
965
1017
  return providerMessageIds;
966
1018
  }
967
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
+
968
1178
  async #deliverArtifacts(toUserId, replyTo, artifacts, contextToken, runId, baseReceipt) {
969
1179
  const sendArtifact = (method, file) => this.#api[method]({
970
1180
  baseUrl: this.#baseUrl,
@@ -988,9 +1198,13 @@ export class WeixinHarnessBridge {
988
1198
  sendFile: typeof this.#api.sendFile === 'function'
989
1199
  ? (file) => sendArtifact('sendFile', file)
990
1200
  : undefined,
991
- 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(
992
1206
  toUserId,
993
- artifactFailureText(artifact?.fileName, error),
1207
+ messageFailureText(failure),
994
1208
  contextToken,
995
1209
  runId,
996
1210
  ),
@@ -1000,6 +1214,10 @@ export class WeixinHarnessBridge {
1000
1214
  + delivery.artifactsSent;
1001
1215
  this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
1002
1216
  + delivery.artifactSendErrors;
1003
- return { receipt: delivery.receipt, userVisible: delivery.userVisible };
1217
+ return {
1218
+ receipt: delivery.receipt,
1219
+ userVisible: delivery.userVisible,
1220
+ artifactSendErrors: delivery.artifactSendErrors,
1221
+ };
1004
1222
  }
1005
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;
@@ -6,6 +6,7 @@ export const WHATSAPP_DESCRIPTOR = Object.freeze({
6
6
  label: 'WhatsApp',
7
7
  // Translated lazily: t() must run after setImHostLanguage, not at import time.
8
8
  get connectionLabel() { return t(' Web 关联设备'); },
9
+ reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
9
10
  });
10
11
 
11
12
  export class WhatsappHarnessBridge extends TextHarnessBridge {
@@ -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
  };
@@ -246,6 +246,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
246
246
  addressed: !group || fromMe || mentioned || replyToSelf,
247
247
  selfChat,
248
248
  replyTarget: { jid: remoteJid, quoted: message, selfChat },
249
+ reactionTarget: { jid: remoteJid, key: message.key },
249
250
  };
250
251
  }
251
252
 
@@ -411,6 +412,41 @@ export class WhatsappBotClient {
411
412
  }, 'image');
412
413
  }
413
414
 
415
+ async addReaction(target, emoji, { signal } = {}) {
416
+ if (typeof emoji !== 'string' || !emoji.trim()) {
417
+ throw new TypeError('A WhatsApp reaction emoji is required');
418
+ }
419
+ const reactionKey = emoji.trim();
420
+ await this.#sendReaction(target, reactionKey, signal);
421
+ return reactionKey;
422
+ }
423
+
424
+ removeReaction(target, _reactionKey, { signal } = {}) {
425
+ return this.#sendReaction(target, '', signal);
426
+ }
427
+
428
+ async #sendReaction(target, text, signal) {
429
+ if (typeof target?.jid !== 'string' || !target.jid || !target.key?.id) {
430
+ throw new TypeError('A WhatsApp reaction target is required');
431
+ }
432
+ const operationSignal = signal ?? this.#signal;
433
+ operationSignal?.throwIfAborted();
434
+ const messageId = randomBytes(10).toString('hex').toUpperCase();
435
+ if (typeof this.#outboundIds.reserve === 'function') {
436
+ this.#outboundIds.reserve(messageId);
437
+ } else {
438
+ this.#outboundIds.remember(messageId);
439
+ }
440
+ const pending = this.#socket.sendMessage(
441
+ target.jid,
442
+ { react: { text, key: target.key } },
443
+ { messageId },
444
+ );
445
+ const result = await waitWithSignal(pending, operationSignal);
446
+ this.#outboundIds.remember(result?.key?.id);
447
+ return result;
448
+ }
449
+
414
450
  async #sendArtifact(target, file, content, presentation) {
415
451
  this.#signal?.throwIfAborted();
416
452
  await this.#stopTyping(target.jid);