@xmanrui/dsh-im 0.9.0 → 0.11.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 (69) hide show
  1. package/README.en.md +7 -3
  2. package/README.md +7 -3
  3. package/lib/client.js +378 -27
  4. package/lib/index.js +134 -131
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/dingtalk/api.js +19 -0
  7. package/plugin-src/client/channels/dingtalk/index.js +67 -10
  8. package/plugin-src/client/channels/feishu/index.js +35 -5
  9. package/plugin-src/client/channels/qq/api.js +21 -0
  10. package/plugin-src/client/channels/qq/index.js +39 -1
  11. package/plugin-src/client/channels/shared/token-channel.js +29 -3
  12. package/plugin-src/client/channels/wecom/api.js +11 -0
  13. package/plugin-src/client/channels/wecom/index.js +71 -1
  14. package/plugin-src/client/channels/weixin/api.js +11 -0
  15. package/plugin-src/client/channels/weixin/index.js +40 -4
  16. package/plugin-src/client/channels/whatsapp/index.js +41 -2
  17. package/plugin-src/client/i18n.js +13 -0
  18. package/plugin-src/host/channels/dingtalk/production.mjs +1 -1
  19. package/plugin-src/host/channels/dingtalk/rpc.mjs +29 -3
  20. package/plugin-src/host/channels/feishu/production.mjs +1 -1
  21. package/plugin-src/host/channels/feishu/rpc.mjs +34 -3
  22. package/plugin-src/host/channels/qq/production.mjs +1 -1
  23. package/plugin-src/host/channels/qq/rpc.mjs +35 -2
  24. package/plugin-src/host/channels/shared/production.mjs +1 -1
  25. package/plugin-src/host/channels/shared/rpc.mjs +25 -1
  26. package/plugin-src/host/channels/slack/production.mjs +1 -1
  27. package/plugin-src/host/channels/slack/rpc.mjs +28 -2
  28. package/plugin-src/host/channels/wecom/production.mjs +1 -1
  29. package/plugin-src/host/channels/wecom/rpc.mjs +31 -2
  30. package/plugin-src/host/channels/weixin/production.mjs +1 -1
  31. package/plugin-src/host/channels/weixin/rpc.mjs +29 -3
  32. package/plugin-src/host/channels/whatsapp/production.mjs +1 -1
  33. package/plugin-src/host/channels/whatsapp/rpc.mjs +31 -3
  34. package/src/channels/dingtalk/dingtalk-api.mjs +82 -1
  35. package/src/channels/dingtalk/dingtalk-bridge.mjs +160 -20
  36. package/src/channels/dingtalk/dingtalk-controller.mjs +18 -0
  37. package/src/channels/dingtalk/dingtalk-runtime.mjs +21 -0
  38. package/src/channels/discord/discord-api.mjs +1 -1
  39. package/src/channels/discord/discord-runtime.mjs +54 -1
  40. package/src/channels/feishu/bridge.mjs +45 -18
  41. package/src/channels/feishu/feishu-runtime.mjs +45 -0
  42. package/src/channels/feishu/message-utils.mjs +227 -6
  43. package/src/channels/feishu/multi-bot-controller.mjs +21 -0
  44. package/src/channels/feishu/plugin-controller.mjs +1 -0
  45. package/src/channels/qq/qq-bridge.mjs +107 -20
  46. package/src/channels/qq/qq-controller.mjs +18 -0
  47. package/src/channels/qq/qq-runtime.mjs +26 -0
  48. package/src/channels/shared/bot-workspace-store.mjs +2 -0
  49. package/src/channels/shared/connection-test.mjs +54 -0
  50. package/src/channels/shared/harness-client.mjs +14 -8
  51. package/src/channels/shared/image-prompt.mjs +268 -0
  52. package/src/channels/shared/text-harness-bridge.mjs +62 -16
  53. package/src/channels/shared/token-bot-controller.mjs +21 -0
  54. package/src/channels/shared/workspace-session.mjs +2 -1
  55. package/src/channels/slack/manifest.mjs +1 -0
  56. package/src/channels/slack/slack-api.mjs +95 -0
  57. package/src/channels/slack/slack-controller.mjs +19 -0
  58. package/src/channels/slack/slack-runtime.mjs +34 -3
  59. package/src/channels/telegram/telegram-api.mjs +37 -0
  60. package/src/channels/telegram/telegram-runtime.mjs +81 -9
  61. package/src/channels/wecom/wecom-bridge.mjs +160 -23
  62. package/src/channels/wecom/wecom-controller.mjs +18 -0
  63. package/src/channels/wecom/wecom-runtime.mjs +18 -0
  64. package/src/channels/weixin/weixin-api.mjs +98 -2
  65. package/src/channels/weixin/weixin-bridge.mjs +52 -19
  66. package/src/channels/weixin/weixin-controller.mjs +18 -0
  67. package/src/channels/weixin/weixin-runtime.mjs +25 -0
  68. package/src/channels/whatsapp/whatsapp-controller.mjs +20 -0
  69. package/src/channels/whatsapp/whatsapp-runtime.mjs +160 -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,7 +1,16 @@
1
1
  import { runWorkspaceCommand } from './workspace-command.mjs';
2
2
  import { runCompactCommand } from './compact-command.mjs';
3
+ import {
4
+ rememberConnectionTestTarget,
5
+ sendRememberedConnectionTest,
6
+ } from './connection-test.mjs';
3
7
  import { askInWorkspaceSession } from './workspace-session.mjs';
4
8
  import { HarnessApprovalQueue } from './harness-approval.mjs';
9
+ import {
10
+ hasInboundImages,
11
+ imagePromptUserMessage,
12
+ promptContentForMessage,
13
+ } from './image-prompt.mjs';
5
14
  import {
6
15
  harnessAnswerForQuestion,
7
16
  harnessQuestionText,
@@ -17,6 +26,7 @@ function cleanText(value) {
17
26
  function canClaimInteractionReply(message, pending, senderId) {
18
27
  return pending.actor === senderId
19
28
  && (message.kind !== 'group' || message.addressed === true)
29
+ && !hasInboundImages(message)
20
30
  && Boolean(cleanText(message.content));
21
31
  }
22
32
 
@@ -91,6 +101,12 @@ export class TextHarnessBridge {
91
101
  return Promise.resolve();
92
102
  }
93
103
  this.#acceptedMessageIds.add(messageId);
104
+ if (normalized.kind === 'direct') {
105
+ rememberConnectionTestTarget(
106
+ this.#state,
107
+ normalized.connectionTestTarget ?? normalized.replyTarget,
108
+ );
109
+ }
94
110
 
95
111
  const key = `${kind}:${conversationId}`;
96
112
  const pending = this.#pendingInteractions.get(key);
@@ -98,7 +114,7 @@ export class TextHarnessBridge {
98
114
  key,
99
115
  actor: senderId,
100
116
  messageId,
101
- text: normalized.content,
117
+ text: hasInboundImages(normalized) ? '' : normalized.content,
102
118
  addressed: normalized.kind !== 'group' || normalized.addressed === true,
103
119
  hasPendingQuestion: Boolean(pending),
104
120
  questionCompletion: pending?.submitting || pending?.claimedReplyMessageId
@@ -188,6 +204,15 @@ export class TextHarnessBridge {
188
204
  ]);
189
205
  }
190
206
 
207
+ sendConnectionTest(text) {
208
+ return sendRememberedConnectionTest({
209
+ state: this.#state,
210
+ text,
211
+ channelLabel: `${this.#descriptor.label}机器人`,
212
+ send: (target, message) => this.#bot.sendText(target, message),
213
+ });
214
+ }
215
+
191
216
  async #process(message, messageId, senderId, conversationKey, {
192
217
  alreadyRecorded = false,
193
218
  } = {}) {
@@ -208,16 +233,17 @@ export class TextHarnessBridge {
208
233
  this.#status.lastRejectedAt = new Date().toISOString();
209
234
  return;
210
235
  }
211
- if (!text) {
212
- await this.#bot.sendText(target, '目前仅支持文字消息。');
236
+ const hasImages = hasInboundImages(message);
237
+ if (!text && !hasImages) {
238
+ await this.#bot.sendText(target, '目前支持文字和图片消息。');
213
239
  return;
214
240
  }
215
241
  const command = text.toLowerCase();
216
- if (command === '/help') {
242
+ if (!hasImages && command === '/help') {
217
243
  await this.#bot.sendText(target, [
218
244
  `${this.#descriptor.label}机器人已连接 DeepSeek Harness。`,
219
245
  '',
220
- '直接发送文字即可继续当前会话。',
246
+ '直接发送文字或图片即可继续当前会话。',
221
247
  '/new 开启一个全新会话',
222
248
  '/compact 压缩当前会话的较早上下文',
223
249
  '/workspace 工作区绝对路径 切换工作区',
@@ -229,30 +255,34 @@ export class TextHarnessBridge {
229
255
  ].join('\n'));
230
256
  return;
231
257
  }
232
- if (command === '/status') {
258
+ if (!hasImages && command === '/status') {
233
259
  await this.#harness.ensureRunning({ signal: this.#signal });
234
260
  await this.#bot.sendText(target, `${this.#descriptor.label}机器人与 DeepSeek Harness 连接正常。`);
235
261
  return;
236
262
  }
237
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, conversationKey);
263
+ const workspaceCommand = !hasImages
264
+ ? await runWorkspaceCommand(text, this.#harness, conversationKey)
265
+ : null;
238
266
  if (workspaceCommand) {
239
267
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
240
268
  await this.#bot.sendText(target, reply);
241
269
  }
242
270
  return;
243
271
  }
244
- if (command === '/new') {
272
+ if (!hasImages && command === '/new') {
245
273
  await this.#state.clearSession(conversationKey);
246
274
  await this.#bot.sendText(target, '已开启新会话。请发送你的问题。');
247
275
  return;
248
276
  }
249
- const compactCommand = await runCompactCommand(
250
- text,
251
- this.#harness,
252
- this.#state,
253
- conversationKey,
254
- { signal: this.#signal },
255
- );
277
+ const compactCommand = !hasImages
278
+ ? await runCompactCommand(
279
+ text,
280
+ this.#harness,
281
+ this.#state,
282
+ conversationKey,
283
+ { signal: this.#signal },
284
+ )
285
+ : null;
256
286
  if (compactCommand) {
257
287
  await this.#bot.sendText(target, compactCommand.message);
258
288
  return;
@@ -272,11 +302,15 @@ export class TextHarnessBridge {
272
302
  );
273
303
  }
274
304
  }
305
+ const content = hasImages
306
+ ? await promptContentForMessage(message, { signal: this.#signal })
307
+ : undefined;
275
308
  const { answer } = await askInWorkspaceSession({
276
309
  harness: this.#harness,
277
310
  state: this.#state,
278
311
  key: conversationKey,
279
312
  text,
313
+ content,
280
314
  createOptions: this.#signal ? { signal: this.#signal } : undefined,
281
315
  existsOptions: this.#signal ? { signal: this.#signal } : undefined,
282
316
  askOptions: {
@@ -316,6 +350,18 @@ export class TextHarnessBridge {
316
350
  stream?.cancel?.();
317
351
  if (this.#signal?.aborted) return;
318
352
  this.#status.lastError = error?.message ?? String(error);
353
+ const imageErrorMessage = imagePromptUserMessage(error);
354
+ if (imageErrorMessage) {
355
+ try {
356
+ await this.#bot.sendText(target, imageErrorMessage);
357
+ } catch (sendError) {
358
+ this.#logger.error?.(
359
+ `[dsh-im:${this.#descriptor.key}] failed to send the image error reply:`,
360
+ sendError,
361
+ );
362
+ }
363
+ return;
364
+ }
319
365
  this.#logger.error?.(`[dsh-im:${this.#descriptor.key}] failed to process a message:`, error);
320
366
  try {
321
367
  await this.#bot.sendText(target, '消息处理失败,请稍后重试。');
@@ -358,7 +404,7 @@ export class TextHarnessBridge {
358
404
 
359
405
  const target = message.replyTarget;
360
406
  const text = cleanText(message.content);
361
- if (!text) {
407
+ if (!text || hasInboundImages(message)) {
362
408
  try {
363
409
  await this.#bot.sendText(target, '请用文字回答当前问题。');
364
410
  } catch (error) {
@@ -1,3 +1,5 @@
1
+ import { connectionTestMessage } from './connection-test.mjs';
2
+
1
3
  function cleanString(value) {
2
4
  return typeof value === 'string' && value.trim() ? value.trim() : null;
3
5
  }
@@ -161,6 +163,25 @@ export class TokenBotController {
161
163
  return this.status();
162
164
  }
163
165
 
166
+ async sendConnectionTest(botId) {
167
+ const config = this.#configStore.get(botId);
168
+ if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
169
+ return this.#withBotTransition(botId, async () => {
170
+ const runtime = this.#runtimes.get(botId);
171
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
172
+ const error = new Error(`${this.#descriptor.label}机器人尚未连接`);
173
+ error.code = 'test-target-unavailable';
174
+ throw error;
175
+ }
176
+ const cardLabel = `${config.name}(${this.#maskPlatformId(config.platformId)})`;
177
+ await runtime.sendConnectionTest(connectionTestMessage(
178
+ cardLabel,
179
+ `${this.#descriptor.label}机器人`,
180
+ ));
181
+ return { sent: true };
182
+ });
183
+ }
184
+
164
185
  async deleteBot(botId) {
165
186
  const config = this.#configStore.get(botId);
166
187
  if (!config) throw new Error(`Unknown ${this.#descriptor.label} bot`);
@@ -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();
@@ -1,3 +1,4 @@
1
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
1
2
  import { deriveSlackBotIdentity, maskSlackBotId } from './config-store.mjs';
2
3
  import { inspectSlackCredentials } from './slack-api.mjs';
3
4
  import { SLACK_DESCRIPTOR } from './slack-bridge.mjs';
@@ -168,6 +169,24 @@ export class SlackController {
168
169
  return this.status();
169
170
  }
170
171
 
172
+ async sendConnectionTest(botId) {
173
+ const config = this.#configStore.get(botId);
174
+ if (!config) throw new Error('Unknown Slack bot');
175
+ return this.#withBotTransition(botId, async () => {
176
+ const runtime = this.#runtimes.get(botId);
177
+ if (!runtime?.status?.ready || typeof runtime.sendConnectionTest !== 'function') {
178
+ const error = new Error('Slack机器人尚未连接');
179
+ error.code = 'test-target-unavailable';
180
+ throw error;
181
+ }
182
+ await runtime.sendConnectionTest(connectionTestMessage(
183
+ `${config.name}(${maskSlackBotId(config.platformId)})`,
184
+ 'Slack机器人',
185
+ ));
186
+ return { sent: true };
187
+ });
188
+ }
189
+
171
190
  async deleteBot(botId) {
172
191
  const config = this.#configStore.get(botId);
173
192
  if (!config) throw new Error('Unknown Slack bot');