@xmanrui/dsh-im 4.5.0 → 4.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 (29) hide show
  1. package/lib/client.js +4 -1
  2. package/lib/index.js +247 -239
  3. package/package.json +1 -1
  4. package/src/channels/dingtalk/dingtalk-bridge.mjs +166 -20
  5. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  6. package/src/channels/dingtalk/state-store.mjs +98 -0
  7. package/src/channels/discord/discord-api.mjs +7 -0
  8. package/src/channels/discord/discord-runtime.mjs +97 -2
  9. package/src/channels/feishu/bridge.mjs +11 -5
  10. package/src/channels/feishu/message-utils.mjs +229 -0
  11. package/src/channels/qq/qq-bridge.mjs +48 -6
  12. package/src/channels/shared/batch-input.mjs +3 -3
  13. package/src/channels/shared/harness-client.mjs +82 -30
  14. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  15. package/src/channels/shared/image-prompt.mjs +51 -0
  16. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  17. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  18. package/src/channels/shared/session-title.mjs +1 -1
  19. package/src/channels/shared/text-harness-bridge.mjs +12 -6
  20. package/src/channels/slack/manifest.mjs +3 -0
  21. package/src/channels/slack/slack-api.mjs +18 -0
  22. package/src/channels/slack/slack-runtime.mjs +56 -0
  23. package/src/channels/telegram/telegram-runtime.mjs +117 -2
  24. package/src/channels/wecom/wecom-bridge.mjs +49 -7
  25. package/src/channels/weixin/state-store.mjs +110 -0
  26. package/src/channels/weixin/weixin-api.mjs +86 -2
  27. package/src/channels/weixin/weixin-bridge.mjs +96 -9
  28. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  29. 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.6.0",
4
4
  "description": "把九种 IM 机器人和公网 AI Office 接入本机 DeepSeek Harness。 Connect nine IM channels and a public AI Office to a local DeepSeek Harness.",
5
5
  "keywords": [
6
6
  "deepseek-harness",
@@ -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,
@@ -185,11 +190,95 @@ function downloadCodeFor(value) {
185
190
  return nonEmptyString(value?.downloadCode) ?? nonEmptyString(value?.pictureDownloadCode);
186
191
  }
187
192
 
193
+ function dingtalkTimestampMs(value) {
194
+ const number = typeof value === 'string' && value.trim() ? Number(value) : value;
195
+ if (!Number.isFinite(number) || number < 0) return null;
196
+ return Math.trunc(number < 10_000_000_000 ? number * 1_000 : number);
197
+ }
198
+
199
+ function usefulReplyText(value) {
200
+ const text = nonEmptyString(value);
201
+ return text && !/^\[interactive card message\]$/iu.test(text) ? text : null;
202
+ }
203
+
204
+ function dingtalkReplyReference(message, options) {
205
+ const replyEnvelope = message?.text;
206
+ if (replyEnvelope?.isReplyMsg !== true) return null;
207
+ const replied = replyEnvelope?.repliedMsg;
208
+ if (!replied || typeof replied !== 'object') {
209
+ return { unavailableReason: 'not-delivered' };
210
+ }
211
+
212
+ const msgtype = nonEmptyString(replied.msgType ?? replied.msgtype)?.toLowerCase() ?? '';
213
+ const repliedContent = parsedMessageContent({ content: replied.content }) ?? {};
214
+ const pseudoMessage = {
215
+ msgtype,
216
+ text: {
217
+ content: nonEmptyString(repliedContent.text)
218
+ ?? (typeof replied.content === 'string' ? replied.content : ''),
219
+ },
220
+ content: repliedContent,
221
+ };
222
+ const normalized = dingtalkInboundMessage(pseudoMessage, options);
223
+ let attachments = [];
224
+ if (msgtype === 'picture') {
225
+ attachments = [{ kind: 'image' }];
226
+ } else if (msgtype === 'file') {
227
+ const name = nonEmptyString(repliedContent.fileName ?? repliedContent.file_name);
228
+ attachments = [{ kind: 'file', ...(name ? { name } : {}) }];
229
+ } else if (msgtype === 'richtext') {
230
+ attachments = richTextEntries(repliedContent)
231
+ .filter((entry) => String(entry?.type ?? '').toLowerCase() === 'picture')
232
+ .map(() => ({ kind: 'image' }));
233
+ } else if (msgtype === 'voice' || msgtype === 'audio') {
234
+ attachments = [{ kind: 'audio' }];
235
+ } else if (msgtype === 'video') {
236
+ attachments = [{ kind: 'video' }];
237
+ }
238
+
239
+ const messageId = nonEmptyString(replied.msgId ?? replied.messageId);
240
+ const authorId = nonEmptyString(replied.senderId ?? replied.senderStaffId);
241
+ const authorName = nonEmptyString(replied.senderNick ?? replied.senderName);
242
+ const content = usefulReplyText(normalized.content)
243
+ ?? usefulReplyText(repliedContent.text)
244
+ ?? usefulReplyText(repliedContent.summary)
245
+ ?? usefulReplyText(repliedContent.title);
246
+ const processQueryKey = nonEmptyString(
247
+ message?.originalProcessQueryKey ?? repliedContent.processQueryKey,
248
+ );
249
+ const createdAt = dingtalkTimestampMs(replied.createdAt ?? replied.createTime);
250
+ const load = !content && attachments.length === 0
251
+ && typeof options?.loadReplyContent === 'function'
252
+ ? ({ signal } = {}) => options.loadReplyContent({
253
+ ...(messageId ? { messageId } : {}),
254
+ ...(processQueryKey ? { processQueryKey } : {}),
255
+ ...(createdAt === null ? {} : { createdAt }),
256
+ }, { signal })
257
+ : null;
258
+ const supported = [
259
+ 'text', 'picture', 'file', 'richtext', 'voice', 'audio', 'video',
260
+ 'interactivecard', 'chatrecord',
261
+ ]
262
+ .includes(msgtype);
263
+ return {
264
+ ...(messageId ? { messageId } : {}),
265
+ ...(authorId ? { authorId } : {}),
266
+ ...(authorName ? { authorName } : {}),
267
+ ...(content ? { content } : {}),
268
+ ...(attachments.length > 0 ? { attachments } : {}),
269
+ ...(load ? { load } : {}),
270
+ ...(!content && attachments.length === 0 && !load
271
+ ? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
272
+ : {}),
273
+ };
274
+ }
275
+
188
276
  /** Normalize DingTalk picture and richText callbacks into lazy image references. */
189
277
  export function dingtalkInboundMessage(message, {
190
278
  api,
191
279
  clientId,
192
280
  clientSecret,
281
+ loadReplyContent,
193
282
  } = {}) {
194
283
  const msgtype = String(message?.msgtype ?? '').toLowerCase();
195
284
  const content = parsedMessageContent(message);
@@ -209,6 +298,12 @@ export function dingtalkInboundMessage(message, {
209
298
  }
210
299
  }
211
300
  const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
301
+ const replyTo = dingtalkReplyReference(message, {
302
+ api,
303
+ clientId,
304
+ clientSecret,
305
+ loadReplyContent,
306
+ });
212
307
  return {
213
308
  content: text,
214
309
  images: imageCodes.map((downloadCode, index) => ({
@@ -242,6 +337,7 @@ export function dingtalkInboundMessage(message, {
242
337
  });
243
338
  },
244
339
  }] : [],
340
+ ...(replyTo ? { replyTo } : {}),
245
341
  };
246
342
  }
247
343
 
@@ -465,11 +561,7 @@ export class DingtalkHarnessBridge {
465
561
  // An unsafe reply route must never be able to submit an approval.
466
562
  }
467
563
  const pending = this.#pendingInteractions.get(key);
468
- const promptMessage = dingtalkInboundMessage(message, {
469
- api: this.#api,
470
- clientId: this.#clientId,
471
- clientSecret: this.#clientSecret,
472
- });
564
+ const promptMessage = this.#inboundMessage(message, key);
473
565
  const commandText = nonEmptyString(promptMessage.content) ?? '';
474
566
  const addressed = String(message.conversationType) !== '2' || message?.isInAtList === true;
475
567
  const direct = String(message.conversationType) !== '2';
@@ -533,7 +625,8 @@ export class DingtalkHarnessBridge {
533
625
  plainText: Boolean(commandText)
534
626
  && String(message?.msgtype).toLowerCase() === 'text'
535
627
  && !hasInboundFiles(promptMessage)
536
- && !hasInboundImages(promptMessage),
628
+ && !hasInboundImages(promptMessage)
629
+ && !hasReplyReference(promptMessage),
537
630
  });
538
631
  if (result.handled) {
539
632
  if (result.kind === 'submit') {
@@ -778,11 +871,7 @@ export class DingtalkHarnessBridge {
778
871
  }
779
872
  const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
780
873
  const preparedMessage = hasSafeReplyRoute && addressed
781
- ? prefetchInboundFiles(dingtalkInboundMessage(message, {
782
- api: this.#api,
783
- clientId: this.#clientId,
784
- clientSecret: this.#clientSecret,
785
- }), { signal: this.#signal })
874
+ ? prefetchInboundFiles(this.#inboundMessage(message, key), { signal: this.#signal })
786
875
  : undefined;
787
876
  const previous = this.#queues.get(key) ?? Promise.resolve();
788
877
  const current = previous
@@ -801,6 +890,50 @@ export class DingtalkHarnessBridge {
801
890
  return current;
802
891
  }
803
892
 
893
+ #inboundMessage(message, key) {
894
+ return dingtalkInboundMessage(message, {
895
+ api: this.#api,
896
+ clientId: this.#clientId,
897
+ clientSecret: this.#clientSecret,
898
+ loadReplyContent: (reference, options) => this.#loadReplyContent(key, reference, options),
899
+ });
900
+ }
901
+
902
+ async #loadReplyContent(key, reference, { signal } = {}) {
903
+ const indexed = this.#state.recentOutboundTextFor?.({
904
+ conversationKey: key,
905
+ ...reference,
906
+ });
907
+ if (indexed) return { content: indexed };
908
+ const quotedAt = dingtalkTimestampMs(reference?.createdAt);
909
+ if (quotedAt === null) return { unavailableReason: 'not-delivered' };
910
+ const sessionId = this.#state.sessionFor(key);
911
+ const session = typeof sessionId === 'string' && sessionId
912
+ ? this.#harness.workspaceSession?.(sessionId)
913
+ : null;
914
+ const text = await recoverAssistantTextByTimestamp({
915
+ session,
916
+ quotedAt,
917
+ signal,
918
+ toleranceMs: DINGTALK_RECENT_OUTBOUND_MATCH_TOLERANCE_MS,
919
+ });
920
+ if (!text) return { unavailableReason: 'not-delivered' };
921
+ try {
922
+ await this.#state.rememberOutboundMessage?.({
923
+ conversationKey: key,
924
+ text,
925
+ sentAt: quotedAt,
926
+ completedAt: quotedAt,
927
+ providerMessageIds: [reference?.processQueryKey, reference?.messageId]
928
+ .map(nonEmptyString)
929
+ .filter(Boolean),
930
+ });
931
+ } catch (error) {
932
+ this.#logger.warn?.('[dsh-dingtalk] failed to remember a recovered quote:', error);
933
+ }
934
+ return { content: text };
935
+ }
936
+
804
937
  async waitForIdle() {
805
938
  await Promise.allSettled([
806
939
  ...this.#queues.values(),
@@ -936,20 +1069,18 @@ export class DingtalkHarnessBridge {
936
1069
  return;
937
1070
  }
938
1071
 
939
- const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
940
- api: this.#api,
941
- clientId: this.#clientId,
942
- clientSecret: this.#clientSecret,
943
- });
1072
+ const promptMessage = preparedMessage ?? this.#inboundMessage(message, key);
944
1073
  const text = promptMessage.content;
945
1074
  const hasImages = hasInboundImages(promptMessage);
946
1075
  const hasFiles = hasInboundFiles(promptMessage);
1076
+ const hasReply = hasReplyReference(promptMessage);
947
1077
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
948
1078
  let cardStream = null;
949
1079
  let cardStarted = false;
1080
+ let cardStartedAt = null;
950
1081
  let batchSettled = batchSubmission === null;
951
1082
  try {
952
- if (!text && !hasImages && !hasFiles) {
1083
+ if (!text && !hasImages && !hasFiles && !hasReply) {
953
1084
  await this.#send(sessionWebhook, t('目前支持文字、图片和文件消息。'), this.#atUsersFor(message));
954
1085
  return;
955
1086
  }
@@ -992,8 +1123,8 @@ export class DingtalkHarnessBridge {
992
1123
  return;
993
1124
  }
994
1125
 
995
- let content = hasImages
996
- ? await promptContentForMessage(promptMessage, { signal: this.#signal })
1126
+ let content = hasImages || hasReply
1127
+ ? await promptContentForInboundMessage(promptMessage, { signal: this.#signal })
997
1128
  : undefined;
998
1129
  const snapshot = this.#acceptedMessageIds.get(messageId);
999
1130
  let contextEnhanced = false;
@@ -1018,7 +1149,9 @@ export class DingtalkHarnessBridge {
1018
1149
  signal: this.#signal,
1019
1150
  logger: this.#logger,
1020
1151
  });
1152
+ const startedAt = Date.now();
1021
1153
  cardStarted = await cardStream.start(t(CARD_INITIAL_TEXT));
1154
+ if (cardStarted) cardStartedAt = startedAt;
1022
1155
  }
1023
1156
  const { answer, artifacts = [] } = await askInWorkspaceSession({
1024
1157
  harness: this.#harness,
@@ -1056,12 +1189,14 @@ export class DingtalkHarnessBridge {
1056
1189
  let textDeliveryError = null;
1057
1190
  let textReceipt = null;
1058
1191
  let streamed = false;
1192
+ const deliveryStartedAt = cardStartedAt ?? Date.now();
1059
1193
  try {
1060
1194
  streamed = cardStarted && await cardStream.finish(answerText);
1061
1195
  if (streamed) {
1062
1196
  textReceipt = createDeliveryReceipt({
1063
1197
  deliveryId: messageId,
1064
1198
  presentation: 'dingtalk-card',
1199
+ providerMessageIds: cardStream.providerMessageIds,
1065
1200
  });
1066
1201
  } else {
1067
1202
  textReceipt = createDeliveryReceipt({
@@ -1070,6 +1205,17 @@ export class DingtalkHarnessBridge {
1070
1205
  providerMessageIds: await this.#send(sessionWebhook, answerText, this.#atUsersFor(message)),
1071
1206
  });
1072
1207
  }
1208
+ try {
1209
+ await this.#state.rememberOutboundMessage?.({
1210
+ conversationKey: key,
1211
+ text: answerText,
1212
+ sentAt: deliveryStartedAt,
1213
+ completedAt: Date.now(),
1214
+ providerMessageIds: providerMessageIdsFor(textReceipt),
1215
+ });
1216
+ } catch (error) {
1217
+ this.#logger.warn?.('[dsh-dingtalk] failed to remember an outbound message:', error);
1218
+ }
1073
1219
  } catch (error) {
1074
1220
  textDeliveryError = channelDeliveryFailure(error);
1075
1221
  }
@@ -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') {
@@ -11,8 +11,11 @@ import {
11
11
  hasInboundImages,
12
12
  imagePromptDiagnostic,
13
13
  imagePromptUserMessage,
14
- promptContentForMessage,
15
14
  } from '../shared/image-prompt.mjs';
15
+ import {
16
+ hasReplyReference,
17
+ promptContentForInboundMessage,
18
+ } from '../shared/semantic/reply-reference.mjs';
16
19
  import {
17
20
  hasInboundFiles,
18
21
  inboundFileUserMessage,
@@ -626,7 +629,9 @@ export class FeishuHarnessBridge {
626
629
  && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
627
630
  ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
628
631
  : this.#batchInputs.handle(key, batchText, {
629
- plainText: event.message.message_type === 'text' && Boolean(batchText),
632
+ plainText: event.message.message_type === 'text'
633
+ && Boolean(batchText)
634
+ && !hasReplyReference(commandMessage),
630
635
  });
631
636
  if (result.handled) {
632
637
  if (result.kind === 'submit') {
@@ -1032,11 +1037,12 @@ export class FeishuHarnessBridge {
1032
1037
  const text = message.content;
1033
1038
  const hasImages = hasInboundImages(message);
1034
1039
  const hasFiles = hasInboundFiles(message);
1040
+ const hasReply = hasReplyReference(message);
1035
1041
  // 命令识别对 text 与纯文本 post 一视同仁:post 富文本若仅含单个
1036
1042
  // 文本段落(如复制粘贴的 /new),同样按命令处理;带图片/文件不认。
1037
1043
  // accept() 侧已用 nonEmptyString(content) 判定,两侧保持一致。
1038
1044
  const commandText = !hasImages && !hasFiles && text ? text.trim() : null;
1039
- if (!text && !hasImages && !hasFiles) {
1045
+ if (!text && !hasImages && !hasFiles && !hasReply) {
1040
1046
  await this.#send(event.message.chat_id, t('目前支持文字、图片和文件消息。'), { replyTo: event.message.message_id });
1041
1047
  return;
1042
1048
  }
@@ -3289,8 +3295,8 @@ export class FeishuHarnessBridge {
3289
3295
  askCompleted = true;
3290
3296
  onAskComplete?.();
3291
3297
  };
3292
- let content = hasInboundImages(message)
3293
- ? await promptContentForMessage(message, { signal: this.#signal })
3298
+ let content = hasInboundImages(message) || hasReplyReference(message)
3299
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
3294
3300
  : undefined;
3295
3301
  const snapshot = this.#acceptedMessageIds.get(messageId);
3296
3302
  let contextEnhanced = false;