@xmanrui/dsh-im 4.5.0 → 4.7.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 (36) hide show
  1. package/README.en.md +2 -0
  2. package/README.md +2 -0
  3. package/lib/client.js +4 -1
  4. package/lib/index.js +249 -240
  5. package/package.json +1 -1
  6. package/src/channels/dingtalk/dingtalk-bridge.mjs +167 -20
  7. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  8. package/src/channels/dingtalk/state-store.mjs +98 -0
  9. package/src/channels/discord/discord-api.mjs +7 -0
  10. package/src/channels/discord/discord-runtime.mjs +97 -2
  11. package/src/channels/feishu/bridge.mjs +44 -13
  12. package/src/channels/feishu/feishu-cards.mjs +2 -0
  13. package/src/channels/feishu/message-utils.mjs +229 -0
  14. package/src/channels/qq/qq-bridge.mjs +49 -6
  15. package/src/channels/shared/batch-input.mjs +3 -3
  16. package/src/channels/shared/harness-client.mjs +82 -30
  17. package/src/channels/shared/i18n-en/feishu.mjs +2 -2
  18. package/src/channels/shared/i18n-en/shared-a.mjs +2 -0
  19. package/src/channels/shared/i18n-en/shared-b.mjs +2 -2
  20. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  21. package/src/channels/shared/image-prompt.mjs +51 -0
  22. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  23. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  24. package/src/channels/shared/session-title.mjs +1 -1
  25. package/src/channels/shared/text-harness-bridge.mjs +13 -6
  26. package/src/channels/shared/workspace-command.mjs +22 -3
  27. package/src/channels/slack/manifest.mjs +3 -0
  28. package/src/channels/slack/slack-api.mjs +18 -0
  29. package/src/channels/slack/slack-runtime.mjs +56 -0
  30. package/src/channels/telegram/telegram-runtime.mjs +117 -2
  31. package/src/channels/wecom/wecom-bridge.mjs +50 -7
  32. package/src/channels/weixin/state-store.mjs +110 -0
  33. package/src/channels/weixin/weixin-api.mjs +86 -2
  34. package/src/channels/weixin/weixin-bridge.mjs +97 -9
  35. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  36. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@xmanrui/dsh-im",
3
- "version": "4.5.0",
3
+ "version": "4.7.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",
@@ -39,7 +39,6 @@ import {
39
39
  hasInboundImages,
40
40
  imagePromptDiagnostic,
41
41
  imagePromptUserMessage,
42
- promptContentForMessage,
43
42
  } from '../shared/image-prompt.mjs';
44
43
  import {
45
44
  hasInboundFiles,
@@ -48,10 +47,16 @@ import {
48
47
  } from '../shared/inbound-file.mjs';
49
48
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
50
49
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
50
+ import {
51
+ hasReplyReference,
52
+ promptContentForInboundMessage,
53
+ } from '../shared/semantic/reply-reference.mjs';
51
54
  import {
52
55
  createDeliveryReceipt,
53
56
  providerMessageIdsFor,
54
57
  } from '../shared/semantic/delivery.mjs';
58
+ import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
59
+ import { DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS } from './state-store.mjs';
55
60
  import {
56
61
  channelDeliveryFailure,
57
62
  clearLastMessageFailure,
@@ -77,6 +82,7 @@ const HELP_TEXT_LINES = [
77
82
  '/workspace 工作区序号或绝对路径 切换工作区',
78
83
  '/workspacelist 列出工作区绝对路径',
79
84
  '/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题',
85
+ '/sessionlist --limit N 仅列出当前工作区前 N 个会话',
80
86
  '/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话',
81
87
  '/models 按序号列出所有可用模型',
82
88
  '/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级',
@@ -185,11 +191,95 @@ function downloadCodeFor(value) {
185
191
  return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
186
192
  }
187
193
 
194
+ function dingtalkTimestampMs(value) {
195
+ const number = typeof value === 'string' && value.trim() ? Number(value) : value;
196
+ if (!Number.isFinite(number) || number < 0) return null;
197
+ return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
198
+ }
199
+
200
+ function usefulReplyText(value) {
201
+ const text = nonEmptyString(value);
202
+ return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
203
+ }
204
+
205
+ function dingtalkReplyReference(message, options) {
206
+ const replyEnvelope = message?.text;
207
+ if (replyEnvelope?.isReplyMsg !== true) return null;
208
+ const replied = replyEnvelope?.repliedMsg;
209
+ if (!replied || typeof replied !== 'object') {
210
+ return { unavailableReason: 'not-delivered' };
211
+ }
212
+
213
+ const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
214
+ const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
215
+ const pseudoMessage = {
216
+ msgtype,
217
+ text: {
218
+ content: nonEmptyString(repliedContent.text)
219
+ ?? (typeof replied.content === 'string' ? replied.content : ''),
220
+ },
221
+ content: repliedContent,
222
+ };
223
+ const normalized = dingtalkInboundMessage(pseudoMessage, options);
224
+ let attachments = [];
225
+ if (msgtype === 'picture') {
226
+ attachments = [{ kind: 'image' }];
227
+ } else if (msgtype === 'file') {
228
+ const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
229
+ attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
230
+ } else if (msgtype === 'richtext') {
231
+ attachments = richTextEntries(repliedContent)
232
+ .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
233
+ .map(() => ({ kind: 'image' }));
234
+ } else if (msgtype === 'voice' || msgtype === 'audio') {
235
+ attachments = [{ kind: 'audio' }];
236
+ } else if (msgtype === 'video') {
237
+ attachments = [{ kind: 'video' }];
238
+ }
239
+
240
+ const messageId = nonEmptyString(replied.msgId ?? replied.messageId);
241
+ const authorId = nonEmptyString(replied.senderId ?? replied.senderStaffId);
242
+ const authorName = nonEmptyString(replied.senderNick ?? replied.senderName);
243
+ const content = usefulReplyText(normalized.content)
244
+ ?? usefulReplyText(repliedContent.text)
245
+ ?? usefulReplyText(repliedContent.summary)
246
+ ?? usefulReplyText(repliedContent.title);
247
+ const processQueryKey = nonEmptyString(
248
+ message?.originalProcessQueryKey ?? repliedContent.processQueryKey,
249
+ );
250
+ const createdAt = dingtalkTimestampMs(replied.createdAt ?? replied.createTime);
251
+ const load = !content && attachments.length === 0
252
+ && typeof options?.loadReplyContent === 'function'
253
+ ? ({ signal } = {}) => options.loadReplyContent({
254
+ ...(messageId ? { messageId } : {}),
255
+ ...(processQueryKey ? { processQueryKey } : {}),
256
+ ...(createdAt === null ? {} : { createdAt }),
257
+ }, { signal })
258
+ : null;
259
+ const supported = [
260
+ 'text', 'picture', 'file', 'richtext', 'voice', 'audio', 'video',
261
+ 'interactivecard', 'chatrecord',
262
+ ]
263
+ .includes(msgtype);
264
+ return {
265
+ ...(messageId ? { messageId } : {}),
266
+ ...(authorId ? { authorId } : {}),
267
+ ...(authorName ? { authorName } : {}),
268
+ ...(content ? { content } : {}),
269
+ ...(attachments.length > 0 ? { attachments } : {}),
270
+ ...(load ? { load } : {}),
271
+ ...(!content && attachments.length === 0 && !load
272
+ ? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
273
+ : {}),
274
+ };
275
+ }
276
+
188
277
  /** Normalize DingTalk picture and richText callbacks into lazy image references. */
189
278
  export function dingtalkInboundMessage(message, {
190
279
  api,
191
280
  clientId,
192
281
  clientSecret,
282
+ loadReplyContent,
193
283
  } = {}) {
194
284
  const msgtype = String(message?.msgtype ?? '').toLowerCase();
195
285
  const content = parsedMessageContent(message);
@@ -209,6 +299,12 @@ export function dingtalkInboundMessage(message, {
209
299
  }
210
300
  }
211
301
  const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
302
+ const replyTo = dingtalkReplyReference(message, {
303
+ api,
304
+ clientId,
305
+ clientSecret,
306
+ loadReplyContent,
307
+ });
212
308
  return {
213
309
  content: text,
214
310
  images: imageCodes.map((downloadCode, index) => ({
@@ -242,6 +338,7 @@ export function dingtalkInboundMessage(message, {
242
338
  });
243
339
  },
244
340
  }] : [],
341
+ ...(replyTo ? { replyTo } : {}),
245
342
  };
246
343
  }
247
344
 
@@ -465,11 +562,7 @@ export class DingtalkHarnessBridge {
465
562
  // An unsafe reply route must never be able to submit an approval.
466
563
  }
467
564
  const pending = this.#pendingInteractions.get(key);
468
- const promptMessage = dingtalkInboundMessage(message, {
469
- api: this.#api,
470
- clientId: this.#clientId,
471
- clientSecret: this.#clientSecret,
472
- });
565
+ const promptMessage = this.#inboundMessage(message, key);
473
566
  const commandText = nonEmptyString(promptMessage.content) ?? '';
474
567
  const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
475
568
  const direct = String(message.conversationType) !== '2';
@@ -533,7 +626,8 @@ export class DingtalkHarnessBridge {
533
626
  plainText: Boolean(commandText)
534
627
  && String(message?.msgtype).toLowerCase() === 'text'
535
628
  && !hasInboundFiles(promptMessage)
536
- && !hasInboundImages(promptMessage),
629
+ && !hasInboundImages(promptMessage)
630
+ && !hasReplyReference(promptMessage),
537
631
  });
538
632
  if (result.handled) {
539
633
  if (result.kind === 'submit') {
@@ -778,11 +872,7 @@ export class DingtalkHarnessBridge {
778
872
  }
779
873
  const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
780
874
  const preparedMessage = hasSafeReplyRoute && addressed
781
- ? prefetchInboundFiles(dingtalkInboundMessage(message, {
782
- api: this.#api,
783
- clientId: this.#clientId,
784
- clientSecret: this.#clientSecret,
785
- }), { signal: this.#signal })
875
+ ? prefetchInboundFiles(this.#inboundMessage(message, key), { signal: this.#signal })
786
876
  : undefined;
787
877
  const previous = this.#queues.get(key) ?? Promise.resolve();
788
878
  const current = previous
@@ -801,6 +891,50 @@ export class DingtalkHarnessBridge {
801
891
  return current;
802
892
  }
803
893
 
894
+ #inboundMessage(message, key) {
895
+ return dingtalkInboundMessage(message, {
896
+ api: this.#api,
897
+ clientId: this.#clientId,
898
+ clientSecret: this.#clientSecret,
899
+ loadReplyContent: (reference, options) => this.#loadReplyContent(key, reference, options),
900
+ });
901
+ }
902
+
903
+ async #loadReplyContent(key, reference, { signal } = {}) {
904
+ const indexed = this.#state.recentOutboundTextFor?.({
905
+ conversationKey: key,
906
+ ...reference,
907
+ });
908
+ if (indexed) return { content: indexed };
909
+ const quotedAt = dingtalkTimestampMs(reference?.createdAt);
910
+ if (quotedAt === null) return { unavailableReason: 'not-delivered' };
911
+ const sessionId = this.#state.sessionFor(key);
912
+ const session = typeof sessionId === 'string' && sessionId
913
+ ? this.#harness.workspaceSession?.(sessionId)
914
+ : null;
915
+ const text = await recoverAssistantTextByTimestamp({
916
+ session,
917
+ quotedAt,
918
+ signal,
919
+ toleranceMs: DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS,
920
+ });
921
+ if (!text) return { unavailableReason: 'not-delivered' };
922
+ try {
923
+ await this.#state.rememberOutboundMessage?.({
924
+ conversationKey: key,
925
+ text,
926
+ sentAt: quotedAt,
927
+ completedAt: quotedAt,
928
+ providerMessageIds: [reference?.processQueryKey, reference?.messageId]
929
+ .map(nonEmptyString)
930
+ .filter(Boolean),
931
+ });
932
+ } catch (error) {
933
+ this.#logger.warn?.('[dsh-dingtalk] failed to remember a recovered quote:', error);
934
+ }
935
+ return { content: text };
936
+ }
937
+
804
938
  async waitForIdle() {
805
939
  await Promise.allSettled([
806
940
  ...this.#queues.values(),
@@ -936,20 +1070,18 @@ export class DingtalkHarnessBridge {
936
1070
  return;
937
1071
  }
938
1072
 
939
- const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
940
- api: this.#api,
941
- clientId: this.#clientId,
942
- clientSecret: this.#clientSecret,
943
- });
1073
+ const promptMessage = preparedMessage ?? this.#inboundMessage(message, key);
944
1074
  const text = promptMessage.content;
945
1075
  const hasImages = hasInboundImages(promptMessage);
946
1076
  const hasFiles = hasInboundFiles(promptMessage);
1077
+ const hasReply = hasReplyReference(promptMessage);
947
1078
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
948
1079
  let cardStream = null;
949
1080
  let cardStarted = false;
1081
+ let cardStartedAt = null;
950
1082
  let batchSettled = batchSubmission === null;
951
1083
  try {
952
- if (!text && !hasImages && !hasFiles) {
1084
+ if (!text && !hasImages && !hasFiles && !hasReply) {
953
1085
  await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
954
1086
  return;
955
1087
  }
@@ -992,8 +1124,8 @@ export class DingtalkHarnessBridge {
992
1124
  return;
993
1125
  }
994
1126
 
995
- let content = hasImages
996
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
1127
+ let content = hasImages || hasReply
1128
+ ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
997
1129
  : undefined;
998
1130
  const snapshot = this.#acceptedMessageIds.get(messageId);
999
1131
  let contextEnhanced = false;
@@ -1018,7 +1150,9 @@ export class DingtalkHarnessBridge {
1018
1150
  signal: this.#signal,
1019
1151
  logger: this.#logger,
1020
1152
  });
1153
+ const startedAt = Date.now();
1021
1154
  cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
1155
+ if (cardStarted) cardStartedAt = startedAt;
1022
1156
  }
1023
1157
  const { answer, artifacts = [] } = await askInWorkspaceSession({
1024
1158
  harness: this.#harness,
@@ -1056,12 +1190,14 @@ export class DingtalkHarnessBridge {
1056
1190
  let textDeliveryError = null;
1057
1191
  let textReceipt = null;
1058
1192
  let streamed = false;
1193
+ const deliveryStartedAt = cardStartedAt ?? Date.now();
1059
1194
  try {
1060
1195
  streamed = cardStarted && await cardStream.finish(answerText);
1061
1196
  if (streamed) {
1062
1197
  textReceipt = createDeliveryReceipt({
1063
1198
  deliveryId: messageId,
1064
1199
  presentation: 'dingtalk-card',
1200
+ providerMessageIds: cardStream.providerMessageIds,
1065
1201
  });
1066
1202
  } else {
1067
1203
  textReceipt = createDeliveryReceipt({
@@ -1070,6 +1206,17 @@ export class DingtalkHarnessBridge {
1070
1206
  providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
1071
1207
  });
1072
1208
  }
1209
+ try {
1210
+ await this.#state.rememberOutboundMessage?.({
1211
+ conversationKey: key,
1212
+ text: answerText,
1213
+ sentAt: deliveryStartedAt,
1214
+ completedAt: Date.now(),
1215
+ providerMessageIds: providerMessageIdsFor(textReceipt),
1216
+ });
1217
+ } catch (error) {
1218
+ this.#logger.warn?.('[dsh-dingtalk] failed to remember an outbound message:', error);
1219
+ }
1073
1220
  } catch (error) {
1074
1221
  textDeliveryError = channelDeliveryFailure(error);
1075
1222
  }
@@ -32,7 +32,7 @@ function requiredCredential(value, name) {
32
32
  * @param {number} [options.updateIntervalMs=500] Minimum delay between updates.
33
33
  * @param {()=>number} [options.clock] Monotonic millisecond clock.
34
34
  * @param {{setTimeout: Function, clearTimeout: Function}} [options.timer] Timer implementation.
35
- * @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>}}
35
+ * @returns {{start(initialText: string): Promise<boolean>, push(progressText: string): void, finish(finalText: string): Promise<boolean>, readonly providerMessageIds: string[]}}
36
36
  * Card stream controller.
37
37
  */
38
38
  export function createDingTalkCardStream({
@@ -231,5 +231,12 @@ export function createDingTalkCardStream({
231
231
  return finishPromise;
232
232
  };
233
233
 
234
- return Object.freeze({ start, push, finish });
234
+ return Object.freeze({
235
+ start,
236
+ push,
237
+ finish,
238
+ get providerMessageIds() {
239
+ return cardRequest?.cardInstanceId ? [cardRequest.cardInstanceId] : [];
240
+ },
241
+ });
235
242
  }
@@ -9,8 +9,14 @@ const EMPTY_STATE = Object.freeze({
9
9
  sessions: {},
10
10
  seenMessageIds: [],
11
11
  pendingSenders: {},
12
+ recentOutboundMessages: [],
12
13
  });
13
14
 
15
+ export const DINGTALK_RECENT_OUTBOUND_LIMIT = 200;
16
+ export const DINGTALK_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
17
+ export const DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
18
+ export const DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
19
+
14
20
  function nonEmptyString(value) {
15
21
  return typeof value === 'string' && value.trim() ? value.trim() : null;
16
22
  }
@@ -19,6 +25,49 @@ function displayName(value) {
19
25
  return (nonEmptyString(value) ?? t('钉钉用户')).slice(0, 100);
20
26
  }
21
27
 
28
+ function timestampMs(value) {
29
+ const number = typeof value === 'string' && value.trim() ? Number(value) : value;
30
+ if (!Number.isFinite(number) || number < 0) return null;
31
+ return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
32
+ }
33
+
34
+ function providerMessageIds(value) {
35
+ if (!Array.isArray(value)) return [];
36
+ return [...new Set(value
37
+ .map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
38
+ .filter(Boolean))];
39
+ }
40
+
41
+ function truncateText(value) {
42
+ const text = nonEmptyString(value);
43
+ return text ? [...text].slice(0, DINGTALK_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
44
+ }
45
+
46
+ function normalizeRecentOutboundMessage(value) {
47
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
48
+ const conversationKey = nonEmptyString(value.conversationKey);
49
+ const text = truncateText(value.text);
50
+ const sentAt = timestampMs(value.sentAt);
51
+ const completedAt = timestampMs(value.completedAt) ?? sentAt;
52
+ if (!conversationKey || !text || sentAt === null || completedAt === null) return null;
53
+ return {
54
+ conversationKey,
55
+ text,
56
+ sentAt,
57
+ completedAt: Math.max(sentAt, completedAt),
58
+ providerMessageIds: providerMessageIds(value.providerMessageIds),
59
+ };
60
+ }
61
+
62
+ function recentOutboundMessages(value, now = Date.now()) {
63
+ if (!Array.isArray(value)) return [];
64
+ const cutoff = now - DINGTALK_RECENT_OUTBOUND_TTL_MS;
65
+ return value
66
+ .map(normalizeRecentOutboundMessage)
67
+ .filter((entry) => entry && entry.completedAt >= cutoff)
68
+ .slice(-DINGTALK_RECENT_OUTBOUND_LIMIT);
69
+ }
70
+
22
71
  function normalizePendingSender(value, fallbackRequestId) {
23
72
  if (!value || typeof value !== 'object') return null;
24
73
  const requestId = nonEmptyString(value.requestId) ?? nonEmptyString(fallbackRequestId);
@@ -69,6 +118,7 @@ function normalizeState(value) {
69
118
  ? [...new Set(value.seenMessageIds.map(nonEmptyString).filter(Boolean))].slice(-1_000)
70
119
  : [],
71
120
  pendingSenders,
121
+ recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
72
122
  };
73
123
  }
74
124
 
@@ -140,6 +190,54 @@ export class DingtalkStateStore {
140
190
  await this.#persist();
141
191
  }
142
192
 
193
+ async rememberOutboundMessage({
194
+ conversationKey,
195
+ text,
196
+ sentAt = Date.now(),
197
+ completedAt = Date.now(),
198
+ providerMessageIds: messageIds = [],
199
+ } = {}) {
200
+ const entry = normalizeRecentOutboundMessage({
201
+ conversationKey,
202
+ text,
203
+ sentAt,
204
+ completedAt,
205
+ providerMessageIds: messageIds,
206
+ });
207
+ if (!entry) throw new TypeError('Invalid DingTalk outbound message');
208
+ this.#state.recentOutboundMessages = recentOutboundMessages([
209
+ ...this.#state.recentOutboundMessages,
210
+ entry,
211
+ ]);
212
+ await this.#persist();
213
+ }
214
+
215
+ recentOutboundTextFor({
216
+ conversationKey,
217
+ processQueryKey,
218
+ messageId,
219
+ createdAt,
220
+ now = Date.now(),
221
+ } = {}) {
222
+ const key = nonEmptyString(conversationKey);
223
+ if (!key) return null;
224
+ const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
225
+ .filter((entry) => entry.conversationKey === key);
226
+ const quotedIds = providerMessageIds([processQueryKey, messageId]);
227
+ for (const quotedId of quotedIds) {
228
+ const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedId));
229
+ if (exact.length === 1) return exact[0].text;
230
+ if (exact.length > 1) return null;
231
+ }
232
+ const quotedAt = timestampMs(createdAt);
233
+ if (quotedAt === null) return null;
234
+ const candidates = active.filter((entry) => (
235
+ quotedAt >= entry.sentAt - DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
236
+ && quotedAt <= entry.completedAt + DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
237
+ ));
238
+ return candidates.length === 1 ? candidates[0].text : null;
239
+ }
240
+
143
241
  pendingSenders() {
144
242
  return Object.values(this.#state.pendingSenders)
145
243
  .sort((left, right) => left.requestedAt.localeCompare(right.requestedAt))
@@ -134,6 +134,13 @@ export class DiscordApi {
134
134
  });
135
135
  }
136
136
 
137
+ getMessage({ channelId, messageId, signal } = {}) {
138
+ return this.#request(
139
+ `channels/${snowflake(channelId, 'channel id')}/messages/${snowflake(messageId, 'message id')}`,
140
+ { method: 'GET', signal },
141
+ );
142
+ }
143
+
137
144
  startThreadFromMessage({ channelId, messageId, name, signal } = {}) {
138
145
  const threadName = cleanString(name);
139
146
  if (!threadName || [...threadName].length > 100) {
@@ -206,12 +206,101 @@ function discordFileSource(attachment, fetchImpl) {
206
206
  };
207
207
  }
208
208
 
209
- export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
209
+ function discordReplyAttachment(attachment) {
210
+ if (!attachment || typeof attachment !== 'object') return null;
211
+ const mediaType = typeof attachment.content_type === 'string'
212
+ ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
213
+ const kind = mediaType.startsWith('image/') ? 'image'
214
+ : mediaType.startsWith('audio/') ? 'audio'
215
+ : mediaType.startsWith('video/') ? 'video' : 'file';
216
+ const name = typeof attachment.filename === 'string' && attachment.filename
217
+ ? attachment.filename : undefined;
218
+ return { kind, ...(name ? { name } : {}) };
219
+ }
220
+
221
+ function discordReplySnapshot(message, fallbackMessageId) {
222
+ if (!message || typeof message !== 'object') return null;
223
+ const messageId = typeof message.id === 'string' && message.id
224
+ ? message.id : fallbackMessageId;
225
+ const authorId = typeof message.author?.id === 'string' && message.author.id
226
+ ? message.author.id : undefined;
227
+ const authorName = [message.member?.nick, message.author?.global_name, message.author?.username]
228
+ .find((value) => typeof value === 'string' && value.trim());
229
+ const attachments = Array.isArray(message.attachments)
230
+ ? message.attachments.map(discordReplyAttachment).filter(Boolean)
231
+ : [];
232
+ if (Array.isArray(message.sticker_items)) {
233
+ attachments.push(...message.sticker_items.map((sticker) => ({
234
+ kind: 'image',
235
+ ...(typeof sticker?.name === 'string' && sticker.name ? { name: sticker.name } : {}),
236
+ })));
237
+ }
238
+ return {
239
+ ...(messageId ? { messageId: String(messageId) } : {}),
240
+ ...(authorId ? { authorId } : {}),
241
+ ...(authorName ? { authorName } : {}),
242
+ content: typeof message.content === 'string' ? message.content : '',
243
+ attachments,
244
+ };
245
+ }
246
+
247
+ function discordReplyReference(message, loadReply) {
248
+ const channelId = String(message?.channel_id ?? '');
249
+ const referenceId = typeof message?.message_reference?.message_id === 'string'
250
+ && message.message_reference.message_id
251
+ ? message.message_reference.message_id : undefined;
252
+ const referenceChannelId = message?.message_reference?.channel_id;
253
+ if (referenceChannelId !== undefined && String(referenceChannelId) !== channelId) {
254
+ return {
255
+ ...(referenceId ? { messageId: referenceId } : {}),
256
+ unavailableReason: 'not-found',
257
+ };
258
+ }
259
+ if (Object.hasOwn(message ?? {}, 'referenced_message')) {
260
+ if (message.referenced_message === null) {
261
+ return {
262
+ ...(referenceId ? { messageId: referenceId } : {}),
263
+ unavailableReason: 'deleted',
264
+ };
265
+ }
266
+ if (message.referenced_message && typeof message.referenced_message === 'object') {
267
+ const snapshotId = typeof message.referenced_message.id === 'string'
268
+ && message.referenced_message.id ? message.referenced_message.id : undefined;
269
+ if (String(message.referenced_message.channel_id ?? '') !== channelId
270
+ || !snapshotId || (referenceId && snapshotId !== referenceId)) {
271
+ return {
272
+ ...(referenceId ? { messageId: referenceId } : {}),
273
+ unavailableReason: 'not-found',
274
+ };
275
+ }
276
+ return discordReplySnapshot(message.referenced_message, referenceId) ?? undefined;
277
+ }
278
+ }
279
+ if (!referenceId) return undefined;
280
+ if (typeof loadReply !== 'function') {
281
+ return { messageId: referenceId, unavailableReason: 'not-delivered' };
282
+ }
283
+ return {
284
+ messageId: referenceId,
285
+ load: async ({ signal } = {}) => {
286
+ const referenced = await loadReply({ channelId, messageId: referenceId, signal });
287
+ if (!referenced || String(referenced.id ?? '') !== referenceId
288
+ || String(referenced.channel_id ?? '') !== channelId) return null;
289
+ return discordReplySnapshot(referenced, referenceId);
290
+ },
291
+ };
292
+ }
293
+
294
+ export function normalizeDiscordMessage(message, botId, {
295
+ fetchImpl = fetch,
296
+ loadReply,
297
+ } = {}) {
210
298
  if (!message?.id || !message?.channel_id || !message?.author?.id
211
299
  || Number(message.type) === 21) return null;
212
300
  const direct = !message.guild_id;
213
301
  const addressed = direct
214
302
  || message.mentions?.some((mention) => String(mention?.id) === String(botId));
303
+ const replyTo = discordReplyReference(message, loadReply);
215
304
  return {
216
305
  messageId: String(message.id),
217
306
  senderId: String(message.author.id),
@@ -232,6 +321,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
232
321
  files: Array.isArray(message.attachments)
233
322
  ? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
234
323
  : [],
324
+ ...(replyTo ? { replyTo } : {}),
235
325
  addressed,
236
326
  replyTarget: {
237
327
  channelId: String(message.channel_id),
@@ -252,7 +342,12 @@ export async function resolveDiscordMessageRoute(message, botId, {
252
342
  signal,
253
343
  onChannel,
254
344
  } = {}) {
255
- const normalized = normalizeDiscordMessage(message, botId, { fetchImpl });
345
+ const normalized = normalizeDiscordMessage(message, botId, {
346
+ fetchImpl,
347
+ loadReply: typeof api?.getMessage === 'function'
348
+ ? (options) => api.getMessage(options)
349
+ : undefined,
350
+ });
256
351
  if (!normalized || normalized.senderIsBot) return normalized;
257
352
  signal?.throwIfAborted();
258
353
  if (normalized.kind === 'direct') {