@xmanrui/dsh-im 0.9.0 → 0.10.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.
@@ -0,0 +1,268 @@
1
+ const DEFAULT_MAX_IMAGE_BYTES = 5 * 1024 * 1024;
2
+ const DEFAULT_MAX_IMAGES = 20;
3
+ const DEFAULT_MAX_TOTAL_IMAGE_BYTES = 20 * 1024 * 1024;
4
+
5
+ export const DEFAULT_IMAGE_PROMPT = '请分析这张图片。';
6
+
7
+ export class ImagePromptError extends Error {
8
+ constructor(code, message, userMessage, options = {}) {
9
+ super(message, options);
10
+ this.name = 'ImagePromptError';
11
+ this.code = code;
12
+ this.userMessage = userMessage;
13
+ }
14
+ }
15
+
16
+ function requestSignal(signal, timeoutMs) {
17
+ const timeout = AbortSignal.timeout(timeoutMs);
18
+ return signal ? AbortSignal.any([signal, timeout]) : timeout;
19
+ }
20
+
21
+ async function cancelResponseBody(response) {
22
+ try {
23
+ await response?.body?.cancel?.();
24
+ } catch {
25
+ // The original download error is more useful than a best-effort cleanup failure.
26
+ }
27
+ }
28
+
29
+ export async function fetchImageBuffer(url, {
30
+ fetchImpl = fetch,
31
+ headers,
32
+ signal,
33
+ maxBytes = DEFAULT_MAX_IMAGE_BYTES,
34
+ timeoutMs = 15_000,
35
+ allowedHosts,
36
+ } = {}) {
37
+ const target = new URL(url);
38
+ if (target.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
39
+ if (Array.isArray(allowedHosts) && !allowedHosts.some((rule) => (
40
+ typeof rule === 'string'
41
+ && (target.hostname === rule
42
+ || (rule.startsWith('.')
43
+ && (target.hostname === rule.slice(1) || target.hostname.endsWith(rule))))
44
+ ))) {
45
+ throw new Error('Image download URL is not hosted by the messaging platform');
46
+ }
47
+ const response = await fetchImpl(target, {
48
+ method: 'GET',
49
+ headers,
50
+ signal: requestSignal(signal, timeoutMs),
51
+ redirect: 'manual',
52
+ });
53
+ if (Number.isInteger(response?.status) && response.status >= 300 && response.status < 400) {
54
+ await cancelResponseBody(response);
55
+ throw new ImagePromptError(
56
+ 'image-redirect-blocked',
57
+ `Image download redirect was blocked (HTTP ${response.status})`,
58
+ '图片下载地址发生了重定向,暂时无法读取。',
59
+ );
60
+ }
61
+ if (!response?.ok) {
62
+ await cancelResponseBody(response);
63
+ throw new ImagePromptError(
64
+ 'image-http-error',
65
+ `Image download failed with HTTP ${response?.status ?? 'unknown'}`,
66
+ `图片下载失败(HTTP ${response?.status ?? 'unknown'}),请重新发送后再试。`,
67
+ );
68
+ }
69
+ const declaredLength = Number(response.headers?.get?.('content-length'));
70
+ if (Number.isFinite(declaredLength) && declaredLength > maxBytes) {
71
+ await cancelResponseBody(response);
72
+ throw new ImagePromptError(
73
+ 'image-too-large',
74
+ `Image response declares ${declaredLength} bytes; the limit is ${maxBytes}`,
75
+ '图片超过 5 MB,请压缩后重试。',
76
+ );
77
+ }
78
+
79
+ if (response.body?.[Symbol.asyncIterator]) {
80
+ const chunks = [];
81
+ let size = 0;
82
+ for await (const chunk of response.body) {
83
+ const data = Buffer.from(chunk);
84
+ size += data.length;
85
+ if (size > maxBytes) {
86
+ await response.body.cancel?.().catch?.(() => undefined);
87
+ throw new ImagePromptError(
88
+ 'image-too-large',
89
+ `Image response exceeded ${maxBytes} bytes`,
90
+ '图片超过 5 MB,请压缩后重试。',
91
+ );
92
+ }
93
+ chunks.push(data);
94
+ }
95
+ return Buffer.concat(chunks, size);
96
+ }
97
+
98
+ const data = Buffer.from(await response.arrayBuffer());
99
+ if (data.length > maxBytes) {
100
+ throw new ImagePromptError(
101
+ 'image-too-large',
102
+ `Image response contains ${data.length} bytes; the limit is ${maxBytes}`,
103
+ '图片超过 5 MB,请压缩后重试。',
104
+ );
105
+ }
106
+ return data;
107
+ }
108
+
109
+ function cleanText(value) {
110
+ return typeof value === 'string' ? value.trim() : '';
111
+ }
112
+
113
+ function imageSources(message) {
114
+ return Array.isArray(message?.images) ? message.images.filter(Boolean) : [];
115
+ }
116
+
117
+ function safeName(value) {
118
+ if (typeof value !== 'string') return undefined;
119
+ const name = value
120
+ .replaceAll('\\', '/')
121
+ .split('/')
122
+ .at(-1)
123
+ ?.replace(/[\u0000-\u001f\u007f]/g, '')
124
+ .trim()
125
+ .slice(0, 255);
126
+ return name || undefined;
127
+ }
128
+
129
+ function detectedImageMediaType(data) {
130
+ if (data.length >= 8
131
+ && data[0] === 0x89 && data[1] === 0x50 && data[2] === 0x4e && data[3] === 0x47
132
+ && data[4] === 0x0d && data[5] === 0x0a && data[6] === 0x1a && data[7] === 0x0a) {
133
+ return 'image/png';
134
+ }
135
+ if (data.length >= 3 && data[0] === 0xff && data[1] === 0xd8 && data[2] === 0xff) {
136
+ return 'image/jpeg';
137
+ }
138
+ if (data.length >= 6) {
139
+ const signature = data.subarray(0, 6).toString('ascii');
140
+ if (signature === 'GIF87a' || signature === 'GIF89a') return 'image/gif';
141
+ }
142
+ if (data.length >= 12
143
+ && data.subarray(0, 4).toString('ascii') === 'RIFF'
144
+ && data.subarray(8, 12).toString('ascii') === 'WEBP') {
145
+ return 'image/webp';
146
+ }
147
+ return null;
148
+ }
149
+
150
+ function loadedImage(value) {
151
+ if (Buffer.isBuffer(value) || value instanceof Uint8Array) {
152
+ return { data: Buffer.from(value) };
153
+ }
154
+ const raw = value?.data ?? value?.buffer;
155
+ if (Buffer.isBuffer(raw) || raw instanceof Uint8Array) {
156
+ return {
157
+ data: Buffer.from(raw),
158
+ name: value?.name ?? value?.filename,
159
+ };
160
+ }
161
+ return null;
162
+ }
163
+
164
+ export function hasInboundImages(message) {
165
+ return imageSources(message).length > 0;
166
+ }
167
+
168
+ export function hasInboundPrompt(message) {
169
+ return Boolean(cleanText(message?.content)) || hasInboundImages(message);
170
+ }
171
+
172
+ export async function promptContentForMessage(message, {
173
+ signal,
174
+ maxImageBytes = DEFAULT_MAX_IMAGE_BYTES,
175
+ maxImages = DEFAULT_MAX_IMAGES,
176
+ maxTotalImageBytes = DEFAULT_MAX_TOTAL_IMAGE_BYTES,
177
+ } = {}) {
178
+ const sources = imageSources(message);
179
+ if (sources.length > maxImages) {
180
+ throw new ImagePromptError(
181
+ 'too-many-images',
182
+ `Image message contains ${sources.length} images; the limit is ${maxImages}`,
183
+ `一次最多只能处理 ${maxImages} 张图片。`,
184
+ );
185
+ }
186
+
187
+ const text = cleanText(message?.content);
188
+ const content = [];
189
+ let totalImageBytes = 0;
190
+ if (text) content.push({ type: 'text', text });
191
+ else if (sources.length > 0) content.push({ type: 'text', text: DEFAULT_IMAGE_PROMPT });
192
+
193
+ for (const [index, source] of sources.entries()) {
194
+ signal?.throwIfAborted();
195
+ if (Number.isFinite(source?.size) && source.size > maxImageBytes) {
196
+ throw new ImagePromptError(
197
+ 'image-too-large',
198
+ `Image ${index + 1} declares ${source.size} bytes; the limit is ${maxImageBytes}`,
199
+ '图片超过 5 MB,请压缩后重试。',
200
+ );
201
+ }
202
+ if (Number.isFinite(source?.size) && totalImageBytes + source.size > maxTotalImageBytes) {
203
+ throw new ImagePromptError(
204
+ 'images-too-large',
205
+ `Images declare more than ${maxTotalImageBytes} bytes in total`,
206
+ '一次发送的图片总大小过大,请减少图片数量或压缩后重试。',
207
+ );
208
+ }
209
+
210
+ let result;
211
+ try {
212
+ result = source?.data === undefined
213
+ ? await source?.load?.({ signal, maxBytes: maxImageBytes })
214
+ : source.data;
215
+ } catch (error) {
216
+ if (signal?.aborted || error?.name === 'AbortError' || error?.name === 'TimeoutError') throw error;
217
+ if (error instanceof ImagePromptError) throw error;
218
+ throw new ImagePromptError(
219
+ 'image-download-failed',
220
+ `Unable to download image ${index + 1}: ${error?.message ?? String(error)}`,
221
+ '图片下载失败,请重新发送后再试。',
222
+ { cause: error },
223
+ );
224
+ }
225
+ const loaded = loadedImage(result);
226
+ if (!loaded?.data.length) {
227
+ throw new ImagePromptError(
228
+ 'invalid-image-data',
229
+ `Image ${index + 1} returned no data`,
230
+ '未能读取图片内容,请重新发送。',
231
+ );
232
+ }
233
+ if (loaded.data.length > maxImageBytes) {
234
+ throw new ImagePromptError(
235
+ 'image-too-large',
236
+ `Image ${index + 1} contains ${loaded.data.length} bytes; the limit is ${maxImageBytes}`,
237
+ '图片超过 5 MB,请压缩后重试。',
238
+ );
239
+ }
240
+ if (totalImageBytes + loaded.data.length > maxTotalImageBytes) {
241
+ throw new ImagePromptError(
242
+ 'images-too-large',
243
+ `Images contain more than ${maxTotalImageBytes} bytes in total`,
244
+ '一次发送的图片总大小过大,请减少图片数量或压缩后重试。',
245
+ );
246
+ }
247
+ totalImageBytes += loaded.data.length;
248
+ const mediaType = detectedImageMediaType(loaded.data);
249
+ if (!mediaType) {
250
+ throw new ImagePromptError(
251
+ 'unsupported-image-type',
252
+ `Image ${index + 1} is not JPEG, PNG, GIF, or WebP`,
253
+ '暂不支持该图片格式,请发送 JPEG、PNG、WebP 或 GIF 图片。',
254
+ );
255
+ }
256
+ content.push({
257
+ type: 'image',
258
+ mediaType,
259
+ data: loaded.data.toString('base64'),
260
+ ...(safeName(loaded.name ?? source?.name) ? { name: safeName(loaded.name ?? source?.name) } : {}),
261
+ });
262
+ }
263
+ return content;
264
+ }
265
+
266
+ export function imagePromptUserMessage(error) {
267
+ return error instanceof ImagePromptError ? error.userMessage : null;
268
+ }
@@ -2,6 +2,11 @@ import { runWorkspaceCommand } from './workspace-command.mjs';
2
2
  import { runCompactCommand } from './compact-command.mjs';
3
3
  import { askInWorkspaceSession } from './workspace-session.mjs';
4
4
  import { HarnessApprovalQueue } from './harness-approval.mjs';
5
+ import {
6
+ hasInboundImages,
7
+ imagePromptUserMessage,
8
+ promptContentForMessage,
9
+ } from './image-prompt.mjs';
5
10
  import {
6
11
  harnessAnswerForQuestion,
7
12
  harnessQuestionText,
@@ -17,6 +22,7 @@ function cleanText(value) {
17
22
  function canClaimInteractionReply(message, pending, senderId) {
18
23
  return pending.actor === senderId
19
24
  && (message.kind !== 'group' || message.addressed === true)
25
+ && !hasInboundImages(message)
20
26
  && Boolean(cleanText(message.content));
21
27
  }
22
28
 
@@ -98,7 +104,7 @@ export class TextHarnessBridge {
98
104
  key,
99
105
  actor: senderId,
100
106
  messageId,
101
- text: normalized.content,
107
+ text: hasInboundImages(normalized) ? '' : normalized.content,
102
108
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
103
109
  hasPendingQuestion: Boolean(pending),
104
110
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -208,16 +214,17 @@ export class TextHarnessBridge {
208
214
  this.#status.lastRejectedAt = new Date().toISOString();
209
215
  return;
210
216
  }
211
- if (!text) {
212
- await this.#bot.sendText(target, '目前仅支持文字消息。');
217
+ const hasImages = hasInboundImages(message);
218
+ if (!text && !hasImages) {
219
+ await this.#bot.sendText(target, '目前支持文字和图片消息。');
213
220
  return;
214
221
  }
215
222
  const command = text.toLowerCase();
216
- if (command === '/help') {
223
+ if (!hasImages && command === '/help') {
217
224
  await this.#bot.sendText(target, [
218
225
  `${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
219
226
  '',
220
- '直接发送文字即可继续当前会话。',
227
+ '直接发送文字或图片即可继续当前会话。',
221
228
  '/new 开启一个全新会话',
222
229
  '/compact 压缩当前会话的较早上下文',
223
230
  '/workspace 工作区绝对路径 切换工作区',
@@ -229,30 +236,34 @@ export class TextHarnessBridge {
229
236
  ].join('\n'));
230
237
  return;
231
238
  }
232
- if (command === '/status') {
239
+ if (!hasImages && command === '/status') {
233
240
  await this.#harness.ensureRunning({ signal: this.#signal });
234
241
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
235
242
  return;
236
243
  }
237
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
244
+ const workspaceCommand = !hasImages
245
+ ? await runWorkspaceCommand(text, this.#harness, conversationKey)
246
+ : null;
238
247
  if (workspaceCommand) {
239
248
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
240
249
  await this.#bot.sendText(target, reply);
241
250
  }
242
251
  return;
243
252
  }
244
- if (command === '/new') {
253
+ if (!hasImages && command === '/new') {
245
254
  await this.#state.clearSession(conversationKey);
246
255
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
247
256
  return;
248
257
  }
249
- const compactCommand = await runCompactCommand(
250
- text,
251
- this.#harness,
252
- this.#state,
253
- conversationKey,
254
- { signal: this.#signal },
255
- );
258
+ const compactCommand = !hasImages
259
+ ? await runCompactCommand(
260
+ text,
261
+ this.#harness,
262
+ this.#state,
263
+ conversationKey,
264
+ { signal: this.#signal },
265
+ )
266
+ : null;
256
267
  if (compactCommand) {
257
268
  await this.#bot.sendText(target, compactCommand.message);
258
269
  return;
@@ -272,11 +283,15 @@ export class TextHarnessBridge {
272
283
  );
273
284
  }
274
285
  }
286
+ const content = hasImages
287
+ ? await promptContentForMessage(message, { signal: this.#signal })
288
+ : undefined;
275
289
  const { answer } = await askInWorkspaceSession({
276
290
  harness: this.#harness,
277
291
  state: this.#state,
278
292
  key: conversationKey,
279
293
  text,
294
+ content,
280
295
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
281
296
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
282
297
  askOptions: {
@@ -316,6 +331,18 @@ export class TextHarnessBridge {
316
331
  stream?.cancel?.();
317
332
  if (this.#signal?.aborted) return;
318
333
  this.#status.lastError = error?.message ?? String(error);
334
+ const imageErrorMessage = imagePromptUserMessage(error);
335
+ if (imageErrorMessage) {
336
+ try {
337
+ await this.#bot.sendText(target, imageErrorMessage);
338
+ } catch (sendError) {
339
+ this.#logger.error?.(
340
+ `[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
341
+ sendError,
342
+ );
343
+ }
344
+ return;
345
+ }
319
346
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
320
347
  try {
321
348
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@@ -358,7 +385,7 @@ export class TextHarnessBridge {
358
385
 
359
386
  const target = message.replyTarget;
360
387
  const text = cleanText(message.content);
361
- if (!text) {
388
+ if (!text || hasInboundImages(message)) {
362
389
  try {
363
390
  await this.#bot.sendText(target, '请用文字回答当前问题。');
364
391
  } catch (error) {
@@ -33,6 +33,7 @@ export async function askInWorkspaceSession({
33
33
  state,
34
34
  key,
35
35
  text,
36
+ content,
36
37
  createOptions,
37
38
  existsOptions,
38
39
  askOptions,
@@ -48,7 +49,7 @@ export async function askInWorkspaceSession({
48
49
  }
49
50
  return {
50
51
  sessionId,
51
- answer: await session.ask(text, askOptions),
52
+ answer: await session.ask(content ?? text, askOptions),
52
53
  };
53
54
  } catch (error) {
54
55
  if (error?.code !== WORKSPACE_SESSION_STALE) throw error;
@@ -17,6 +17,7 @@ oauth_config:
17
17
  bot:
18
18
  - app_mentions:read
19
19
  - chat:write
20
+ - files:read
20
21
  - im:history
21
22
  settings:
22
23
  event_subscriptions:
@@ -1,4 +1,10 @@
1
+ import { fetchImageBuffer, ImagePromptError } from '../shared/image-prompt.mjs';
2
+
1
3
  const DEFAULT_BASE_URL = 'https://slack.com/api/';
4
+ const SLACK_FILE_HOST = 'files.slack.com';
5
+ const LEGACY_SLACK_FILE_HOST = 'slack.com';
6
+ const SLACK_FILE_PATH_PREFIX = '/files-pri/';
7
+ const SLACK_FILE_HOSTS = Object.freeze([SLACK_FILE_HOST]);
2
8
 
3
9
  function cleanString(value) {
4
10
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -9,6 +15,61 @@ function requestSignal(signal, timeoutMs) {
9
15
  return signal ? AbortSignal.any([signal, timeout]) : timeout;
10
16
  }
11
17
 
18
+ function isRedirectStatus(status) {
19
+ return Number.isInteger(status) && status >= 300 && status < 400;
20
+ }
21
+
22
+ async function cancelResponseBody(response) {
23
+ try {
24
+ await response?.body?.cancel?.();
25
+ } catch {
26
+ // Preserve the download failure when response cleanup also fails.
27
+ }
28
+ }
29
+
30
+ function secureSlackFileUrl(value) {
31
+ const url = new URL(value);
32
+ if (url.protocol !== 'https:') throw new Error('Image download URL must use HTTPS');
33
+ if (url.username || url.password || (url.port && url.port !== '443')) {
34
+ throw new Error('Image download URL is not hosted by the messaging platform');
35
+ }
36
+ if (url.hostname === LEGACY_SLACK_FILE_HOST && url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
37
+ url.hostname = SLACK_FILE_HOST;
38
+ }
39
+ if (url.hostname !== SLACK_FILE_HOST || !url.pathname.startsWith(SLACK_FILE_PATH_PREFIX)) {
40
+ throw new Error('Image download URL is not hosted by the messaging platform');
41
+ }
42
+ url.hash = '';
43
+ return url;
44
+ }
45
+
46
+ function redirectUrl(response, source) {
47
+ const location = response?.headers?.get?.('location');
48
+ if (!location) return null;
49
+ try {
50
+ return new URL(location, source);
51
+ } catch {
52
+ return null;
53
+ }
54
+ }
55
+
56
+ function responseOAuthScopes(response) {
57
+ const raw = response?.headers?.get?.('x-oauth-scopes');
58
+ if (raw === null || raw === undefined) return null;
59
+ return new Set(raw.split(',').map((scope) => scope.trim()).filter(Boolean));
60
+ }
61
+
62
+ function isSlackWorkspaceRedirect(target) {
63
+ return target?.protocol === 'https:'
64
+ && !target.username
65
+ && !target.password
66
+ && (!target.port || target.port === '443')
67
+ && target.hostname.endsWith('.slack.com')
68
+ && target.hostname !== SLACK_FILE_HOST
69
+ && target.pathname === '/'
70
+ && target.searchParams.has('redir');
71
+ }
72
+
12
73
  function delay(ms, signal) {
13
74
  return new Promise((resolve, reject) => {
14
75
  if (signal?.aborted) {
@@ -79,6 +140,7 @@ export class SlackApi {
79
140
  #appToken;
80
141
  #fetch;
81
142
  #baseUrl;
143
+ #botScopes = null;
82
144
 
83
145
  constructor({ botToken, appToken, fetchImpl = fetch, baseUrl = DEFAULT_BASE_URL }) {
84
146
  if (botToken !== undefined && !validSlackBotToken(botToken)) {
@@ -177,6 +239,34 @@ export class SlackApi {
177
239
  });
178
240
  }
179
241
 
242
+ async downloadFile({ url, signal, maxBytes }) {
243
+ if (!this.#botToken) throw new TypeError('Slack bot token is required for file download');
244
+ const target = secureSlackFileUrl(url);
245
+ const fetchSlackFile = async (requestUrl, options) => {
246
+ const response = await this.#fetch(requestUrl, options);
247
+ if (!isRedirectStatus(response?.status)) return response;
248
+
249
+ const next = redirectUrl(response, requestUrl);
250
+ if ((this.#botScopes && !this.#botScopes.has('files:read'))
251
+ || isSlackWorkspaceRedirect(next)) {
252
+ await cancelResponseBody(response);
253
+ throw new ImagePromptError(
254
+ 'slack-file-access-required',
255
+ 'Slack redirected a private file request to the workspace because file access was not granted',
256
+ 'Slack 未授权机器人读取该文件。请为应用添加 files:read 后重新安装,再重新发送图片。',
257
+ );
258
+ }
259
+ return response;
260
+ };
261
+ return fetchImageBuffer(target, {
262
+ fetchImpl: fetchSlackFile,
263
+ headers: { authorization: `Bearer ${this.#botToken}` },
264
+ signal,
265
+ maxBytes,
266
+ allowedHosts: SLACK_FILE_HOSTS,
267
+ });
268
+ }
269
+
180
270
  async #request(method, {
181
271
  tokenKind,
182
272
  body,
@@ -206,6 +296,11 @@ export class SlackApi {
206
296
  throw new Error(`Slack ${method} transport failed`);
207
297
  }
208
298
 
299
+ if (tokenKind === 'bot') {
300
+ const scopes = responseOAuthScopes(response);
301
+ if (scopes) this.#botScopes = scopes;
302
+ }
303
+
209
304
  let payload;
210
305
  try {
211
306
  payload = await response.json();
@@ -5,6 +5,7 @@ import { createSlackBridgeStatus, SlackHarnessBridge } from './slack-bridge.mjs'
5
5
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
6
6
  const SLACK_MESSAGE_LIMIT = 38_000;
7
7
  const SLACK_STREAM_CHUNK_LIMIT = 11_000;
8
+ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
8
9
 
9
10
  function addSocketListener(socket, event, listener) {
10
11
  if (typeof socket.addEventListener === 'function') socket.addEventListener(event, listener);
@@ -42,13 +43,28 @@ function stripBotMention(value, botUserId) {
42
43
  .trim();
43
44
  }
44
45
 
45
- export function normalizeSlackEvent(payload, botUserId) {
46
+ function slackImageSource(file, loadFile) {
47
+ 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;
50
+ if (!IMAGE_MEDIA_TYPES.has(mediaType) || typeof url !== 'string' || !url) return null;
51
+ return {
52
+ name: typeof file.name === 'string' ? file.name : undefined,
53
+ mediaType,
54
+ size: Number.isSafeInteger(file.size) && file.size >= 0 ? file.size : undefined,
55
+ load: (options) => loadFile(url, options),
56
+ };
57
+ }
58
+
59
+ export function normalizeSlackEvent(payload, botUserId, { loadFile = async () => {
60
+ throw new Error('Slack file downloader is unavailable');
61
+ } } = {}) {
46
62
  const event = payload?.event;
47
63
  if (!event || !payload?.event_id || !event.channel || !event.user || !event.ts) return null;
48
64
  const direct = event.type === 'message' && event.channel_type === 'im';
49
65
  const mentioned = event.type === 'app_mention';
50
66
  if (!direct && !mentioned) return null;
51
- if (event.subtype || event.bot_id || event.app_id) return null;
67
+ if ((event.subtype && event.subtype !== 'file_share') || event.bot_id || event.app_id) return null;
52
68
  const threadTs = String(event.thread_ts ?? event.ts);
53
69
  return {
54
70
  messageId: String(payload.event_id),
@@ -57,6 +73,9 @@ export function normalizeSlackEvent(payload, botUserId) {
57
73
  kind: direct ? 'direct' : 'group',
58
74
  conversationId: direct ? String(event.channel) : `${event.channel}:${threadTs}`,
59
75
  content: stripBotMention(event.text ?? '', botUserId),
76
+ images: Array.isArray(event.files)
77
+ ? event.files.map((file) => slackImageSource(file, loadFile)).filter(Boolean)
78
+ : [],
60
79
  addressed: direct || mentioned,
61
80
  replyTarget: {
62
81
  channelId: String(event.channel),
@@ -389,7 +408,9 @@ export class SlackRuntime {
389
408
  if (packet.type !== 'events_api' || packet.payload?.type !== 'event_callback') return;
390
409
  if (this.#appId && packet.payload.api_app_id
391
410
  && packet.payload.api_app_id !== this.#appId) return;
392
- const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1]);
411
+ const message = normalizeSlackEvent(packet.payload, this.#config.platformId.split(':')[1], {
412
+ loadFile: (url, options) => this.#api.downloadFile({ url, ...options }),
413
+ });
393
414
  const bridge = this.#bridge;
394
415
  if (message && bridge) {
395
416
  void bridge.accept(message).catch((error) => {
@@ -1,4 +1,7 @@
1
+ import { fetchImageBuffer } from '../shared/image-prompt.mjs';
2
+
1
3
  const DEFAULT_BASE_URL = 'https://api.telegram.org/';
4
+ const TELEGRAM_FILE_HOSTS = Object.freeze(['api.telegram.org']);
2
5
 
3
6
  function cleanString(value) {
4
7
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -47,6 +50,40 @@ export class TelegramApi {
47
50
  });
48
51
  }
49
52
 
53
+ async getFile({ fileId, signal } = {}) {
54
+ const id = cleanString(fileId);
55
+ if (!id || !/^[A-Za-z0-9_-]{1,512}$/.test(id)) {
56
+ throw new TypeError('Telegram file id is invalid');
57
+ }
58
+ return this.#call('getFile', { file_id: id }, { signal });
59
+ }
60
+
61
+ async downloadFile({ fileId, signal, maxBytes } = {}) {
62
+ const file = await this.getFile({ fileId, signal });
63
+ const filePath = cleanString(file?.file_path);
64
+ if (!filePath || filePath.startsWith('/') || filePath.includes('\\')
65
+ || filePath.includes('?') || filePath.includes('#')) {
66
+ throw new Error('Telegram returned an invalid file path');
67
+ }
68
+ let decodedSegments;
69
+ try {
70
+ decodedSegments = filePath.split('/').map((segment) => decodeURIComponent(segment));
71
+ } catch {
72
+ throw new Error('Telegram returned an invalid file path');
73
+ }
74
+ if (decodedSegments.some((segment) => !segment || segment === '.' || segment === '..')) {
75
+ throw new Error('Telegram returned an invalid file path');
76
+ }
77
+ const url = new URL(this.#baseUrl);
78
+ url.pathname = `/file/bot${this.#token}/${filePath}`;
79
+ return fetchImageBuffer(url, {
80
+ fetchImpl: this.#fetch,
81
+ signal,
82
+ maxBytes,
83
+ allowedHosts: TELEGRAM_FILE_HOSTS,
84
+ });
85
+ }
86
+
50
87
  async sendMessage({ chatId, text, replyToMessageId, messageThreadId, signal }) {
51
88
  return this.#call('sendMessage', {
52
89
  chat_id: chatId,