@xmanrui/dsh-im 2.5.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "2.5.0",
3
+ "version": "2.6.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -8,8 +8,14 @@ export const DINGTALK_REGISTRATION_BASE_URL = 'https://oapi.dingtalk.com/';
8
8
  export const DINGTALK_API_BASE_URL = 'https://api.dingtalk.com/';
9
9
  export const DINGTALK_REGISTRATION_SOURCE = 'DING_DWS_CLAW';
10
10
  export const DINGTALK_AI_CARD_TEMPLATE_ID = '02fcf2f4-5e02-4a85-b672-46d1f715543e.schema';
11
+ export const DINGTALK_THINKING_REACTION_NAME = '🤔思考中';
12
+ export const DINGTALK_DONE_REACTION_NAME = '✅已完成';
13
+ export const DINGTALK_ERROR_REACTION_NAME = '❌处理失败';
11
14
 
12
15
  const DEFAULT_TIMEOUT_MS = 15_000;
16
+ const REACTION_TIMEOUT_MS = 5_000;
17
+ const TEXT_REACTION_ID = '2659900';
18
+ const TEXT_REACTION_BACKGROUND_ID = 'im_bg_1';
13
19
  const REGISTRATION_STATUSES = new Set(['WAITING', 'SUCCESS', 'FAIL', 'EXPIRED']);
14
20
 
15
21
  export class DingtalkApiError extends Error {
@@ -406,18 +412,20 @@ export function createDingtalkApi({
406
412
  if (!source) throw new TypeError('registrationSource is required');
407
413
  const tokenCache = new Map();
408
414
  const tokenRequests = new Map();
415
+ const reactionTokenRequests = new Map();
409
416
  let cardSlotTail = Promise.resolve();
410
417
  let nextCardRequestAt = 0;
411
418
 
412
419
  const endpoint = (base, pathname) => new URL(pathname.replace(/^\//, ''), base);
413
420
 
414
- async function accessToken({ clientId, clientSecret, signal }) {
421
+ async function accessToken({ clientId, clientSecret, signal, requestKind = 'normal' }) {
415
422
  const appKey = nonEmptyString(clientId);
416
423
  const appSecret = nonEmptyString(clientSecret);
417
424
  if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
418
425
  const cached = tokenCache.get(appKey);
419
426
  if (cached && cached.expiresAt > now()) return cached.token;
420
- if (tokenRequests.has(appKey)) return tokenRequests.get(appKey);
427
+ const requests = requestKind === 'reaction' ? reactionTokenRequests : tokenRequests;
428
+ if (requests.has(appKey)) return requests.get(appKey);
421
429
 
422
430
  const request = (async () => {
423
431
  const value = await requestJson(fetchImpl, endpoint(apiBase, 'v1.0/oauth2/accessToken'), {
@@ -431,9 +439,75 @@ export function createDingtalkApi({
431
439
  const refreshAfterMs = Math.max(1_000, (expiresInSeconds - 60) * 1_000);
432
440
  tokenCache.set(appKey, { token, expiresAt: now() + refreshAfterMs });
433
441
  return token;
434
- })().finally(() => tokenRequests.delete(appKey));
435
- tokenRequests.set(appKey, request);
436
- return request;
442
+ })();
443
+ const shared = request.finally(() => requests.delete(appKey));
444
+ requests.set(appKey, shared);
445
+ return shared;
446
+ }
447
+
448
+ async function changeReaction({
449
+ clientId,
450
+ clientSecret,
451
+ robotCode,
452
+ messageId,
453
+ conversationId,
454
+ reactionName = DINGTALK_THINKING_REACTION_NAME,
455
+ signal,
456
+ }, action) {
457
+ const appKey = nonEmptyString(clientId);
458
+ const appSecret = nonEmptyString(clientSecret);
459
+ const botCode = nonEmptyString(robotCode) ?? appKey;
460
+ const openMsgId = nonEmptyString(messageId);
461
+ const openConversationId = nonEmptyString(conversationId);
462
+ const emotionName = nonEmptyString(reactionName);
463
+ if (!appKey || !appSecret) throw new TypeError('clientId and clientSecret are required');
464
+ if (!botCode) throw new TypeError('robotCode is required');
465
+ if (!openMsgId || !openConversationId) {
466
+ throw new TypeError('messageId and conversationId are required');
467
+ }
468
+ if (!emotionName) throw new TypeError('reactionName is required');
469
+ // Reactions deduplicate with each other but never own the normal reply's
470
+ // in-flight token request. Both paths may still reuse a cached token.
471
+ const token = await accessToken({
472
+ clientId: appKey,
473
+ clientSecret: appSecret,
474
+ signal,
475
+ requestKind: 'reaction',
476
+ });
477
+ const response = await requestJson(
478
+ fetchImpl,
479
+ endpoint(apiBase, `v1.0/robot/emotion/${action}`),
480
+ {
481
+ body: {
482
+ robotCode: botCode,
483
+ openMsgId,
484
+ openConversationId,
485
+ emotionType: 2,
486
+ emotionName,
487
+ textEmotion: {
488
+ emotionId: TEXT_REACTION_ID,
489
+ emotionName,
490
+ text: emotionName,
491
+ backgroundId: TEXT_REACTION_BACKGROUND_ID,
492
+ },
493
+ },
494
+ headers: { 'x-acs-dingtalk-access-token': token },
495
+ signal,
496
+ timeoutMs: REACTION_TIMEOUT_MS,
497
+ action: action === 'reply' ? '消息状态添加' : '消息状态撤回',
498
+ },
499
+ );
500
+ const rejection = response?.success === false
501
+ ? safeProviderCode(response?.code ?? response?.errcode) ?? 'rejected'
502
+ : rejectedProviderResponse(response);
503
+ if (rejection) {
504
+ throw new DingtalkApiError(
505
+ 'reaction-rejected',
506
+ '钉钉服务拒绝了消息状态请求。',
507
+ { providerCode: rejection },
508
+ );
509
+ }
510
+ return true;
437
511
  }
438
512
 
439
513
  async function messageFileDownloadUrl({
@@ -690,6 +764,28 @@ export function createDingtalkApi({
690
764
 
691
765
  accessToken,
692
766
 
767
+ async addReaction(request) {
768
+ return changeReaction(request, 'reply');
769
+ },
770
+
771
+ async recallReaction(request) {
772
+ return changeReaction(request, 'recall');
773
+ },
774
+
775
+ async addThinkingReaction(request) {
776
+ return changeReaction({
777
+ ...request,
778
+ reactionName: DINGTALK_THINKING_REACTION_NAME,
779
+ }, 'reply');
780
+ },
781
+
782
+ async recallThinkingReaction(request) {
783
+ return changeReaction({
784
+ ...request,
785
+ reactionName: DINGTALK_THINKING_REACTION_NAME,
786
+ }, 'recall');
787
+ },
788
+
693
789
  async downloadImage({
694
790
  clientId,
695
791
  clientSecret,
@@ -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';
@@ -309,7 +312,15 @@ function canClaimInteractionReply(message, pending, sender) {
309
312
 
310
313
  function ensureStats(status) {
311
314
  status.stats ??= {};
312
- 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
+ ]) {
313
324
  status[key] ??= 0;
314
325
  status.stats[key] = status[key];
315
326
  }
@@ -328,6 +339,9 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
328
339
  messagesReplied: 0,
329
340
  messagesRejected: 0,
330
341
  messagesIgnored: 0,
342
+ reactionsAdded: 0,
343
+ reactionsRemoved: 0,
344
+ reactionErrors: 0,
331
345
  lastMessageAt: null,
332
346
  lastReplyAt: null,
333
347
  lastRejectedAt: null,
@@ -339,6 +353,9 @@ export function createDingtalkBridgeStatus({ pendingSenders = [] } = {}) {
339
353
  messagesReplied: 0,
340
354
  messagesRejected: 0,
341
355
  messagesIgnored: 0,
356
+ reactionsAdded: 0,
357
+ reactionsRemoved: 0,
358
+ reactionErrors: 0,
342
359
  },
343
360
  };
344
361
  }
@@ -352,6 +369,7 @@ export class DingtalkHarnessBridge {
352
369
  #status;
353
370
  #logger;
354
371
  #replyTimeoutMs;
372
+ #reactionTimeoutMs;
355
373
  #maxMessageChars;
356
374
  #signal;
357
375
  #queues = new Map();
@@ -372,6 +390,7 @@ export class DingtalkHarnessBridge {
372
390
  status = createDingtalkBridgeStatus(),
373
391
  logger = console,
374
392
  replyTimeoutMs = 600_000,
393
+ reactionTimeoutMs = 5_000,
375
394
  maxMessageChars = 4_000,
376
395
  signal,
377
396
  }) {
@@ -389,6 +408,9 @@ export class DingtalkHarnessBridge {
389
408
  this.#logger = logger;
390
409
  this.#approvals = new HarnessApprovalQueue({ label: 'DingTalk', logger });
391
410
  this.#replyTimeoutMs = replyTimeoutMs;
411
+ this.#reactionTimeoutMs = Number.isFinite(reactionTimeoutMs) && reactionTimeoutMs > 0
412
+ ? Math.floor(reactionTimeoutMs)
413
+ : 5_000;
392
414
  this.#maxMessageChars = maxMessageChars;
393
415
  this.#signal = signal;
394
416
  ensureStats(this.#status);
@@ -436,15 +458,35 @@ export class DingtalkHarnessBridge {
436
458
  const commandText = nonEmptyString(promptMessage.content) ?? '';
437
459
  const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
438
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
+ );
439
480
  const batchCommand = String(message?.msgtype).toLowerCase() === 'text'
440
481
  && isBatchInputCommand(commandText);
441
482
  const batchStatus = this.#batchInputs.status(key);
442
483
  if (batchCommand && !direct && sessionWebhook && addressed) {
443
- return this.#finishBatchResult(
484
+ return finish(this.#finishBatchResult(
444
485
  messageId,
445
486
  sessionWebhook,
446
487
  { message: batchInputGroupUnsupportedMessage() },
447
- );
488
+ statusReaction,
489
+ ));
448
490
  }
449
491
  if (direct && sessionWebhook && (batchCommand || batchStatus.phase === 'collecting')) {
450
492
  const exactBatchStart = /^\/batch$/iu.test(commandText);
@@ -460,7 +502,7 @@ export class DingtalkHarnessBridge {
460
502
  });
461
503
  if (result.handled) {
462
504
  if (result.kind === 'submit') {
463
- return this.#enqueueMessage(
505
+ return finish(this.#enqueueMessage(
464
506
  {
465
507
  ...message,
466
508
  msgtype: 'text',
@@ -469,10 +511,15 @@ export class DingtalkHarnessBridge {
469
511
  messageId,
470
512
  sender,
471
513
  key,
472
- { batchSubmission: result },
473
- );
514
+ { batchSubmission: result, statusReaction },
515
+ ));
474
516
  }
475
- return this.#finishBatchResult(messageId, sessionWebhook, result);
517
+ return finish(this.#finishBatchResult(
518
+ messageId,
519
+ sessionWebhook,
520
+ result,
521
+ statusReaction,
522
+ ));
476
523
  }
477
524
  }
478
525
  const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
@@ -490,7 +537,11 @@ export class DingtalkHarnessBridge {
490
537
  promptMessage,
491
538
  commandRunner,
492
539
  ).catch((error) => {
493
- if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
540
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) {
541
+ this.#finishStatusReaction(statusReaction, 'clear');
542
+ return;
543
+ }
544
+ this.#finishStatusReaction(statusReaction, 'error');
494
545
  this.#status.lastError = error?.message ?? String(error);
495
546
  const failure = setLastMessageFailure(this.#status, error);
496
547
  this.#logger.error?.(
@@ -503,7 +554,7 @@ export class DingtalkHarnessBridge {
503
554
  this.#commandTasks.delete(task);
504
555
  });
505
556
  this.#commandTasks.add(task);
506
- return task;
557
+ return finish(task);
507
558
  }
508
559
  const approvalReply = this.#approvals.claimReply({
509
560
  key,
@@ -537,7 +588,11 @@ export class DingtalkHarnessBridge {
537
588
  return true;
538
589
  })
539
590
  .catch((error) => {
540
- if (this.#signal?.aborted) return;
591
+ if (this.#signal?.aborted) {
592
+ this.#finishStatusReaction(statusReaction, 'clear');
593
+ return;
594
+ }
595
+ this.#finishStatusReaction(statusReaction, 'error');
541
596
  this.#status.lastError = t('钉钉审批处理失败。');
542
597
  this.#logger.error?.('[dsh-dingtalk] failed to process an approval reply', error);
543
598
  })
@@ -546,18 +601,18 @@ export class DingtalkHarnessBridge {
546
601
  this.#interactionTasks.delete(current);
547
602
  });
548
603
  this.#interactionTasks.add(current);
549
- return current;
604
+ return finish(current);
550
605
  }
551
606
 
552
607
  if (pending && pending.actor !== sender) {
553
- return this.#enqueueMessage(message, messageId, sender, key);
608
+ return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
554
609
  }
555
610
  // Once one valid answer has been claimed, later messages are subsequent
556
611
  // prompts even if the network submission eventually needs a retry. Invalid
557
612
  // replies do not claim the question, so the next valid answer can still
558
613
  // pass through this interaction queue.
559
614
  if (pending?.submitting || pending?.claimedReplyMessageId) {
560
- return this.#enqueueMessage(message, messageId, sender, key);
615
+ return finish(this.#enqueueMessage(message, messageId, sender, key, { statusReaction }));
561
616
  }
562
617
  if (pending) {
563
618
  if (canClaimInteractionReply(message, pending, sender)) {
@@ -566,7 +621,14 @@ export class DingtalkHarnessBridge {
566
621
  const previous = pending.queue ?? Promise.resolve();
567
622
  const current = previous
568
623
  .catch(() => undefined)
569
- .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
+ ))
570
632
  .finally(() => {
571
633
  this.#acceptedMessageIds.delete(messageId);
572
634
  if (pending.claimedReplyMessageId === messageId) {
@@ -575,15 +637,101 @@ export class DingtalkHarnessBridge {
575
637
  if (pending.queue === current) pending.queue = null;
576
638
  });
577
639
  pending.queue = current;
578
- return current;
640
+ return finish(current);
579
641
  }
580
- 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);
581
728
  }
582
729
 
583
730
  #enqueueMessage(message, messageId, sender, key, {
584
731
  releaseMessageId = true,
585
732
  alreadyRecorded = false,
586
733
  batchSubmission = null,
734
+ statusReaction = null,
587
735
  } = {}) {
588
736
  let hasSafeReplyRoute = false;
589
737
  try {
@@ -607,6 +755,7 @@ export class DingtalkHarnessBridge {
607
755
  alreadyRecorded,
608
756
  preparedMessage,
609
757
  batchSubmission,
758
+ statusReaction,
610
759
  }))
611
760
  .finally(() => {
612
761
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
@@ -659,7 +808,7 @@ export class DingtalkHarnessBridge {
659
808
  this.#status.lastError = null;
660
809
  }
661
810
 
662
- #finishBatchResult(messageId, sessionWebhook, result) {
811
+ #finishBatchResult(messageId, sessionWebhook, result, statusReaction) {
663
812
  let task;
664
813
  task = Promise.resolve().then(async () => {
665
814
  if (this.#state.hasSeen(messageId)) return;
@@ -669,7 +818,11 @@ export class DingtalkHarnessBridge {
669
818
  if (result.message) await this.#send(sessionWebhook, result.message);
670
819
  this.#status.lastError = null;
671
820
  }).catch(async (error) => {
672
- if (this.#signal?.aborted) return;
821
+ if (this.#signal?.aborted) {
822
+ this.#finishStatusReaction(statusReaction, 'clear');
823
+ return;
824
+ }
825
+ this.#finishStatusReaction(statusReaction, 'error');
673
826
  this.#status.lastError = error?.message ?? String(error);
674
827
  const failure = setLastMessageFailure(this.#status, error);
675
828
  this.#logger.error?.(
@@ -689,6 +842,7 @@ export class DingtalkHarnessBridge {
689
842
  alreadyRecorded = false,
690
843
  preparedMessage,
691
844
  batchSubmission = null,
845
+ statusReaction = null,
692
846
  } = {}) {
693
847
  this.#signal?.throwIfAborted();
694
848
  if (!alreadyRecorded) {
@@ -865,10 +1019,15 @@ export class DingtalkHarnessBridge {
865
1019
  batchSettled = true;
866
1020
  }
867
1021
  if (error?.code === 'turn-stopped') {
1022
+ this.#finishStatusReaction(statusReaction, 'clear');
868
1023
  if (cardStarted) await cardStream.finish(t('已停止。')).catch(() => undefined);
869
1024
  return;
870
1025
  }
871
- if (this.#signal?.aborted) return;
1026
+ if (this.#signal?.aborted) {
1027
+ this.#finishStatusReaction(statusReaction, 'clear');
1028
+ return;
1029
+ }
1030
+ this.#finishStatusReaction(statusReaction, 'error');
872
1031
  this.#status.lastError = error?.message ?? String(error);
873
1032
  const userMessage = inboundFileUserMessage(error)
874
1033
  ?? dingtalkImageErrorUserMessage(error);
@@ -896,7 +1055,14 @@ export class DingtalkHarnessBridge {
896
1055
  }
897
1056
  }
898
1057
 
899
- async #processInteractionReply(message, messageId, sender, key, expected) {
1058
+ async #processInteractionReply(
1059
+ message,
1060
+ messageId,
1061
+ sender,
1062
+ key,
1063
+ expected,
1064
+ statusReaction,
1065
+ ) {
900
1066
  this.#signal?.throwIfAborted();
901
1067
  const current = this.#pendingInteractions.get(key);
902
1068
  const claimed = expected.claimedReplyMessageId === messageId;
@@ -904,7 +1070,10 @@ export class DingtalkHarnessBridge {
904
1070
  if (claimed && (!current || current !== expected)) {
905
1071
  return this.#discardResolvedInteractionReply(message, messageId);
906
1072
  }
907
- return this.#enqueueMessage(message, messageId, sender, key, { releaseMessageId: false });
1073
+ return this.#enqueueMessage(message, messageId, sender, key, {
1074
+ releaseMessageId: false,
1075
+ statusReaction,
1076
+ });
908
1077
  }
909
1078
  if (this.#state.hasSeen(messageId)) return;
910
1079
  await this.#state.markSeen(messageId);
@@ -949,6 +1118,7 @@ export class DingtalkHarnessBridge {
949
1118
  return this.#enqueueMessage(message, messageId, sender, key, {
950
1119
  releaseMessageId: false,
951
1120
  alreadyRecorded: true,
1121
+ statusReaction,
952
1122
  });
953
1123
  }
954
1124
  pending.sessionWebhook = sessionWebhook;
@@ -956,6 +1126,7 @@ export class DingtalkHarnessBridge {
956
1126
  try {
957
1127
  await this.#presentInteraction(pending);
958
1128
  } catch {
1129
+ this.#finishStatusReaction(statusReaction, 'error');
959
1130
  this.#status.lastError = t('钉钉交互问题发送失败。');
960
1131
  this.#logger.error?.('[dsh-dingtalk] failed to retry an interaction question');
961
1132
  pending.interaction.reconnect?.();
@@ -975,6 +1146,7 @@ export class DingtalkHarnessBridge {
975
1146
  try {
976
1147
  await this.#presentInteraction(pending);
977
1148
  } catch {
1149
+ this.#finishStatusReaction(statusReaction, 'error');
978
1150
  this.#status.lastError = t('钉钉交互问题发送失败。');
979
1151
  this.#logger.error?.('[dsh-dingtalk] failed to send the next interaction question');
980
1152
  pending.interaction.reconnect?.();
@@ -994,7 +1166,10 @@ export class DingtalkHarnessBridge {
994
1166
  this.#clearPendingInteraction(key, pending.interactionId);
995
1167
  this.#status.lastError = null;
996
1168
  } catch (error) {
997
- if (this.#signal?.aborted) return;
1169
+ if (this.#signal?.aborted) {
1170
+ this.#finishStatusReaction(statusReaction, 'clear');
1171
+ return;
1172
+ }
998
1173
  if (this.#pendingInteractions.get(key) !== pending) return;
999
1174
  if (error?.code === 'interaction-not-pending') {
1000
1175
  this.#clearPendingInteraction(key, pending.interactionId);
@@ -1005,6 +1180,7 @@ export class DingtalkHarnessBridge {
1005
1180
  }
1006
1181
  return;
1007
1182
  }
1183
+ this.#finishStatusReaction(statusReaction, 'error');
1008
1184
  pending.submitting = false;
1009
1185
  pending.answers.pop();
1010
1186
  pending.index -= 1;
@@ -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() {