@xmanrui/dsh-im 1.1.0 → 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/README.en.md +5 -3
  2. package/README.md +5 -3
  3. package/lib/client.js +212 -0
  4. package/lib/index.js +166 -163
  5. package/package.json +1 -1
  6. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  7. package/plugin-src/client/channels/whatsapp/index.js +125 -0
  8. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  9. package/plugin-src/client/i18n.js +18 -0
  10. package/plugin-src/host/channels/dingtalk/production.mjs +3 -1
  11. package/plugin-src/host/channels/feishu/production.mjs +3 -1
  12. package/plugin-src/host/channels/qq/production.mjs +3 -1
  13. package/plugin-src/host/channels/shared/production.mjs +3 -1
  14. package/plugin-src/host/channels/slack/production.mjs +3 -1
  15. package/plugin-src/host/channels/wecom/production.mjs +3 -1
  16. package/plugin-src/host/channels/weixin/production.mjs +3 -1
  17. package/plugin-src/host/channels/whatsapp/production.mjs +3 -1
  18. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  19. package/plugin-src/host/harness-session-coordinator.mjs +32 -5
  20. package/src/channels/dingtalk/dingtalk-api.mjs +93 -27
  21. package/src/channels/dingtalk/dingtalk-bridge.mjs +60 -13
  22. package/src/channels/discord/discord-runtime.mjs +23 -0
  23. package/src/channels/feishu/bridge.mjs +18 -10
  24. package/src/channels/feishu/message-utils.mjs +47 -0
  25. package/src/channels/qq/qq-bridge.mjs +80 -28
  26. package/src/channels/shared/file-download.mjs +64 -0
  27. package/src/channels/shared/harness-client.mjs +45 -0
  28. package/src/channels/shared/inbound-file.mjs +206 -0
  29. package/src/channels/shared/text-harness-bridge.mjs +31 -11
  30. package/src/channels/slack/slack-api.mjs +27 -4
  31. package/src/channels/slack/slack-runtime.mjs +55 -5
  32. package/src/channels/telegram/telegram-api.mjs +21 -6
  33. package/src/channels/telegram/telegram-runtime.mjs +24 -3
  34. package/src/channels/wecom/wecom-bridge.mjs +73 -10
  35. package/src/channels/weixin/weixin-api.mjs +45 -0
  36. package/src/channels/weixin/weixin-bridge.mjs +52 -18
  37. package/src/channels/whatsapp/config-store.mjs +43 -0
  38. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  39. package/src/channels/whatsapp/whatsapp-runtime.mjs +83 -2
  40. package/src/channels/whatsapp/whatsapp-web-session.mjs +1 -1
@@ -435,6 +435,48 @@ export function createDingtalkApi({
435
435
  return request;
436
436
  }
437
437
 
438
+ async function messageFileDownloadUrl({
439
+ clientId,
440
+ clientSecret,
441
+ robotCode,
442
+ downloadCode,
443
+ signal,
444
+ kind,
445
+ }) {
446
+ const botCode = nonEmptyString(robotCode);
447
+ const fileCode = nonEmptyString(downloadCode);
448
+ if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
449
+ const token = await accessToken({ clientId, clientSecret, signal });
450
+ let response;
451
+ try {
452
+ response = await requestJson(
453
+ fetchImpl,
454
+ endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
455
+ {
456
+ body: { downloadCode: fileCode, robotCode: botCode },
457
+ headers: { 'x-acs-dingtalk-access-token': token },
458
+ signal,
459
+ action: `${kind === 'image' ? '图片' : '文件'}下载地址`,
460
+ },
461
+ );
462
+ } catch (error) {
463
+ if (signal?.aborted) throw error;
464
+ throw new DingtalkApiError(
465
+ `${kind}-download-address-failed`,
466
+ `钉钉${kind === 'image' ? '图片' : '文件'}下载地址获取失败。`,
467
+ { cause: error, status: error?.status, providerCode: error?.providerCode },
468
+ );
469
+ }
470
+ const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
471
+ if (!downloadUrl) {
472
+ throw new DingtalkApiError(
473
+ `invalid-${kind}-download`,
474
+ `钉钉服务没有返回${kind === 'image' ? '图片' : '文件'}下载地址。`,
475
+ );
476
+ }
477
+ return secureDingtalkDownloadUrl(downloadUrl);
478
+ }
479
+
438
480
  function acquireCardRequestSlot(signal) {
439
481
  const acquire = async () => {
440
482
  const waitMs = Math.max(0, nextCardRequestAt - now());
@@ -568,45 +610,69 @@ export function createDingtalkApi({
568
610
  signal,
569
611
  maxBytes,
570
612
  }) {
571
- const botCode = nonEmptyString(robotCode);
572
- const fileCode = nonEmptyString(downloadCode);
573
- if (!botCode || !fileCode) throw new TypeError('robotCode and downloadCode are required');
574
- const token = await accessToken({ clientId, clientSecret, signal });
575
- let response;
613
+ const downloadUrl = await messageFileDownloadUrl({
614
+ clientId,
615
+ clientSecret,
616
+ robotCode,
617
+ downloadCode,
618
+ signal,
619
+ kind: 'image',
620
+ });
576
621
  try {
577
- response = await requestJson(
622
+ return await fetchImageBuffer(downloadUrl, {
578
623
  fetchImpl,
579
- endpoint(apiBase, 'v1.0/robot/messageFiles/download'),
580
- {
581
- body: { downloadCode: fileCode, robotCode: botCode },
582
- headers: { 'x-acs-dingtalk-access-token': token },
583
- signal,
584
- action: '图片下载地址',
585
- },
586
- );
624
+ signal,
625
+ maxBytes,
626
+ });
587
627
  } catch (error) {
588
- if (signal?.aborted) throw error;
628
+ if (signal?.aborted || error instanceof ImagePromptError) throw error;
589
629
  throw new DingtalkApiError(
590
- 'image-download-address-failed',
591
- '钉钉图片下载地址获取失败。',
592
- { cause: error, status: error?.status, providerCode: error?.providerCode },
630
+ 'image-content-download-failed',
631
+ '钉钉图片内容下载失败。',
632
+ { cause: error },
593
633
  );
594
634
  }
595
- const downloadUrl = nonEmptyString(response?.downloadUrl ?? response?.download_url);
596
- if (!downloadUrl) {
597
- throw new DingtalkApiError('invalid-image-download', '钉钉服务没有返回图片下载地址。');
635
+ },
636
+
637
+ async downloadFile({
638
+ clientId,
639
+ clientSecret,
640
+ robotCode,
641
+ downloadCode,
642
+ signal,
643
+ }) {
644
+ const downloadUrl = await messageFileDownloadUrl({
645
+ clientId,
646
+ clientSecret,
647
+ robotCode,
648
+ downloadCode,
649
+ signal,
650
+ kind: 'file',
651
+ });
652
+ if (downloadUrl.protocol !== 'https:') {
653
+ throw new DingtalkApiError('invalid-file-download', '钉钉服务返回了无效的文件下载地址。');
598
654
  }
599
655
  try {
600
- return await fetchImageBuffer(secureDingtalkDownloadUrl(downloadUrl), {
601
- fetchImpl,
656
+ const response = await fetchImpl(downloadUrl, {
657
+ method: 'GET',
658
+ redirect: 'follow',
602
659
  signal,
603
- maxBytes,
604
660
  });
661
+ if (!response?.ok) {
662
+ await response?.body?.cancel?.().catch?.(() => undefined);
663
+ throw new DingtalkApiError(
664
+ 'file-content-download-failed',
665
+ `钉钉文件内容下载失败(HTTP ${response?.status ?? 'unknown'})。`,
666
+ { status: response?.status },
667
+ );
668
+ }
669
+ return Buffer.from(await response.arrayBuffer());
605
670
  } catch (error) {
606
- if (signal?.aborted || error instanceof ImagePromptError) throw error;
671
+ if (signal?.aborted) throw abortError(signal);
672
+ if (error instanceof DingtalkApiError) throw error;
607
673
  throw new DingtalkApiError(
608
- 'image-content-download-failed',
609
- '钉钉图片内容下载失败。',
674
+ 'file-content-download-failed',
675
+ '钉钉文件内容下载失败。',
610
676
  { cause: error },
611
677
  );
612
678
  }
@@ -29,6 +29,11 @@ import {
29
29
  imagePromptUserMessage,
30
30
  promptContentForMessage,
31
31
  } from '../shared/image-prompt.mjs';
32
+ import {
33
+ hasInboundFiles,
34
+ inboundFileUserMessage,
35
+ prefetchInboundFiles,
36
+ } from '../shared/inbound-file.mjs';
32
37
  import { rememberConnectionTestTarget } from '../shared/connection-test.mjs';
33
38
  import {
34
39
  materializeOutboundArtifact,
@@ -48,7 +53,7 @@ const INTERACTION_RESOLVED_TEXT = '这个问题已在其他客户端处理,无
48
53
  const HELP_TEXT = [
49
54
  '钉钉机器人已连接 DeepSeek Harness。',
50
55
  '',
51
- '直接发送文字或图片即可继续当前会话。',
56
+ '直接发送文字、图片或文件即可继续当前会话。',
52
57
  '/new 开启一个全新会话',
53
58
  '/compact 压缩当前会话的较早上下文',
54
59
  '/workspace 工作区绝对路径 切换工作区',
@@ -175,6 +180,7 @@ export function dingtalkInboundMessage(message, {
175
180
  if (code) imageCodes.push(code);
176
181
  }
177
182
  }
183
+ const fileCode = msgtype === 'file' ? downloadCodeFor(content) : null;
178
184
  return {
179
185
  content: text,
180
186
  images: imageCodes.map((downloadCode, index) => ({
@@ -193,6 +199,21 @@ export function dingtalkInboundMessage(message, {
193
199
  });
194
200
  },
195
201
  })),
202
+ files: fileCode ? [{
203
+ name: nonEmptyString(content?.fileName ?? content?.file_name) ?? 'file',
204
+ load: ({ signal } = {}) => {
205
+ if (typeof api?.downloadFile !== 'function') {
206
+ throw new Error('DingTalk API does not support file downloads');
207
+ }
208
+ return api.downloadFile({
209
+ clientId,
210
+ clientSecret,
211
+ robotCode: message?.robotCode,
212
+ downloadCode: fileCode,
213
+ signal,
214
+ });
215
+ },
216
+ }] : [],
196
217
  };
197
218
  }
198
219
 
@@ -393,7 +414,7 @@ export class DingtalkHarnessBridge {
393
414
  clientSecret: this.#clientSecret,
394
415
  });
395
416
  const commandText = nonEmptyString(promptMessage.content) ?? '';
396
- const commandRunner = isControlCommand(commandText)
417
+ const commandRunner = hasInboundFiles(promptMessage) ? null : isControlCommand(commandText)
397
418
  ? runControlCommand
398
419
  : (isModelCommand(commandText)
399
420
  ? runModelCommand
@@ -499,10 +520,28 @@ export class DingtalkHarnessBridge {
499
520
  releaseMessageId = true,
500
521
  alreadyRecorded = false,
501
522
  } = {}) {
523
+ let hasSafeReplyRoute = false;
524
+ try {
525
+ normalizeDingtalkSessionWebhook(message.sessionWebhook);
526
+ hasSafeReplyRoute = true;
527
+ } catch {
528
+ // Keep the existing rejection path without downloading an unusable file.
529
+ }
530
+ const addressed = String(message.conversationType) !== '2' || message.isInAtList === true;
531
+ const preparedMessage = hasSafeReplyRoute && addressed
532
+ ? prefetchInboundFiles(dingtalkInboundMessage(message, {
533
+ api: this.#api,
534
+ clientId: this.#clientId,
535
+ clientSecret: this.#clientSecret,
536
+ }), { signal: this.#signal })
537
+ : undefined;
502
538
  const previous = this.#queues.get(key) ?? Promise.resolve();
503
539
  const current = previous
504
540
  .catch(() => undefined)
505
- .then(() => this.#process(message, messageId, sender, key, { alreadyRecorded }))
541
+ .then(() => this.#process(message, messageId, sender, key, {
542
+ alreadyRecorded,
543
+ preparedMessage,
544
+ }))
506
545
  .finally(() => {
507
546
  if (releaseMessageId) this.#acceptedMessageIds.delete(messageId);
508
547
  if (this.#queues.get(key) === current) this.#queues.delete(key);
@@ -536,6 +575,7 @@ export class DingtalkHarnessBridge {
536
575
  {
537
576
  signal: this.#signal,
538
577
  hasImages: hasInboundImages(prompt),
578
+ hasFiles: hasInboundFiles(prompt),
539
579
  pendingInteraction: this.#pendingInteractions.has(key)
540
580
  || this.#approvals.hasPending(key),
541
581
  control: { owner: this, key },
@@ -553,7 +593,10 @@ export class DingtalkHarnessBridge {
553
593
  this.#status.lastError = null;
554
594
  }
555
595
 
556
- async #process(message, messageId, sender, key, { alreadyRecorded = false } = {}) {
596
+ async #process(message, messageId, sender, key, {
597
+ alreadyRecorded = false,
598
+ preparedMessage,
599
+ } = {}) {
557
600
  this.#signal?.throwIfAborted();
558
601
  if (!alreadyRecorded) {
559
602
  if (this.#state.hasSeen(messageId)) return;
@@ -577,38 +620,39 @@ export class DingtalkHarnessBridge {
577
620
  return;
578
621
  }
579
622
 
580
- const promptMessage = dingtalkInboundMessage(message, {
623
+ const promptMessage = preparedMessage ?? dingtalkInboundMessage(message, {
581
624
  api: this.#api,
582
625
  clientId: this.#clientId,
583
626
  clientSecret: this.#clientSecret,
584
627
  });
585
628
  const text = promptMessage.content;
586
629
  const hasImages = hasInboundImages(promptMessage);
630
+ const hasFiles = hasInboundFiles(promptMessage);
587
631
  const isPlainText = String(message?.msgtype).toLowerCase() === 'text';
588
632
  let cardStream = null;
589
633
  let cardStarted = false;
590
634
  try {
591
- if (!text && !hasImages) {
592
- await this.#send(sessionWebhook, '目前支持文字和图片消息。');
635
+ if (!text && !hasImages && !hasFiles) {
636
+ await this.#send(sessionWebhook, '目前支持文字、图片和文件消息。');
593
637
  return;
594
638
  }
595
639
 
596
640
  const command = text.toLowerCase();
597
- if (isPlainText && !hasImages && command === '/help') {
641
+ if (isPlainText && !hasImages && !hasFiles && command === '/help') {
598
642
  await this.#send(sessionWebhook, HELP_TEXT);
599
643
  return;
600
644
  }
601
- if (isPlainText && !hasImages && command === '/status') {
645
+ if (isPlainText && !hasImages && !hasFiles && command === '/status') {
602
646
  await this.#harness.ensureRunning({ signal: this.#signal });
603
647
  await this.#send(sessionWebhook, '钉钉机器人与 DeepSeek Harness 连接正常。');
604
648
  return;
605
649
  }
606
- if (isPlainText && !hasImages && command === '/new') {
650
+ if (isPlainText && !hasImages && !hasFiles && command === '/new') {
607
651
  await this.#state.clearSession(key);
608
652
  await this.#send(sessionWebhook, '已开启新会话。请发送你的问题。');
609
653
  return;
610
654
  }
611
- const workspaceCommand = isPlainText && !hasImages
655
+ const workspaceCommand = isPlainText && !hasImages && !hasFiles
612
656
  ? await runWorkspaceCommand(text, this.#harness, key)
613
657
  : null;
614
658
  if (workspaceCommand) {
@@ -617,7 +661,7 @@ export class DingtalkHarnessBridge {
617
661
  }
618
662
  return;
619
663
  }
620
- const compactCommand = isPlainText && !hasImages
664
+ const compactCommand = isPlainText && !hasImages && !hasFiles
621
665
  ? await runCompactCommand(
622
666
  text,
623
667
  this.#harness,
@@ -668,6 +712,7 @@ export class DingtalkHarnessBridge {
668
712
  requiresMention: String(message.conversationType) === '2',
669
713
  }),
670
714
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
715
+ files: promptMessage.files,
671
716
  },
672
717
  });
673
718
  const answerText = typeof answer === 'string' && answer.trim()
@@ -717,7 +762,9 @@ export class DingtalkHarnessBridge {
717
762
  safeErrorDiagnostic(error),
718
763
  );
719
764
  try {
720
- const errorText = dingtalkImageErrorUserMessage(error) ?? CARD_ERROR_TEXT;
765
+ const errorText = inboundFileUserMessage(error)
766
+ ?? dingtalkImageErrorUserMessage(error)
767
+ ?? CARD_ERROR_TEXT;
721
768
  const streamed = cardStarted && await cardStream.finish(errorText);
722
769
  if (!streamed) await this.#send(sessionWebhook, errorText);
723
770
  } catch {
@@ -1,4 +1,5 @@
1
1
  import { createEditableMessageStream, splitMessageText } from '../shared/editable-message-stream.mjs';
2
+ import { fetchFileStream } from '../shared/file-download.mjs';
2
3
  import { fetchImageBuffer } from '../shared/image-prompt.mjs';
3
4
  import { DiscordApi } from './discord-api.mjs';
4
5
  import { createDiscordBridgeStatus, DiscordHarnessBridge } from './discord-bridge.mjs';
@@ -88,6 +89,25 @@ function discordImageSource(attachment, fetchImpl) {
88
89
  };
89
90
  }
90
91
 
92
+ function discordFileSource(attachment, fetchImpl) {
93
+ if (attachmentMediaType(attachment) || typeof attachment?.url !== 'string' || !attachment.url) {
94
+ return null;
95
+ }
96
+ const mediaType = typeof attachment.content_type === 'string' && attachment.content_type
97
+ ? attachment.content_type.split(';', 1)[0].trim().toLowerCase() : undefined;
98
+ return {
99
+ name: typeof attachment.filename === 'string' && attachment.filename
100
+ ? attachment.filename : String(attachment.id ?? 'discord-file'),
101
+ ...(mediaType ? { mediaType } : {}),
102
+ size: attachmentSize(attachment.size),
103
+ load: ({ signal } = {}) => fetchFileStream(attachment.url, {
104
+ fetchImpl,
105
+ signal,
106
+ allowedHosts: DISCORD_IMAGE_HOSTS,
107
+ }),
108
+ };
109
+ }
110
+
91
111
  export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } = {}) {
92
112
  if (!message?.id || !message?.channel_id || !message?.author?.id) return null;
93
113
  const direct = !message.guild_id;
@@ -103,6 +123,9 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
103
123
  images: Array.isArray(message.attachments)
104
124
  ? message.attachments.map((attachment) => discordImageSource(attachment, fetchImpl)).filter(Boolean)
105
125
  : [],
126
+ files: Array.isArray(message.attachments)
127
+ ? message.attachments.map((attachment) => discordFileSource(attachment, fetchImpl)).filter(Boolean)
128
+ : [],
106
129
  addressed,
107
130
  replyTarget: {
108
131
  channelId: String(message.channel_id),
@@ -12,6 +12,10 @@ import {
12
12
  imagePromptUserMessage,
13
13
  promptContentForMessage,
14
14
  } from '../shared/image-prompt.mjs';
15
+ import {
16
+ hasInboundFiles,
17
+ inboundFileUserMessage,
18
+ } from '../shared/inbound-file.mjs';
15
19
  import {
16
20
  harnessAnswerForQuestion,
17
21
  harnessQuestionText,
@@ -91,7 +95,7 @@ const REPAIR_URL_HOSTS = new Set([
91
95
  const HELP_TEXT = [
92
96
  '北汇星河 AIOS 已连接 DeepSeek Harness。',
93
97
  '',
94
- '直接发送文字或图片即可继续当前会话。',
98
+ '直接发送文字、图片或文件即可继续当前会话。',
95
99
  '/new 开启一个全新会话',
96
100
  '/compact 压缩当前会话的较早上下文',
97
101
  '/workspace 工作区绝对路径 切换工作区',
@@ -431,7 +435,7 @@ export class FeishuHarnessBridge {
431
435
  const processingReaction = this.#addReaction(messageId, 'OnIt');
432
436
  const commandMessage = extractInboundMessage(event, this.#client);
433
437
  const commandText = nonEmptyString(commandMessage.content) ?? '';
434
- const commandRunner = isControlCommand(commandText)
438
+ const commandRunner = hasInboundFiles(commandMessage) ? null : isControlCommand(commandText)
435
439
  ? runControlCommand
436
440
  : (isModelCommand(commandText)
437
441
  ? runModelCommand
@@ -610,7 +614,8 @@ export class FeishuHarnessBridge {
610
614
  await this.#finishReaction(messageId, processingReaction, 'ERROR');
611
615
  await this.#send(
612
616
  event.message.chat_id,
613
- imagePromptUserMessage(error)
617
+ inboundFileUserMessage(error)
618
+ ?? imagePromptUserMessage(error)
614
619
  ?? '处理失败,请稍后重试。如果问题持续,请在 DeepSeek Harness 的飞书插件页面检查连接状态。',
615
620
  ).catch(() => undefined);
616
621
  }
@@ -641,6 +646,7 @@ export class FeishuHarnessBridge {
641
646
  {
642
647
  signal: this.#signal,
643
648
  hasImages: hasInboundImages(message),
649
+ hasFiles: hasInboundFiles(message),
644
650
  pendingInteraction: this.#pendingInteractions.has(key)
645
651
  || this.#approvals.hasPending(key),
646
652
  control: { owner: this, key },
@@ -671,9 +677,10 @@ export class FeishuHarnessBridge {
671
677
  const message = extractInboundMessage(event, this.#client);
672
678
  const text = message.content;
673
679
  const hasImages = hasInboundImages(message);
674
- const commandText = event.message.message_type === 'text' && !hasImages ? text : null;
675
- if (!text && !hasImages) {
676
- await this.#send(event.message.chat_id, '目前支持文字和图片消息。');
680
+ const hasFiles = hasInboundFiles(message);
681
+ const commandText = event.message.message_type === 'text' && !hasImages && !hasFiles ? text : null;
682
+ if (!text && !hasImages && !hasFiles) {
683
+ await this.#send(event.message.chat_id, '目前支持文字、图片和文件消息。');
677
684
  return;
678
685
  }
679
686
 
@@ -1603,7 +1610,7 @@ export class FeishuHarnessBridge {
1603
1610
  }
1604
1611
  }
1605
1612
 
1606
- #interactionAskOptions(event, key) {
1613
+ #interactionAskOptions(event, key, files) {
1607
1614
  return {
1608
1615
  timeoutMs: this.#replyTimeoutMs,
1609
1616
  signal: this.#signal,
@@ -1615,6 +1622,7 @@ export class FeishuHarnessBridge {
1615
1622
  requiresMention: event.message.chat_type !== 'p2p',
1616
1623
  }),
1617
1624
  onInteractionResolved: (resolution) => this.#handleInteractionResolved(resolution),
1625
+ files,
1618
1626
  };
1619
1627
  }
1620
1628
 
@@ -1709,7 +1717,7 @@ export class FeishuHarnessBridge {
1709
1717
  content,
1710
1718
  createOptions: { signal: this.#signal },
1711
1719
  existsOptions: { signal: this.#signal },
1712
- askOptions: this.#interactionAskOptions(event, key),
1720
+ askOptions: this.#interactionAskOptions(event, key, message.files),
1713
1721
  });
1714
1722
  let textReceipt;
1715
1723
  let textSendError = null;
@@ -1749,7 +1757,7 @@ export class FeishuHarnessBridge {
1749
1757
  markdown: async (controller) => {
1750
1758
  promptStarted = true;
1751
1759
  const askOptions = {
1752
- ...this.#interactionAskOptions(event, key),
1760
+ ...this.#interactionAskOptions(event, key, message.files),
1753
1761
  onUpdate: async (update) => {
1754
1762
  await controller.setContent(this.#progressText(update));
1755
1763
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
@@ -1821,7 +1829,7 @@ export class FeishuHarnessBridge {
1821
1829
  content,
1822
1830
  createOptions: { signal: this.#signal },
1823
1831
  existsOptions: { signal: this.#signal },
1824
- askOptions: this.#interactionAskOptions(event, key),
1832
+ askOptions: this.#interactionAskOptions(event, key, message.files),
1825
1833
  });
1826
1834
  let textReceipt;
1827
1835
  let textSendError = null;
@@ -125,6 +125,31 @@ async function readBoundedStream(stream, { signal, maxBytes }) {
125
125
  }
126
126
  }
127
127
 
128
+ async function readStream(stream, { signal }) {
129
+ if (!stream || typeof stream[Symbol.asyncIterator] !== 'function') {
130
+ throw new Error('Feishu file download returned no readable stream');
131
+ }
132
+ signal?.throwIfAborted();
133
+ const abort = () => stream.destroy?.(
134
+ signal.reason ?? new DOMException('Feishu file download aborted', 'AbortError'),
135
+ );
136
+ signal?.addEventListener('abort', abort, { once: true });
137
+ const chunks = [];
138
+ let size = 0;
139
+ try {
140
+ for await (const chunk of stream) {
141
+ signal?.throwIfAborted();
142
+ const data = Buffer.from(chunk);
143
+ size += data.length;
144
+ chunks.push(data);
145
+ }
146
+ signal?.throwIfAborted();
147
+ return Buffer.concat(chunks, size);
148
+ } finally {
149
+ signal?.removeEventListener('abort', abort);
150
+ }
151
+ }
152
+
128
153
  function providerCode(value) {
129
154
  if (!value || typeof value !== 'object') return null;
130
155
  const code = value.code ?? value.error?.code;
@@ -232,6 +257,26 @@ function feishuImageSource(event, client, key) {
232
257
  };
233
258
  }
234
259
 
260
+ function feishuFileSource(event, client, file) {
261
+ const key = nonEmptyString(file?.file_key);
262
+ if (!key) return null;
263
+ return {
264
+ name: nonEmptyString(file?.file_name) ?? 'file',
265
+ async load({ signal } = {}) {
266
+ signal?.throwIfAborted();
267
+ const resource = await client?.im?.v1?.messageResource?.get?.({
268
+ path: {
269
+ message_id: event.message.message_id,
270
+ file_key: key,
271
+ },
272
+ params: { type: 'file' },
273
+ });
274
+ signal?.throwIfAborted();
275
+ return readStream(resource?.getReadableStream?.(), { signal });
276
+ },
277
+ };
278
+ }
279
+
235
280
  export function extractInboundMessage(event, client) {
236
281
  const messageType = event?.message?.message_type;
237
282
  const parsed = parsedMessageContent(event);
@@ -240,9 +285,11 @@ export function extractInboundMessage(event, client) {
240
285
  ? nonEmptyString(parsed?.image_key)
241
286
  : null;
242
287
  const imageKeys = standaloneImageKey ? [standaloneImageKey] : post?.imageKeys ?? [];
288
+ const file = messageType === 'file' ? feishuFileSource(event, client, parsed) : null;
243
289
  return {
244
290
  content: messageType === 'text' ? extractText(event) ?? '' : post?.text ?? '',
245
291
  images: imageKeys.map((key) => feishuImageSource(event, client, key)),
292
+ files: file ? [file] : [],
246
293
  };
247
294
  }
248
295