@xmanrui/dsh-im 2.5.0 → 3.0.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 (37) hide show
  1. package/README.en.md +4 -4
  2. package/README.md +4 -4
  3. package/bin/dsh-im.mjs +1 -1
  4. package/lib/client.js +1410 -1409
  5. package/lib/index.js +206 -206
  6. package/package.json +2 -2
  7. package/plugin-src/client/channels/dingtalk/index.js +0 -17
  8. package/plugin-src/client/channels/dingtalk/styles.js +2 -2
  9. package/plugin-src/client/channels/feishu/index.js +0 -29
  10. package/plugin-src/client/channels/feishu/styles.js +2 -2
  11. package/plugin-src/client/channels/qq/index.js +0 -9
  12. package/plugin-src/client/channels/wecom/index.js +0 -9
  13. package/plugin-src/client/channels/weixin/index.js +0 -19
  14. package/plugin-src/client/channels/weixin/styles.js +2 -2
  15. package/plugin-src/client/index.js +4 -4
  16. package/scripts/verify-package.mjs +48 -11
  17. package/src/channels/dingtalk/dingtalk-api.mjs +101 -5
  18. package/src/channels/dingtalk/dingtalk-bridge.mjs +198 -22
  19. package/src/channels/discord/discord-api.mjs +35 -0
  20. package/src/channels/discord/discord-bridge.mjs +1 -0
  21. package/src/channels/discord/discord-runtime.mjs +26 -0
  22. package/src/channels/feishu/bridge.mjs +21 -28
  23. package/src/channels/feishu/message-utils.mjs +1 -1
  24. package/src/channels/shared/connection-test.mjs +1 -1
  25. package/src/channels/shared/i18n-en/feishu.mjs +6 -6
  26. package/src/channels/shared/i18n-en/shared-c.mjs +2 -2
  27. package/src/channels/shared/status-reaction.mjs +107 -0
  28. package/src/channels/shared/text-harness-bridge.mjs +58 -6
  29. package/src/channels/slack/manifest.mjs +1 -0
  30. package/src/channels/slack/slack-api.mjs +28 -0
  31. package/src/channels/slack/slack-bridge.mjs +5 -0
  32. package/src/channels/slack/slack-runtime.mjs +24 -0
  33. package/src/channels/telegram/telegram-api.mjs +9 -0
  34. package/src/channels/telegram/telegram-bridge.mjs +1 -0
  35. package/src/channels/telegram/telegram-runtime.mjs +20 -0
  36. package/src/channels/whatsapp/whatsapp-bridge.mjs +1 -0
  37. 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';
@@ -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() {
@@ -55,6 +55,7 @@ import {
55
55
  messageFailureText,
56
56
  setLastMessageFailure,
57
57
  } from '../shared/message-failure.mjs';
58
+ import { beginStatusReaction } from '../shared/status-reaction.mjs';
58
59
  import {
59
60
  MENU_PAGE_SIZE,
60
61
  PRESET_FOLLOW_DEFAULT_SENTINEL,
@@ -158,7 +159,7 @@ function artifactFailureText(fileName, error) {
158
159
  const name = String(fileName ?? t('结果文件')).replace(/[\r\n]+/g, ' ').trim() || t('结果文件');
159
160
  switch (error?.code) {
160
161
  case 'artifact-permission-required':
161
- return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
162
+ return t('结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。', { name });
162
163
  case 'artifact-too-large':
163
164
  return t('结果文件「{name}」超过飞书 30 MB 上限,未发送。', { name });
164
165
  case 'artifact-empty':
@@ -549,7 +550,7 @@ export class FeishuHarnessBridge {
549
550
  }
550
551
 
551
552
  this.#acceptedMessageIds.add(messageId);
552
- const processingReaction = this.#addReaction(messageId, 'OnIt');
553
+ const processingReaction = this.#beginReaction(messageId);
553
554
  const commandMessage = extractInboundMessage(event, this.#client);
554
555
  const commandText = nonEmptyString(commandMessage.content) ?? '';
555
556
  const batchText = event.message.message_type === 'text'
@@ -944,7 +945,10 @@ export class FeishuHarnessBridge {
944
945
  const text = message.content;
945
946
  const hasImages = hasInboundImages(message);
946
947
  const hasFiles = hasInboundFiles(message);
947
- const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
948
+ // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
949
+ // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
950
+ // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
951
+ const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
948
952
  if (!text && !hasImages && !hasFiles) {
949
953
  await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'));
950
954
  return;
@@ -3603,35 +3607,24 @@ export class FeishuHarnessBridge {
3603
3607
  return t('_{text}_', { text: update.text || t('正在处理…') });
3604
3608
  }
3605
3609
 
3606
- async #addReaction(messageId, emojiType) {
3607
- if (!this.#channel?.addReaction) return null;
3608
- try {
3609
- const reactionId = await this.#channel.addReaction(messageId, emojiType);
3610
- this.#status.reactionsAdded = (this.#status.reactionsAdded ?? 0) + 1;
3611
- return reactionId;
3612
- } catch (error) {
3613
- this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
3614
- this.#logger.warn?.(`[dsh-feishu] unable to add ${emojiType} reaction:`, error.message);
3615
- return null;
3616
- }
3610
+ #beginReaction(messageId) {
3611
+ return beginStatusReaction({
3612
+ adapter: this.#channel,
3613
+ target: messageId,
3614
+ reactions: { processing: 'OnIt', success: 'DONE', error: 'ERROR' },
3615
+ status: this.#status,
3616
+ logger: this.#logger,
3617
+ label: 'feishu',
3618
+ });
3617
3619
  }
3618
3620
 
3619
- async #removeProcessingReaction(messageId, processingReaction) {
3620
- const reactionId = await processingReaction;
3621
- if (reactionId && this.#channel?.removeReaction) {
3622
- try {
3623
- await this.#channel.removeReaction(messageId, reactionId);
3624
- this.#status.reactionsRemoved = (this.#status.reactionsRemoved ?? 0) + 1;
3625
- } catch (error) {
3626
- this.#status.reactionErrors = (this.#status.reactionErrors ?? 0) + 1;
3627
- this.#logger.warn?.('[dsh-feishu] unable to remove processing reaction:', error.message);
3628
- }
3629
- }
3621
+ #removeProcessingReaction(_messageId, processingReaction) {
3622
+ processingReaction.clear();
3630
3623
  }
3631
3624
 
3632
- async #finishReaction(messageId, processingReaction, finalEmojiType) {
3633
- await this.#removeProcessingReaction(messageId, processingReaction);
3634
- await this.#addReaction(messageId, finalEmojiType);
3625
+ #finishReaction(_messageId, processingReaction, finalEmojiType) {
3626
+ if (finalEmojiType === 'ERROR') processingReaction.error();
3627
+ else processingReaction.success();
3635
3628
  }
3636
3629
 
3637
3630
  async #send(chatId, text) {
@@ -5,7 +5,7 @@ const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
5
5
  const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
6
6
  const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
7
7
  const FEISHU_IMAGE_PERMISSION_MESSAGE =
8
- '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
8
+ '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。';
9
9
 
10
10
  export function conversationKey(event) {
11
11
  const chatType = event?.message?.chat_type;
@@ -29,7 +29,7 @@ export function connectionTestTarget(state) {
29
29
 
30
30
  export function connectionTestMessage(botName, channelLabel = t('机器人')) {
31
31
  const name = cleanText(botName) ?? channelLabel;
32
- return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由插件页面中的“{name}”机器人卡片发出。', { name })}`;
32
+ return `${t('✅ DeepSeek Harness 连接测试成功')}\n${t('这条消息由「IM机器人」设置页中的“{name}”机器人卡片发出。', { name })}`;
33
33
  }
34
34
 
35
35
  export function connectionTestTargetUnavailable(channelLabel = t('机器人')) {
@@ -18,8 +18,8 @@ export default {
18
18
  '机器人正在移除或已重新接入,无法操作原会话的工作区。':
19
19
  'The bot is being removed or has been reconnected; the original session’s workspace cannot be changed.',
20
20
  '操作失败,请稍后重试。': 'The operation failed. Please try again later.',
21
- '结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。':
22
- 'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the plugin page and scan the QR code. Complete any publishing approval Feishu requires, then try again.',
21
+ '结果文件「{name}」已生成,但机器人缺少飞书文件上传权限 im:resource。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。完成飞书要求的发布审批后重试。':
22
+ 'The result file "{name}" was generated, but the bot lacks the Feishu file-upload scope im:resource. Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Complete any publishing approval Feishu requires, then try again.',
23
23
  '结果文件「{name}」超过飞书 30 MB 上限,未发送。':
24
24
  'The result file "{name}" exceeds the Feishu 30 MB limit and was not sent.',
25
25
  '结果文件「{name}」为空,飞书不允许发送空文件。':
@@ -28,8 +28,8 @@ export default {
28
28
  'The result file "{name}" was temporarily rate-limited by Feishu and could not be sent. Please try again later.',
29
29
  '结果文件「{name}」已生成,但暂时未能发送,请稍后重试。':
30
30
  'The result file "{name}" was generated but could not be sent right now. Please try again later.',
31
- '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。':
32
- 'Message processing failed. Please try again later. If the problem persists, check the connection status on the Feishu plugin page in DeepSeek Harness.',
31
+ '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的「IM机器人」设置页检查飞书连接状态。':
32
+ 'Message processing failed. Please try again later. If the problem persists, check the Feishu connection status on the IM Bot settings page in DeepSeek Harness.',
33
33
  '已开启全新 Harness 会话。': 'A brand-new Harness session has started.',
34
34
  '飞书机器人与 DeepSeek Harness 连接正常。':
35
35
  'The Feishu bot is connected to DeepSeek Harness and working normally.',
@@ -338,8 +338,8 @@ export default {
338
338
  '飞书机器人': 'Feishu bot',
339
339
 
340
340
  // feishu/message-utils.mjs
341
- '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在插件页面点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。':
342
- 'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the plugin page and scan the QR code. Publish a new version and complete any approval requested by Feishu, then resend the image.',
341
+ '飞书机器人缺少图片读取权限 im:message:readonly(飞书显示为“获取单聊、群组消息”)。请私聊机器人执行 /repair 命令,或者在「IM机器人」设置页点击“补全权限”按钮并扫码。按飞书提示发布新版本、完成必要审批后,再重新发送图片。':
342
+ 'The Feishu bot is missing the image-read scope im:message:readonly, shown by Feishu as “Read direct and group messages.” Run /repair in a direct chat with the bot, or click the “Complete permissions” button on the IM Bot settings page and scan the QR code. Publish a new version and complete any approval requested by Feishu, then resend the image.',
343
343
 
344
344
  // feishu/feishu-runtime.mjs — callback probe notices
345
345
  '✅ 修复完成:已实测收到 card.action.trigger,菜单按钮现在可用。':