@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
@@ -3,6 +3,7 @@ import {
3
3
  createDingtalkBridgeStatus,
4
4
  DingtalkHarnessBridge,
5
5
  } from './dingtalk-bridge.mjs';
6
+ import { sendRememberedConnectionTest } from '../shared/connection-test.mjs';
6
7
 
7
8
  function nonEmptyString(value) {
8
9
  return typeof value === 'string' && value.trim() ? value.trim() : null;
@@ -347,6 +348,26 @@ export class DingtalkRuntime {
347
348
  return this.status;
348
349
  }
349
350
 
351
+ async sendConnectionTest(text) {
352
+ return sendRememberedConnectionTest({
353
+ state: this.#state,
354
+ text,
355
+ channelLabel: '钉钉机器人',
356
+ send: async ({ sessionWebhook }, content) => {
357
+ if (!this.#status.ready || !this.#abortController) {
358
+ throw new Error('DingTalk runtime is not connected');
359
+ }
360
+ await this.#api.sendText({
361
+ clientId: this.#config.clientId,
362
+ clientSecret: this.#clientSecret,
363
+ sessionWebhook,
364
+ text: content,
365
+ signal: this.#abortController.signal,
366
+ });
367
+ },
368
+ });
369
+ }
370
+
350
371
  #pendingSenders() {
351
372
  return typeof this.#state.pendingSenders === 'function'
352
373
  ? this.#state.pendingSenders()
@@ -108,7 +108,7 @@ export class DiscordApi {
108
108
  headers: {
109
109
  authorization: `Bot ${this.#token}`,
110
110
  'content-type': 'application/json',
111
- 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.9.0)',
111
+ 'user-agent': 'DeepSeek-Harness-dsh-im (https://github.com/xmanrui/dsh-im, 0.11.0)',
112
112
  },
113
113
  ...(body === undefined ? {} : { body: JSON.stringify(body) }),
114
114
  signal: requestSignal(signal, timeoutMs),
@@ -1,9 +1,19 @@
1
1
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { fetchImageBuffer } from '../shared/image-prompt.mjs';
2
3
  import { DiscordApi } from './discord-api.mjs';
3
4
  import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
4
5
 
5
6
  const DISCORD_GATEWAY_INTENTS = (1 << 0) | (1 << 9) | (1 << 12);
6
7
  const RECONNECT_DELAYS_MS = Object.freeze([1_000, 3_000, 5_000, 10_000, 30_000]);
8
+ const IMAGE_MEDIA_TYPES = new Set(['image/jpeg', 'image/png', 'image/webp', 'image/gif']);
9
+ const IMAGE_FILE_TYPES = new Map([
10
+ ['.jpg', 'image/jpeg'],
11
+ ['.jpeg', 'image/jpeg'],
12
+ ['.png', 'image/png'],
13
+ ['.webp', 'image/webp'],
14
+ ['.gif', 'image/gif'],
15
+ ]);
16
+ const DISCORD_IMAGE_HOSTS = Object.freeze(['cdn.discordapp.com']);
7
17
 
8
18
  function socketUrl(value) {
9
19
  const url = new URL(value);
@@ -48,7 +58,37 @@ function stripBotMention(text, botId) {
48
58
  return text.replace(new RegExp(`<@!?${botId}>`, 'g'), '').trim();
49
59
  }
50
60
 
51
- export function normalizeDiscordMessage(message, botId) {
61
+ function attachmentMediaType(attachment) {
62
+ const value = typeof attachment?.content_type === 'string'
63
+ ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : '';
64
+ if (IMAGE_MEDIA_TYPES.has(value)) return value;
65
+ const filename = typeof attachment?.filename === 'string' ? attachment.filename.toLowerCase() : '';
66
+ for (const [extension, mediaType] of IMAGE_FILE_TYPES) {
67
+ if (filename.endsWith(extension)) return mediaType;
68
+ }
69
+ return null;
70
+ }
71
+
72
+ function attachmentSize(value) {
73
+ return Number.isSafeInteger(value) && value >= 0 ? value : undefined;
74
+ }
75
+
76
+ function discordImageSource(attachment, fetchImpl) {
77
+ const mediaType = attachmentMediaType(attachment);
78
+ if (!mediaType || typeof attachment?.url !== 'string') return null;
79
+ return {
80
+ name: typeof attachment.filename === 'string' ? attachment.filename : undefined,
81
+ mediaType,
82
+ size: attachmentSize(attachment.size),
83
+ load: (options) => fetchImageBuffer(attachment.url, {
84
+ ...options,
85
+ fetchImpl,
86
+ allowedHosts: DISCORD_IMAGE_HOSTS,
87
+ }),
88
+ };
89
+ }
90
+
91
+ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
52
92
  if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
53
93
  const direct = !message.guild_id;
54
94
  const addressed = direct
@@ -60,11 +100,15 @@ export function normalizeDiscordMessage(message, botId) {
60
100
  kind: direct ? 'direct' : 'group',
61
101
  conversationId: String(message.channel_id),
62
102
  content: stripBotMention(message.content ?? '', botId),
103
+ images: Array.isArray(message.attachments)
104
+ ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
105
+ : [],
63
106
  addressed,
64
107
  replyTarget: {
65
108
  channelId: String(message.channel_id),
66
109
  replyToMessageId: String(message.id),
67
110
  },
111
+ connectionTestTarget: { channelId: String(message.channel_id) },
68
112
  };
69
113
  }
70
114
 
@@ -196,6 +240,15 @@ export class DiscordRuntime {
196
240
  return structuredClone(this.#status);
197
241
  }
198
242
 
243
+ async sendConnectionTest(text) {
244
+ if (!this.#status.ready || !this.#bridge) {
245
+ const error = new Error('Discord bot is not connected');
246
+ error.code = 'test-target-unavailable';
247
+ throw error;
248
+ }
249
+ return this.#bridge.sendConnectionTest(text);
250
+ }
251
+
199
252
  async start() {
200
253
  if (this.#status.ready && this.#socket) return this.status;
201
254
  if (this.#starting) return this.#starting;
@@ -1,10 +1,16 @@
1
1
  import {
2
2
  conversationKey,
3
+ extractInboundMessage,
3
4
  extractText,
4
5
  isAllowedSender,
5
6
  isBotSender,
6
7
  splitText,
7
8
  } from './message-utils.mjs';
9
+ import {
10
+ hasInboundImages,
11
+ imagePromptUserMessage,
12
+ promptContentForMessage,
13
+ } from '../shared/image-prompt.mjs';
8
14
  import {
9
15
  harnessAnswerForQuestion,
10
16
  harnessQuestionText,
@@ -12,6 +18,7 @@ import {
12
18
  } from '../shared/harness-question.mjs';
13
19
  import { HarnessApprovalQueue } from '../shared/harness-approval.mjs';
14
20
  import { runCompactCommand } from '../shared/compact-command.mjs';
21
+ import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
15
22
  import { runWorkspaceCommand } from '../shared/workspace-command.mjs';
16
23
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
17
24
 
@@ -21,7 +28,7 @@ const RESOLVED_REPLY_TTL_MS = 30 * 60_000;
21
28
  const HELP_TEXT = [
22
29
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
23
30
  '',
24
- '直接发送问题即可继续当前会话。',
31
+ '直接发送文字或图片即可继续当前会话。',
25
32
  '/new 开启一个全新会话',
26
33
  '/compact 压缩当前会话的较早上下文',
27
34
  '/workspace 工作区绝对路径 切换工作区',
@@ -127,6 +134,11 @@ export class FeishuHarnessBridge {
127
134
  return Promise.resolve();
128
135
  }
129
136
 
137
+ if (event.message.chat_type === 'p2p') {
138
+ const chatId = nonEmptyString(event.message.chat_id);
139
+ if (chatId) rememberConnectionTestTarget(this.#state, { chatId });
140
+ }
141
+
130
142
  this.#acceptedMessageIds.add(messageId);
131
143
  const processingReaction = this.#addReaction(messageId, 'OnIt');
132
144
  if (this.#isResolvedQuestionReply(event, key)) {
@@ -273,7 +285,8 @@ export class FeishuHarnessBridge {
273
285
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
274
286
  await this.#send(
275
287
  event.message.chat_id,
276
- '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
288
+ imagePromptUserMessage(error)
289
+ ?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
277
290
  ).catch(() => undefined);
278
291
  }
279
292
 
@@ -297,40 +310,47 @@ export class FeishuHarnessBridge {
297
310
  this.#status.messagesReceived += 1;
298
311
  }
299
312
 
300
- const text = extractText(event);
301
- if (!text) {
302
- await this.#send(event.message.chat_id, '目前仅支持文字消息。');
313
+ const message = extractInboundMessage(event, this.#client);
314
+ const text = message.content;
315
+ const hasImages = hasInboundImages(message);
316
+ const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
317
+ if (!text && !hasImages) {
318
+ await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
303
319
  return;
304
320
  }
305
321
 
306
- if (text === '/help') {
322
+ if (commandText === '/help') {
307
323
  await this.#send(event.message.chat_id, HELP_TEXT);
308
324
  return;
309
325
  }
310
- if (text === '/new') {
326
+ if (commandText === '/new') {
311
327
  await this.#state.clearSession(key);
312
328
  await this.#send(event.message.chat_id, '已开启全新 Harness 会话。');
313
329
  return;
314
330
  }
315
- if (text === '/status') {
331
+ if (commandText === '/status') {
316
332
  await this.#harness.ensureRunning({ signal: this.#signal });
317
333
  await this.#send(event.message.chat_id, '飞书机器人与 DeepSeek Harness 连接正常。');
318
334
  return;
319
335
  }
320
- const workspaceCommand = await runWorkspaceCommand(text, this.#harness, key);
336
+ const workspaceCommand = commandText === null
337
+ ? null
338
+ : await runWorkspaceCommand(text, this.#harness, key);
321
339
  if (workspaceCommand) {
322
340
  for (const reply of workspaceCommand.messages ?? [workspaceCommand.message]) {
323
341
  await this.#send(event.message.chat_id, reply);
324
342
  }
325
343
  return;
326
344
  }
327
- const compactCommand = await runCompactCommand(
328
- text,
329
- this.#harness,
330
- this.#state,
331
- key,
332
- { signal: this.#signal },
333
- );
345
+ const compactCommand = commandText === null
346
+ ? null
347
+ : await runCompactCommand(
348
+ commandText,
349
+ this.#harness,
350
+ this.#state,
351
+ key,
352
+ { signal: this.#signal },
353
+ );
334
354
  if (compactCommand) {
335
355
  await this.#send(event.message.chat_id, compactCommand.message);
336
356
  return;
@@ -338,7 +358,7 @@ export class FeishuHarnessBridge {
338
358
 
339
359
  this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
340
360
  try {
341
- await this.#answerWithStream(event, key, text);
361
+ await this.#answerWithStream(event, key, message);
342
362
  this.#status.messagesReplied += 1;
343
363
  this.#status.lastReplyAt = new Date().toISOString();
344
364
  this.#status.lastError = null;
@@ -362,15 +382,20 @@ export class FeishuHarnessBridge {
362
382
  };
363
383
  }
364
384
 
365
- async #answerWithStream(event, key, text) {
385
+ async #answerWithStream(event, key, message) {
366
386
  const chatId = event.message.chat_id;
367
387
  const messageId = event.message.message_id;
388
+ const text = message.content;
389
+ const content = hasInboundImages(message)
390
+ ? await promptContentForMessage(message, { signal: this.#signal })
391
+ : undefined;
368
392
  if (!this.#channel?.stream) {
369
393
  const { answer } = await askInWorkspaceSession({
370
394
  harness: this.#harness,
371
395
  state: this.#state,
372
396
  key,
373
397
  text,
398
+ content,
374
399
  createOptions: { signal: this.#signal },
375
400
  existsOptions: { signal: this.#signal },
376
401
  askOptions: this.#interactionAskOptions(event, key),
@@ -398,6 +423,7 @@ export class FeishuHarnessBridge {
398
423
  state: this.#state,
399
424
  key,
400
425
  text,
426
+ content,
401
427
  createOptions: { signal: this.#signal },
402
428
  existsOptions: { signal: this.#signal },
403
429
  askOptions,
@@ -425,6 +451,7 @@ export class FeishuHarnessBridge {
425
451
  state: this.#state,
426
452
  key,
427
453
  text,
454
+ content,
428
455
  createOptions: { signal: this.#signal },
429
456
  existsOptions: { signal: this.#signal },
430
457
  askOptions: this.#interactionAskOptions(event, key),
@@ -1,5 +1,9 @@
1
1
  import { FeishuHarnessBridge } from './bridge.mjs';
2
2
  import { VerifiedFeishuChannel } from './feishu-channel.mjs';
3
+ import {
4
+ connectionTestTargetUnavailable,
5
+ sendRememberedConnectionTest,
6
+ } from '../shared/connection-test.mjs';
3
7
 
4
8
  const DEFAULT_REQUEST_TIMEOUT_MS = 15_000;
5
9
 
@@ -240,6 +244,47 @@ export class FeishuRuntime {
240
244
  }
241
245
  }
242
246
 
247
+ async sendConnectionTest(text) {
248
+ if (!this.#status.ready || !this.#client) {
249
+ const error = new Error('飞书机器人尚未连接');
250
+ error.code = 'test-target-unavailable';
251
+ throw error;
252
+ }
253
+ if (typeof text !== 'string' || !text.trim()) {
254
+ throw new TypeError('Feishu connection test text is required');
255
+ }
256
+ const send = async (receiveIdType, receiveId, content) => {
257
+ const response = await this.#client.im.v1.message.create({
258
+ params: { receive_id_type: receiveIdType },
259
+ data: {
260
+ receive_id: receiveId,
261
+ msg_type: 'text',
262
+ content: JSON.stringify({ text: content }),
263
+ },
264
+ });
265
+ if (response?.code && response.code !== 0) {
266
+ throw new Error(`Feishu connection test failed: ${response.msg || response.code}`);
267
+ }
268
+ };
269
+
270
+ const ownerOpenId = this.#ownerOpenIds.find((value) => value !== '*');
271
+ if (ownerOpenId) {
272
+ await send('open_id', ownerOpenId, text);
273
+ return { sent: true };
274
+ }
275
+
276
+ return sendRememberedConnectionTest({
277
+ state: this.#state,
278
+ text,
279
+ channelLabel: '飞书机器人',
280
+ send: async (target, content) => {
281
+ const chatId = typeof target?.chatId === 'string' ? target.chatId.trim() : '';
282
+ if (!chatId) throw connectionTestTargetUnavailable('飞书机器人');
283
+ await send('chat_id', chatId, content);
284
+ },
285
+ });
286
+ }
287
+
243
288
  async stop({ preserveError = false } = {}) {
244
289
  const error = preserveError ? this.#status.lastError : null;
245
290
  const abortController = this.#abortController;
@@ -1,3 +1,11 @@
1
+ import { ImagePromptError } from '../shared/image-prompt.mjs';
2
+
3
+ const FEISHU_MISSING_MESSAGE_SCOPE_CODE = 99991672;
4
+ const FEISHU_ERROR_BODY_LIMIT = 64 * 1024;
5
+ const FEISHU_ERROR_BODY_TIMEOUT_MS = 1_000;
6
+ const FEISHU_IMAGE_PERMISSION_MESSAGE =
7
+ '飞书机器人缺少图片读取权限。请在飞书开放平台为该应用添加 im:message:readonly,发布新版本并完成必要的管理员审批后,再重新发送图片。';
8
+
1
9
  export function conversationKey(event) {
2
10
  const chatType = event?.message?.chat_type;
3
11
  if (chatType === 'p2p') {
@@ -10,19 +18,232 @@ export function conversationKey(event) {
10
18
  return `group:${chatId}`;
11
19
  }
12
20
 
21
+ function parsedMessageContent(event) {
22
+ const value = event?.message?.content;
23
+ if (value && typeof value === 'object') return value;
24
+ if (typeof value !== 'string') return null;
25
+ try {
26
+ const parsed = JSON.parse(value);
27
+ return parsed && typeof parsed === 'object' ? parsed : null;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ function withoutMentions(text, event) {
34
+ let result = typeof text === 'string' ? text : '';
35
+ for (const mention of event?.message?.mentions ?? []) {
36
+ if (typeof mention?.key === 'string' && mention.key) {
37
+ result = result.replaceAll(mention.key, '');
38
+ }
39
+ }
40
+ return result.trim();
41
+ }
42
+
13
43
  export function extractText(event) {
14
44
  if (event?.message?.message_type !== 'text') return null;
15
- let parsed;
45
+ const parsed = parsedMessageContent(event);
46
+ return parsed ? withoutMentions(parsed.text, event) : null;
47
+ }
48
+
49
+ function nonEmptyString(value) {
50
+ return typeof value === 'string' && value.trim() ? value.trim() : null;
51
+ }
52
+
53
+ function postContent(event, parsed = parsedMessageContent(event)) {
54
+ if (event?.message?.message_type !== 'post') return null;
55
+ if (!parsed) return null;
56
+
57
+ const lines = [];
58
+ const title = nonEmptyString(withoutMentions(parsed.title, event));
59
+ if (title) lines.push(title);
60
+ const imageKeys = [];
61
+ for (const paragraph of Array.isArray(parsed.content) ? parsed.content : []) {
62
+ if (!Array.isArray(paragraph)) continue;
63
+ let visibleText = '';
64
+ for (const element of paragraph) {
65
+ const tag = String(element?.tag ?? '').toLowerCase();
66
+ if (tag === 'img') {
67
+ const key = nonEmptyString(element?.image_key);
68
+ if (key) imageKeys.push(key);
69
+ } else if (tag === 'text' || tag === 'a' || tag === 'link') {
70
+ if (typeof element?.text === 'string') visibleText += element.text;
71
+ }
72
+ }
73
+ const line = nonEmptyString(withoutMentions(visibleText, event));
74
+ if (line) lines.push(line);
75
+ }
76
+
77
+ return {
78
+ text: lines.join('\n'),
79
+ imageKeys,
80
+ };
81
+ }
82
+
83
+ function headerValue(headers, name) {
84
+ if (typeof headers?.get === 'function') return headers.get(name);
85
+ return headers?.[name] ?? headers?.[name.toLowerCase()] ?? null;
86
+ }
87
+
88
+ function declaredSize(headers) {
89
+ const header = headerValue(headers, 'content-length');
90
+ if (header === null || header === undefined || header === '') return null;
91
+ const value = Number(header);
92
+ return Number.isFinite(value) && value >= 0 ? value : null;
93
+ }
94
+
95
+ async function readBoundedStream(stream, { signal, maxBytes }) {
96
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
97
+ throw new Error('Feishu image download returned no readable stream');
98
+ }
99
+ signal?.throwIfAborted();
100
+ const abort = () => stream.destroy?.(
101
+ signal.reason ?? new DOMException('Feishu image download aborted', 'AbortError'),
102
+ );
103
+ signal?.addEventListener('abort', abort, { once: true });
104
+ const chunks = [];
105
+ let size = 0;
106
+ try {
107
+ for await (const chunk of stream) {
108
+ signal?.throwIfAborted();
109
+ const data = Buffer.from(chunk);
110
+ size += data.length;
111
+ if (size > maxBytes) {
112
+ stream.destroy?.();
113
+ throw new ImagePromptError(
114
+ 'image-too-large',
115
+ `Feishu image exceeds ${maxBytes} bytes`,
116
+ '图片超过 5 MB,请压缩后重试。',
117
+ );
118
+ }
119
+ chunks.push(data);
120
+ }
121
+ signal?.throwIfAborted();
122
+ return Buffer.concat(chunks, size);
123
+ } finally {
124
+ signal?.removeEventListener('abort', abort);
125
+ }
126
+ }
127
+
128
+ function providerCode(value) {
129
+ if (!value || typeof value !== 'object') return null;
130
+ const code = value.code ?? value.error?.code;
131
+ return Number.isSafeInteger(Number(code)) ? Number(code) : null;
132
+ }
133
+
134
+ async function readFeishuErrorBody(stream, signal) {
135
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') return null;
136
+ signal?.throwIfAborted();
137
+ const timeout = AbortSignal.timeout(FEISHU_ERROR_BODY_TIMEOUT_MS);
138
+ const readSignal = signal ? AbortSignal.any([signal, timeout]) : timeout;
139
+ const abort = () => stream.destroy?.(readSignal.reason);
140
+ readSignal.addEventListener('abort', abort, { once: true });
141
+ const chunks = [];
142
+ let size = 0;
16
143
  try {
17
- parsed = JSON.parse(event.message.content);
144
+ for await (const chunk of stream) {
145
+ const data = Buffer.from(chunk);
146
+ size += data.length;
147
+ if (size > FEISHU_ERROR_BODY_LIMIT) {
148
+ stream.destroy?.();
149
+ return null;
150
+ }
151
+ chunks.push(data);
152
+ }
153
+ return Buffer.concat(chunks, size).toString('utf8');
18
154
  } catch {
155
+ signal?.throwIfAborted();
19
156
  return null;
157
+ } finally {
158
+ readSignal.removeEventListener('abort', abort);
20
159
  }
21
- let text = typeof parsed.text === 'string' ? parsed.text : '';
22
- for (const mention of event.message.mentions ?? []) {
23
- if (typeof mention.key === 'string' && mention.key) text = text.replaceAll(mention.key, '');
160
+ }
161
+
162
+ async function feishuProviderCode(error, signal) {
163
+ const pending = [error];
164
+ const seen = new Set();
165
+ while (pending.length > 0 && seen.size < 8) {
166
+ const value = pending.shift();
167
+ if (!value || (typeof value !== 'object' && typeof value !== 'function') || seen.has(value)) {
168
+ continue;
169
+ }
170
+ seen.add(value);
171
+ const directCode = providerCode(value);
172
+ const data = value.response?.data ?? value.data;
173
+ if (directCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) {
174
+ data?.destroy?.();
175
+ return directCode;
176
+ }
177
+ if (data && typeof data[Symbol.asyncIterator] === 'function') {
178
+ const body = await readFeishuErrorBody(data, signal);
179
+ try {
180
+ const parsedCode = providerCode(JSON.parse(body));
181
+ if (parsedCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return parsedCode;
182
+ } catch {
183
+ // Non-JSON provider failures keep the generic image download message.
184
+ }
185
+ } else {
186
+ const dataCode = providerCode(data);
187
+ if (dataCode === FEISHU_MISSING_MESSAGE_SCOPE_CODE) return dataCode;
188
+ }
189
+ pending.push(value.cause);
24
190
  }
25
- return text.trim();
191
+ return null;
192
+ }
193
+
194
+ async function feishuImageDownloadError(error, signal) {
195
+ if (await feishuProviderCode(error, signal) !== FEISHU_MISSING_MESSAGE_SCOPE_CODE) return error;
196
+ return new ImagePromptError(
197
+ 'feishu-image-permission-required',
198
+ 'Feishu image download requires the im:message:readonly tenant scope',
199
+ FEISHU_IMAGE_PERMISSION_MESSAGE,
200
+ { cause: error },
201
+ );
202
+ }
203
+
204
+ function feishuImageSource(event, client, key) {
205
+ return {
206
+ async load({ signal, maxBytes }) {
207
+ signal?.throwIfAborted();
208
+ let resource;
209
+ try {
210
+ resource = await client?.im?.v1?.messageResource?.get?.({
211
+ path: {
212
+ message_id: event.message.message_id,
213
+ file_key: key,
214
+ },
215
+ params: { type: 'image' },
216
+ });
217
+ } catch (error) {
218
+ throw await feishuImageDownloadError(error, signal);
219
+ }
220
+ signal?.throwIfAborted();
221
+ const size = declaredSize(resource?.headers);
222
+ if (size !== null && size > maxBytes) {
223
+ resource?.getReadableStream?.().destroy?.();
224
+ throw new ImagePromptError(
225
+ 'image-too-large',
226
+ `Feishu image declares ${size} bytes; the limit is ${maxBytes}`,
227
+ '图片超过 5 MB,请压缩后重试。',
228
+ );
229
+ }
230
+ return readBoundedStream(resource?.getReadableStream?.(), { signal, maxBytes });
231
+ },
232
+ };
233
+ }
234
+
235
+ export function extractInboundMessage(event, client) {
236
+ const messageType = event?.message?.message_type;
237
+ const parsed = parsedMessageContent(event);
238
+ const post = postContent(event, parsed);
239
+ const standaloneImageKey = messageType === 'image'
240
+ ? nonEmptyString(parsed?.image_key)
241
+ : null;
242
+ const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
243
+ return {
244
+ content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
245
+ images: imageKeys.map((key) => feishuImageSource(event, client, key)),
246
+ };
26
247
  }
27
248
 
28
249
  export function splitText(text, maxChars = 9000) {
@@ -1,4 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
+ import { connectionTestMessage } from '../shared/connection-test.mjs';
2
3
  import { RegistrationManager } from './registration-manager.mjs';
3
4
  import { REQUIRED_TENANT_SCOPES } from './plugin-controller.mjs';
4
5
 
@@ -339,6 +340,26 @@ export class MultiBotDshFeishuController {
339
340
  });
340
341
  }
341
342
 
343
+ async sendConnectionTest(botId) {
344
+ this.#assertOpen();
345
+ return this.#withBotTransition(botId, async () => {
346
+ const config = this.#requireBot(botId);
347
+ const runtime = this.#runtimes.get(botId);
348
+ if (!isConnected(connectionStatus(runtime))
349
+ || typeof runtime.sendConnectionTest !== 'function') {
350
+ const error = new Error('飞书机器人尚未连接');
351
+ error.code = 'test-target-unavailable';
352
+ throw error;
353
+ }
354
+ return runtime.sendConnectionTest(
355
+ connectionTestMessage(
356
+ `${config.botName}(${maskedAppId(config.appId)})`,
357
+ '飞书机器人',
358
+ ),
359
+ );
360
+ });
361
+ }
362
+
342
363
  async disconnectBot(botId) {
343
364
  this.#assertOpen();
344
365
  // An operational pause only: credentials/config remain durable, so the
@@ -5,6 +5,7 @@ export const FEISHU_SECRET_REF = 'DSH_FEISHU_APP_SECRET';
5
5
  export const REQUIRED_TENANT_SCOPES = Object.freeze([
6
6
  'im:message.p2p_msg:readonly',
7
7
  'im:message.group_at_msg:readonly',
8
+ 'im:message:readonly',
8
9
  'im:message:send_as_bot',
9
10
  'im:message.reactions:write_only',
10
11
  'im:message:recall',