@xmanrui/dsh-im 1.2.0 → 1.4.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 (33) hide show
  1. package/README.en.md +1 -1
  2. package/README.md +1 -1
  3. package/lib/index.js +176 -163
  4. package/package.json +1 -1
  5. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  6. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  7. package/plugin-src/host/channels/qq/production.mjs +3 -1
  8. package/plugin-src/host/channels/shared/production.mjs +3 -1
  9. package/plugin-src/host/channels/slack/production.mjs +3 -1
  10. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  11. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  12. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  13. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  14. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  15. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  16. package/src/channels/discord/discord-runtime.mjs +23 -0
  17. package/src/channels/feishu/bridge.mjs +18 -10
  18. package/src/channels/feishu/message-utils.mjs +47 -0
  19. package/src/channels/qq/markdown-reply.mjs +176 -0
  20. package/src/channels/qq/qq-bridge.mjs +124 -55
  21. package/src/channels/shared/file-download.mjs +64 -0
  22. package/src/channels/shared/harness-client.mjs +111 -13
  23. package/src/channels/shared/inbound-file.mjs +206 -0
  24. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  25. package/src/channels/slack/slack-api.mjs +27 -4
  26. package/src/channels/slack/slack-runtime.mjs +55 -5
  27. package/src/channels/telegram/telegram-api.mjs +21 -6
  28. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  29. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  30. package/src/channels/weixin/weixin-api.mjs +45 -0
  31. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  32. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
  33. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -43,10 +43,14 @@ function stripBotMention(value, botUserId) {
43
43
  .trim();
44
44
  }
45
45
 
46
+ function slackFileUrl(file) {
47
+ return typeof file?.url_private_download === 'string' && file.url_private_download
48
+ ? file.url_private_download : file?.url_private;
49
+ }
50
+
46
51
  function slackImageSource(file, loadFile) {
47
52
  const mediaType = typeof file?.mimetype === 'string' ? file.mimetype.toLowerCase() : '';
48
- const url = typeof file?.url_private_download === 'string' && file.url_private_download
49
- ? file.url_private_download : file?.url_private;
53
+ const url = slackFileUrl(file);
50
54
  if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
51
55
  return {
52
56
  name: typeof file.name === 'string' ? file.name : undefined,
@@ -56,9 +60,50 @@ function slackImageSource(file, loadFile) {
56
60
  };
57
61
  }
58
62
 
59
- export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => {
60
- throw new Error('Slack file downloader is unavailable');
61
- } } = {}) {
63
+ function slackFileSource(file, loadFile, loadFileInfo) {
64
+ const mediaType = typeof file?.mimetype === 'string' && file.mimetype
65
+ ? file.mimetype.toLowerCase() : undefined;
66
+ const url = slackFileUrl(file);
67
+ if (IMAGE_MEDIA_TYPES.has(mediaType)) return null;
68
+ const requiresInfo = file?.file_access === 'check_file_info'
69
+ && typeof file?.id === 'string' && file.id;
70
+ if ((typeof url !== 'string' || !url) && !requiresInfo) return null;
71
+ return {
72
+ name: typeof file?.name === 'string' && file.name
73
+ ? file.name : typeof file?.title === 'string' && file.title
74
+ ? file.title : requiresInfo ? file.id : 'slack-file',
75
+ ...(mediaType ? { mediaType } : {}),
76
+ size: Number.isSafeInteger(file?.size) && file.size >= 0 ? file.size : undefined,
77
+ load: async ({ signal } = {}) => {
78
+ if (!requiresInfo) return loadFile(url, { signal });
79
+ const resolved = await loadFileInfo(file.id, { signal });
80
+ const resolvedUrl = slackFileUrl(resolved);
81
+ if (typeof resolvedUrl !== 'string' || !resolvedUrl) {
82
+ throw new Error('Slack files.info returned no downloadable URL');
83
+ }
84
+ const loaded = await loadFile(resolvedUrl, { signal });
85
+ const name = typeof resolved?.name === 'string' && resolved.name
86
+ ? resolved.name : typeof resolved?.title === 'string' && resolved.title
87
+ ? resolved.title : file.id;
88
+ const resolvedMediaType = typeof resolved?.mimetype === 'string' && resolved.mimetype
89
+ ? resolved.mimetype.toLowerCase() : undefined;
90
+ if (Buffer.isBuffer(loaded) || loaded instanceof Uint8Array) {
91
+ return { data: loaded, name, ...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}) };
92
+ }
93
+ return {
94
+ ...loaded,
95
+ name,
96
+ ...(resolvedMediaType ? { mediaType: resolvedMediaType } : {}),
97
+ };
98
+ },
99
+ };
100
+ }
101
+
102
+ export function normalizeSlackEvent(payload, botUserId, {
103
+ loadFile = async () => { throw new Error('Slack file downloader is unavailable'); },
104
+ loadFileStream = loadFile,
105
+ loadFileInfo = async () => { throw new Error('Slack file metadata loader is unavailable'); },
106
+ } = {}) {
62
107
  const event = payload?.event;
63
108
  if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
64
109
  const direct = event.type === 'message' && event.channel_type === 'im';
@@ -76,6 +121,9 @@ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () =>
76
121
  images: Array.isArray(event.files)
77
122
  ? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
78
123
  : [],
124
+ files: Array.isArray(event.files)
125
+ ? event.files.map((file) => slackFileSource(file, loadFileStream, loadFileInfo)).filter(Boolean)
126
+ : [],
79
127
  addressed: direct || mentioned,
80
128
  replyTarget: {
81
129
  channelId: String(event.channel),
@@ -436,6 +484,8 @@ export class SlackRuntime {
436
484
  && packet.payload.api_app_id !== this.#appId) return;
437
485
  const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
438
486
  loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
487
+ loadFileStream: (url, options) => this.#api.downloadFileStream({ url, ...options }),
488
+ loadFileInfo: (fileId, options) => this.#api.fileInfo({ fileId, ...options }),
439
489
  });
440
490
  const bridge = this.#bridge;
441
491
  if (message && bridge) {
@@ -1,3 +1,4 @@
1
+ import { fetchFileStream } from '../shared/file-download.mjs';
1
2
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
2
3
 
3
4
  const DEFAULT_BASE_URL = 'https://api.telegram.org/';
@@ -131,6 +132,25 @@ export class TelegramApi {
131
132
  }
132
133
 
133
134
  async downloadFile({ fileId, signal, maxBytes } = {}) {
135
+ const url = await this.#downloadUrl(fileId, signal);
136
+ return fetchImageBuffer(url, {
137
+ fetchImpl: this.#fetch,
138
+ signal,
139
+ maxBytes,
140
+ allowedHosts: TELEGRAM_FILE_HOSTS,
141
+ });
142
+ }
143
+
144
+ async downloadFileStream({ fileId, signal } = {}) {
145
+ const url = await this.#downloadUrl(fileId, signal);
146
+ return fetchFileStream(url, {
147
+ fetchImpl: this.#fetch,
148
+ signal,
149
+ allowedHosts: TELEGRAM_FILE_HOSTS,
150
+ });
151
+ }
152
+
153
+ async #downloadUrl(fileId, signal) {
134
154
  const file = await this.getFile({ fileId, signal });
135
155
  const filePath = cleanString(file?.file_path);
136
156
  if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
@@ -148,12 +168,7 @@ export class TelegramApi {
148
168
  }
149
169
  const url = new URL(this.#baseUrl);
150
170
  url.pathname = `/file/bot${this.#token}/${filePath}`;
151
- return fetchImageBuffer(url, {
152
- fetchImpl: this.#fetch,
153
- signal,
154
- maxBytes,
155
- allowedHosts: TELEGRAM_FILE_HOSTS,
156
- });
171
+ return url;
157
172
  }
158
173
 
159
174
  async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
@@ -100,9 +100,27 @@ function telegramImageSource(message, loadFile) {
100
100
  };
101
101
  }
102
102
 
103
- export function normalizeTelegramUpdate(update, { botId, username, loadFile = async () => {
104
- throw new Error('Telegram file downloader is unavailable');
105
- } }) {
103
+ function telegramFileSource(message, loadFile) {
104
+ const file = message?.document;
105
+ if (!file || imageTypeForDocument(file)
106
+ || typeof file.file_id !== 'string' || !file.file_id) return null;
107
+ const mediaType = typeof file.mime_type === 'string' && file.mime_type
108
+ ? file.mime_type.toLowerCase() : undefined;
109
+ return {
110
+ name: typeof file.file_name === 'string' && file.file_name
111
+ ? file.file_name : String(file.file_unique_id ?? file.file_id),
112
+ ...(mediaType ? { mediaType } : {}),
113
+ size: fileSize(file.file_size),
114
+ load: ({ signal } = {}) => loadFile(file.file_id, { signal }),
115
+ };
116
+ }
117
+
118
+ export function normalizeTelegramUpdate(update, {
119
+ botId,
120
+ username,
121
+ loadFile = async () => { throw new Error('Telegram file downloader is unavailable'); },
122
+ loadFileStream = loadFile,
123
+ }) {
106
124
  const message = update?.message;
107
125
  const chatId = message?.chat?.id;
108
126
  const senderId = message?.from?.id;
@@ -117,6 +135,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
117
135
  const messageThreadId = Number.isSafeInteger(message.message_thread_id)
118
136
  ? message.message_thread_id : undefined;
119
137
  const image = telegramImageSource(message, loadFile);
138
+ const file = telegramFileSource(message, loadFileStream);
120
139
  return {
121
140
  messageId: String(update.update_id),
122
141
  senderId: String(senderId),
@@ -126,6 +145,7 @@ export function normalizeTelegramUpdate(update, { botId, username, loadFile = as
126
145
  ? String(chatId) : `${chatId}:${messageThreadId}`,
127
146
  content: withoutBotMention(message.text ?? message.caption ?? '', username),
128
147
  images: image ? [image] : [],
148
+ files: file ? [file] : [],
129
149
  addressed,
130
150
  replyTarget: {
131
151
  chatId,
@@ -381,6 +401,7 @@ export class TelegramRuntime {
381
401
  botId: this.#config.platformId,
382
402
  username: this.#config.username,
383
403
  loadFile: (fileId, options) => this.#api.downloadFile({ fileId, ...options }),
404
+ loadFileStream: (fileId, options) => this.#api.downloadFileStream({ fileId, ...options }),
384
405
  });
385
406
  if (message && telegramInboundAllowed(message, {
386
407
  accessMode: this.#accessMode,
@@ -26,6 +26,10 @@ import {
26
26
  imagePromptUserMessage,
27
27
  promptContentForMessage,
28
28
  } from '../shared/image-prompt.mjs';
29
+ import {
30
+ hasInboundFiles,
31
+ inboundFileUserMessage,
32
+ } from '../shared/inbound-file.mjs';
29
33
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
30
34
  import {
31
35
  materializeOutboundArtifact,
@@ -43,7 +47,7 @@ const DEFAULT_FILE_UPLOAD_TIMEOUT_MS = 120_000;
43
47
  const HELP_TEXT = [
44
48
  '企业微信机器人已连接 DeepSeek Harness。',
45
49
  '',
46
- '直接发送文字或图片即可继续当前会话。',
50
+ '直接发送文字、图片或文件即可继续当前会话。',
47
51
  '/new 开启一个全新会话',
48
52
  '/compact 压缩当前会话的较早上下文',
49
53
  '/workspace 工作区绝对路径 切换工作区',
@@ -110,6 +114,13 @@ function imageContents(frame) {
110
114
  .map((item) => item.image);
111
115
  }
112
116
 
117
+ function fileContents(frame) {
118
+ const body = bodyOf(frame);
119
+ return body.msgtype === 'file' && body.file && typeof body.file === 'object'
120
+ ? [body.file]
121
+ : [];
122
+ }
123
+
113
124
  function imageSource(client, image) {
114
125
  const url = nonEmptyString(image?.url);
115
126
  if (!url) return null;
@@ -139,10 +150,56 @@ function imageSource(client, image) {
139
150
  };
140
151
  }
141
152
 
153
+ function fileSource(client, file) {
154
+ const url = nonEmptyString(file?.url);
155
+ if (!url) return null;
156
+ const aeskey = nonEmptyString(file?.aeskey) ?? undefined;
157
+ return {
158
+ name: nonEmptyString(file?.filename ?? file?.file_name ?? file?.name) ?? 'file',
159
+ async load({ signal } = {}) {
160
+ signal?.throwIfAborted();
161
+ if (typeof client?.downloadFile !== 'function') {
162
+ throw new Error('Enterprise WeChat file download is unavailable');
163
+ }
164
+ const result = await client.downloadFile(url, aeskey);
165
+ signal?.throwIfAborted();
166
+ const raw = result?.buffer ?? result?.data;
167
+ if (!Buffer.isBuffer(raw) && !(raw instanceof Uint8Array)) {
168
+ throw new Error('Enterprise WeChat file download returned no data');
169
+ }
170
+ return {
171
+ data: Buffer.from(raw),
172
+ ...(nonEmptyString(result?.filename) ? { name: result.filename.trim() } : {}),
173
+ };
174
+ },
175
+ };
176
+ }
177
+
142
178
  export function wecomInboundMessage(frame, client) {
143
179
  return {
144
180
  content: messageText(frame),
145
181
  images: imageContents(frame).map((image) => imageSource(client, image)).filter(Boolean),
182
+ files: fileContents(frame).map((file) => fileSource(client, file)).filter(Boolean),
183
+ };
184
+ }
185
+
186
+ function prefetchInboundFiles(message, signal) {
187
+ if (!Array.isArray(message?.files) || message.files.length === 0) return message;
188
+ return {
189
+ ...message,
190
+ files: message.files.map((source) => {
191
+ const download = source.load({ signal });
192
+ download.catch(() => undefined);
193
+ return {
194
+ ...source,
195
+ async load({ signal: loadSignal } = {}) {
196
+ loadSignal?.throwIfAborted();
197
+ const result = await download;
198
+ loadSignal?.throwIfAborted();
199
+ return result;
200
+ },
201
+ };
202
+ }),
146
203
  };
147
204
  }
148
205
 
@@ -400,7 +457,7 @@ export class WecomHarnessBridge {
400
457
  const pending = this.#pendingInteractions.get(key);
401
458
  const commandMessage = wecomInboundMessage(frame, this.#client);
402
459
  const commandText = nonEmptyString(commandMessage.content) ?? '';
403
- const commandRunner = isControlCommand(commandText)
460
+ const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
404
461
  ? runControlCommand
405
462
  : (isModelCommand(commandText)
406
463
  ? runModelCommand
@@ -510,6 +567,7 @@ export class WecomHarnessBridge {
510
567
  preparedMessage = imageQueueFullMessage(inboundMessage);
511
568
  }
512
569
  }
570
+ preparedMessage = prefetchInboundFiles(preparedMessage, this.#signal);
513
571
  const previous = this.#queues.get(key) ?? Promise.resolve();
514
572
  const current = previous
515
573
  .catch(() => undefined)
@@ -543,6 +601,7 @@ export class WecomHarnessBridge {
543
601
  const result = await runner(message.content, this.#harness, this.#state, key, {
544
602
  signal: this.#signal,
545
603
  hasImages: hasInboundImages(message),
604
+ hasFiles: hasInboundFiles(message),
546
605
  pendingInteraction: this.#pendingInteractions.has(key)
547
606
  || this.#approvals.hasPending(key),
548
607
  control: { owner: this, key },
@@ -701,34 +760,35 @@ export class WecomHarnessBridge {
701
760
  const message = preparedMessage ?? wecomInboundMessage(frame, this.#client);
702
761
  const text = message.content;
703
762
  const hasImages = hasInboundImages(message);
763
+ const hasFiles = hasInboundFiles(message);
704
764
  const key = conversationKey(frame);
705
765
  let streamId = null;
706
766
  let streamStarted = false;
707
767
  try {
708
- if (!text && !hasImages) {
709
- await this.#sendImmediate(frame, chatId, '目前支持文字、图片和语音转写消息。');
768
+ if (!text && !hasImages && !hasFiles) {
769
+ await this.#sendImmediate(frame, chatId, '目前支持文字、图片、文件和语音转写消息。');
710
770
  await this.#state.markSeen(messageId);
711
771
  return;
712
772
  }
713
773
  const command = text.toLowerCase();
714
- if (!hasImages && command === '/help') {
774
+ if (!hasImages && !hasFiles && command === '/help') {
715
775
  await this.#sendImmediate(frame, chatId, HELP_TEXT);
716
776
  await this.#state.markSeen(messageId);
717
777
  return;
718
778
  }
719
- if (!hasImages && command === '/status') {
779
+ if (!hasImages && !hasFiles && command === '/status') {
720
780
  await this.#harness.ensureRunning({ signal: this.#signal });
721
781
  await this.#sendImmediate(frame, chatId, '企业微信机器人与 DeepSeek Harness 连接正常。');
722
782
  await this.#state.markSeen(messageId);
723
783
  return;
724
784
  }
725
- if (!hasImages && command === '/new') {
785
+ if (!hasImages && !hasFiles && command === '/new') {
726
786
  await this.#state.clearSession(key);
727
787
  await this.#sendImmediate(frame, chatId, '已开启新会话。请发送你的问题。');
728
788
  await this.#state.markSeen(messageId);
729
789
  return;
730
790
  }
731
- const workspaceCommand = hasImages
791
+ const workspaceCommand = hasImages || hasFiles
732
792
  ? null
733
793
  : await runWorkspaceCommand(text, this.#harness, key);
734
794
  if (workspaceCommand) {
@@ -738,7 +798,7 @@ export class WecomHarnessBridge {
738
798
  await this.#state.markSeen(messageId);
739
799
  return;
740
800
  }
741
- const compactCommand = hasImages
801
+ const compactCommand = hasImages || hasFiles
742
802
  ? null
743
803
  : await runCompactCommand(
744
804
  text,
@@ -789,6 +849,7 @@ export class WecomHarnessBridge {
789
849
  requiresMention: body.chattype === 'group',
790
850
  }),
791
851
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
852
+ files: message.files,
792
853
  },
793
854
  });
794
855
 
@@ -862,7 +923,9 @@ export class WecomHarnessBridge {
862
923
  if (this.#signal?.aborted) return;
863
924
  this.#status.lastError = error?.message ?? String(error);
864
925
  this.#logger.error?.('[dsh-im:wecom] failed to process an inbound message');
865
- const errorText = imagePromptUserMessage(error) ?? '消息处理失败,请稍后重试。';
926
+ const errorText = inboundFileUserMessage(error)
927
+ ?? imagePromptUserMessage(error)
928
+ ?? '消息处理失败,请稍后重试。';
866
929
  try {
867
930
  if (streamStarted && streamId) {
868
931
  await this.#client.replyStream(frame, streamId, errorText, true);
@@ -196,6 +196,47 @@ export function extractWeixinImages(message, { fetchImpl = fetch } = {}) {
196
196
  return images;
197
197
  }
198
198
 
199
+ async function fetchWeixinFileCiphertext(url, { fetchImpl, signal }) {
200
+ const response = await fetchImpl(new URL(url), {
201
+ method: 'GET',
202
+ signal,
203
+ redirect: 'manual',
204
+ });
205
+ if (!response?.ok) {
206
+ await response?.body?.cancel?.().catch?.(() => undefined);
207
+ throw new WeixinApiError(
208
+ 'file-download-failed',
209
+ `微信文件下载失败(HTTP ${response?.status ?? 'unknown'})。`,
210
+ { status: response?.status },
211
+ );
212
+ }
213
+ return Buffer.from(await response.arrayBuffer());
214
+ }
215
+
216
+ /** Convert native iLink file items into lazily downloaded, decrypted file references. */
217
+ export function extractWeixinFiles(message, { fetchImpl = fetch } = {}) {
218
+ if (typeof fetchImpl !== 'function') throw new TypeError('fetchImpl must be a function');
219
+ const files = [];
220
+ for (const item of message?.item_list ?? []) {
221
+ const fileItem = item?.file_item;
222
+ if (!fileItem || typeof fileItem !== 'object') continue;
223
+ const declaredSize = Number(fileItem.len);
224
+ files.push({
225
+ name: nonEmptyString(fileItem.file_name) ?? (files.length === 0 ? 'file' : `file-${files.length + 1}`),
226
+ ...(Number.isFinite(declaredSize) && declaredSize >= 0 ? { size: declaredSize } : {}),
227
+ load: async ({ signal } = {}) => {
228
+ signal?.throwIfAborted();
229
+ const key = parseWeixinImageAesKey(fileItem);
230
+ const url = weixinImageDownloadUrl(fileItem.media);
231
+ const ciphertext = await fetchWeixinFileCiphertext(url, { fetchImpl, signal });
232
+ signal?.throwIfAborted();
233
+ return decryptWeixinImage(ciphertext, key);
234
+ },
235
+ });
236
+ }
237
+ return files;
238
+ }
239
+
199
240
  function isWeixinHost(hostname) {
200
241
  const normalized = hostname.toLowerCase().replace(/\.$/, '');
201
242
  return normalized === 'weixin.qq.com' || normalized.endsWith('.weixin.qq.com');
@@ -421,6 +462,10 @@ export function createWeixinApi({ fetchImpl = fetch } = {}) {
421
462
  return extractWeixinImages(message, { fetchImpl });
422
463
  },
423
464
 
465
+ inboundFiles(message) {
466
+ return extractWeixinFiles(message, { fetchImpl });
467
+ },
468
+
424
469
  async beginLogin({ localTokens = [], botType = DEFAULT_BOT_TYPE, signal } = {}) {
425
470
  const tokens = [...new Set(localTokens.map(nonEmptyString).filter(Boolean))].slice(-10);
426
471
  const response = await requestJson(fetchImpl, {
@@ -1,4 +1,5 @@
1
1
  import {
2
+ extractWeixinFiles,
2
3
  extractWeixinImages,
3
4
  extractWeixinText,
4
5
  splitWeixinText,
@@ -31,6 +32,11 @@ import {
31
32
  imagePromptUserMessage,
32
33
  promptContentForMessage,
33
34
  } from '../shared/image-prompt.mjs';
35
+ import {
36
+ hasInboundFiles,
37
+ inboundFileUserMessage,
38
+ prefetchInboundFiles,
39
+ } from '../shared/inbound-file.mjs';
34
40
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
35
41
  import {
36
42
  materializeOutboundArtifact,
@@ -49,7 +55,7 @@ const GENERIC_PROCESSING_ERROR = '消息处理失败,请稍后重试。';
49
55
  const HELP_TEXT = [
50
56
  '微信已连接 DeepSeek Harness。',
51
57
  '',
52
- '直接发送文字、图片或带文字识别结果的语音即可继续当前会话。',
58
+ '直接发送文字、图片、文件或带文字识别结果的语音即可继续当前会话。',
53
59
  '/new 开启一个全新会话',
54
60
  '/compact 压缩当前会话的较早上下文',
55
61
  '/workspace 工作区绝对路径 切换工作区',
@@ -77,15 +83,33 @@ function nonEmptyString(value) {
77
83
  return typeof value === 'string' && value.trim() ? value.trim() : null;
78
84
  }
79
85
 
86
+ export function weixinInboundMessage(message, api) {
87
+ return {
88
+ content: extractWeixinText(message) ?? '',
89
+ images: typeof api?.inboundImages === 'function'
90
+ ? api.inboundImages(message)
91
+ : extractWeixinImages(message),
92
+ files: typeof api?.inboundFiles === 'function'
93
+ ? api.inboundFiles(message)
94
+ : extractWeixinFiles(message),
95
+ };
96
+ }
97
+
80
98
  function hasWeixinImageItems(message) {
81
99
  return Array.isArray(message?.item_list)
82
100
  && message.item_list.some((item) => item?.image_item && typeof item.image_item === 'object');
83
101
  }
84
102
 
103
+ function hasWeixinFileItems(message) {
104
+ return Array.isArray(message?.item_list)
105
+ && message.item_list.some((item) => item?.file_item && typeof item.file_item === 'object');
106
+ }
107
+
85
108
  function canClaimInteractionReply(message, pending) {
86
109
  return pending.questions[pending.index]
87
110
  && nonEmptyString(message?.from_user_id) === pending.actor
88
111
  && !hasWeixinImageItems(message)
112
+ && !hasWeixinFileItems(message)
89
113
  && nonEmptyString(extractWeixinText(message));
90
114
  }
91
115
 
@@ -204,7 +228,7 @@ export class WeixinHarnessBridge {
204
228
  const runId = nonEmptyString(message?.run_id) ?? undefined;
205
229
  const pending = this.#pendingInteractions.get(key);
206
230
  const commandText = nonEmptyString(extractWeixinText(message)) ?? '';
207
- const commandRunner = isControlCommand(commandText)
231
+ const commandRunner = hasWeixinFileItems(message) ? null : isControlCommand(commandText)
208
232
  ? runControlCommand
209
233
  : (isModelCommand(commandText)
210
234
  ? runModelCommand
@@ -238,7 +262,9 @@ export class WeixinHarnessBridge {
238
262
  key,
239
263
  actor: sender,
240
264
  messageId,
241
- text: hasWeixinImageItems(message) ? '' : extractWeixinText(message),
265
+ text: hasWeixinImageItems(message) || hasWeixinFileItems(message)
266
+ ? ''
267
+ : extractWeixinText(message),
242
268
  addressed: true,
243
269
  hasPendingQuestion: Boolean(pending),
244
270
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -290,10 +316,16 @@ export class WeixinHarnessBridge {
290
316
  releaseMessageId = true,
291
317
  alreadyRecorded = false,
292
318
  } = {}) {
319
+ const preparedMessage = message.from_user_id === this.#ownerUserId
320
+ ? prefetchInboundFiles(
321
+ weixinInboundMessage(message, this.#api),
322
+ { signal: this.#signal },
323
+ )
324
+ : undefined;
293
325
  const previous = this.#queues.get(key) ?? Promise.resolve();
294
326
  const current = previous
295
327
  .catch(() => undefined)
296
- .then(() => this.#process(message, key, { alreadyRecorded }))
328
+ .then(() => this.#process(message, key, { alreadyRecorded, preparedMessage }))
297
329
  .finally(() => {
298
330
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
299
331
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -331,6 +363,7 @@ export class WeixinHarnessBridge {
331
363
  const result = await runner(text, this.#harness, this.#state, key, {
332
364
  signal: this.#signal,
333
365
  hasImages: hasWeixinImageItems(message),
366
+ hasFiles: hasWeixinFileItems(message),
334
367
  pendingInteraction: this.#pendingInteractions.has(key)
335
368
  || this.#approvals.hasPending(key),
336
369
  control: { owner: this, key },
@@ -348,7 +381,7 @@ export class WeixinHarnessBridge {
348
381
  this.#status.lastMessageError = null;
349
382
  }
350
383
 
351
- async #process(message, key, { alreadyRecorded = false } = {}) {
384
+ async #process(message, key, { alreadyRecorded = false, preparedMessage } = {}) {
352
385
  this.#signal?.throwIfAborted();
353
386
  const messageId = weixinMessageId(message);
354
387
  const sender = nonEmptyString(message?.from_user_id);
@@ -366,38 +399,36 @@ export class WeixinHarnessBridge {
366
399
 
367
400
  const contextToken = typeof message.context_token === 'string' ? message.context_token : undefined;
368
401
  const runId = typeof message.run_id === 'string' ? message.run_id : undefined;
369
- const text = extractWeixinText(message) ?? '';
370
402
  try {
371
- const images = typeof this.#api.inboundImages === 'function'
372
- ? this.#api.inboundImages(message)
373
- : extractWeixinImages(message);
374
- const promptMessage = { content: text, images };
403
+ const promptMessage = preparedMessage ?? weixinInboundMessage(message, this.#api);
404
+ const text = promptMessage.content;
375
405
  const hasImages = hasInboundImages(promptMessage);
376
- if (!text && !hasImages) {
377
- await this.#send(sender, '目前支持文字、图片,以及微信已转成文字的语音消息。', contextToken, runId);
406
+ const hasFiles = hasInboundFiles(promptMessage);
407
+ if (!text && !hasImages && !hasFiles) {
408
+ await this.#send(sender, '目前支持文字、图片、文件,以及微信已转成文字的语音消息。', contextToken, runId);
378
409
  await this.#state.markSeen(messageId);
379
410
  return;
380
411
  }
381
412
 
382
413
  const command = text.trim().toLowerCase();
383
- if (!hasImages && command === '/help') {
414
+ if (!hasImages && !hasFiles && command === '/help') {
384
415
  await this.#send(sender, HELP_TEXT, contextToken, runId);
385
416
  await this.#state.markSeen(messageId);
386
417
  return;
387
418
  }
388
- if (!hasImages && command === '/status') {
419
+ if (!hasImages && !hasFiles && command === '/status') {
389
420
  await this.#harness.ensureRunning({ signal: this.#signal });
390
421
  await this.#send(sender, '微信与 DeepSeek Harness 连接正常。', contextToken, runId);
391
422
  await this.#state.markSeen(messageId);
392
423
  return;
393
424
  }
394
- if (!hasImages && command === '/new') {
425
+ if (!hasImages && !hasFiles && command === '/new') {
395
426
  await this.#state.clearSession(key);
396
427
  await this.#send(sender, '已开启新会话。请发送你的问题。', contextToken, runId);
397
428
  await this.#state.markSeen(messageId);
398
429
  return;
399
430
  }
400
- const workspaceCommand = hasImages
431
+ const workspaceCommand = hasImages || hasFiles
401
432
  ? null
402
433
  : await runWorkspaceCommand(text, this.#harness, key);
403
434
  if (workspaceCommand) {
@@ -407,7 +438,7 @@ export class WeixinHarnessBridge {
407
438
  await this.#state.markSeen(messageId);
408
439
  return;
409
440
  }
410
- const compactCommand = hasImages
441
+ const compactCommand = hasImages || hasFiles
411
442
  ? null
412
443
  : await runCompactCommand(
413
444
  text,
@@ -446,6 +477,7 @@ export class WeixinHarnessBridge {
446
477
  runId,
447
478
  }),
448
479
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
480
+ files: promptMessage.files,
449
481
  },
450
482
  }));
451
483
  } finally {
@@ -490,7 +522,9 @@ export class WeixinHarnessBridge {
490
522
  }
491
523
  if (this.#signal?.aborted) return;
492
524
  this.#status.lastError = error?.message ?? String(error);
493
- const userMessage = imagePromptUserMessage(error) ?? GENERIC_PROCESSING_ERROR;
525
+ const userMessage = inboundFileUserMessage(error)
526
+ ?? imagePromptUserMessage(error)
527
+ ?? GENERIC_PROCESSING_ERROR;
494
528
  this.#status.lastMessageError = safeMessageError(error, userMessage);
495
529
  this.#logger.error?.('[dsh-weixin] failed to process an inbound message:', error);
496
530
  try {