@xmanrui/dsh-im 1.2.0 → 1.3.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 (30) hide show
  1. package/lib/index.js +166 -163
  2. package/package.json +1 -1
  3. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  4. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  5. package/plugin-src/host/channels/qq/production.mjs +3 -1
  6. package/plugin-src/host/channels/shared/production.mjs +3 -1
  7. package/plugin-src/host/channels/slack/production.mjs +3 -1
  8. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  9. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  10. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  11. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  12. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  13. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  14. package/src/channels/discord/discord-runtime.mjs +23 -0
  15. package/src/channels/feishu/bridge.mjs +18 -10
  16. package/src/channels/feishu/message-utils.mjs +47 -0
  17. package/src/channels/qq/qq-bridge.mjs +80 -28
  18. package/src/channels/shared/file-download.mjs +64 -0
  19. package/src/channels/shared/harness-client.mjs +45 -0
  20. package/src/channels/shared/inbound-file.mjs +206 -0
  21. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  22. package/src/channels/slack/slack-api.mjs +27 -4
  23. package/src/channels/slack/slack-runtime.mjs +55 -5
  24. package/src/channels/telegram/telegram-api.mjs +21 -6
  25. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  26. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  27. package/src/channels/weixin/weixin-api.mjs +45 -0
  28. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  29. package/src/channels/whatsapp/whatsapp-runtime.mjs +42 -2
  30. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -0,0 +1,206 @@
1
+ import { createWriteStream } from 'node:fs';
2
+ import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
3
+ import { isAbsolute, join, relative, resolve } from 'node:path';
4
+ import { pipeline } from 'node:stream/promises';
5
+
6
+ const FILES_DIRECTORY = join('.dsh-im', 'inbound');
7
+
8
+ export class InboundFileError extends Error {
9
+ constructor(code, message, userMessage = '文件接收失败,请重新发送后再试。', options = {}) {
10
+ super(message, options);
11
+ this.name = 'InboundFileError';
12
+ this.code = code;
13
+ this.userMessage = userMessage;
14
+ }
15
+ }
16
+
17
+ function fileSources(message) {
18
+ return Array.isArray(message?.files) ? message.files.filter(Boolean) : [];
19
+ }
20
+
21
+ function displayName(value, fallback) {
22
+ if (typeof value !== 'string') return fallback;
23
+ const cleaned = value
24
+ .replaceAll('\\', '/')
25
+ .split('/')
26
+ .at(-1)
27
+ ?.replace(/[\u0000-\u001f\u007f]/g, '')
28
+ .trim();
29
+ return cleaned || fallback;
30
+ }
31
+
32
+ function storageName(value, index) {
33
+ const cleaned = displayName(value, 'file')
34
+ .replace(/[^\p{L}\p{N}._ -]/gu, '_')
35
+ .replace(/^\.+/, '')
36
+ .slice(0, 160) || 'file';
37
+ return `${String(index + 1).padStart(2, '0')}-${cleaned}`;
38
+ }
39
+
40
+ function loadedFile(value) {
41
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
42
+ return { data: Buffer.from(value) };
43
+ }
44
+ const raw = value?.data ?? value?.buffer;
45
+ if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
46
+ return {
47
+ data: Buffer.from(raw),
48
+ name: value?.name ?? value?.filename,
49
+ mediaType: value?.mediaType ?? value?.mimetype,
50
+ };
51
+ }
52
+ const stream = value?.stream ?? value;
53
+ if (stream && typeof stream[Symbol.asyncIterator] === 'function') {
54
+ return {
55
+ stream,
56
+ name: value?.name ?? value?.filename,
57
+ mediaType: value?.mediaType ?? value?.mimetype,
58
+ };
59
+ }
60
+ return null;
61
+ }
62
+
63
+ export function hasInboundFiles(message) {
64
+ return fileSources(message).length > 0;
65
+ }
66
+
67
+ /** Start provider downloads immediately while preserving the lazy file-source contract. */
68
+ export function prefetchInboundFiles(message, { signal } = {}) {
69
+ const sources = fileSources(message);
70
+ if (sources.length === 0) return message;
71
+ return {
72
+ ...message,
73
+ files: sources.map((source) => {
74
+ if (source?.data !== undefined || typeof source?.load !== 'function') return source;
75
+ let download;
76
+ try {
77
+ download = Promise.resolve(source.load({ signal }));
78
+ } catch (error) {
79
+ download = Promise.reject(error);
80
+ }
81
+ download.catch(() => undefined);
82
+ return {
83
+ ...source,
84
+ async load({ signal: loadSignal } = {}) {
85
+ loadSignal?.throwIfAborted();
86
+ const result = await download;
87
+ loadSignal?.throwIfAborted();
88
+ return result;
89
+ },
90
+ };
91
+ }),
92
+ };
93
+ }
94
+
95
+ export async function stageInboundFiles(message, {
96
+ workspace,
97
+ signal,
98
+ } = {}) {
99
+ const sources = fileSources(message);
100
+ if (sources.length === 0) return null;
101
+ if (typeof workspace !== 'string' || !isAbsolute(workspace)) {
102
+ throw new InboundFileError(
103
+ 'inbound-file-workspace-unavailable',
104
+ 'The Harness Session workspace is unavailable for inbound files.',
105
+ );
106
+ }
107
+
108
+ signal?.throwIfAborted();
109
+ const root = resolve(workspace, FILES_DIRECTORY);
110
+ await mkdir(root, { recursive: true, mode: 0o700 });
111
+ const directory = await mkdtemp(join(root, 'turn-'));
112
+ const files = [];
113
+
114
+ try {
115
+ for (const [index, source] of sources.entries()) {
116
+ signal?.throwIfAborted();
117
+ let value;
118
+ try {
119
+ value = source?.data === undefined
120
+ ? await source?.load?.({ signal })
121
+ : source.data;
122
+ } catch (error) {
123
+ if (signal?.aborted) throw error;
124
+ throw new InboundFileError(
125
+ 'inbound-file-download-failed',
126
+ `Unable to download inbound file ${index + 1}: ${error?.message ?? String(error)}`,
127
+ '文件下载失败,请重新发送后再试。',
128
+ { cause: error },
129
+ );
130
+ }
131
+
132
+ const loaded = loadedFile(value);
133
+ if (!loaded) {
134
+ throw new InboundFileError(
135
+ 'inbound-file-data-invalid',
136
+ `Inbound file ${index + 1} returned no readable data.`,
137
+ );
138
+ }
139
+ const name = displayName(loaded.name ?? source?.name, `file-${index + 1}`);
140
+ const path = join(directory, storageName(name, index));
141
+ if (loaded.data) {
142
+ await writeFile(path, loaded.data, { mode: 0o600, signal });
143
+ } else {
144
+ try {
145
+ await pipeline(
146
+ loaded.stream,
147
+ createWriteStream(path, { flags: 'wx', mode: 0o600 }),
148
+ { signal },
149
+ );
150
+ } catch (error) {
151
+ if (signal?.aborted) throw error;
152
+ throw new InboundFileError(
153
+ 'inbound-file-download-failed',
154
+ `Unable to stream inbound file ${index + 1}: ${error?.message ?? String(error)}`,
155
+ '文件下载失败,请重新发送后再试。',
156
+ { cause: error },
157
+ );
158
+ }
159
+ }
160
+ const relativePath = relative(resolve(workspace), path);
161
+ if (!relativePath || relativePath.startsWith('..') || isAbsolute(relativePath)) {
162
+ throw new InboundFileError(
163
+ 'inbound-file-path-invalid',
164
+ 'The staged inbound file escaped the Harness Session workspace.',
165
+ );
166
+ }
167
+ files.push(Object.freeze({
168
+ name,
169
+ path: relativePath,
170
+ ...(typeof (loaded.mediaType ?? source?.mediaType) === 'string'
171
+ && (loaded.mediaType ?? source.mediaType).trim()
172
+ ? { mediaType: (loaded.mediaType ?? source.mediaType).trim() }
173
+ : {}),
174
+ }));
175
+ }
176
+ return Object.freeze({
177
+ files: Object.freeze(files),
178
+ async cleanup() {
179
+ await rm(directory, { recursive: true, force: true });
180
+ },
181
+ });
182
+ } catch (error) {
183
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
184
+ throw error;
185
+ }
186
+ }
187
+
188
+ export function appendInboundFilesToPrompt(prompt, staged) {
189
+ if (!staged?.files?.length) return prompt;
190
+ const manifest = [
191
+ '<dsh_im_files>',
192
+ JSON.stringify({
193
+ description: 'Files uploaded with this user message. Paths are relative to the current Harness workspace.',
194
+ files: staged.files,
195
+ }),
196
+ '</dsh_im_files>',
197
+ ].join('\n');
198
+
199
+ if (Array.isArray(prompt)) return [...prompt, { type: 'text', text: manifest }];
200
+ const text = typeof prompt === 'string' ? prompt.trim() : '';
201
+ return text ? `${text}\n\n${manifest}` : manifest;
202
+ }
203
+
204
+ export function inboundFileUserMessage(error) {
205
+ return error instanceof InboundFileError ? error.userMessage : null;
206
+ }
@@ -23,6 +23,10 @@ import {
23
23
  imagePromptUserMessage,
24
24
  promptContentForMessage,
25
25
  } from './image-prompt.mjs';
26
+ import {
27
+ hasInboundFiles,
28
+ inboundFileUserMessage,
29
+ } from './inbound-file.mjs';
26
30
  import {
27
31
  harnessAnswerForQuestion,
28
32
  harnessQuestionText,
@@ -50,6 +54,7 @@ function canClaimInteractionReply(message, pending, senderId) {
50
54
  return pending.actor === senderId
51
55
  && (message.kind !== 'group' || message.addressed === true)
52
56
  && !hasInboundImages(message)
57
+ && !hasInboundFiles(message)
53
58
  && Boolean(cleanText(message.content));
54
59
  }
55
60
 
@@ -171,7 +176,7 @@ export class TextHarnessBridge {
171
176
  const key = `${kind}:${conversationId}`;
172
177
  const pending = this.#pendingInteractions.get(key);
173
178
  const text = cleanText(normalized.content);
174
- const commandRunner = isControlCommand(text)
179
+ const commandRunner = hasInboundFiles(normalized) ? null : isControlCommand(text)
175
180
  ? runControlCommand
176
181
  : (isModelCommand(text)
177
182
  ? runModelCommand
@@ -194,7 +199,7 @@ export class TextHarnessBridge {
194
199
  key,
195
200
  actor: senderId,
196
201
  messageId,
197
- text: hasInboundImages(normalized) ? '' : normalized.content,
202
+ text: hasInboundImages(normalized) || hasInboundFiles(normalized) ? '' : normalized.content,
198
203
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
199
204
  hasPendingQuestion: Boolean(pending),
200
205
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -300,6 +305,7 @@ export class TextHarnessBridge {
300
305
  {
301
306
  signal: this.#signal,
302
307
  hasImages: hasInboundImages(message),
308
+ hasFiles: hasInboundFiles(message),
303
309
  pendingInteraction: this.#pendingInteractions.has(key)
304
310
  || this.#approvals.hasPending(key),
305
311
  control: { owner: this, key },
@@ -423,16 +429,17 @@ export class TextHarnessBridge {
423
429
  return;
424
430
  }
425
431
  const hasImages = hasInboundImages(message);
426
- if (!text && !hasImages) {
427
- await this.#bot.sendText(target, '目前支持文字和图片消息。');
432
+ const hasFiles = hasInboundFiles(message);
433
+ if (!text && !hasImages && !hasFiles) {
434
+ await this.#bot.sendText(target, '目前支持文字、图片和文件消息。');
428
435
  return;
429
436
  }
430
437
  const command = text.toLowerCase();
431
- if (!hasImages && command === '/help') {
438
+ if (!hasImages && !hasFiles && command === '/help') {
432
439
  await this.#bot.sendText(target, [
433
440
  `${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
434
441
  '',
435
- '直接发送文字或图片即可继续当前会话。',
442
+ '直接发送文字、图片或文件即可继续当前会话。',
436
443
  '/new 开启一个全新会话',
437
444
  '/compact 压缩当前会话的较早上下文',
438
445
  '/workspace 工作区绝对路径 切换工作区',
@@ -453,12 +460,12 @@ export class TextHarnessBridge {
453
460
  ].join('\n'));
454
461
  return;
455
462
  }
456
- if (!hasImages && command === '/status') {
463
+ if (!hasImages && !hasFiles && command === '/status') {
457
464
  await this.#harness.ensureRunning({ signal: this.#signal });
458
465
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
459
466
  return;
460
467
  }
461
- const workspaceCommand = !hasImages
468
+ const workspaceCommand = !hasImages && !hasFiles
462
469
  ? await runWorkspaceCommand(text, this.#harness, conversationKey)
463
470
  : null;
464
471
  if (workspaceCommand) {
@@ -467,12 +474,12 @@ export class TextHarnessBridge {
467
474
  }
468
475
  return;
469
476
  }
470
- if (!hasImages && command === '/new') {
477
+ if (!hasImages && !hasFiles && command === '/new') {
471
478
  await this.#state.clearSession(conversationKey);
472
479
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
473
480
  return;
474
481
  }
475
- const compactCommand = !hasImages
482
+ const compactCommand = !hasImages && !hasFiles
476
483
  ? await runCompactCommand(
477
484
  text,
478
485
  this.#harness,
@@ -527,6 +534,7 @@ export class TextHarnessBridge {
527
534
  requiresMention: message.kind === 'group',
528
535
  }),
529
536
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
537
+ files: message.files,
530
538
  },
531
539
  });
532
540
  const visibleAnswer = !cleanText(answer) && artifacts.length > 0
@@ -600,6 +608,18 @@ export class TextHarnessBridge {
600
608
  }
601
609
  return;
602
610
  }
611
+ const fileErrorMessage = inboundFileUserMessage(error);
612
+ if (fileErrorMessage) {
613
+ try {
614
+ await this.#bot.sendText(target, fileErrorMessage);
615
+ } catch (sendError) {
616
+ this.#logger.error?.(
617
+ `[dsh-im:${this.#descriptor.key}] failed to send the file error reply:`,
618
+ sendError,
619
+ );
620
+ }
621
+ return;
622
+ }
603
623
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
604
624
  try {
605
625
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@@ -642,7 +662,7 @@ export class TextHarnessBridge {
642
662
 
643
663
  const target = message.replyTarget;
644
664
  const text = cleanText(message.content);
645
- if (!text || hasInboundImages(message)) {
665
+ if (!text || hasInboundImages(message) || hasInboundFiles(message)) {
646
666
  try {
647
667
  await this.#bot.sendText(target, '请用文字回答当前问题。');
648
668
  } catch (error) {
@@ -1,3 +1,4 @@
1
+ import { fetchFileStream } from '../shared/file-download.mjs';
1
2
  import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
2
3
 
3
4
  const DEFAULT_BASE_URL = 'https://slack.com/api/';
@@ -232,6 +233,18 @@ export class SlackApi {
232
233
  });
233
234
  }
234
235
 
236
+ async fileInfo({ fileId, signal } = {}) {
237
+ const value = await this.#request('files.info', {
238
+ tokenKind: 'bot',
239
+ signal,
240
+ body: { file: slackId(fileId, 'file id') },
241
+ });
242
+ if (!value?.file || typeof value.file !== 'object' || Array.isArray(value.file)) {
243
+ throw new Error('Slack files.info returned no file object');
244
+ }
245
+ return value.file;
246
+ }
247
+
235
248
  postMessage({ channelId, text, threadTs, signal }) {
236
249
  return this.#request('chat.postMessage', {
237
250
  tokenKind: 'bot',
@@ -396,6 +409,14 @@ export class SlackApi {
396
409
  }
397
410
 
398
411
  async downloadFile({ url, signal, maxBytes }) {
412
+ return this.#downloadFile({ url, signal, maxBytes, stream: false });
413
+ }
414
+
415
+ async downloadFileStream({ url, signal }) {
416
+ return this.#downloadFile({ url, signal, stream: true });
417
+ }
418
+
419
+ async #downloadFile({ url, signal, maxBytes, stream }) {
399
420
  if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
400
421
  const target = secureSlackFileUrl(url);
401
422
  const fetchSlackFile = async (requestUrl, options) => {
@@ -414,13 +435,15 @@ export class SlackApi {
414
435
  }
415
436
  return response;
416
437
  };
417
- return fetchImageBuffer(target, {
438
+ const options = {
418
439
  fetchImpl: fetchSlackFile,
419
440
  headers: { authorization: `Bearer ${this.#botToken}` },
420
441
  signal,
421
- maxBytes,
422
442
  allowedHosts: SLACK_FILE_HOSTS,
423
- });
443
+ };
444
+ return stream
445
+ ? fetchFileStream(target, options)
446
+ : fetchImageBuffer(target, { ...options, maxBytes });
424
447
  }
425
448
 
426
449
  async #request(method, {
@@ -432,7 +455,7 @@ export class SlackApi {
432
455
  }) {
433
456
  const token = tokenKind === 'app' ? this.#appToken : this.#botToken;
434
457
  if (!token) throw new TypeError(`Slack ${tokenKind} token is required for ${method}`);
435
- const formEncoded = method === 'files.getUploadURLExternal';
458
+ const formEncoded = method === 'files.getUploadURLExternal' || method === 'files.info';
436
459
  let response;
437
460
  try {
438
461
  response = await this.#fetch(new URL(method, this.#baseUrl), {
@@ -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,