@xmanrui/dsh-im 0.8.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.
Files changed (48) hide show
  1. package/README.en.md +8 -4
  2. package/README.md +8 -4
  3. package/lib/client.js +16 -14
  4. package/lib/index.js +123 -121
  5. package/package.json +1 -1
  6. package/plugin-src/client/styles.js +15 -14
  7. package/plugin-src/host/channels/dingtalk/index.mjs +1 -1
  8. package/plugin-src/host/channels/dingtalk/production.mjs +3 -0
  9. package/plugin-src/host/channels/discord/index.mjs +1 -1
  10. package/plugin-src/host/channels/feishu/index.mjs +1 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -0
  12. package/plugin-src/host/channels/qq/index.mjs +1 -1
  13. package/plugin-src/host/channels/qq/production.mjs +3 -0
  14. package/plugin-src/host/channels/shared/production.mjs +3 -0
  15. package/plugin-src/host/channels/slack/index.mjs +1 -1
  16. package/plugin-src/host/channels/slack/production.mjs +3 -0
  17. package/plugin-src/host/channels/telegram/index.mjs +1 -1
  18. package/plugin-src/host/channels/wecom/index.mjs +1 -1
  19. package/plugin-src/host/channels/wecom/production.mjs +3 -0
  20. package/plugin-src/host/channels/weixin/index.mjs +1 -1
  21. package/plugin-src/host/channels/weixin/production.mjs +3 -0
  22. package/plugin-src/host/channels/whatsapp/index.mjs +1 -1
  23. package/plugin-src/host/channels/whatsapp/production.mjs +3 -0
  24. package/plugin-src/host/harness-command-executor.mjs +21 -0
  25. package/plugin-src/host/index.mjs +1 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +162 -13
  28. package/src/channels/discord/discord-api.mjs +1 -1
  29. package/src/channels/discord/discord-runtime.mjs +44 -1
  30. package/src/channels/feishu/bridge.mjs +45 -11
  31. package/src/channels/feishu/message-utils.mjs +142 -8
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +107 -13
  34. package/src/channels/shared/bot-workspace-store.mjs +13 -0
  35. package/src/channels/shared/compact-command.mjs +95 -0
  36. package/src/channels/shared/harness-client.mjs +32 -2
  37. package/src/channels/shared/image-prompt.mjs +268 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +49 -9
  39. package/src/channels/shared/workspace-session.mjs +2 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +95 -0
  42. package/src/channels/slack/slack-runtime.mjs +24 -3
  43. package/src/channels/telegram/telegram-api.mjs +37 -0
  44. package/src/channels/telegram/telegram-runtime.mjs +71 -9
  45. package/src/channels/wecom/wecom-bridge.mjs +163 -16
  46. package/src/channels/weixin/weixin-api.mjs +98 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +55 -12
  48. package/src/channels/whatsapp/whatsapp-runtime.mjs +144 -1
@@ -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
+ }
@@ -1,6 +1,12 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
+ import { runCompactCommand } from './compact-command.mjs';
2
3
  import { askInWorkspaceSession } from './workspace-session.mjs';
3
4
  import { HarnessApprovalQueue } from './harness-approval.mjs';
5
+ import {
6
+ hasInboundImages,
7
+ imagePromptUserMessage,
8
+ promptContentForMessage,
9
+ } from './image-prompt.mjs';
4
10
  import {
5
11
  harnessAnswerForQuestion,
6
12
  harnessQuestionText,
@@ -16,6 +22,7 @@ function cleanText(value) {
16
22
  function canClaimInteractionReply(message, pending, senderId) {
17
23
  return pending.actor === senderId
18
24
  && (message.kind !== 'group' || message.addressed === true)
25
+ && !hasInboundImages(message)
19
26
  && Boolean(cleanText(message.content));
20
27
  }
21
28
 
@@ -97,7 +104,7 @@ export class TextHarnessBridge {
97
104
  key,
98
105
  actor: senderId,
99
106
  messageId,
100
- text: normalized.content,
107
+ text: hasInboundImages(normalized) ? '' : normalized.content,
101
108
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
102
109
  hasPendingQuestion: Boolean(pending),
103
110
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -207,17 +214,19 @@ export class TextHarnessBridge {
207
214
  this.#status.lastRejectedAt = new Date().toISOString();
208
215
  return;
209
216
  }
210
- if (!text) {
211
- await this.#bot.sendText(target, '目前仅支持文字消息。');
217
+ const hasImages = hasInboundImages(message);
218
+ if (!text && !hasImages) {
219
+ await this.#bot.sendText(target, '目前支持文字和图片消息。');
212
220
  return;
213
221
  }
214
222
  const command = text.toLowerCase();
215
- if (command === '/help') {
223
+ if (!hasImages && command === '/help') {
216
224
  await this.#bot.sendText(target, [
217
225
  `${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
218
226
  '',
219
- '直接发送文字即可继续当前会话。',
227
+ '直接发送文字或图片即可继续当前会话。',
220
228
  '/new 开启一个全新会话',
229
+ '/compact 压缩当前会话的较早上下文',
221
230
  '/workspace 工作区绝对路径 切换工作区',
222
231
  '/workspacelist 列出工作区绝对路径',
223
232
  '/sessionlist [工作区序号或绝对路径] 列出会话 ID 和标题',
@@ -227,23 +236,38 @@ export class TextHarnessBridge {
227
236
  ].join('\n'));
228
237
  return;
229
238
  }
230
- if (command === '/status') {
239
+ if (!hasImages && command === '/status') {
231
240
  await this.#harness.ensureRunning({ signal: this.#signal });
232
241
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
233
242
  return;
234
243
  }
235
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
244
+ const workspaceCommand = !hasImages
245
+ ? await runWorkspaceCommand(text, this.#harness, conversationKey)
246
+ : null;
236
247
  if (workspaceCommand) {
237
248
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
238
249
  await this.#bot.sendText(target, reply);
239
250
  }
240
251
  return;
241
252
  }
242
- if (command === '/new') {
253
+ if (!hasImages && command === '/new') {
243
254
  await this.#state.clearSession(conversationKey);
244
255
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
245
256
  return;
246
257
  }
258
+ const compactCommand = !hasImages
259
+ ? await runCompactCommand(
260
+ text,
261
+ this.#harness,
262
+ this.#state,
263
+ conversationKey,
264
+ { signal: this.#signal },
265
+ )
266
+ : null;
267
+ if (compactCommand) {
268
+ await this.#bot.sendText(target, compactCommand.message);
269
+ return;
270
+ }
247
271
 
248
272
  await this.#bot.sendTyping?.(target).catch((error) => {
249
273
  this.#logger.warn?.(`[dsh-im:${this.#descriptor.key}] typing indicator failed:`, error);
@@ -259,11 +283,15 @@ export class TextHarnessBridge {
259
283
  );
260
284
  }
261
285
  }
286
+ const content = hasImages
287
+ ? await promptContentForMessage(message, { signal: this.#signal })
288
+ : undefined;
262
289
  const { answer } = await askInWorkspaceSession({
263
290
  harness: this.#harness,
264
291
  state: this.#state,
265
292
  key: conversationKey,
266
293
  text,
294
+ content,
267
295
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
268
296
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
269
297
  askOptions: {
@@ -303,6 +331,18 @@ export class TextHarnessBridge {
303
331
  stream?.cancel?.();
304
332
  if (this.#signal?.aborted) return;
305
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
+ }
306
346
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
307
347
  try {
308
348
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@@ -345,7 +385,7 @@ export class TextHarnessBridge {
345
385
 
346
386
  const target = message.replyTarget;
347
387
  const text = cleanText(message.content);
348
- if (!text) {
388
+ if (!text || hasInboundImages(message)) {
349
389
  try {
350
390
  await this.#bot.sendText(target, '请用文字回答当前问题。');
351
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,