@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.
@@ -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,
@@ -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'
@@ -3603,35 +3604,24 @@ export class FeishuHarnessBridge {
3603
3604
  return t('_{text}_', { text: update.text || t('正在处理…') });
3604
3605
  }
3605
3606
 
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
- }
3607
+ #beginReaction(messageId) {
3608
+ return beginStatusReaction({
3609
+ adapter: this.#channel,
3610
+ target: messageId,
3611
+ reactions: { processing: 'OnIt', success: 'DONE', error: 'ERROR' },
3612
+ status: this.#status,
3613
+ logger: this.#logger,
3614
+ label: 'feishu',
3615
+ });
3617
3616
  }
3618
3617
 
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
- }
3618
+ #removeProcessingReaction(_messageId, processingReaction) {
3619
+ processingReaction.clear();
3630
3620
  }
3631
3621
 
3632
- async #finishReaction(messageId, processingReaction, finalEmojiType) {
3633
- await this.#removeProcessingReaction(messageId, processingReaction);
3634
- await this.#addReaction(messageId, finalEmojiType);
3622
+ #finishReaction(_messageId, processingReaction, finalEmojiType) {
3623
+ if (finalEmojiType === 'ERROR') processingReaction.error();
3624
+ else processingReaction.success();
3635
3625
  }
3636
3626
 
3637
3627
  async #send(chatId, text) {
@@ -0,0 +1,107 @@
1
+ const DEFAULT_TIMEOUT_MS = 2_000;
2
+
3
+ const NOOP_REACTION = Object.freeze({
4
+ success() {},
5
+ error() {},
6
+ clear() {},
7
+ settled: () => Promise.resolve(),
8
+ });
9
+
10
+ function increment(status, key) {
11
+ if (!status || typeof status !== 'object') return;
12
+ status[key] = (status[key] ?? 0) + 1;
13
+ }
14
+
15
+ async function runWithTimeout(operation, timeoutMs) {
16
+ const signal = AbortSignal.timeout(timeoutMs);
17
+ let onAbort;
18
+ const aborted = new Promise((_, reject) => {
19
+ onAbort = () => reject(signal.reason ?? new DOMException('Timed out', 'TimeoutError'));
20
+ signal.addEventListener('abort', onAbort, { once: true });
21
+ });
22
+ try {
23
+ return await Promise.race([operation(signal), aborted]);
24
+ } finally {
25
+ signal.removeEventListener('abort', onAbort);
26
+ }
27
+ }
28
+
29
+ /**
30
+ * Starts a best-effort status reaction lifecycle which is deliberately not
31
+ * part of the caller's message queue. Calls are serialized only for this one
32
+ * source message; every provider operation is bounded and absorbs failures.
33
+ */
34
+ export function beginStatusReaction({
35
+ adapter,
36
+ target,
37
+ reactions,
38
+ status,
39
+ logger = console,
40
+ label = 'channel',
41
+ timeoutMs = DEFAULT_TIMEOUT_MS,
42
+ } = {}) {
43
+ if (!target
44
+ || typeof adapter?.addReaction !== 'function'
45
+ || typeof adapter?.removeReaction !== 'function'
46
+ || typeof reactions?.processing !== 'string'
47
+ || !reactions.processing
48
+ || !Number.isSafeInteger(timeoutMs)
49
+ || timeoutMs <= 0) return NOOP_REACTION;
50
+
51
+ let currentReaction = null;
52
+ let terminal = false;
53
+
54
+ const safely = async (kind, operation) => {
55
+ try {
56
+ const value = await runWithTimeout(operation, timeoutMs);
57
+ increment(status, kind === 'add' ? 'reactionsAdded' : 'reactionsRemoved');
58
+ return { ok: true, value };
59
+ } catch (cause) {
60
+ increment(status, 'reactionErrors');
61
+ logger.warn?.(
62
+ `[dsh-im:${label}] status reaction ${kind} failed:`,
63
+ cause?.message ?? cause?.name ?? String(cause),
64
+ );
65
+ return { ok: false, value: null };
66
+ }
67
+ };
68
+
69
+ const transition = async (emoji) => {
70
+ if (currentReaction !== null) {
71
+ const previous = currentReaction;
72
+ currentReaction = null;
73
+ await safely('remove', (signal) => adapter.removeReaction(
74
+ target,
75
+ previous,
76
+ { signal },
77
+ ));
78
+ }
79
+ if (typeof emoji !== 'string' || !emoji) return;
80
+ const added = await safely('add', (signal) => adapter.addReaction(
81
+ target,
82
+ emoji,
83
+ { signal },
84
+ ));
85
+ if (added.ok && added.value !== undefined && added.value !== null) {
86
+ currentReaction = added.value;
87
+ }
88
+ };
89
+
90
+ // Calling an async function starts the provider request synchronously up to
91
+ // its first await, while the returned tail remains completely detached from
92
+ // normal message processing.
93
+ let tail = transition(reactions.processing);
94
+ const finish = (emoji) => {
95
+ if (terminal) return;
96
+ terminal = true;
97
+ tail = tail.then(() => transition(emoji), () => transition(emoji));
98
+ void tail.catch(() => undefined);
99
+ };
100
+
101
+ return Object.freeze({
102
+ success: () => finish(reactions.success),
103
+ error: () => finish(reactions.error),
104
+ clear: () => finish(null),
105
+ settled: () => tail,
106
+ });
107
+ }
@@ -52,6 +52,7 @@ import {
52
52
  messageFailureText,
53
53
  setLastMessageFailure,
54
54
  } from './message-failure.mjs';
55
+ import { beginStatusReaction } from './status-reaction.mjs';
55
56
 
56
57
  const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无需再次回答。';
57
58
  const FILE_ONLY_COMPLETION_TEXT = '任务已完成。';
@@ -114,6 +115,9 @@ export function createTextBridgeStatus() {
114
115
  lastRejectedAt: null,
115
116
  lastError: null,
116
117
  lastMessageError: null,
118
+ reactionsAdded: 0,
119
+ reactionsRemoved: 0,
120
+ reactionErrors: 0,
117
121
  };
118
122
  }
119
123
 
@@ -178,6 +182,27 @@ export class TextHarnessBridge {
178
182
  return Promise.resolve();
179
183
  }
180
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) {
181
206
  if (normalized.kind === 'direct') {
182
207
  rememberConnectionTestTarget(
183
208
  this.#state,
@@ -185,7 +210,7 @@ export class TextHarnessBridge {
185
210
  );
186
211
  }
187
212
 
188
- const key = `${kind}:${conversationId}`;
213
+ const key = `${normalized.kind}:${normalized.conversationId}`;
189
214
  const pending = this.#pendingInteractions.get(key);
190
215
  const text = cleanText(normalized.content);
191
216
  const batchCommand = isBatchInputCommand(text);
@@ -326,7 +351,11 @@ export class TextHarnessBridge {
326
351
  if (reply) await this.#bot.sendText(message.replyTarget, reply);
327
352
  this.#status.lastError = null;
328
353
  })().catch(async (error) => {
329
- if (this.#signal?.aborted) return;
354
+ if (this.#signal?.aborted) {
355
+ message.statusReaction?.clear();
356
+ return;
357
+ }
358
+ message.statusReaction?.error();
330
359
  this.#status.lastError = error?.message ?? String(error);
331
360
  const failure = setLastMessageFailure(this.#status, error);
332
361
  this.#logger.error?.(
@@ -406,7 +435,11 @@ export class TextHarnessBridge {
406
435
  }
407
436
  this.#status.lastError = null;
408
437
  } catch (error) {
409
- 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();
410
443
  this.#status.lastError = error?.message ?? String(error);
411
444
  const failure = setLastMessageFailure(this.#status, error);
412
445
  this.#logger.error?.(
@@ -706,6 +739,7 @@ export class TextHarnessBridge {
706
739
  ? this.#batches.fail(conversationKey, batchSubmission.token)
707
740
  : null;
708
741
  if (turnStopped) {
742
+ message.statusReaction?.clear();
709
743
  if (stream) {
710
744
  try {
711
745
  await stream.finish(t('已停止。'));
@@ -716,9 +750,11 @@ export class TextHarnessBridge {
716
750
  return;
717
751
  }
718
752
  if (this.#signal?.aborted) {
753
+ message.statusReaction?.clear();
719
754
  stream?.cancel?.();
720
755
  return;
721
756
  }
757
+ message.statusReaction?.error();
722
758
  this.#status.lastError = error?.message ?? String(error);
723
759
  const presentStreamFailure = async (text) => {
724
760
  const method = typeof stream?.fail === 'function'
@@ -773,7 +809,10 @@ export class TextHarnessBridge {
773
809
  }
774
810
 
775
811
  async #processInteractionReply(message, messageId, senderId, key, expected) {
776
- if (this.#signal?.aborted) return;
812
+ if (this.#signal?.aborted) {
813
+ message.statusReaction?.clear();
814
+ return;
815
+ }
777
816
  const current = this.#pendingInteractions.get(key);
778
817
  const claimed = expected.claimedReplyMessageId === messageId;
779
818
  if (!current || current !== expected || current.submitting) {
@@ -827,6 +866,7 @@ export class TextHarnessBridge {
827
866
  try {
828
867
  await this.#presentInteraction(pending);
829
868
  } catch (error) {
869
+ message.statusReaction?.error();
830
870
  this.#status.lastError = t('{label}交互问题发送失败。', { label: this.#descriptor.label });
831
871
  this.#logger.error?.(
832
872
  `[dsh-im:${this.#descriptor.key}] failed to retry an interaction question:`,
@@ -865,6 +905,7 @@ export class TextHarnessBridge {
865
905
  try {
866
906
  await this.#presentInteraction(pending);
867
907
  } catch (error) {
908
+ message.statusReaction?.error();
868
909
  this.#status.lastError = t('{label}交互问题发送失败。', { label: this.#descriptor.label });
869
910
  this.#logger.error?.(
870
911
  `[dsh-im:${this.#descriptor.key}] failed to send the next interaction question:`,
@@ -889,7 +930,10 @@ export class TextHarnessBridge {
889
930
  } catch (error) {
890
931
  if (error?.code === 'interaction-not-pending') {
891
932
  this.#clearPendingInteraction(key, pending.interactionId);
892
- if (this.#signal?.aborted) return;
933
+ if (this.#signal?.aborted) {
934
+ message.statusReaction?.clear();
935
+ return;
936
+ }
893
937
  try {
894
938
  await this.#bot.sendText(target, t(INTERACTION_RESOLVED_TEXT));
895
939
  } catch (sendError) {
@@ -900,7 +944,15 @@ export class TextHarnessBridge {
900
944
  }
901
945
  return;
902
946
  }
903
- 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();
904
956
  pending.submitting = false;
905
957
  pending.answers.pop();
906
958
  pending.index -= 1;
@@ -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 {
@@ -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,
@@ -6,6 +6,7 @@ export const WHATSAPP_DESCRIPTOR = Object.freeze({
6
6
  label: 'WhatsApp',
7
7
  // Translated lazily: t() must run after setImHostLanguage, not at import time.
8
8
  get connectionLabel() { return t(' Web 关联设备'); },
9
+ reactions: Object.freeze({ processing: '👀', success: '✅', error: '❌' }),
9
10
  });
10
11
 
11
12
  export class WhatsappHarnessBridge extends TextHarnessBridge {
@@ -246,6 +246,7 @@ export function normalizeWhatsappMessage(message, accountJid, {
246
246
  addressed: !group || fromMe || mentioned || replyToSelf,
247
247
  selfChat,
248
248
  replyTarget: { jid: remoteJid, quoted: message, selfChat },
249
+ reactionTarget: { jid: remoteJid, key: message.key },
249
250
  };
250
251
  }
251
252
 
@@ -411,6 +412,41 @@ export class WhatsappBotClient {
411
412
  }, 'image');
412
413
  }
413
414
 
415
+ async addReaction(target, emoji, { signal } = {}) {
416
+ if (typeof emoji !== 'string' || !emoji.trim()) {
417
+ throw new TypeError('A WhatsApp reaction emoji is required');
418
+ }
419
+ const reactionKey = emoji.trim();
420
+ await this.#sendReaction(target, reactionKey, signal);
421
+ return reactionKey;
422
+ }
423
+
424
+ removeReaction(target, _reactionKey, { signal } = {}) {
425
+ return this.#sendReaction(target, '', signal);
426
+ }
427
+
428
+ async #sendReaction(target, text, signal) {
429
+ if (typeof target?.jid !== 'string' || !target.jid || !target.key?.id) {
430
+ throw new TypeError('A WhatsApp reaction target is required');
431
+ }
432
+ const operationSignal = signal ?? this.#signal;
433
+ operationSignal?.throwIfAborted();
434
+ const messageId = randomBytes(10).toString('hex').toUpperCase();
435
+ if (typeof this.#outboundIds.reserve === 'function') {
436
+ this.#outboundIds.reserve(messageId);
437
+ } else {
438
+ this.#outboundIds.remember(messageId);
439
+ }
440
+ const pending = this.#socket.sendMessage(
441
+ target.jid,
442
+ { react: { text, key: target.key } },
443
+ { messageId },
444
+ );
445
+ const result = await waitWithSignal(pending, operationSignal);
446
+ this.#outboundIds.remember(result?.key?.id);
447
+ return result;
448
+ }
449
+
414
450
  async #sendArtifact(target, file, content, presentation) {
415
451
  this.#signal?.throwIfAborted();
416
452
  await this.#stopTyping(target.jid);