@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
@@ -27,6 +27,7 @@ import {
27
27
  } from './batch-input.mjs';
28
28
  import {
29
29
  hasInboundImages,
30
+ imagePromptDiagnostic,
30
31
  imagePromptUserMessage,
31
32
  promptContentForMessage,
32
33
  } from './image-prompt.mjs';
@@ -45,6 +46,13 @@ import {
45
46
  createTextDeliveryBlock,
46
47
  providerMessageIdsFor,
47
48
  } from './semantic/delivery.mjs';
49
+ import {
50
+ channelDeliveryFailure,
51
+ clearLastMessageFailure,
52
+ messageFailureText,
53
+ setLastMessageFailure,
54
+ } from './message-failure.mjs';
55
+ import { beginStatusReaction } from './status-reaction.mjs';
48
56
 
49
57
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
50
58
  const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
@@ -106,6 +114,10 @@ export function createTextBridgeStatus() {
106
114
  lastReplyAt: null,
107
115
  lastRejectedAt: null,
108
116
  lastError: null,
117
+ lastMessageError: null,
118
+ reactionsAdded: 0,
119
+ reactionsRemoved: 0,
120
+ reactionErrors: 0,
109
121
  };
110
122
  }
111
123
 
@@ -170,6 +182,27 @@ export class TextHarnessBridge {
170
182
  return Promise.resolve();
171
183
  }
172
184
  this.#acceptedMessageIds.add(messageId);
185
+ const statusReaction = beginStatusReaction({
186
+ adapter: this.#bot,
187
+ target: normalized.kind === 'direct' || normalized.addressed === true
188
+ ? normalized.reactionTarget
189
+ : null,
190
+ reactions: this.#descriptor.reactions,
191
+ status: this.#status,
192
+ logger: this.#logger,
193
+ label: this.#descriptor.key,
194
+ });
195
+ normalized.statusReaction = statusReaction;
196
+
197
+ const processing = this.#acceptAcceptedMessage(normalized, messageId, senderId);
198
+ void processing.then(
199
+ () => statusReaction.success(),
200
+ () => statusReaction.error(),
201
+ );
202
+ return processing;
203
+ }
204
+
205
+ #acceptAcceptedMessage(normalized, messageId, senderId) {
173
206
  if (normalized.kind === 'direct') {
174
207
  rememberConnectionTestTarget(
175
208
  this.#state,
@@ -177,7 +210,7 @@ export class TextHarnessBridge {
177
210
  );
178
211
  }
179
212
 
180
- const key = `${kind}:${conversationId}`;
213
+ const key = `${normalized.kind}:${normalized.conversationId}`;
181
214
  const pending = this.#pendingInteractions.get(key);
182
215
  const text = cleanText(normalized.content);
183
216
  const batchCommand = isBatchInputCommand(text);
@@ -318,10 +351,15 @@ export class TextHarnessBridge {
318
351
  if (reply) await this.#bot.sendText(message.replyTarget, reply);
319
352
  this.#status.lastError = null;
320
353
  })().catch(async (error) => {
321
- if (this.#signal?.aborted) return;
354
+ if (this.#signal?.aborted) {
355
+ message.statusReaction?.clear();
356
+ return;
357
+ }
358
+ message.statusReaction?.error();
322
359
  this.#status.lastError = error?.message ?? String(error);
360
+ const failure = setLastMessageFailure(this.#status, error);
323
361
  this.#logger.error?.(
324
- `[dsh-im:${this.#descriptor.key}] failed to process a batch input message:`,
362
+ `[dsh-im:${this.#descriptor.key}] failed to process a batch input message [${failure.referenceId}]:`,
325
363
  error,
326
364
  );
327
365
  }).finally(() => {
@@ -397,10 +435,18 @@ export class TextHarnessBridge {
397
435
  }
398
436
  this.#status.lastError = null;
399
437
  } catch (error) {
400
- if (error?.code === 'turn-stopped' || this.#signal?.aborted) return;
438
+ if (error?.code === 'turn-stopped' || this.#signal?.aborted) {
439
+ message.statusReaction?.clear();
440
+ return;
441
+ }
442
+ message.statusReaction?.error();
401
443
  this.#status.lastError = error?.message ?? String(error);
402
- this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a command:`, error);
403
- await this.#bot.sendText(target, t('消息处理失败,请稍后重试。')).catch(() => undefined);
444
+ const failure = setLastMessageFailure(this.#status, error);
445
+ this.#logger.error?.(
446
+ `[dsh-im:${this.#descriptor.key}] failed to process a command [${failure.referenceId}]:`,
447
+ error,
448
+ );
449
+ await this.#bot.sendText(target, messageFailureText(failure)).catch(() => undefined);
404
450
  }
405
451
  }
406
452
 
@@ -426,9 +472,13 @@ export class TextHarnessBridge {
426
472
  sendFile: typeof this.#bot.sendFile === 'function'
427
473
  ? (file) => this.#bot.sendFile(target, file)
428
474
  : undefined,
429
- sendFailureNotice: (artifact, error) => this.#bot.sendText(
475
+ onFailure: (artifact, error) => setLastMessageFailure(this.#status, error, {
476
+ userMessage: artifactFailureText(artifact?.fileName, error, this.#descriptor),
477
+ reason: error?.code,
478
+ }),
479
+ sendFailureNotice: (_artifact, _error, failure) => this.#bot.sendText(
430
480
  target,
431
- artifactFailureText(artifact?.fileName, error, this.#descriptor),
481
+ messageFailureText(failure),
432
482
  ),
433
483
  logger: this.#logger,
434
484
  });
@@ -436,7 +486,11 @@ export class TextHarnessBridge {
436
486
  + delivery.artifactsSent;
437
487
  this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0)
438
488
  + delivery.artifactSendErrors;
439
- return { receipt: delivery.receipt, userVisible: delivery.userVisible };
489
+ return {
490
+ receipt: delivery.receipt,
491
+ userVisible: delivery.userVisible,
492
+ artifactSendErrors: delivery.artifactSendErrors,
493
+ };
440
494
  }
441
495
 
442
496
  async #process(message, messageId, senderId, conversationKey, {
@@ -643,25 +697,37 @@ export class TextHarnessBridge {
643
697
  reason: result?.reason,
644
698
  });
645
699
  } catch (error) {
646
- textDeliveryError = error;
700
+ textDeliveryError = channelDeliveryFailure(error);
647
701
  }
648
702
  }
703
+ const finalDeliveryUnknown = textReceipt?.deliveryOutcome === 'unknown';
649
704
  if (textReceipt?.deliveryOutcome === 'failed') {
650
- const reason = textReceipt.reason ?? 'text-delivery-failed';
651
- textDeliveryError = new Error(`Final text delivery failed (${reason})`);
652
- textDeliveryError.code = reason;
705
+ textDeliveryError = channelDeliveryFailure(
706
+ new Error(`Final text delivery failed (${textReceipt.reason ?? 'unknown'})`),
707
+ { uncertain: false },
708
+ );
709
+ } else if (finalDeliveryUnknown) {
710
+ textDeliveryError = channelDeliveryFailure(
711
+ new Error(`Final text delivery outcome is unknown (${textReceipt.reason ?? 'unknown'})`),
712
+ );
653
713
  }
654
714
  // A failed final text must not discard an already registered result file.
655
715
  // Settle the independent attachment path before surfacing the text error.
656
716
  const delivery = await this.#deliverArtifacts(target, messageId, artifacts, textReceipt);
657
- if (textDeliveryError && !delivery.userVisible) {
717
+ if (textDeliveryError && (!delivery.userVisible || finalDeliveryUnknown)) {
658
718
  textDeliveryError.deliveryReceipt = delivery.receipt;
659
719
  throw textDeliveryError;
660
720
  }
721
+ if (textDeliveryError && delivery.artifactSendErrors === 0) {
722
+ setLastMessageFailure(this.#status, textDeliveryError);
723
+ }
661
724
  if (delivery.userVisible) {
662
725
  this.#status.messagesReplied += 1;
663
726
  this.#status.lastReplyAt = new Date().toISOString();
664
727
  this.#status.lastError = null;
728
+ if (!textDeliveryError && delivery.artifactSendErrors === 0) {
729
+ clearLastMessageFailure(this.#status);
730
+ }
665
731
  }
666
732
  return delivery.receipt;
667
733
  } catch (error) {
@@ -673,6 +739,7 @@ export class TextHarnessBridge {
673
739
  ? this.#batches.fail(conversationKey, batchSubmission.token)
674
740
  : null;
675
741
  if (turnStopped) {
742
+ message.statusReaction?.clear();
676
743
  if (stream) {
677
744
  try {
678
745
  await stream.finish(t('已停止。'));
@@ -683,15 +750,22 @@ export class TextHarnessBridge {
683
750
  return;
684
751
  }
685
752
  if (this.#signal?.aborted) {
753
+ message.statusReaction?.clear();
686
754
  stream?.cancel?.();
687
755
  return;
688
756
  }
757
+ message.statusReaction?.error();
689
758
  this.#status.lastError = error?.message ?? String(error);
690
759
  const presentStreamFailure = async (text) => {
691
- if (typeof stream?.fail !== 'function') return false;
760
+ const method = typeof stream?.fail === 'function'
761
+ ? 'fail'
762
+ : (typeof stream?.finish === 'function' ? 'finish' : null);
763
+ if (!method) return false;
692
764
  try {
693
- const result = await stream.fail(text);
694
- return Boolean(result) && result.deliveryOutcome !== 'failed';
765
+ const result = await stream[method](text);
766
+ return method === 'fail'
767
+ ? Boolean(result) && result.deliveryOutcome !== 'failed'
768
+ : result !== false && result?.deliveryOutcome !== 'failed';
695
769
  } catch (streamError) {
696
770
  this.#logger.warn?.(
697
771
  `[dsh-im:${this.#descriptor.key}] unable to finalize the failed stream:`,
@@ -700,58 +774,25 @@ export class TextHarnessBridge {
700
774
  return false;
701
775
  }
702
776
  };
703
- if (failedBatch?.retained) {
704
- this.#logger.error?.(
705
- `[dsh-im:${this.#descriptor.key}] failed to submit a batch input:`,
706
- error,
707
- );
708
- if (await presentStreamFailure(failedBatch.message)) return;
709
- stream?.cancel?.();
710
- try {
711
- await this.#bot.sendText(target, failedBatch.message);
712
- } catch (sendError) {
713
- this.#logger.error?.(
714
- `[dsh-im:${this.#descriptor.key}] failed to send the batch retry notice:`,
715
- sendError,
716
- );
717
- }
718
- return;
719
- }
720
777
  const imageErrorMessage = imagePromptUserMessage(error);
721
- if (imageErrorMessage) {
722
- if (await presentStreamFailure(imageErrorMessage)) return;
723
- stream?.cancel?.();
724
- try {
725
- await this.#bot.sendText(target, imageErrorMessage);
726
- } catch (sendError) {
727
- this.#logger.error?.(
728
- `[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
729
- sendError,
730
- );
731
- }
732
- return;
733
- }
734
778
  const fileErrorMessage = inboundFileUserMessage(error);
735
- if (fileErrorMessage) {
736
- if (await presentStreamFailure(fileErrorMessage)) return;
737
- stream?.cancel?.();
738
- try {
739
- await this.#bot.sendText(target, fileErrorMessage);
740
- } catch (sendError) {
741
- this.#logger.error?.(
742
- `[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
743
- sendError,
744
- );
745
- }
746
- return;
747
- }
748
- this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
749
- if (await presentStreamFailure('消息处理失败,请稍后重试。')) {
779
+ const failure = setLastMessageFailure(this.#status, error, {
780
+ userMessage: fileErrorMessage ?? imageErrorMessage,
781
+ reason: imagePromptDiagnostic(error)?.reason,
782
+ });
783
+ const failureText = failedBatch?.retained
784
+ ? `${messageFailureText(failure)}\n\n${failedBatch.message}`
785
+ : messageFailureText(failure);
786
+ this.#logger.error?.(
787
+ `[dsh-im:${this.#descriptor.key}] failed to process a message [${failure.referenceId}]:`,
788
+ error,
789
+ );
790
+ if (await presentStreamFailure(failureText)) {
750
791
  return error.deliveryReceipt;
751
792
  }
752
793
  stream?.cancel?.();
753
794
  try {
754
- await this.#bot.sendText(target, t('消息处理失败,请稍后重试。'));
795
+ await this.#bot.sendText(target, failureText);
755
796
  } catch (sendError) {
756
797
  this.#logger.error?.(
757
798
  `[dsh-im:${this.#descriptor.key}] failed to send the safe error reply:`,
@@ -768,7 +809,10 @@ export class TextHarnessBridge {
768
809
  }
769
810
 
770
811
  async #processInteractionReply(message, messageId, senderId, key, expected) {
771
- if (this.#signal?.aborted) return;
812
+ if (this.#signal?.aborted) {
813
+ message.statusReaction?.clear();
814
+ return;
815
+ }
772
816
  const current = this.#pendingInteractions.get(key);
773
817
  const claimed = expected.claimedReplyMessageId === messageId;
774
818
  if (!current || current !== expected || current.submitting) {
@@ -822,6 +866,7 @@ export class TextHarnessBridge {
822
866
  try {
823
867
  await this.#presentInteraction(pending);
824
868
  } catch (error) {
869
+ message.statusReaction?.error();
825
870
  this.#status.lastError = t('{label}交互问题发送失败。', { label: this.#descriptor.label });
826
871
  this.#logger.error?.(
827
872
  `[dsh-im:${this.#descriptor.key}] failed to retry an interaction question:`,
@@ -860,6 +905,7 @@ export class TextHarnessBridge {
860
905
  try {
861
906
  await this.#presentInteraction(pending);
862
907
  } catch (error) {
908
+ message.statusReaction?.error();
863
909
  this.#status.lastError = t('{label}交互问题发送失败。', { label: this.#descriptor.label });
864
910
  this.#logger.error?.(
865
911
  `[dsh-im:${this.#descriptor.key}] failed to send the next interaction question:`,
@@ -884,7 +930,10 @@ export class TextHarnessBridge {
884
930
  } catch (error) {
885
931
  if (error?.code === 'interaction-not-pending') {
886
932
  this.#clearPendingInteraction(key, pending.interactionId);
887
- if (this.#signal?.aborted) return;
933
+ if (this.#signal?.aborted) {
934
+ message.statusReaction?.clear();
935
+ return;
936
+ }
888
937
  try {
889
938
  await this.#bot.sendText(target, t(INTERACTION_RESOLVED_TEXT));
890
939
  } catch (sendError) {
@@ -895,7 +944,15 @@ export class TextHarnessBridge {
895
944
  }
896
945
  return;
897
946
  }
898
- if (this.#signal?.aborted || this.#pendingInteractions.get(key) !== pending) return;
947
+ if (this.#signal?.aborted) {
948
+ message.statusReaction?.clear();
949
+ return;
950
+ }
951
+ if (this.#pendingInteractions.get(key) !== pending) {
952
+ message.statusReaction?.clear();
953
+ return;
954
+ }
955
+ message.statusReaction?.error();
899
956
  pending.submitting = false;
900
957
  pending.answers.pop();
901
958
  pending.index -= 1;
@@ -1,5 +1,6 @@
1
1
  import { connectionTestMessage } from './connection-test.mjs';
2
2
  import { t } from './i18n.mjs';
3
+ import { publicMessageFailure } from './message-failure.mjs';
3
4
 
4
5
  function cleanString(value) {
5
6
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -291,6 +292,7 @@ export class TokenBotController {
291
292
  messagesReceived: runtimeStatus?.messagesReceived ?? 0,
292
293
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
293
294
  },
295
+ lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
294
296
  error: structuredClone(this.#errors.get(config.botId) ?? null),
295
297
  };
296
298
  });
@@ -20,6 +20,7 @@ oauth_config:
20
20
  - files:read
21
21
  - files:write
22
22
  - im:history
23
+ - reactions:write
23
24
  settings:
24
25
  event_subscriptions:
25
26
  bot_events:
@@ -276,6 +276,34 @@ export class SlackApi {
276
276
  });
277
277
  }
278
278
 
279
+ addReaction({ channelId, messageTs, emojiName, signal, timeoutMs }) {
280
+ return this.#request('reactions.add', {
281
+ tokenKind: 'bot',
282
+ signal,
283
+ timeoutMs,
284
+ retry: false,
285
+ body: {
286
+ channel: slackId(channelId, 'channel id'),
287
+ timestamp: requiredString(messageTs, 'message timestamp'),
288
+ name: requiredString(emojiName, 'reaction name'),
289
+ },
290
+ });
291
+ }
292
+
293
+ removeReaction({ channelId, messageTs, emojiName, signal, timeoutMs }) {
294
+ return this.#request('reactions.remove', {
295
+ tokenKind: 'bot',
296
+ signal,
297
+ timeoutMs,
298
+ retry: false,
299
+ body: {
300
+ channel: slackId(channelId, 'channel id'),
301
+ timestamp: requiredString(messageTs, 'message timestamp'),
302
+ name: requiredString(emojiName, 'reaction name'),
303
+ },
304
+ });
305
+ }
306
+
279
307
  startStream({ channelId, threadTs, recipientTeamId, recipientUserId, markdownText, signal }) {
280
308
  return this.#request('chat.startStream', {
281
309
  tokenKind: 'bot',
@@ -4,6 +4,11 @@ export const SLACK_DESCRIPTOR = Object.freeze({
4
4
  key: 'slack',
5
5
  label: 'Slack',
6
6
  connectionLabel: ' Socket Mode 长连接',
7
+ reactions: Object.freeze({
8
+ processing: 'eyes',
9
+ success: 'white_check_mark',
10
+ error: 'x',
11
+ }),
7
12
  });
8
13
 
9
14
  export class SlackHarnessBridge extends TextHarnessBridge {
@@ -1,4 +1,5 @@
1
1
  import { connectionTestMessage } from '../shared/connection-test.mjs';
2
+ import { publicMessageFailure } from '../shared/message-failure.mjs';
2
3
  import { t } from '../shared/i18n.mjs';
3
4
  import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
4
5
  import { inspectSlackCredentials } from './slack-api.mjs';
@@ -254,6 +255,7 @@ export class SlackController {
254
255
  messagesReceived: runtimeStatus?.messagesReceived ?? 0,
255
256
  messagesReplied: runtimeStatus?.messagesReplied ?? 0,
256
257
  },
258
+ lastMessageError: publicMessageFailure(runtimeStatus?.lastMessageError),
257
259
  error: structuredClone(this.#errors.get(config.botId) ?? null),
258
260
  };
259
261
  });
@@ -127,6 +127,10 @@ export function normalizeSlackEvent(payload, botUserId, {
127
127
  ? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
128
128
  : [],
129
129
  addressed: direct || mentioned,
130
+ reactionTarget: {
131
+ channelId: String(event.channel),
132
+ messageTs: String(event.ts),
133
+ },
130
134
  replyTarget: {
131
135
  channelId: String(event.channel),
132
136
  threadTs,
@@ -284,6 +288,26 @@ export class SlackBotClient {
284
288
  return { providerMessageIds };
285
289
  }
286
290
 
291
+ async addReaction(target, emoji, { signal } = {}) {
292
+ const reactionKey = String(emoji ?? '').trim();
293
+ await this.#api.addReaction({
294
+ channelId: target.channelId,
295
+ messageTs: target.messageTs,
296
+ emojiName: reactionKey,
297
+ signal: signal ?? this.#signal,
298
+ });
299
+ return reactionKey;
300
+ }
301
+
302
+ removeReaction(target, reactionKey, { signal } = {}) {
303
+ return this.#api.removeReaction({
304
+ channelId: target.channelId,
305
+ messageTs: target.messageTs,
306
+ emojiName: reactionKey,
307
+ signal: signal ?? this.#signal,
308
+ });
309
+ }
310
+
287
311
  openStream(target) {
288
312
  return createSlackMessageStream({
289
313
  api: this.#api,
@@ -203,6 +203,15 @@ export class TelegramApi {
203
203
  }, { signal });
204
204
  }
205
205
 
206
+ async setMessageReaction({ chatId, messageId, emoji, signal, timeoutMs }) {
207
+ const normalizedEmoji = cleanString(emoji);
208
+ return this.#call('setMessageReaction', {
209
+ chat_id: chatId,
210
+ message_id: messageId,
211
+ reaction: normalizedEmoji ? [{ type: 'emoji', emoji: normalizedEmoji }] : [],
212
+ }, { signal, timeoutMs });
213
+ }
214
+
206
215
  async sendRichMessage({
207
216
  chatId,
208
217
  richMessage,
@@ -4,6 +4,7 @@ export const TELEGRAM_DESCRIPTOR = Object.freeze({
4
4
  key: 'telegram',
5
5
  label: 'Telegram',
6
6
  connectionLabel: ' Bot API 长轮询',
7
+ reactions: Object.freeze({ processing: '👀', success: '👍', error: '👎' }),
7
8
  });
8
9
 
9
10
  export class TelegramHarnessBridge extends TextHarnessBridge {
@@ -168,6 +168,7 @@ export function normalizeTelegramUpdate(update, {
168
168
  images: image ? [image] : [],
169
169
  files: file ? [file] : [],
170
170
  addressed,
171
+ reactionTarget: { chatId, messageId },
171
172
  replyTarget: {
172
173
  chatId,
173
174
  chatType: message.chat.type,
@@ -303,6 +304,25 @@ export class TelegramBotClient {
303
304
  return { providerMessageIds };
304
305
  }
305
306
 
307
+ async addReaction(target, emoji, { signal } = {}) {
308
+ const reactionKey = String(emoji ?? '').trim();
309
+ await this.#api.setMessageReaction({
310
+ chatId: target.chatId,
311
+ messageId: target.messageId,
312
+ emoji: reactionKey,
313
+ signal: signal ?? this.#signal,
314
+ });
315
+ return reactionKey;
316
+ }
317
+
318
+ removeReaction(target, _reactionKey, { signal } = {}) {
319
+ return this.#api.setMessageReaction({
320
+ chatId: target.chatId,
321
+ messageId: target.messageId,
322
+ signal: signal ?? this.#signal,
323
+ });
324
+ }
325
+
306
326
  sendTyping(target) {
307
327
  return this.#api.sendChatAction({
308
328
  chatId: target.chatId,