@xmanrui/dsh-im 4.4.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 (43) hide show
  1. package/README.en.md +2 -2
  2. package/README.md +2 -2
  3. package/lib/client.js +29 -9
  4. package/lib/index.js +252 -241
  5. package/package.json +1 -1
  6. package/plugin-src/client/context-enhancement.js +19 -5
  7. package/plugin-src/client/i18n.js +5 -1
  8. package/plugin-src/host/modern-harness-api.mjs +3 -0
  9. package/src/channels/dingtalk/dingtalk-bridge.mjs +175 -23
  10. package/src/channels/dingtalk/dingtalk-card-stream.mjs +9 -2
  11. package/src/channels/dingtalk/state-store.mjs +98 -0
  12. package/src/channels/discord/discord-api.mjs +7 -0
  13. package/src/channels/discord/discord-runtime.mjs +97 -2
  14. package/src/channels/feishu/bridge.mjs +30 -7
  15. package/src/channels/feishu/feishu-cards.mjs +2 -2
  16. package/src/channels/feishu/feishu-channel.mjs +36 -6
  17. package/src/channels/feishu/message-utils.mjs +229 -0
  18. package/src/channels/qq/qq-bridge.mjs +56 -9
  19. package/src/channels/shared/batch-input.mjs +3 -3
  20. package/src/channels/shared/bot-workspace-store.mjs +5 -0
  21. package/src/channels/shared/context-enhancement.mjs +9 -4
  22. package/src/channels/shared/harness-client.mjs +88 -30
  23. package/src/channels/shared/i18n-en/feishu.mjs +3 -3
  24. package/src/channels/shared/i18n-en/shared-a.mjs +2 -1
  25. package/src/channels/shared/i18n-en/shared-b.mjs +6 -3
  26. package/src/channels/shared/i18n-en/shared-c.mjs +6 -4
  27. package/src/channels/shared/image-prompt.mjs +51 -0
  28. package/src/channels/shared/semantic/reply-reference.mjs +153 -0
  29. package/src/channels/shared/session-reply-recovery.mjs +104 -0
  30. package/src/channels/shared/session-title.mjs +74 -0
  31. package/src/channels/shared/text-harness-bridge.mjs +19 -8
  32. package/src/channels/shared/workspace-command.mjs +16 -4
  33. package/src/channels/shared/workspace-session.mjs +28 -2
  34. package/src/channels/slack/manifest.mjs +3 -0
  35. package/src/channels/slack/slack-api.mjs +18 -0
  36. package/src/channels/slack/slack-runtime.mjs +56 -0
  37. package/src/channels/telegram/telegram-runtime.mjs +118 -2
  38. package/src/channels/wecom/wecom-bridge.mjs +55 -9
  39. package/src/channels/weixin/state-store.mjs +110 -0
  40. package/src/channels/weixin/weixin-api.mjs +86 -2
  41. package/src/channels/weixin/weixin-bridge.mjs +104 -12
  42. package/src/channels/weixin/weixin-runtime.mjs +26 -6
  43. package/src/channels/whatsapp/whatsapp-runtime.mjs +56 -0
@@ -4,6 +4,7 @@ import { createEditableMessageStream, splitMessageText } from '../shared/editabl
4
4
  import { createTextDeliveryBlock } from '../shared/semantic/delivery.mjs';
5
5
  import { t } from '../shared/i18n.mjs';
6
6
  import { captureContextEnhancement } from '../shared/context-enhancement.mjs';
7
+ import { recoverAssistantTextByTimestamp } from '../shared/session-reply-recovery.mjs';
7
8
  import { COMMANDS_MENU_BUTTON, TelegramApi } from './telegram-api.mjs';
8
9
  import { createTelegramHttpTransport } from './telegram-http.mjs';
9
10
  import { createTelegramBridgeStatus, TelegramHarnessBridge } from './telegram-bridge.mjs';
@@ -138,11 +139,94 @@ function telegramFileSource(message, loadFile) {
138
139
  };
139
140
  }
140
141
 
142
+ function telegramReplyAttachment(kind, file, fallbackName) {
143
+ if (!file || typeof file !== 'object') return null;
144
+ const name = typeof file.file_name === 'string' && file.file_name
145
+ ? file.file_name : typeof fallbackName === 'string' && fallbackName
146
+ ? fallbackName : undefined;
147
+ return { kind, ...(name ? { name } : {}) };
148
+ }
149
+
150
+ function telegramReplyAttachments(message) {
151
+ const attachments = [];
152
+ if (Array.isArray(message?.photo) && message.photo.length > 0) {
153
+ const largest = message.photo.reduce((best, candidate) => (
154
+ photoScore(candidate) > photoScore(best) ? candidate : best
155
+ ));
156
+ attachments.push(telegramReplyAttachment(
157
+ 'image',
158
+ largest,
159
+ `${largest.file_unique_id ?? largest.file_id ?? 'telegram-photo'}.jpg`,
160
+ ));
161
+ } else if (message?.document) {
162
+ attachments.push(telegramReplyAttachment(
163
+ imageTypeForDocument(message.document) ? 'image' : 'file',
164
+ message.document,
165
+ ));
166
+ }
167
+ for (const [field, kind] of [
168
+ ['audio', 'audio'],
169
+ ['voice', 'audio'],
170
+ ['video', 'video'],
171
+ ['video_note', 'video'],
172
+ ['animation', 'video'],
173
+ ]) {
174
+ if (message?.[field]) attachments.push(telegramReplyAttachment(kind, message[field]));
175
+ }
176
+ if (message?.sticker) {
177
+ attachments.push(telegramReplyAttachment(
178
+ message.sticker.is_video === true ? 'video' : 'image',
179
+ message.sticker,
180
+ message.sticker.file_unique_id ?? message.sticker.file_id,
181
+ ));
182
+ }
183
+ return attachments.filter(Boolean);
184
+ }
185
+
186
+ function telegramReplyReference(message, { quote, loadReplyContent } = {}) {
187
+ if ((!message || typeof message !== 'object')
188
+ && (!quote || typeof quote !== 'object')) return undefined;
189
+ const authorId = message?.from?.id === undefined ? undefined : String(message.from.id);
190
+ const authorName = [message?.from?.first_name, message?.from?.last_name]
191
+ .filter((value) => typeof value === 'string' && value.trim())
192
+ .map((value) => value.trim()).join(' ') || (
193
+ typeof message?.from?.username === 'string' && message.from.username
194
+ ? message.from.username : undefined
195
+ );
196
+ const content = typeof message?.text === 'string'
197
+ ? message.text : typeof message?.caption === 'string'
198
+ ? message.caption : typeof quote?.text === 'string' ? quote.text : '';
199
+ const attachments = telegramReplyAttachments(message);
200
+ const messageId = Number.isSafeInteger(message?.message_id)
201
+ ? String(message.message_id) : undefined;
202
+ const createdAt = Number.isSafeInteger(message?.date) && message.date >= 0
203
+ ? message.date * 1_000 : undefined;
204
+ const load = !content.trim() && attachments.length === 0
205
+ && typeof loadReplyContent === 'function'
206
+ ? ({ signal } = {}) => loadReplyContent({
207
+ ...(messageId ? { messageId } : {}),
208
+ ...(createdAt === undefined ? {} : { createdAt }),
209
+ }, { signal })
210
+ : null;
211
+ return {
212
+ ...(messageId ? { messageId } : {}),
213
+ ...(authorId ? { authorId } : {}),
214
+ ...(authorName ? { authorName } : {}),
215
+ ...(content.trim() ? { content } : {}),
216
+ ...(attachments.length > 0 ? { attachments } : {}),
217
+ ...(load ? { load } : {}),
218
+ ...(!content.trim() && attachments.length === 0 && !load
219
+ ? { unavailableReason: 'not-delivered' }
220
+ : {}),
221
+ };
222
+ }
223
+
141
224
  export function normalizeTelegramUpdate(update, {
142
225
  botId,
143
226
  username,
144
227
  loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
145
228
  loadFileStream = loadFile,
229
+ loadReplyContent,
146
230
  }) {
147
231
  const message = update?.message;
148
232
  const chatId = message?.chat?.id;
@@ -159,6 +243,21 @@ export function normalizeTelegramUpdate(update, {
159
243
  ? message.message_thread_id : undefined;
160
244
  const image = telegramImageSource(message, loadFile);
161
245
  const file = telegramFileSource(message, loadFileStream);
246
+ const conversationId = messageThreadId === undefined
247
+ ? String(chatId) : `${chatId}:${messageThreadId}`;
248
+ const key = `${direct ? 'direct' : 'group'}:${conversationId}`;
249
+ const replyTo = telegramReplyReference(
250
+ message.reply_to_message ?? message.external_reply,
251
+ {
252
+ quote: message.quote,
253
+ ...(typeof loadReplyContent === 'function' ? {
254
+ loadReplyContent: (reference, options) => loadReplyContent({
255
+ conversationKey: key,
256
+ ...reference,
257
+ }, options),
258
+ } : {}),
259
+ },
260
+ );
162
261
  return {
163
262
  messageId: String(update.update_id),
164
263
  senderId: String(senderId),
@@ -166,15 +265,16 @@ export function normalizeTelegramUpdate(update, {
166
265
  senderName: [message.from?.first_name, message.from?.last_name]
167
266
  .filter((value) => typeof value === 'string' && value.trim())
168
267
  .map((value) => value.trim()).join(' ') || message.from?.username,
268
+ conversationTitle: direct ? undefined : message.chat?.title,
169
269
  }),
170
270
  senderIsBot: message.from?.is_bot === true,
171
271
  kind: direct ? 'direct' : 'group',
172
- conversationId: messageThreadId === undefined
173
- ? String(chatId) : `${chatId}:${messageThreadId}`,
272
+ conversationId,
174
273
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
175
274
  plainText: typeof message.text === 'string',
176
275
  images: image ? [image] : [],
177
276
  files: file ? [file] : [],
277
+ ...(replyTo ? { replyTo } : {}),
178
278
  addressed,
179
279
  reactionTarget: { chatId, messageId },
180
280
  replyTarget: {
@@ -838,6 +938,21 @@ export class TelegramRuntime {
838
938
  }
839
939
  }
840
940
 
941
+ async #loadReplyContent(reference, { signal } = {}) {
942
+ const key = typeof reference?.conversationKey === 'string'
943
+ ? reference.conversationKey.trim() : '';
944
+ const quotedAt = Number(reference?.createdAt);
945
+ if (!key || !Number.isFinite(quotedAt)) {
946
+ return { unavailableReason: 'not-delivered' };
947
+ }
948
+ const sessionId = this.#state.sessionFor(key);
949
+ const session = typeof sessionId === 'string' && sessionId
950
+ ? this.#harness.workspaceSession?.(sessionId)
951
+ : null;
952
+ const text = await recoverAssistantTextByTimestamp({ session, quotedAt, signal });
953
+ return text ? { content: text } : { unavailableReason: 'not-delivered' };
954
+ }
955
+
841
956
  async #poll(initialCursor, signal) {
842
957
  let cursor = initialCursor;
843
958
  while (!signal.aborted) {
@@ -862,6 +977,7 @@ export class TelegramRuntime {
862
977
  username: this.#config.username,
863
978
  loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
864
979
  loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
980
+ loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
865
981
  });
866
982
  if (message) {
867
983
  void this.#bridge.accept(message, { contextSnapshot }).catch((error) => {
@@ -33,7 +33,6 @@ import {
33
33
  ImagePromptError,
34
34
  imagePromptDiagnostic,
35
35
  imagePromptUserMessage,
36
- promptContentForMessage,
37
36
  } from '../shared/image-prompt.mjs';
38
37
  import {
39
38
  hasInboundFiles,
@@ -42,6 +41,10 @@ import {
42
41
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
43
42
  import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
44
43
  import { deliverOutboundArtifacts } from '../shared/semantic/artifact-delivery.mjs';
44
+ import {
45
+ hasReplyReference,
46
+ promptContentForInboundMessage,
47
+ } from '../shared/semantic/reply-reference.mjs';
45
48
  import {
46
49
  createDeliveryReceipt,
47
50
  } from '../shared/semantic/delivery.mjs';
@@ -68,7 +71,7 @@ function helpText() {
68
71
  t('/new 开启一个全新会话'),
69
72
  t('/compact 压缩当前会话的较早上下文'),
70
73
  t('/history [数量] 查看最近历史消息(默认 3 条,最多 5 条)'),
71
- t('/workspace 工作区绝对路径 切换工作区'),
74
+ t('/workspace 工作区序号或绝对路径 切换工作区'),
72
75
  t('/workspacelist 列出工作区绝对路径'),
73
76
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
74
77
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
@@ -108,8 +111,7 @@ function conversationKey(frame) {
108
111
  return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
109
112
  }
110
113
 
111
- function messageText(frame) {
112
- const body = bodyOf(frame);
114
+ function messageContentText(body) {
113
115
  let text = '';
114
116
  if (body.msgtype === 'text') {
115
117
  text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
@@ -122,6 +124,12 @@ function messageText(frame) {
122
124
  .join('\n')
123
125
  .trim();
124
126
  }
127
+ return text;
128
+ }
129
+
130
+ function messageText(frame) {
131
+ const body = bodyOf(frame);
132
+ const text = messageContentText(body);
125
133
  // Group callbacks retain the leading @bot mention that caused delivery.
126
134
  // It is routing metadata rather than part of the user's prompt or answer.
127
135
  return body.chattype === 'group'
@@ -145,6 +153,36 @@ function fileContents(frame) {
145
153
  : [];
146
154
  }
147
155
 
156
+ function quoteAttachments(quote) {
157
+ if (quote?.msgtype === 'image') return [{ kind: 'image' }];
158
+ if (quote?.msgtype === 'voice') return [{ kind: 'audio' }];
159
+ if (quote?.msgtype === 'file') {
160
+ const name = nonEmptyString(
161
+ quote.file?.filename ?? quote.file?.file_name ?? quote.file?.name,
162
+ );
163
+ return [{ kind: 'file', ...(name ? { name } : {}) }];
164
+ }
165
+ if (quote?.msgtype !== 'mixed' || !Array.isArray(quote.mixed?.msg_item)) return [];
166
+ return quote.mixed.msg_item
167
+ .filter((item) => item?.msgtype === 'image')
168
+ .map(() => ({ kind: 'image' }));
169
+ }
170
+
171
+ function replyReferenceForBody(body) {
172
+ const quote = body?.quote;
173
+ if (!quote || typeof quote !== 'object') return null;
174
+ const content = messageContentText(quote);
175
+ const attachments = quoteAttachments(quote);
176
+ const supported = ['text', 'image', 'mixed', 'voice', 'file'].includes(quote.msgtype);
177
+ return {
178
+ ...(content ? { content } : {}),
179
+ ...(attachments.length > 0 ? { attachments } : {}),
180
+ ...(!content && attachments.length === 0
181
+ ? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
182
+ : {}),
183
+ };
184
+ }
185
+
148
186
  function imageSource(client, image) {
149
187
  const url = nonEmptyString(image?.url);
150
188
  if (!url) return null;
@@ -200,10 +238,13 @@ function fileSource(client, file) {
200
238
  }
201
239
 
202
240
  export function wecomInboundMessage(frame, client) {
241
+ const body = bodyOf(frame);
242
+ const replyTo = replyReferenceForBody(body);
203
243
  return {
204
244
  content: messageText(frame),
205
245
  images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
206
246
  files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
247
+ ...(replyTo ? { replyTo } : {}),
207
248
  };
208
249
  }
209
250
 
@@ -604,7 +645,7 @@ export class WecomHarnessBridge {
604
645
  && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
605
646
  ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
606
647
  : this.#batchInputs.handle(key, commandText, {
607
- plainText: isNativeWecomText(frame),
648
+ plainText: isNativeWecomText(frame) && !hasReplyReference(commandMessage),
608
649
  });
609
650
  if (result.handled) {
610
651
  if (result.kind === 'submit') {
@@ -934,6 +975,7 @@ export class WecomHarnessBridge {
934
975
  const text = message.content;
935
976
  const hasImages = hasInboundImages(message);
936
977
  const hasFiles = hasInboundFiles(message);
978
+ const hasReply = hasReplyReference(message);
937
979
  const key = conversationKey(frame);
938
980
  let streamId = null;
939
981
  let streamStarted = false;
@@ -942,7 +984,7 @@ export class WecomHarnessBridge {
942
984
  let batchSettled = batchSubmission === null;
943
985
  let promptRecorded = false;
944
986
  try {
945
- if (!text && !hasImages && !hasFiles) {
987
+ if (!text && !hasImages && !hasFiles && !hasReply) {
946
988
  await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
947
989
  await this.#state.markSeen(messageId);
948
990
  return;
@@ -1003,15 +1045,18 @@ export class WecomHarnessBridge {
1003
1045
  this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
1004
1046
  }
1005
1047
 
1006
- let content = hasImages
1007
- ? await promptContentForMessage(message, { signal: this.#signal })
1048
+ let content = hasImages || hasReply
1049
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
1008
1050
  : undefined;
1009
1051
  const snapshot = this.#acceptedMessageIds.get(messageId);
1052
+ let contextEnhanced = false;
1010
1053
  if (snapshot) {
1011
- content = enhanceContextContent(content ?? text, snapshot, () => ({
1054
+ const originalContent = content ?? text;
1055
+ content = enhanceContextContent(originalContent, snapshot, () => ({
1012
1056
  channel: 'wecom',
1013
1057
  senderId,
1014
1058
  }));
1059
+ contextEnhanced = content !== originalContent;
1015
1060
  }
1016
1061
  await this.#state.markSeen(messageId);
1017
1062
  promptRecorded = true;
@@ -1021,6 +1066,7 @@ export class WecomHarnessBridge {
1021
1066
  key,
1022
1067
  text,
1023
1068
  content,
1069
+ contextEnhanced,
1024
1070
  createOptions: { signal: this.#signal },
1025
1071
  existsOptions: { signal: this.#signal },
1026
1072
  askOptions: {
@@ -1,13 +1,67 @@
1
1
  import { mkdir, readFile, rename, unlink, writeFile } from 'node:fs/promises';
2
2
  import { dirname } from 'node:path';
3
3
 
4
+ import { weixinMessageTimestampMs } from './weixin-api.mjs';
5
+
4
6
  const EMPTY_STATE = Object.freeze({
5
7
  version: 1,
6
8
  sessions: {},
7
9
  seenMessageIds: [],
8
10
  getUpdatesBuf: '',
11
+ recentOutboundMessages: [],
9
12
  });
10
13
 
14
+ export const WEIXIN_RECENT_OUTBOUND_LIMIT = 200;
15
+ export const WEIXIN_RECENT_OUTBOUND_TTL_MS = 30 * 24 * 60 * 60 * 1_000;
16
+ export const WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT = 8_000;
17
+ export const WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS = 15_000;
18
+
19
+ function nonEmptyString(value) {
20
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
21
+ }
22
+
23
+ function timestamp(value) {
24
+ const number = typeof value === 'string' && value.trim() ? Number(value) : value;
25
+ return Number.isFinite(number) && number >= 0 ? Math.trunc(number) : null;
26
+ }
27
+
28
+ function providerMessageIds(value) {
29
+ if (!Array.isArray(value)) return [];
30
+ return [...new Set(value
31
+ .map((id) => (id === undefined || id === null ? null : nonEmptyString(String(id))))
32
+ .filter(Boolean))];
33
+ }
34
+
35
+ function truncateText(value) {
36
+ const text = nonEmptyString(value);
37
+ return text ? [...text].slice(0, WEIXIN_RECENT_OUTBOUND_TEXT_LIMIT).join('') : null;
38
+ }
39
+
40
+ function normalizeRecentOutboundMessage(value) {
41
+ if (!value || typeof value !== 'object' || Array.isArray(value)) return null;
42
+ const toUserId = nonEmptyString(value.toUserId);
43
+ const text = truncateText(value.text);
44
+ const sentAt = timestamp(value.sentAt);
45
+ const completedAt = timestamp(value.completedAt) ?? sentAt;
46
+ if (!toUserId || !text || sentAt === null || completedAt === null) return null;
47
+ return {
48
+ toUserId,
49
+ text,
50
+ sentAt,
51
+ completedAt: Math.max(sentAt, completedAt),
52
+ providerMessageIds: providerMessageIds(value.providerMessageIds),
53
+ };
54
+ }
55
+
56
+ function recentOutboundMessages(value, now = Date.now()) {
57
+ if (!Array.isArray(value)) return [];
58
+ const cutoff = now - WEIXIN_RECENT_OUTBOUND_TTL_MS;
59
+ return value
60
+ .map(normalizeRecentOutboundMessage)
61
+ .filter((entry) => entry && entry.completedAt >= cutoff)
62
+ .slice(-WEIXIN_RECENT_OUTBOUND_LIMIT);
63
+ }
64
+
11
65
  function normalizeState(value) {
12
66
  if (!value || typeof value !== 'object') return structuredClone(EMPTY_STATE);
13
67
  const sessions = {};
@@ -25,6 +79,7 @@ function normalizeState(value) {
25
79
  ? value.seenMessageIds.filter((id) => typeof id === 'string').slice(-1_000)
26
80
  : [],
27
81
  getUpdatesBuf: typeof value.getUpdatesBuf === 'string' ? value.getUpdatesBuf : '',
82
+ recentOutboundMessages: recentOutboundMessages(value.recentOutboundMessages),
28
83
  };
29
84
  }
30
85
 
@@ -90,6 +145,61 @@ export class WeixinStateStore {
90
145
  await this.#persist();
91
146
  }
92
147
 
148
+ async rememberOutboundMessage({
149
+ toUserId,
150
+ text,
151
+ sentAt = Date.now(),
152
+ completedAt = Date.now(),
153
+ providerMessageIds: messageIds = [],
154
+ } = {}) {
155
+ const entry = normalizeRecentOutboundMessage({
156
+ toUserId,
157
+ text,
158
+ sentAt,
159
+ completedAt,
160
+ providerMessageIds: messageIds,
161
+ });
162
+ if (!entry) throw new TypeError('Invalid Weixin outbound message');
163
+ this.#state.recentOutboundMessages = recentOutboundMessages([
164
+ ...this.#state.recentOutboundMessages,
165
+ entry,
166
+ ]);
167
+ await this.#persist();
168
+ }
169
+
170
+ recentOutboundTextFor({
171
+ toUserId,
172
+ messageId,
173
+ createTimeMs,
174
+ updateTimeMs,
175
+ now = Date.now(),
176
+ } = {}) {
177
+ const recipient = nonEmptyString(toUserId);
178
+ if (!recipient) return null;
179
+ const active = recentOutboundMessages(this.#state.recentOutboundMessages, now)
180
+ .filter((entry) => entry.toUserId === recipient);
181
+ const quotedMessageId = messageId === undefined || messageId === null
182
+ ? null
183
+ : nonEmptyString(String(messageId));
184
+ if (quotedMessageId) {
185
+ const exact = active.filter((entry) => entry.providerMessageIds.includes(quotedMessageId));
186
+ if (exact.length === 1) return exact[0].text;
187
+ if (exact.length > 1) return null;
188
+ }
189
+ const quotedTimes = [
190
+ timestamp(createTimeMs),
191
+ timestamp(updateTimeMs),
192
+ weixinMessageTimestampMs(quotedMessageId, { now }),
193
+ ]
194
+ .filter((value) => value !== null);
195
+ if (quotedTimes.length === 0) return null;
196
+ const candidates = active.filter((entry) => quotedTimes.some((quotedAt) => (
197
+ quotedAt >= entry.sentAt - WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
198
+ && quotedAt <= entry.completedAt + WEIXIN_RECENT_OUTBOUND_MATCH_TOLERANCE_MS
199
+ )));
200
+ return candidates.length === 1 ? candidates[0].text : null;
201
+ }
202
+
93
203
  snapshot() {
94
204
  return structuredClone(this.#state);
95
205
  }
@@ -21,6 +21,9 @@ const ILINK_CLIENT_VERSION = (2 << 16) | (4 << 8) | 6;
21
21
  const DEFAULT_TIMEOUT_MS = 15_000;
22
22
  const DEFAULT_LONG_POLL_TIMEOUT_MS = 35_000;
23
23
  const WEIXIN_CDN_UPLOAD_RETRIES = 3;
24
+ const WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT = 22n;
25
+ const WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS = Date.UTC(2020, 0, 1);
26
+ const WEIXIN_MESSAGE_ID_MAX_FUTURE_MS = 24 * 60 * 60 * 1_000;
24
27
  const LOGIN_STATUSES = new Set([
25
28
  'wait',
26
29
  'scaned',
@@ -689,6 +692,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
689
692
  const recipient = nonEmptyString(toUserId);
690
693
  const content = nonEmptyString(text);
691
694
  if (!recipient || !content) throw new TypeError('toUserId and text are required');
695
+ const clientId = `dsh-weixin-${randomUUID()}`;
692
696
  const response = await requestJson(fetchImpl, {
693
697
  method: 'POST',
694
698
  baseUrl,
@@ -699,7 +703,7 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
699
703
  msg: {
700
704
  from_user_id: '',
701
705
  to_user_id: recipient,
702
- client_id: `dsh-weixin-${randomUUID()}`,
706
+ client_id: clientId,
703
707
  message_type: 2,
704
708
  message_state: 2,
705
709
  item_list: [{ type: 1, text_item: { text: content } }],
@@ -717,7 +721,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
717
721
  { providerCode: sendRejection },
718
722
  );
719
723
  }
720
- return true;
724
+ return {
725
+ ...(response && typeof response === 'object' ? response : {}),
726
+ providerMessageIds: [clientId],
727
+ };
721
728
  },
722
729
 
723
730
  async sendFile(request) {
@@ -791,6 +798,83 @@ export function extractWeixinText(message) {
791
798
  return null;
792
799
  }
793
800
 
801
+ function weixinReplyAttachment(item) {
802
+ if (item?.type === 2 || (item?.image_item && typeof item.image_item === 'object')) {
803
+ return { kind: 'image' };
804
+ }
805
+ if (item?.type === 3 || (item?.voice_item && typeof item.voice_item === 'object')) {
806
+ return { kind: 'audio' };
807
+ }
808
+ if (item?.type === 4 || (item?.file_item && typeof item.file_item === 'object')) {
809
+ const name = nonEmptyString(item?.file_item?.file_name);
810
+ return { kind: 'file', ...(name ? { name } : {}) };
811
+ }
812
+ if (item?.type === 5 || (item?.video_item && typeof item.video_item === 'object')) {
813
+ return { kind: 'video' };
814
+ }
815
+ return null;
816
+ }
817
+
818
+ function weixinQuotedText(item) {
819
+ const text = nonEmptyString(item?.text_item?.text);
820
+ if (text) return text;
821
+ return nonEmptyString(item?.voice_item?.text);
822
+ }
823
+
824
+ /** Extract the one-level reply snapshot embedded in an inbound iLink message item. */
825
+ export function extractWeixinReplyReference(message, { resolveContent, loadContent } = {}) {
826
+ const container = (message?.item_list ?? []).find((item) => (
827
+ item?.ref_msg && typeof item.ref_msg === 'object'
828
+ ));
829
+ if (!container) return null;
830
+ const ref = container.ref_msg;
831
+ const quotedItem = ref.message_item && typeof ref.message_item === 'object'
832
+ ? ref.message_item
833
+ : null;
834
+ const quotedText = weixinQuotedText(quotedItem);
835
+ let content = quotedText ?? nonEmptyString(ref.title);
836
+ const attachment = weixinReplyAttachment(quotedItem);
837
+ const messageId = quotedItem?.msg_id === undefined || quotedItem?.msg_id === null
838
+ ? null
839
+ : nonEmptyString(String(quotedItem.msg_id));
840
+ const referenceDetails = {
841
+ messageId,
842
+ createTimeMs: quotedItem?.create_time_ms ?? ref.create_time_ms,
843
+ updateTimeMs: quotedItem?.update_time_ms ?? ref.update_time_ms,
844
+ };
845
+ if (!content && !attachment && typeof resolveContent === 'function') {
846
+ content = nonEmptyString(resolveContent(referenceDetails));
847
+ }
848
+ const load = !content && !attachment && typeof loadContent === 'function'
849
+ ? (options) => loadContent(referenceDetails, options)
850
+ : null;
851
+ const knownType = quotedItem && [1, 2, 3, 4, 5, 8].includes(quotedItem.type);
852
+ return {
853
+ ...(messageId ? { messageId } : {}),
854
+ ...(content ? { content } : {}),
855
+ ...(attachment ? { attachments: [attachment] } : {}),
856
+ ...(load ? { load } : {}),
857
+ ...(!content && !attachment && !load
858
+ ? { unavailableReason: quotedItem && !knownType ? 'unsupported' : 'not-delivered' }
859
+ : {}),
860
+ };
861
+ }
862
+
863
+ /** Decode the millisecond timestamp carried by current 64-bit iLink message IDs. */
864
+ export function weixinMessageTimestampMs(messageId, { now = Date.now() } = {}) {
865
+ const value = messageId === undefined || messageId === null ? '' : String(messageId).trim();
866
+ if (!/^\d{16,20}$/u.test(value)) return null;
867
+ try {
868
+ const timestampMs = Number(BigInt(value) >> WEIXIN_MESSAGE_ID_TIMESTAMP_SHIFT);
869
+ if (!Number.isSafeInteger(timestampMs)
870
+ || timestampMs < WEIXIN_MESSAGE_ID_MIN_TIMESTAMP_MS
871
+ || timestampMs > now + WEIXIN_MESSAGE_ID_MAX_FUTURE_MS) return null;
872
+ return timestampMs;
873
+ } catch {
874
+ return null;
875
+ }
876
+ }
877
+
794
878
  export function weixinMessageId(message) {
795
879
  if (message?.message_id !== undefined && message.message_id !== null) {
796
880
  return String(message.message_id);