@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
@@ -1,4 +1,7 @@
1
1
  import {
2
+ DINGTALK_DONE_REACTION_NAME,
3
+ DINGTALK_ERROR_REACTION_NAME,
4
+ DINGTALK_THINKING_REACTION_NAME,
2
5
  normalizeDingtalkSessionWebhook,
3
6
  splitDingtalkText,
4
7
  } from './dingtalk-api.mjs';
@@ -32,6 +35,7 @@ import {
32
35
  } from '../shared/batch-input.mjs';
33
36
  import {
34
37
  hasInboundImages,
38
+ imagePromptDiagnostic,
35
39
  imagePromptUserMessage,
36
40
  promptContentForMessage,
37
41
  } from '../shared/image-prompt.mjs';
@@ -46,10 +50,15 @@ import {
46
50
  createDeliveryReceipt,
47
51
  providerMessageIdsFor,
48
52
  } from '../shared/semantic/delivery.mjs';
53
+ import {
54
+ channelDeliveryFailure,
55
+ clearLastMessageFailure,
56
+ messageFailureText,
57
+ setLastMessageFailure,
58
+ } from '../shared/message-failure.mjs';
49
59
  import { t } from '../shared/i18n.mjs';
50
60
 
51
61
  const CARD_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
52
- const CARD_ERROR_TEXT = '消息处理失败,请稍后重试。';
53
62
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
54
63
 
55
64
  const HELP_TEXT_LINES = [
@@ -303,7 +312,15 @@ function canClaimInteractionReply(message, pending, sender) {
303
312
 
304
313
  function ensureStats(status) {
305
314
  status.stats ??= {};
306
- for (const key of ['messagesReceived', 'messagesReplied', 'messagesRejected', 'messagesIgnored']) {
315
+ for (const key of [
316
+ 'messagesReceived',
317
+ 'messagesReplied',
318
+ 'messagesRejected',
319
+ 'messagesIgnored',
320
+ 'reactionsAdded',
321
+ 'reactionsRemoved',
322
+ 'reactionErrors',
323
+ ]) {
307
324
  status[key] ??= 0;
308
325
  status.stats[key] = status[key];
309
326
  }
@@ -322,16 +339,23 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
322
339
  messagesReplied: 0,
323
340
  messagesRejected: 0,
324
341
  messagesIgnored: 0,
342
+ reactionsAdded: 0,
343
+ reactionsRemoved: 0,
344
+ reactionErrors: 0,
325
345
  lastMessageAt: null,
326
346
  lastReplyAt: null,
327
347
  lastRejectedAt: null,
328
348
  lastError: null,
349
+ lastMessageError: null,
329
350
  pendingSenders: structuredClone(pendingSenders),
330
351
  stats: {
331
352
  messagesReceived: 0,
332
353
  messagesReplied: 0,
333
354
  messagesRejected: 0,
334
355
  messagesIgnored: 0,
356
+ reactionsAdded: 0,
357
+ reactionsRemoved: 0,
358
+ reactionErrors: 0,
335
359
  },
336
360
  };
337
361
  }
@@ -345,6 +369,7 @@ export class DingtalkHarnessBridge {
345
369
  #status;
346
370
  #logger;
347
371
  #replyTimeoutMs;
372
+ #reactionTimeoutMs;
348
373
  #maxMessageChars;
349
374
  #signal;
350
375
  #queues = new Map();
@@ -365,6 +390,7 @@ export class DingtalkHarnessBridge {
365
390
  status = createDingtalkBridgeStatus(),
366
391
  logger = console,
367
392
  replyTimeoutMs = 600_000,
393
+ reactionTimeoutMs = 5_000,
368
394
  maxMessageChars = 4_000,
369
395
  signal,
370
396
  }) {
@@ -382,6 +408,9 @@ export class DingtalkHarnessBridge {
382
408
  this.#logger = logger;
383
409
  this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
384
410
  this.#replyTimeoutMs = replyTimeoutMs;
411
+ this.#reactionTimeoutMs = Number.isFinite(reactionTimeoutMs) && reactionTimeoutMs > 0
412
+ ? Math.floor(reactionTimeoutMs)
413
+ : 5_000;
385
414
  this.#maxMessageChars = maxMessageChars;
386
415
  this.#signal = signal;
387
416
  ensureStats(this.#status);
@@ -429,15 +458,35 @@ export class DingtalkHarnessBridge {
429
458
  const commandText = nonEmptyString(promptMessage.content) ?? '';
430
459
  const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
431
460
  const direct = String(message.conversationType) !== '2';
461
+ const statusReaction = sessionWebhook && addressed ? this.#startStatusReaction(message) : null;
462
+ const finish = (task) => Promise.resolve(task).then(
463
+ (value) => {
464
+ this.#finishStatusReaction(
465
+ statusReaction,
466
+ this.#signal?.aborted ? 'clear' : 'success',
467
+ );
468
+ return value;
469
+ },
470
+ (error) => {
471
+ this.#finishStatusReaction(
472
+ statusReaction,
473
+ this.#signal?.aborted || error?.name === 'AbortError' || error?.code === 'turn-stopped'
474
+ ? 'clear'
475
+ : 'error',
476
+ );
477
+ throw error;
478
+ },
479
+ );
432
480
  const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
433
481
  && isBatchInputCommand(commandText);
434
482
  const batchStatus = this.#batchInputs.status(key);
435
483
  if (batchCommand && !direct && sessionWebhook && addressed) {
436
- return this.#finishBatchResult(
484
+ return finish(this.#finishBatchResult(
437
485
  messageId,
438
486
  sessionWebhook,
439
487
  { message: batchInputGroupUnsupportedMessage() },
440
- );
488
+ statusReaction,
489
+ ));
441
490
  }
442
491
  if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
443
492
  const exactBatchStart = /^\/batch$/iu.test(commandText);
@@ -453,7 +502,7 @@ export class DingtalkHarnessBridge {
453
502
  });
454
503
  if (result.handled) {
455
504
  if (result.kind === 'submit') {
456
- return this.#enqueueMessage(
505
+ return finish(this.#enqueueMessage(
457
506
  {
458
507
  ...message,
459
508
  msgtype: 'text',
@@ -462,10 +511,15 @@ export class DingtalkHarnessBridge {
462
511
  messageId,
463
512
  sender,
464
513
  key,
465
- { batchSubmission: result },
466
- );
514
+ { batchSubmission: result, statusReaction },
515
+ ));
467
516
  }
468
- return this.#finishBatchResult(messageId, sessionWebhook, result);
517
+ return finish(this.#finishBatchResult(
518
+ messageId,
519
+ sessionWebhook,
520
+ result,
521
+ statusReaction,
522
+ ));
469
523
  }
470
524
  }
471
525
  const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
@@ -483,16 +537,24 @@ export class DingtalkHarnessBridge {
483
537
  promptMessage,
484
538
  commandRunner,
485
539
  ).catch((error) => {
486
- if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
487
- this.#status.lastError = t('钉钉命令处理失败。');
488
- this.#logger.error?.('[dsh-dingtalk] failed to process a command', safeErrorDiagnostic(error));
489
- return this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
540
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) {
541
+ this.#finishStatusReaction(statusReaction, 'clear');
542
+ return;
543
+ }
544
+ this.#finishStatusReaction(statusReaction, 'error');
545
+ this.#status.lastError = error?.message ?? String(error);
546
+ const failure = setLastMessageFailure(this.#status, error);
547
+ this.#logger.error?.(
548
+ `[dsh-dingtalk] failed to process a command [${failure.referenceId}]`,
549
+ safeErrorDiagnostic(error),
550
+ );
551
+ return this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
490
552
  }).finally(() => {
491
553
  this.#acceptedMessageIds.delete(messageId);
492
554
  this.#commandTasks.delete(task);
493
555
  });
494
556
  this.#commandTasks.add(task);
495
- return task;
557
+ return finish(task);
496
558
  }
497
559
  const approvalReply = this.#approvals.claimReply({
498
560
  key,
@@ -526,7 +588,11 @@ export class DingtalkHarnessBridge {
526
588
  return true;
527
589
  })
528
590
  .catch((error) => {
529
- if (this.#signal?.aborted) return;
591
+ if (this.#signal?.aborted) {
592
+ this.#finishStatusReaction(statusReaction, 'clear');
593
+ return;
594
+ }
595
+ this.#finishStatusReaction(statusReaction, 'error');
530
596
  this.#status.lastError = t('钉钉审批处理失败。');
531
597
  this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
532
598
  })
@@ -535,18 +601,18 @@ export class DingtalkHarnessBridge {
535
601
  this.#interactionTasks.delete(current);
536
602
  });
537
603
  this.#interactionTasks.add(current);
538
- return current;
604
+ return finish(current);
539
605
  }
540
606
 
541
607
  if (pending && pending.actor !== sender) {
542
- return this.#enqueueMessage(message, messageId, sender, key);
608
+ return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
543
609
  }
544
610
  // Once one valid answer has been claimed, later messages are subsequent
545
611
  // prompts even if the network submission eventually needs a retry. Invalid
546
612
  // replies do not claim the question, so the next valid answer can still
547
613
  // pass through this interaction queue.
548
614
  if (pending?.submitting || pending?.claimedReplyMessageId) {
549
- return this.#enqueueMessage(message, messageId, sender, key);
615
+ return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
550
616
  }
551
617
  if (pending) {
552
618
  if (canClaimInteractionReply(message, pending, sender)) {
@@ -555,7 +621,14 @@ export class DingtalkHarnessBridge {
555
621
  const previous = pending.queue ?? Promise.resolve();
556
622
  const current = previous
557
623
  .catch(() => undefined)
558
- .then(() => this.#processInteractionReply(message, messageId, sender, key, pending))
624
+ .then(() => this.#processInteractionReply(
625
+ message,
626
+ messageId,
627
+ sender,
628
+ key,
629
+ pending,
630
+ statusReaction,
631
+ ))
559
632
  .finally(() => {
560
633
  this.#acceptedMessageIds.delete(messageId);
561
634
  if (pending.claimedReplyMessageId === messageId) {
@@ -564,15 +637,101 @@ export class DingtalkHarnessBridge {
564
637
  if (pending.queue === current) pending.queue = null;
565
638
  });
566
639
  pending.queue = current;
567
- return current;
640
+ return finish(current);
568
641
  }
569
- return this.#enqueueMessage(message, messageId, sender, key);
642
+ return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
643
+ }
644
+
645
+ #runReactionCall(method, target, reactionName, kind) {
646
+ const controller = new AbortController();
647
+ const operation = Promise.resolve().then(() => this.#api[method]({
648
+ clientId: this.#clientId,
649
+ clientSecret: this.#clientSecret,
650
+ ...target,
651
+ reactionName,
652
+ signal: controller.signal,
653
+ }));
654
+ let timer;
655
+ const timeout = new Promise((_, reject) => {
656
+ timer = setTimeout(() => {
657
+ const error = new DOMException('DingTalk reaction timed out', 'TimeoutError');
658
+ controller.abort(error);
659
+ reject(error);
660
+ }, this.#reactionTimeoutMs);
661
+ timer.unref?.();
662
+ });
663
+ return Promise.race([operation, timeout])
664
+ .then(() => {
665
+ increment(this.#status, kind === 'add' ? 'reactionsAdded' : 'reactionsRemoved');
666
+ return true;
667
+ })
668
+ .catch((error) => {
669
+ increment(this.#status, 'reactionErrors');
670
+ this.#logger.debug?.(`[dsh-dingtalk] ${method} failed`, safeErrorDiagnostic(error));
671
+ return false;
672
+ })
673
+ .finally(() => clearTimeout(timer));
674
+ }
675
+
676
+ #startStatusReaction(message) {
677
+ if (typeof this.#api.addReaction !== 'function'
678
+ || typeof this.#api.recallReaction !== 'function') return null;
679
+ const messageId = nonEmptyString(message?.msgId);
680
+ const conversationId = nonEmptyString(message?.conversationId);
681
+ if (!messageId || !conversationId) return null;
682
+ const target = {
683
+ messageId,
684
+ conversationId,
685
+ robotCode: nonEmptyString(message?.robotCode) ?? this.#clientId,
686
+ };
687
+ return {
688
+ target,
689
+ attached: this.#runReactionCall(
690
+ 'addReaction',
691
+ target,
692
+ DINGTALK_THINKING_REACTION_NAME,
693
+ 'add',
694
+ ),
695
+ terminal: false,
696
+ };
697
+ }
698
+
699
+ #finishStatusReaction(reaction, outcome) {
700
+ if (!reaction || reaction.terminal) return;
701
+ reaction.terminal = true;
702
+ const terminalName = outcome === 'success'
703
+ ? DINGTALK_DONE_REACTION_NAME
704
+ : outcome === 'error' ? DINGTALK_ERROR_REACTION_NAME : null;
705
+ // Preserve attach -> recall -> terminal ordering without extending the message task.
706
+ void reaction.attached.then(async (attached) => {
707
+ let cleaned = await this.#runReactionCall(
708
+ 'recallReaction',
709
+ reaction.target,
710
+ DINGTALK_THINKING_REACTION_NAME,
711
+ 'remove',
712
+ );
713
+ if (!attached || !cleaned) {
714
+ await new Promise((resolve) => {
715
+ const retry = setTimeout(resolve, Math.min(1_000, this.#reactionTimeoutMs));
716
+ retry.unref?.();
717
+ });
718
+ cleaned = await this.#runReactionCall(
719
+ 'recallReaction',
720
+ reaction.target,
721
+ DINGTALK_THINKING_REACTION_NAME,
722
+ 'remove',
723
+ ) || cleaned;
724
+ }
725
+ if (!cleaned || !terminalName || this.#signal?.aborted) return;
726
+ await this.#runReactionCall('addReaction', reaction.target, terminalName, 'add');
727
+ }).catch(() => undefined);
570
728
  }
571
729
 
572
730
  #enqueueMessage(message, messageId, sender, key, {
573
731
  releaseMessageId = true,
574
732
  alreadyRecorded = false,
575
733
  batchSubmission = null,
734
+ statusReaction = null,
576
735
  } = {}) {
577
736
  let hasSafeReplyRoute = false;
578
737
  try {
@@ -596,6 +755,7 @@ export class DingtalkHarnessBridge {
596
755
  alreadyRecorded,
597
756
  preparedMessage,
598
757
  batchSubmission,
758
+ statusReaction,
599
759
  }))
600
760
  .finally(() => {
601
761
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
@@ -648,7 +808,7 @@ export class DingtalkHarnessBridge {
648
808
  this.#status.lastError = null;
649
809
  }
650
810
 
651
- #finishBatchResult(messageId, sessionWebhook, result) {
811
+ #finishBatchResult(messageId, sessionWebhook, result, statusReaction) {
652
812
  let task;
653
813
  task = Promise.resolve().then(async () => {
654
814
  if (this.#state.hasSeen(messageId)) return;
@@ -658,10 +818,18 @@ export class DingtalkHarnessBridge {
658
818
  if (result.message) await this.#send(sessionWebhook, result.message);
659
819
  this.#status.lastError = null;
660
820
  }).catch(async (error) => {
661
- if (this.#signal?.aborted) return;
662
- this.#status.lastError = t('钉钉命令处理失败。');
663
- this.#logger.error?.('[dsh-dingtalk] failed to process a batch input message', safeErrorDiagnostic(error));
664
- await this.#send(sessionWebhook, t(CARD_ERROR_TEXT)).catch(() => undefined);
821
+ if (this.#signal?.aborted) {
822
+ this.#finishStatusReaction(statusReaction, 'clear');
823
+ return;
824
+ }
825
+ this.#finishStatusReaction(statusReaction, 'error');
826
+ this.#status.lastError = error?.message ?? String(error);
827
+ const failure = setLastMessageFailure(this.#status, error);
828
+ this.#logger.error?.(
829
+ `[dsh-dingtalk] failed to process a batch input message [${failure.referenceId}]`,
830
+ safeErrorDiagnostic(error),
831
+ );
832
+ await this.#send(sessionWebhook, messageFailureText(failure)).catch(() => undefined);
665
833
  }).finally(() => {
666
834
  this.#acceptedMessageIds.delete(messageId);
667
835
  this.#commandTasks.delete(task);
@@ -674,6 +842,7 @@ export class DingtalkHarnessBridge {
674
842
  alreadyRecorded = false,
675
843
  preparedMessage,
676
844
  batchSubmission = null,
845
+ statusReaction = null,
677
846
  } = {}) {
678
847
  this.#signal?.throwIfAborted();
679
848
  if (!alreadyRecorded) {
@@ -819,7 +988,7 @@ export class DingtalkHarnessBridge {
819
988
  });
820
989
  }
821
990
  } catch (error) {
822
- textDeliveryError = error;
991
+ textDeliveryError = channelDeliveryFailure(error);
823
992
  }
824
993
  const delivery = await this.#deliverArtifacts(
825
994
  fileTarget(message, sender, this.#clientId),
@@ -829,9 +998,15 @@ export class DingtalkHarnessBridge {
829
998
  textReceipt,
830
999
  );
831
1000
  if (textDeliveryError && !delivery.userVisible) throw textDeliveryError;
1001
+ if (textDeliveryError && delivery.artifactSendErrors === 0) {
1002
+ setLastMessageFailure(this.#status, textDeliveryError);
1003
+ }
832
1004
  increment(this.#status, 'messagesReplied');
833
1005
  this.#status.lastReplyAt = new Date().toISOString();
834
1006
  this.#status.lastError = null;
1007
+ if (!textDeliveryError && delivery.artifactSendErrors === 0) {
1008
+ clearLastMessageFailure(this.#status);
1009
+ }
835
1010
  return delivery.receipt;
836
1011
  } catch (error) {
837
1012
  let batchFailureMessage = null;
@@ -844,19 +1019,28 @@ export class DingtalkHarnessBridge {
844
1019
  batchSettled = true;
845
1020
  }
846
1021
  if (error?.code === 'turn-stopped') {
1022
+ this.#finishStatusReaction(statusReaction, 'clear');
847
1023
  if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
848
1024
  return;
849
1025
  }
850
- if (this.#signal?.aborted) return;
851
- this.#status.lastError = t('钉钉消息处理失败。');
1026
+ if (this.#signal?.aborted) {
1027
+ this.#finishStatusReaction(statusReaction, 'clear');
1028
+ return;
1029
+ }
1030
+ this.#finishStatusReaction(statusReaction, 'error');
1031
+ this.#status.lastError = error?.message ?? String(error);
1032
+ const userMessage = inboundFileUserMessage(error)
1033
+ ?? dingtalkImageErrorUserMessage(error);
1034
+ const failure = setLastMessageFailure(this.#status, error, {
1035
+ userMessage,
1036
+ reason: imagePromptDiagnostic(error)?.reason,
1037
+ });
852
1038
  this.#logger.error?.(
853
- '[dsh-dingtalk] failed to process an inbound message',
1039
+ `[dsh-dingtalk] failed to process an inbound message [${failure.referenceId}]`,
854
1040
  safeErrorDiagnostic(error),
855
1041
  );
856
1042
  try {
857
- const errorText = inboundFileUserMessage(error)
858
- ?? dingtalkImageErrorUserMessage(error)
859
- ?? t(CARD_ERROR_TEXT);
1043
+ const errorText = messageFailureText(failure);
860
1044
  const visibleError = batchFailureMessage
861
1045
  ? `${errorText}\n\n${batchFailureMessage}`
862
1046
  : errorText;
@@ -871,7 +1055,14 @@ export class DingtalkHarnessBridge {
871
1055
  }
872
1056
  }
873
1057
 
874
- async #processInteractionReply(message, messageId, sender, key, expected) {
1058
+ async #processInteractionReply(
1059
+ message,
1060
+ messageId,
1061
+ sender,
1062
+ key,
1063
+ expected,
1064
+ statusReaction,
1065
+ ) {
875
1066
  this.#signal?.throwIfAborted();
876
1067
  const current = this.#pendingInteractions.get(key);
877
1068
  const claimed = expected.claimedReplyMessageId === messageId;
@@ -879,7 +1070,10 @@ export class DingtalkHarnessBridge {
879
1070
  if (claimed && (!current || current !== expected)) {
880
1071
  return this.#discardResolvedInteractionReply(message, messageId);
881
1072
  }
882
- return this.#enqueueMessage(message, messageId, sender, key, { releaseMessageId: false });
1073
+ return this.#enqueueMessage(message, messageId, sender, key, {
1074
+ releaseMessageId: false,
1075
+ statusReaction,
1076
+ });
883
1077
  }
884
1078
  if (this.#state.hasSeen(messageId)) return;
885
1079
  await this.#state.markSeen(messageId);
@@ -924,6 +1118,7 @@ export class DingtalkHarnessBridge {
924
1118
  return this.#enqueueMessage(message, messageId, sender, key, {
925
1119
  releaseMessageId: false,
926
1120
  alreadyRecorded: true,
1121
+ statusReaction,
927
1122
  });
928
1123
  }
929
1124
  pending.sessionWebhook = sessionWebhook;
@@ -931,6 +1126,7 @@ export class DingtalkHarnessBridge {
931
1126
  try {
932
1127
  await this.#presentInteraction(pending);
933
1128
  } catch {
1129
+ this.#finishStatusReaction(statusReaction, 'error');
934
1130
  this.#status.lastError = t('钉钉交互问题发送失败。');
935
1131
  this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
936
1132
  pending.interaction.reconnect?.();
@@ -950,6 +1146,7 @@ export class DingtalkHarnessBridge {
950
1146
  try {
951
1147
  await this.#presentInteraction(pending);
952
1148
  } catch {
1149
+ this.#finishStatusReaction(statusReaction, 'error');
953
1150
  this.#status.lastError = t('钉钉交互问题发送失败。');
954
1151
  this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
955
1152
  pending.interaction.reconnect?.();
@@ -969,7 +1166,10 @@ export class DingtalkHarnessBridge {
969
1166
  this.#clearPendingInteraction(key, pending.interactionId);
970
1167
  this.#status.lastError = null;
971
1168
  } catch (error) {
972
- if (this.#signal?.aborted) return;
1169
+ if (this.#signal?.aborted) {
1170
+ this.#finishStatusReaction(statusReaction, 'clear');
1171
+ return;
1172
+ }
973
1173
  if (this.#pendingInteractions.get(key) !== pending) return;
974
1174
  if (error?.code === 'interaction-not-pending') {
975
1175
  this.#clearPendingInteraction(key, pending.interactionId);
@@ -980,6 +1180,7 @@ export class DingtalkHarnessBridge {
980
1180
  }
981
1181
  return;
982
1182
  }
1183
+ this.#finishStatusReaction(statusReaction, 'error');
983
1184
  pending.submitting = false;
984
1185
  pending.answers.pop();
985
1186
  pending.index -= 1;
@@ -1201,9 +1402,13 @@ export class DingtalkHarnessBridge {
1201
1402
  sendFile: typeof this.#api.sendFile === 'function'
1202
1403
  ? (file) => sendArtifact('sendFile', file)
1203
1404
  : undefined,
1204
- sendFailureNotice: (artifact, error) => this.#send(
1405
+ onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
1406
+ userMessage: artifactFailureText(artifact?.fileName, error),
1407
+ reason: error?.code,
1408
+ }),
1409
+ sendFailureNotice: (_artifact, _error, failure) => this.#send(
1205
1410
  sessionWebhook,
1206
- artifactFailureText(artifact?.fileName, error),
1411
+ messageFailureText(failure),
1207
1412
  ),
1208
1413
  logger: this.#logger,
1209
1414
  });
@@ -1211,7 +1416,11 @@ export class DingtalkHarnessBridge {
1211
1416
  + delivery.artifactsSent;
1212
1417
  this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
1213
1418
  + delivery.artifactSendErrors;
1214
- return { receipt: delivery.receipt, userVisible: delivery.userVisible };
1419
+ return {
1420
+ receipt: delivery.receipt,
1421
+ userVisible: delivery.userVisible,
1422
+ artifactSendErrors: delivery.artifactSendErrors,
1423
+ };
1215
1424
  }
1216
1425
  }
1217
1426
 
@@ -1,7 +1,7 @@
1
1
  import { t } from '../shared/i18n.mjs';
2
2
 
3
3
  const DEFAULT_UPDATE_INTERVAL_MS = 500;
4
- const FAILURE_TEXT = '消息处理失败,请稍后重试。';
4
+ const CLOSED_TEXT = '卡片已结束,请查看后续消息。';
5
5
 
6
6
  function requiredText(value, name) {
7
7
  if (typeof value !== 'string') throw new TypeError(`${name} must be a string`);
@@ -102,7 +102,7 @@ export function createDingTalkCardStream({
102
102
  if (!cleanupPromise) {
103
103
  cleanupPromise = api.failAiCard({
104
104
  ...cardRequest,
105
- text: t(FAILURE_TEXT),
105
+ text: t(CLOSED_TEXT),
106
106
  signal: AbortSignal.timeout(5_000),
107
107
  }).then(
108
108
  () => true,
@@ -11,6 +11,7 @@ import {
11
11
  connectionTestTargetUnavailable,
12
12
  } from '../shared/connection-test.mjs';
13
13
  import { t } from '../shared/i18n.mjs';
14
+ import { publicMessageFailure } from '../shared/message-failure.mjs';
14
15
 
15
16
  const ACTIVE_ATTEMPT_STATES = new Set(['starting', 'pending', 'connecting']);
16
17
  const TERMINAL_ATTEMPT_STATES = new Set(['connected', 'expired', 'failed', 'cancelled']);
@@ -516,6 +517,7 @@ export class DingtalkController {
516
517
  messagesReceived: Number(currentStatus.messagesReceived) || 0,
517
518
  messagesReplied: Number(currentStatus.messagesReplied) || 0,
518
519
  },
520
+ lastMessageError: publicMessageFailure(currentStatus.lastMessageError),
519
521
  senders: {
520
522
  pending,
521
523
  approved: config.approvedSenders.map(publicApprovedSender),
@@ -88,6 +88,12 @@ function snowflake(value, name) {
88
88
  return id;
89
89
  }
90
90
 
91
+ function reactionEmoji(value) {
92
+ const emoji = cleanString(value);
93
+ if (!emoji) throw new TypeError('A Discord reaction emoji is required');
94
+ return emoji;
95
+ }
96
+
91
97
  export function validDiscordToken(value) {
92
98
  return typeof value === 'string'
93
99
  && /^[A-Za-z0-9_-]{20,}\.[A-Za-z0-9_-]{4,}\.[A-Za-z0-9_-]{20,}$/.test(value.trim());
@@ -229,6 +235,35 @@ export class DiscordApi {
229
235
  });
230
236
  }
231
237
 
238
+ async addOwnReaction({ channelId, messageId, emoji, signal } = {}) {
239
+ const normalizedEmoji = reactionEmoji(emoji);
240
+ await this.#request(
241
+ `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`
242
+ + `/reactions/${encodeURIComponent(normalizedEmoji)}/@me`,
243
+ {
244
+ method: 'PUT',
245
+ signal,
246
+ expectBody: false,
247
+ retry: false,
248
+ },
249
+ );
250
+ return normalizedEmoji;
251
+ }
252
+
253
+ removeOwnReaction({ channelId, messageId, emoji, signal } = {}) {
254
+ const normalizedEmoji = reactionEmoji(emoji);
255
+ return this.#request(
256
+ `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`
257
+ + `/reactions/${encodeURIComponent(normalizedEmoji)}/@me`,
258
+ {
259
+ method: 'DELETE',
260
+ signal,
261
+ expectBody: false,
262
+ retry: false,
263
+ },
264
+ );
265
+ }
266
+
232
267
  async #request(path, {
233
268
  method,
234
269
  body,
@@ -4,6 +4,7 @@ export const DISCORD_DESCRIPTOR = Object.freeze({
4
4
  key: 'discord',
5
5
  label: 'Discord',
6
6
  connectionLabel: ' Gateway 长连接',
7
+ reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
7
8
  });
8
9
 
9
10
  export class DiscordHarnessBridge extends TextHarnessBridge {
@@ -231,6 +231,10 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
231
231
  channelId: String(message.channel_id),
232
232
  replyToMessageId: String(message.id),
233
233
  },
234
+ reactionTarget: {
235
+ channelId: String(message.channel_id),
236
+ messageId: String(message.id),
237
+ },
234
238
  connectionTestTarget: { channelId: String(message.channel_id) },
235
239
  };
236
240
  }
@@ -350,6 +354,24 @@ export class DiscordBotClient {
350
354
  });
351
355
  }
352
356
 
357
+ addReaction(target, emoji, { signal } = {}) {
358
+ return this.#api.addOwnReaction({
359
+ channelId: target.channelId,
360
+ messageId: target.messageId,
361
+ emoji,
362
+ signal: this.#operationSignal(signal),
363
+ });
364
+ }
365
+
366
+ removeReaction(target, reactionKey, { signal } = {}) {
367
+ return this.#api.removeOwnReaction({
368
+ channelId: target.channelId,
369
+ messageId: target.messageId,
370
+ emoji: reactionKey,
371
+ signal: this.#operationSignal(signal),
372
+ });
373
+ }
374
+
353
375
  async openStream(target) {
354
376
  const notice = !this.#deliveredNotices.has(target) && target?.notice
355
377
  ? String(target.notice) : null;
@@ -381,6 +403,10 @@ export class DiscordBotClient {
381
403
  });
382
404
  return stream.start();
383
405
  }
406
+
407
+ #operationSignal(signal) {
408
+ return signal ?? this.#signal;
409
+ }
384
410
  }
385
411
 
386
412
  export function createDiscordRuntimeStatus() {