@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
@@ -18,6 +18,7 @@ const SESSION_BIND_USAGE = '用法:/session Session ID 或当前工作区序
18
18
  const SESSION_LIST_USAGE = [
19
19
  '用法:',
20
20
  '/sessionlist 列出当前工作区会话',
21
+ '/sessionlist --limit N 列出当前工作区前 N 个会话(N 为正整数)',
21
22
  '/sessionlist 工作区序号 按 /workspacelist 序号列出会话',
22
23
  '/sessionlist 工作区绝对路径 列出指定工作区会话',
23
24
  ].join('\n');
@@ -45,6 +46,20 @@ function validSessionId(value) {
45
46
  && !UNSAFE_DISPLAY_TEXT.test(value);
46
47
  }
47
48
 
49
+ export function parseSessionListArgument(value) {
50
+ const argument = typeof value === 'string' ? value.trim() : '';
51
+ if (!argument) return { selector: '', limit: null };
52
+ if (!argument.toLowerCase().startsWith('--limit')) {
53
+ return { selector: argument, limit: null };
54
+ }
55
+ const match = /^--limit[ \t]+(\d+)$/iu.exec(argument);
56
+ const limit = match ? Number(match[1]) : null;
57
+ if (!Number.isSafeInteger(limit) || limit < 1) {
58
+ return { error: t(SESSION_LIST_USAGE) };
59
+ }
60
+ return { selector: '', limit };
61
+ }
62
+
48
63
  async function existingWorkspacePaths(values) {
49
64
  const checked = await Promise.all(values.map(async (value) => {
50
65
  const workspace = normalizedWorkspacePath(value);
@@ -235,12 +250,13 @@ async function currentSessionListWorkspace(harness) {
235
250
  }
236
251
 
237
252
  async function runSessionListCommand(match, harness) {
253
+ const request = parseSessionListArgument(match[1]);
254
+ if (request.error) return commandResult(request.error);
238
255
  if (typeof harness?.listWorkspaceSessions !== 'function') {
239
256
  return commandResult(t('当前机器人暂不支持列出工作区会话。'));
240
257
  }
241
- const selector = match[1]?.trim() ?? '';
242
258
  try {
243
- const resolved = await resolveSessionListWorkspace(selector, harness);
259
+ const resolved = await resolveSessionListWorkspace(request.selector, harness);
244
260
  if (resolved.error) return commandResult(resolved.error);
245
261
  const listed = await harness.listWorkspaceSessions(resolved.workspace);
246
262
  if (!listed || !Array.isArray(listed.sessions)) {
@@ -249,7 +265,10 @@ async function runSessionListCommand(match, harness) {
249
265
  harness.assertWorkspaceScope?.();
250
266
  const workspace = normalizedWorkspacePath(listed.workspace) ?? resolved.workspace;
251
267
  const currentWorkspace = await currentSessionListWorkspace(harness);
252
- const message = sessionListMessage(workspace, listed.sessions, {
268
+ const sessions = request.limit === null
269
+ ? listed.sessions
270
+ : listed.sessions.slice(0, request.limit);
271
+ const message = sessionListMessage(workspace, sessions, {
253
272
  currentWorkspace: workspace === currentWorkspace,
254
273
  });
255
274
  return commandResult(message, splitWorkspaceCommandMessage(message));
@@ -17,9 +17,12 @@ oauth_config:
17
17
  bot:
18
18
  - app_mentions:read
19
19
  - chat:write
20
+ - channels:history
20
21
  - files:read
21
22
  - files:write
23
+ - groups:history
22
24
  - im:history
25
+ - mpim:history
23
26
  - reactions:write
24
27
  settings:
25
28
  event_subscriptions:
@@ -246,6 +246,24 @@ export class SlackApi {
246
246
  return value.file;
247
247
  }
248
248
 
249
+ async getMessage({ channelId, messageTs, signal } = {}) {
250
+ const timestamp = requiredString(messageTs, 'message timestamp');
251
+ const value = await this.#request('conversations.history', {
252
+ tokenKind: 'bot',
253
+ signal,
254
+ body: {
255
+ channel: slackId(channelId, 'channel id'),
256
+ oldest: timestamp,
257
+ latest: timestamp,
258
+ inclusive: true,
259
+ limit: 1,
260
+ },
261
+ });
262
+ return Array.isArray(value?.messages)
263
+ ? value.messages.find((message) => String(message?.ts ?? '') === timestamp) ?? null
264
+ : null;
265
+ }
266
+
249
267
  postMessage({ channelId, text, threadTs, signal }) {
250
268
  return this.#request('chat.postMessage', {
251
269
  tokenKind: 'bot',
@@ -49,6 +49,58 @@ function slackFileUrl(file) {
49
49
  ? file.url_private_download : file?.url_private;
50
50
  }
51
51
 
52
+ function slackReplyAttachment(file) {
53
+ if (!file || typeof file !== 'object') return null;
54
+ const mediaType = typeof file.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
55
+ const kind = mediaType.startsWith('image/') ? 'image'
56
+ : mediaType.startsWith('audio/') ? 'audio'
57
+ : mediaType.startsWith('video/') ? 'video' : 'file';
58
+ const name = typeof file.name === 'string' && file.name
59
+ ? file.name : typeof file.title === 'string' && file.title ? file.title : undefined;
60
+ return { kind, ...(name ? { name } : {}) };
61
+ }
62
+
63
+ function slackReplySnapshot(message, messageTs) {
64
+ if (!message || String(message.ts ?? '') !== messageTs) return null;
65
+ const authorId = typeof message.user === 'string' && message.user
66
+ ? message.user : typeof message.bot_id === 'string' && message.bot_id
67
+ ? message.bot_id : undefined;
68
+ const authorName = typeof message.username === 'string' && message.username
69
+ ? message.username : undefined;
70
+ return {
71
+ messageId: messageTs,
72
+ ...(authorId ? { authorId } : {}),
73
+ ...(authorName ? { authorName } : {}),
74
+ content: decodeSlackText(message.text ?? ''),
75
+ attachments: Array.isArray(message.files)
76
+ ? message.files.map(slackReplyAttachment).filter(Boolean)
77
+ : [],
78
+ };
79
+ }
80
+
81
+ function slackThreadReplyReference(event, loadReply) {
82
+ const messageTs = typeof event?.thread_ts === 'string' ? event.thread_ts : '';
83
+ if (!messageTs || messageTs === String(event?.ts ?? '')) return undefined;
84
+ return {
85
+ messageId: messageTs,
86
+ load: async ({ signal } = {}) => {
87
+ try {
88
+ const message = await loadReply({
89
+ channelId: String(event.channel),
90
+ messageTs,
91
+ signal,
92
+ });
93
+ return slackReplySnapshot(message, messageTs);
94
+ } catch (error) {
95
+ if (error?.code === 'slack-missing-scope') {
96
+ return { messageId: messageTs, unavailableReason: 'permission-denied' };
97
+ }
98
+ throw error;
99
+ }
100
+ },
101
+ };
102
+ }
103
+
52
104
  function slackImageSource(file, loadFile) {
53
105
  const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
54
106
  const url = slackFileUrl(file);
@@ -104,6 +156,7 @@ export function normalizeSlackEvent(payload, botUserId, {
104
156
  loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
105
157
  loadFileStream = loadFile,
106
158
  loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
159
+ loadReply = async () => { throw new Error('Slack reply loader is unavailable'); },
107
160
  } = {}) {
108
161
  const event = payload?.event;
109
162
  if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
@@ -112,6 +165,7 @@ export function normalizeSlackEvent(payload, botUserId, {
112
165
  if (!direct && !mentioned) return null;
113
166
  if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
114
167
  const threadTs = String(event.thread_ts ?? event.ts);
168
+ const replyTo = slackThreadReplyReference(event, loadReply);
115
169
  return {
116
170
  messageId: String(payload.event_id),
117
171
  senderId: String(event.user),
@@ -126,6 +180,7 @@ export function normalizeSlackEvent(payload, botUserId, {
126
180
  files: Array.isArray(event.files)
127
181
  ? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
128
182
  : [],
183
+ ...(replyTo ? { replyTo } : {}),
129
184
  addressed: direct || mentioned,
130
185
  reactionTarget: {
131
186
  channelId: String(event.channel),
@@ -555,6 +610,7 @@ export class SlackRuntime {
555
610
  loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
556
611
  loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
557
612
  loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
613
+ loadReply: (options) => this.#api.getMessage(options),
558
614
  });
559
615
  const bridge = this.#bridge;
560
616
  if (message && bridge) {
@@ -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),
@@ -170,12 +269,12 @@ export function normalizeTelegramUpdate(update, {
170
269
  }),
171
270
  senderIsBot: message.from?.is_bot === true,
172
271
  kind: direct ? 'direct' : 'group',
173
- conversationId: messageThreadId === undefined
174
- ? String(chatId) : `${chatId}:${messageThreadId}`,
272
+ conversationId,
175
273
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
176
274
  plainText: typeof message.text === 'string',
177
275
  images: image ? [image] : [],
178
276
  files: file ? [file] : [],
277
+ ...(replyTo ? { replyTo } : {}),
179
278
  addressed,
180
279
  reactionTarget: { chatId, messageId },
181
280
  replyTarget: {
@@ -839,6 +938,21 @@ export class TelegramRuntime {
839
938
  }
840
939
  }
841
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
+
842
956
  async #poll(initialCursor, signal) {
843
957
  let cursor = initialCursor;
844
958
  while (!signal.aborted) {
@@ -863,6 +977,7 @@ export class TelegramRuntime {
863
977
  username: this.#config.username,
864
978
  loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
865
979
  loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
980
+ loadReplyContent: (reference, options) => this.#loadReplyContent(reference, options),
866
981
  });
867
982
  if (message) {
868
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';
@@ -71,6 +74,7 @@ function helpText() {
71
74
  t('/workspace 工作区序号或绝对路径 切换工作区'),
72
75
  t('/workspacelist 列出工作区绝对路径'),
73
76
  t('/sessionlist 或 /sessions [工作区序号或绝对路径] 列出会话 ID 和标题'),
77
+ t('/sessionlist --limit N 仅列出当前工作区前 N 个会话'),
74
78
  t('/session Session ID 或当前工作区序号 将当前聊天绑定到指定会话'),
75
79
  t('/models 按序号列出所有可用模型'),
76
80
  t('/reasoninglist 或 /reasonings 按序号列出当前模型可用推理等级'),
@@ -108,8 +112,7 @@ function conversationKey(frame) {
108
112
  return body.chattype === 'group' ? `group:${body.chatid}` : `direct:${body.from?.userid}`;
109
113
  }
110
114
 
111
- function messageText(frame) {
112
- const body = bodyOf(frame);
115
+ function messageContentText(body) {
113
116
  let text = '';
114
117
  if (body.msgtype === 'text') {
115
118
  text = typeof body.text?.content === 'string' ? body.text.content.trim() : '';
@@ -122,6 +125,12 @@ function messageText(frame) {
122
125
  .join('\n')
123
126
  .trim();
124
127
  }
128
+ return text;
129
+ }
130
+
131
+ function messageText(frame) {
132
+ const body = bodyOf(frame);
133
+ const text = messageContentText(body);
125
134
  // Group callbacks retain the leading @bot mention that caused delivery.
126
135
  // It is routing metadata rather than part of the user's prompt or answer.
127
136
  return body.chattype === 'group'
@@ -145,6 +154,36 @@ function fileContents(frame) {
145
154
  : [];
146
155
  }
147
156
 
157
+ function quoteAttachments(quote) {
158
+ if (quote?.msgtype === 'image') return [{ kind: 'image' }];
159
+ if (quote?.msgtype === 'voice') return [{ kind: 'audio' }];
160
+ if (quote?.msgtype === 'file') {
161
+ const name = nonEmptyString(
162
+ quote.file?.filename ?? quote.file?.file_name ?? quote.file?.name,
163
+ );
164
+ return [{ kind: 'file', ...(name ? { name } : {}) }];
165
+ }
166
+ if (quote?.msgtype !== 'mixed' || !Array.isArray(quote.mixed?.msg_item)) return [];
167
+ return quote.mixed.msg_item
168
+ .filter((item) => item?.msgtype === 'image')
169
+ .map(() => ({ kind: 'image' }));
170
+ }
171
+
172
+ function replyReferenceForBody(body) {
173
+ const quote = body?.quote;
174
+ if (!quote || typeof quote !== 'object') return null;
175
+ const content = messageContentText(quote);
176
+ const attachments = quoteAttachments(quote);
177
+ const supported = ['text', 'image', 'mixed', 'voice', 'file'].includes(quote.msgtype);
178
+ return {
179
+ ...(content ? { content } : {}),
180
+ ...(attachments.length > 0 ? { attachments } : {}),
181
+ ...(!content && attachments.length === 0
182
+ ? { unavailableReason: supported ? 'not-delivered' : 'unsupported' }
183
+ : {}),
184
+ };
185
+ }
186
+
148
187
  function imageSource(client, image) {
149
188
  const url = nonEmptyString(image?.url);
150
189
  if (!url) return null;
@@ -200,10 +239,13 @@ function fileSource(client, file) {
200
239
  }
201
240
 
202
241
  export function wecomInboundMessage(frame, client) {
242
+ const body = bodyOf(frame);
243
+ const replyTo = replyReferenceForBody(body);
203
244
  return {
204
245
  content: messageText(frame),
205
246
  images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
206
247
  files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
248
+ ...(replyTo ? { replyTo } : {}),
207
249
  };
208
250
  }
209
251
 
@@ -604,7 +646,7 @@ export class WecomHarnessBridge {
604
646
  && (this.#queues.has(key) || pending || this.#approvals.hasPending(key))
605
647
  ? { handled: true, kind: 'busy', message: batchInputBusyMessage() }
606
648
  : this.#batchInputs.handle(key, commandText, {
607
- plainText: isNativeWecomText(frame),
649
+ plainText: isNativeWecomText(frame) && !hasReplyReference(commandMessage),
608
650
  });
609
651
  if (result.handled) {
610
652
  if (result.kind === 'submit') {
@@ -934,6 +976,7 @@ export class WecomHarnessBridge {
934
976
  const text = message.content;
935
977
  const hasImages = hasInboundImages(message);
936
978
  const hasFiles = hasInboundFiles(message);
979
+ const hasReply = hasReplyReference(message);
937
980
  const key = conversationKey(frame);
938
981
  let streamId = null;
939
982
  let streamStarted = false;
@@ -942,7 +985,7 @@ export class WecomHarnessBridge {
942
985
  let batchSettled = batchSubmission === null;
943
986
  let promptRecorded = false;
944
987
  try {
945
- if (!text && !hasImages && !hasFiles) {
988
+ if (!text && !hasImages && !hasFiles && !hasReply) {
946
989
  await this.#sendImmediate(frame, chatId, t('目前支持文字、图片、文件和语音转写消息。'));
947
990
  await this.#state.markSeen(messageId);
948
991
  return;
@@ -1003,8 +1046,8 @@ export class WecomHarnessBridge {
1003
1046
  this.#logger.warn?.('[dsh-im:wecom] unable to start a stream; using an active reply:', error);
1004
1047
  }
1005
1048
 
1006
- let content = hasImages
1007
- ? await promptContentForMessage(message, { signal: this.#signal })
1049
+ let content = hasImages || hasReply
1050
+ ? await promptContentForInboundMessage(message, { signal: this.#signal })
1008
1051
  : undefined;
1009
1052
  const snapshot = this.#acceptedMessageIds.get(messageId);
1010
1053
  let contextEnhanced = false;
@@ -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
  }