@xmanrui/dsh-im 1.0.2 → 1.2.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 (52) hide show
  1. package/README.en.md +23 -3
  2. package/README.md +23 -3
  3. package/assets/logo-dsh-im-chinese-readme-3x2.png +0 -0
  4. package/assets/logo_cn.png +0 -0
  5. package/lib/client.js +815 -560
  6. package/lib/index.js +163 -163
  7. package/package.json +1 -1
  8. package/plugin-src/client/agent-preset.js +15 -6
  9. package/plugin-src/client/channel-card-meta.js +48 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -19
  11. package/plugin-src/client/channels/dingtalk/styles.js +0 -6
  12. package/plugin-src/client/channels/feishu/index.js +41 -35
  13. package/plugin-src/client/channels/feishu/styles.js +0 -5
  14. package/plugin-src/client/channels/qq/index.js +24 -16
  15. package/plugin-src/client/channels/shared/token-channel.js +32 -24
  16. package/plugin-src/client/channels/wecom/index.js +24 -16
  17. package/plugin-src/client/channels/weixin/index.js +29 -23
  18. package/plugin-src/client/channels/weixin/styles.js +0 -5
  19. package/plugin-src/client/channels/whatsapp/api.js +11 -0
  20. package/plugin-src/client/channels/whatsapp/index.js +152 -23
  21. package/plugin-src/client/channels/whatsapp/styles.js +25 -0
  22. package/plugin-src/client/i18n.js +20 -0
  23. package/plugin-src/client/styles.js +23 -8
  24. package/plugin-src/host/channels/whatsapp/rpc.mjs +19 -1
  25. package/plugin-src/host/index.mjs +14 -1
  26. package/src/channels/dingtalk/dingtalk-api.mjs +215 -2
  27. package/src/channels/dingtalk/dingtalk-bridge.mjs +155 -4
  28. package/src/channels/discord/discord-api.mjs +134 -6
  29. package/src/channels/discord/discord-runtime.mjs +15 -4
  30. package/src/channels/feishu/bridge.mjs +223 -15
  31. package/src/channels/feishu/feishu-channel.mjs +227 -1
  32. package/src/channels/feishu/plugin-controller.mjs +1 -0
  33. package/src/channels/qq/qq-bridge.mjs +217 -10
  34. package/src/channels/shared/editable-message-stream.mjs +18 -1
  35. package/src/channels/shared/harness-client.mjs +99 -7
  36. package/src/channels/shared/semantic/artifact.mjs +748 -0
  37. package/src/channels/shared/semantic/delivery.mjs +153 -0
  38. package/src/channels/shared/text-harness-bridge.mjs +149 -3
  39. package/src/channels/shared/workspace-session.mjs +15 -1
  40. package/src/channels/slack/manifest.mjs +1 -0
  41. package/src/channels/slack/slack-api.mjs +167 -4
  42. package/src/channels/slack/slack-runtime.mjs +21 -5
  43. package/src/channels/telegram/telegram-api.mjs +111 -5
  44. package/src/channels/telegram/telegram-runtime.mjs +18 -4
  45. package/src/channels/wecom/wecom-bridge.mjs +260 -12
  46. package/src/channels/weixin/weixin-api.mjs +268 -2
  47. package/src/channels/weixin/weixin-bridge.mjs +134 -3
  48. package/src/channels/weixin/weixin-controller.mjs +5 -1
  49. package/src/channels/weixin/weixin-runtime.mjs +5 -1
  50. package/src/channels/whatsapp/config-store.mjs +43 -0
  51. package/src/channels/whatsapp/whatsapp-controller.mjs +22 -1
  52. package/src/channels/whatsapp/whatsapp-runtime.mjs +149 -5
@@ -112,7 +112,7 @@ export function normalizeDiscordMessage(message, botId, { fetchImpl = fetch } =
112
112
  };
113
113
  }
114
114
 
115
- class DiscordBotClient {
115
+ export class DiscordBotClient {
116
116
  #api;
117
117
  #signal;
118
118
 
@@ -123,22 +123,32 @@ class DiscordBotClient {
123
123
 
124
124
  async sendText(target, text) {
125
125
  const chunks = splitMessageText(text, 1_900);
126
- let result = null;
126
+ const providerMessageIds = [];
127
127
  for (const [index, chunk] of chunks.entries()) {
128
- result = await this.#api.createMessage({
128
+ const result = await this.#api.createMessage({
129
129
  channelId: target.channelId,
130
130
  content: chunk,
131
131
  replyToMessageId: index === 0 ? target.replyToMessageId : undefined,
132
132
  signal: this.#signal,
133
133
  });
134
+ if (typeof result?.id === 'string' && result.id) providerMessageIds.push(result.id);
134
135
  }
135
- return result;
136
+ return { providerMessageIds };
136
137
  }
137
138
 
138
139
  sendTyping(target) {
139
140
  return this.#api.sendTyping({ channelId: target.channelId, signal: this.#signal });
140
141
  }
141
142
 
143
+ sendFile(target, file) {
144
+ return this.#api.createFileMessage({
145
+ channelId: target.channelId,
146
+ file,
147
+ replyToMessageId: target.replyToMessageId,
148
+ signal: this.#signal,
149
+ });
150
+ }
151
+
142
152
  async openStream(target) {
143
153
  const stream = createEditableMessageStream({
144
154
  limit: 1_900,
@@ -162,6 +172,7 @@ class DiscordBotClient {
162
172
  content,
163
173
  signal: this.#signal,
164
174
  }),
175
+ messageIdForResult: (message) => message?.id,
165
176
  });
166
177
  return stream.start();
167
178
  }
@@ -34,6 +34,15 @@ import {
34
34
  } from '../shared/preset-command.mjs';
35
35
  import { runWorkspaceCommand, resolveSessionListWorkspace, workspacePathSnapshot } from '../shared/workspace-command.mjs';
36
36
  import { askInWorkspaceSession } from '../shared/workspace-session.mjs';
37
+ import {
38
+ materializeOutboundArtifact,
39
+ releaseOutboundArtifact,
40
+ } from '../shared/semantic/artifact.mjs';
41
+ import {
42
+ createArtifactFailureReceipt,
43
+ createDeliveryReceipt,
44
+ mergeDeliveryReceipts,
45
+ } from '../shared/semantic/delivery.mjs';
37
46
  import {
38
47
  MENU_PAGE_SIZE,
39
48
  completionCard,
@@ -126,6 +135,33 @@ function safeErrorText(error) {
126
135
  }
127
136
  }
128
137
 
138
+ function artifactFailureText(fileName, error) {
139
+ const name = String(fileName ?? '结果文件').replace(/[\r\n]+/g, ' ').trim() || '结果文件';
140
+ switch (error?.code) {
141
+ case 'artifact-permission-required':
142
+ return `结果文件「${name}」已生成,但机器人缺少飞书文件上传权限。请为应用添加 im:resource 并完成必要审批后重试。`;
143
+ case 'artifact-too-large':
144
+ return `结果文件「${name}」超过飞书 30 MB 上限,未发送。`;
145
+ case 'artifact-empty':
146
+ return `结果文件「${name}」为空,飞书不允许发送空文件。`;
147
+ case 'artifact-changed':
148
+ case 'artifact-invalid':
149
+ case 'artifact-unavailable':
150
+ return `结果文件「${name}」暂时无法读取或准备发送,请确认文件仍可访问后重试。`;
151
+ case 'artifact-rate-limited':
152
+ return `结果文件「${name}」暂时被飞书限流,未能发送,请稍后重试。`;
153
+ case 'artifact-delivery-uncertain':
154
+ return `结果文件「${name}」发送结果未能确认,请先检查聊天内是否已收到,不要立即重试。`;
155
+ default:
156
+ return `结果文件「${name}」已生成,但暂时未能发送,请稍后重试。`;
157
+ }
158
+ }
159
+
160
+ function answerTextForDelivery(answer, artifacts) {
161
+ if (typeof answer === 'string' && answer.trim()) return answer;
162
+ return artifacts.length > 0 ? '结果文件已生成。' : answer;
163
+ }
164
+
129
165
  function nonEmptyString(value) {
130
166
  return typeof value === 'string' && value.trim() ? value.trim() : null;
131
167
  }
@@ -540,7 +576,10 @@ export class FeishuHarnessBridge {
540
576
  .then(() => this.#handle(event, key, { alreadyRecorded }));
541
577
  const settled = finalize
542
578
  ? work
543
- .then(() => this.#finishReaction(messageId, processingReaction, 'DONE'))
579
+ .then(async (receipt) => {
580
+ await this.#finishReaction(messageId, processingReaction, 'DONE');
581
+ return receipt;
582
+ })
544
583
  .catch((error) => this.#handleMessageFailure(
545
584
  event,
546
585
  messageId,
@@ -736,10 +775,11 @@ export class FeishuHarnessBridge {
736
775
 
737
776
  this.#logger.info?.(`[dsh-feishu] processing ${event.message.chat_type} message ${messageId}`);
738
777
  try {
739
- await this.#answerWithStream(event, key, message);
778
+ const receipt = await this.#answerWithStream(event, key, message);
740
779
  this.#status.messagesReplied += 1;
741
780
  this.#status.lastReplyAt = new Date().toISOString();
742
781
  this.#status.lastError = null;
782
+ return receipt;
743
783
  } finally {
744
784
  await this.#cancelPendingInteraction(key);
745
785
  await this.#approvals.closeRoute(key);
@@ -1578,6 +1618,81 @@ export class FeishuHarnessBridge {
1578
1618
  };
1579
1619
  }
1580
1620
 
1621
+ async #sendAnswerText(chatId, answer, { deliveryId, presentation }) {
1622
+ const providerMessageIds = [];
1623
+ for (const chunk of splitText(answer)) {
1624
+ this.#signal?.throwIfAborted();
1625
+ const messageId = await this.#send(chatId, chunk);
1626
+ if (messageId) providerMessageIds.push(messageId);
1627
+ }
1628
+ return createDeliveryReceipt({
1629
+ deliveryId,
1630
+ presentation,
1631
+ providerMessageIds,
1632
+ });
1633
+ }
1634
+
1635
+ async #deliverArtifacts(chatId, replyTo, artifacts = [], baseReceipt) {
1636
+ const receipts = baseReceipt ? [baseReceipt] : [];
1637
+ let failureNoticeVisible = false;
1638
+ for (const artifact of artifacts) {
1639
+ this.#signal?.throwIfAborted();
1640
+ try {
1641
+ if (typeof this.#channel?.sendFile !== 'function') {
1642
+ const unavailable = new Error('Feishu file delivery is unavailable');
1643
+ unavailable.code = 'artifact-provider-unavailable';
1644
+ throw unavailable;
1645
+ }
1646
+ const file = await materializeOutboundArtifact(artifact, {
1647
+ signal: this.#signal,
1648
+ });
1649
+ receipts.push(await this.#channel.sendFile(chatId, file, {
1650
+ replyTo,
1651
+ signal: this.#signal,
1652
+ }));
1653
+ this.#status.artifactsSent = (this.#status.artifactsSent ?? 0) + 1;
1654
+ } catch (error) {
1655
+ if (this.#signal?.aborted) throw error;
1656
+ this.#status.artifactSendErrors = (this.#status.artifactSendErrors ?? 0) + 1;
1657
+ this.#logger.warn?.(
1658
+ `[dsh-feishu] result file delivery failed (${error?.code ?? 'unknown'})`,
1659
+ );
1660
+ let noticeMessageId = null;
1661
+ try {
1662
+ noticeMessageId = await this.#send(chatId, artifactFailureText(artifact?.fileName, error));
1663
+ failureNoticeVisible = true;
1664
+ } catch {
1665
+ this.#logger.warn?.('[dsh-feishu] unable to send the safe result-file failure notice');
1666
+ }
1667
+ receipts.push(createArtifactFailureReceipt({
1668
+ artifactId: artifact?.artifactId ?? 'unknown',
1669
+ deliveryId: artifact?.deliveryKey ?? artifact?.artifactId ?? 'unknown',
1670
+ error,
1671
+ providerMessageIds: noticeMessageId ? [noticeMessageId] : [],
1672
+ }));
1673
+ } finally {
1674
+ releaseOutboundArtifact(artifact);
1675
+ }
1676
+ }
1677
+ if (receipts.length === 0) {
1678
+ return {
1679
+ receipt: createDeliveryReceipt({
1680
+ deliveryId: replyTo,
1681
+ presentation: 'feishu-files',
1682
+ }),
1683
+ failureNoticeVisible,
1684
+ };
1685
+ }
1686
+ const receipt = receipts.length === 1
1687
+ ? receipts[0]
1688
+ : mergeDeliveryReceipts({
1689
+ deliveryId: baseReceipt?.deliveryId ?? artifacts[0]?.deliveryKey ?? replyTo,
1690
+ presentation: baseReceipt ? 'feishu-text-and-files' : 'feishu-files',
1691
+ receipts,
1692
+ });
1693
+ return { receipt, failureNoticeVisible };
1694
+ }
1695
+
1581
1696
  async #answerWithStream(event, key, message) {
1582
1697
  const chatId = event.message.chat_id;
1583
1698
  const messageId = event.message.message_id;
@@ -1586,7 +1701,7 @@ export class FeishuHarnessBridge {
1586
1701
  ? await promptContentForMessage(message, { signal: this.#signal })
1587
1702
  : undefined;
1588
1703
  if (!this.#channel?.stream) {
1589
- const { answer } = await askInWorkspaceSession({
1704
+ const { answer, artifacts = [] } = await askInWorkspaceSession({
1590
1705
  harness: this.#harness,
1591
1706
  state: this.#state,
1592
1707
  key,
@@ -1596,15 +1711,41 @@ export class FeishuHarnessBridge {
1596
1711
  existsOptions: { signal: this.#signal },
1597
1712
  askOptions: this.#interactionAskOptions(event, key),
1598
1713
  });
1599
- for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
1714
+ let textReceipt;
1715
+ let textSendError = null;
1716
+ try {
1717
+ textReceipt = await this.#sendAnswerText(
1718
+ chatId,
1719
+ answerTextForDelivery(answer, artifacts),
1720
+ {
1721
+ deliveryId: messageId,
1722
+ presentation: 'feishu-text',
1723
+ },
1724
+ );
1725
+ } catch (error) {
1726
+ textSendError = error;
1727
+ this.#logger.warn?.(
1728
+ '[dsh-feishu] final text delivery failed; continuing with result files:',
1729
+ error,
1730
+ );
1731
+ }
1732
+ const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
1733
+ const artifactDispatched = delivery.receipt.artifacts.some(
1734
+ ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
1735
+ );
1736
+ if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
1737
+ throw textSendError;
1738
+ }
1600
1739
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
1601
- return;
1740
+ return delivery.receipt;
1602
1741
  }
1603
1742
 
1604
1743
  let promptStarted = false;
1605
1744
  let completedAnswer = '';
1745
+ let completedArtifacts = [];
1746
+ let stream;
1606
1747
  try {
1607
- await this.#channel.stream(chatId, {
1748
+ stream = await this.#channel.stream(chatId, {
1608
1749
  markdown: async (controller) => {
1609
1750
  promptStarted = true;
1610
1751
  const askOptions = {
@@ -1614,7 +1755,7 @@ export class FeishuHarnessBridge {
1614
1755
  this.#status.streamUpdates = (this.#status.streamUpdates ?? 0) + 1;
1615
1756
  },
1616
1757
  };
1617
- ({ answer: completedAnswer } = await askInWorkspaceSession({
1758
+ const completed = await askInWorkspaceSession({
1618
1759
  harness: this.#harness,
1619
1760
  state: this.#state,
1620
1761
  key,
@@ -1623,26 +1764,56 @@ export class FeishuHarnessBridge {
1623
1764
  createOptions: { signal: this.#signal },
1624
1765
  existsOptions: { signal: this.#signal },
1625
1766
  askOptions,
1626
- }));
1627
- await controller.setContent(completedAnswer);
1767
+ });
1768
+ completedAnswer = completed.answer;
1769
+ completedArtifacts = completed.artifacts ?? [];
1770
+ await controller.setContent(answerTextForDelivery(completedAnswer, completedArtifacts));
1628
1771
  },
1629
1772
  }, { replyTo: messageId });
1630
- this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
1631
1773
  } catch (error) {
1632
1774
  this.#status.streamErrors = (this.#status.streamErrors ?? 0) + 1;
1633
- if (completedAnswer) {
1775
+ if (completedAnswer || completedArtifacts.length > 0) {
1634
1776
  this.#logger.warn?.(
1635
1777
  '[dsh-feishu] native stream failed after generation; sending final text:',
1636
1778
  error.message,
1637
1779
  );
1638
- for (const chunk of splitText(completedAnswer)) await this.#send(chatId, chunk);
1780
+ let textReceipt;
1781
+ let textSendError = null;
1782
+ try {
1783
+ textReceipt = await this.#sendAnswerText(
1784
+ chatId,
1785
+ answerTextForDelivery(completedAnswer, completedArtifacts),
1786
+ {
1787
+ deliveryId: messageId,
1788
+ presentation: 'feishu-text-fallback',
1789
+ },
1790
+ );
1791
+ } catch (fallbackError) {
1792
+ textSendError = fallbackError;
1793
+ this.#logger.warn?.(
1794
+ '[dsh-feishu] fallback text delivery failed; continuing with result files:',
1795
+ fallbackError,
1796
+ );
1797
+ }
1798
+ const delivery = await this.#deliverArtifacts(
1799
+ chatId,
1800
+ messageId,
1801
+ completedArtifacts,
1802
+ textReceipt,
1803
+ );
1804
+ const artifactDispatched = delivery.receipt.artifacts.some(
1805
+ ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
1806
+ );
1807
+ if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
1808
+ throw textSendError;
1809
+ }
1639
1810
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
1640
- return;
1811
+ return delivery.receipt;
1641
1812
  }
1642
1813
  if (promptStarted) throw error;
1643
1814
 
1644
1815
  this.#logger.warn?.('[dsh-feishu] native stream unavailable; using text fallback:', error.message);
1645
- const { answer } = await askInWorkspaceSession({
1816
+ const { answer, artifacts = [] } = await askInWorkspaceSession({
1646
1817
  harness: this.#harness,
1647
1818
  state: this.#state,
1648
1819
  key,
@@ -1652,9 +1823,46 @@ export class FeishuHarnessBridge {
1652
1823
  existsOptions: { signal: this.#signal },
1653
1824
  askOptions: this.#interactionAskOptions(event, key),
1654
1825
  });
1655
- for (const chunk of splitText(answer)) await this.#send(chatId, chunk);
1826
+ let textReceipt;
1827
+ let textSendError = null;
1828
+ try {
1829
+ textReceipt = await this.#sendAnswerText(
1830
+ chatId,
1831
+ answerTextForDelivery(answer, artifacts),
1832
+ {
1833
+ deliveryId: messageId,
1834
+ presentation: 'feishu-text-fallback',
1835
+ },
1836
+ );
1837
+ } catch (fallbackError) {
1838
+ textSendError = fallbackError;
1839
+ this.#logger.warn?.(
1840
+ '[dsh-feishu] fallback text delivery failed; continuing with result files:',
1841
+ fallbackError,
1842
+ );
1843
+ }
1844
+ const delivery = await this.#deliverArtifacts(chatId, messageId, artifacts, textReceipt);
1845
+ const artifactDispatched = delivery.receipt.artifacts.some(
1846
+ ({ outcome }) => outcome === 'sent' || outcome === 'unknown',
1847
+ );
1848
+ if (textSendError && !artifactDispatched && !delivery.failureNoticeVisible) {
1849
+ throw textSendError;
1850
+ }
1656
1851
  this.#status.streamFallbacks = (this.#status.streamFallbacks ?? 0) + 1;
1852
+ return delivery.receipt;
1657
1853
  }
1854
+ const delivery = await this.#deliverArtifacts(
1855
+ chatId,
1856
+ messageId,
1857
+ completedArtifacts,
1858
+ createDeliveryReceipt({
1859
+ deliveryId: messageId,
1860
+ presentation: 'feishu-cardkit',
1861
+ providerMessageIds: stream?.messageId ? [stream.messageId] : [],
1862
+ }),
1863
+ );
1864
+ this.#status.streamResponses = (this.#status.streamResponses ?? 0) + 1;
1865
+ return delivery.receipt;
1658
1866
  }
1659
1867
 
1660
1868
  async #processInteractionReply(event, messageId, key, expected, processingReaction) {
@@ -1,6 +1,22 @@
1
+ import { createHash } from 'node:crypto';
2
+
3
+ import { trackOutboundArtifactProviderPromise } from '../shared/semantic/artifact.mjs';
4
+ import { createDeliveryReceipt } from '../shared/semantic/delivery.mjs';
5
+
1
6
  const STREAM_ELEMENT_ID = 'stream_md';
2
7
  const DEFAULT_INITIAL_TEXT = '已连接 DeepSeek Harness,正在思考…';
3
8
  const MAX_STREAM_CHARS = 28000;
9
+ const MAX_FILE_OPERATION_TIMEOUT_MS = 120_000;
10
+
11
+ const FILE_DELIVERY_ERRORS = new Map([
12
+ [99991672, ['artifact-permission-required', 'Feishu file delivery requires the im:resource permission.']],
13
+ [234006, ['artifact-too-large', 'The result file exceeds Feishu\'s size limit.']],
14
+ [234010, ['artifact-empty', 'Feishu does not accept empty files.']],
15
+ [230017, ['artifact-provider-rejected', 'Feishu rejected the uploaded file ownership.']],
16
+ [230020, ['artifact-rate-limited', 'Feishu temporarily rate-limited file delivery.']],
17
+ [230049, ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']],
18
+ [230055, ['artifact-provider-rejected', 'Feishu rejected the file message type.']],
19
+ ]);
4
20
 
5
21
  function assertApiSuccess(operation, response) {
6
22
  if (response?.code && response.code !== 0) {
@@ -9,6 +25,103 @@ function assertApiSuccess(operation, response) {
9
25
  return response;
10
26
  }
11
27
 
28
+ function providerErrorCode(cause) {
29
+ const pending = [cause];
30
+ const seen = new Set();
31
+ let fallback;
32
+ while (pending.length > 0) {
33
+ const value = pending.shift();
34
+ if (!value || seen.has(value)) continue;
35
+ if (typeof value === 'object') seen.add(value);
36
+ if (Array.isArray(value)) {
37
+ pending.push(...value);
38
+ continue;
39
+ }
40
+ const code = Number(value?.code);
41
+ if (Number.isFinite(code) && code !== 0) {
42
+ if (FILE_DELIVERY_ERRORS.has(code)) return code;
43
+ fallback ??= code;
44
+ }
45
+ pending.push(value?.response?.data, value?.data, value?.error, value?.cause);
46
+ }
47
+ return fallback;
48
+ }
49
+
50
+ function fileDeliveryError(stage, cause, providerCode, { uncertain = false } = {}) {
51
+ const explicitCode = providerCode === undefined || providerCode === null
52
+ ? undefined
53
+ : Number(providerCode);
54
+ const code = Number.isFinite(explicitCode) && explicitCode !== 0
55
+ ? explicitCode
56
+ : providerErrorCode(cause);
57
+ const fallback = Number.isFinite(code)
58
+ ? ['artifact-provider-rejected', `Feishu rejected file ${stage}.`]
59
+ : uncertain
60
+ ? ['artifact-delivery-uncertain', 'Feishu could not confirm the file message result.']
61
+ : ['artifact-provider-failed', `Feishu file ${stage} failed.`];
62
+ const [errorCode, message] = FILE_DELIVERY_ERRORS.get(code) ?? fallback;
63
+ const error = new Error(message, { cause });
64
+ error.code = errorCode;
65
+ if (Number.isFinite(code)) error.providerCode = code;
66
+ return error;
67
+ }
68
+
69
+ function boundedFileTimeout(value, name) {
70
+ if (!Number.isInteger(value) || value < 1 || value > MAX_FILE_OPERATION_TIMEOUT_MS) {
71
+ throw new TypeError(`${name} must be an integer between 1 and ${MAX_FILE_OPERATION_TIMEOUT_MS}`);
72
+ }
73
+ return value;
74
+ }
75
+
76
+ function abortReason(signal) {
77
+ return signal?.reason ?? new DOMException('The operation was aborted', 'AbortError');
78
+ }
79
+
80
+ function operationTimeout(stage) {
81
+ const error = new Error(`Feishu file ${stage} timed out.`);
82
+ error.code = 'provider-timeout';
83
+ return error;
84
+ }
85
+
86
+ function waitForFileOperation(operation, { signal, timeoutMs, stage }) {
87
+ signal?.throwIfAborted();
88
+ const deadline = new AbortController();
89
+ const operationSignal = signal
90
+ ? AbortSignal.any([signal, deadline.signal])
91
+ : deadline.signal;
92
+
93
+ return new Promise((resolve, reject) => {
94
+ let settled = false;
95
+ const finish = (callback, value) => {
96
+ if (settled) return;
97
+ settled = true;
98
+ clearTimeout(timer);
99
+ operationSignal.removeEventListener('abort', onAbort);
100
+ callback(value);
101
+ };
102
+ const onAbort = () => finish(
103
+ reject,
104
+ signal?.aborted ? abortReason(signal) : operationTimeout(stage),
105
+ );
106
+ const timer = setTimeout(() => deadline.abort(), timeoutMs);
107
+ operationSignal.addEventListener('abort', onAbort, { once: true });
108
+
109
+ Promise.resolve().then(() => operation(operationSignal)).then(
110
+ (value) => finish(resolve, value),
111
+ (error) => finish(reject, error),
112
+ );
113
+ if (operationSignal.aborted) onAbort();
114
+ });
115
+ }
116
+
117
+ function deliveryUuid(file, chatId) {
118
+ const digest = createHash('sha256')
119
+ .update(`${file.deliveryKey}\u0000${chatId}`)
120
+ .digest('hex')
121
+ .slice(0, 40);
122
+ return `dshim_${digest}`;
123
+ }
124
+
12
125
  function summaryOf(text) {
13
126
  const summary = String(text ?? '').replace(/\s+/g, ' ').trim();
14
127
  return summary.length <= 50 ? summary : `${summary.slice(0, 49)}…`;
@@ -39,10 +152,19 @@ function streamingCard(initialText) {
39
152
  export class VerifiedFeishuChannel {
40
153
  #client;
41
154
  #initialText;
155
+ #fileUploadTimeoutMs;
156
+ #fileMessageTimeoutMs;
42
157
 
43
- constructor({ client, initialText = DEFAULT_INITIAL_TEXT }) {
158
+ constructor({
159
+ client,
160
+ initialText = DEFAULT_INITIAL_TEXT,
161
+ fileUploadTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
162
+ fileMessageTimeoutMs = MAX_FILE_OPERATION_TIMEOUT_MS,
163
+ }) {
44
164
  this.#client = client;
45
165
  this.#initialText = initialText;
166
+ this.#fileUploadTimeoutMs = boundedFileTimeout(fileUploadTimeoutMs, 'fileUploadTimeoutMs');
167
+ this.#fileMessageTimeoutMs = boundedFileTimeout(fileMessageTimeoutMs, 'fileMessageTimeoutMs');
46
168
  }
47
169
 
48
170
  async stream(chatId, input, options = {}) {
@@ -107,6 +229,110 @@ export class VerifiedFeishuChannel {
107
229
  }
108
230
  }
109
231
 
232
+ async sendFile(chatId, file, { replyTo, signal } = {}) {
233
+ signal?.throwIfAborted();
234
+ if (typeof chatId !== 'string' || !chatId) throw new TypeError('chatId is required');
235
+ if (!file || typeof file !== 'object'
236
+ || typeof file.artifactId !== 'string' || !file.artifactId
237
+ || typeof file.deliveryKey !== 'string' || !file.deliveryKey
238
+ || typeof file.fileName !== 'string' || !file.fileName
239
+ || !Buffer.isBuffer(file.bytes)) {
240
+ throw new TypeError('A materialized result file is required');
241
+ }
242
+ let uploaded;
243
+ try {
244
+ uploaded = await waitForFileOperation((operationSignal) => {
245
+ operationSignal.throwIfAborted();
246
+ const pending = this.#client.im.v1.file.create({
247
+ data: {
248
+ file_type: 'stream',
249
+ file_name: file.fileName,
250
+ file: file.bytes,
251
+ },
252
+ });
253
+ trackOutboundArtifactProviderPromise(file, pending);
254
+ return pending;
255
+ }, {
256
+ signal,
257
+ timeoutMs: this.#fileUploadTimeoutMs,
258
+ stage: 'upload',
259
+ });
260
+ } catch (error) {
261
+ if (signal?.aborted) throw abortReason(signal);
262
+ throw fileDeliveryError('upload', error);
263
+ }
264
+ signal?.throwIfAborted();
265
+ const fileKey = uploaded?.file_key;
266
+ if (typeof fileKey !== 'string' || !fileKey) {
267
+ throw fileDeliveryError('upload', undefined, uploaded?.code);
268
+ }
269
+
270
+ const uuid = deliveryUuid(file, chatId);
271
+ const content = JSON.stringify({ file_key: fileKey });
272
+ const request = replyTo
273
+ ? {
274
+ path: { message_id: replyTo },
275
+ data: { msg_type: 'file', content, uuid },
276
+ }
277
+ : {
278
+ params: { receive_id_type: 'chat_id' },
279
+ data: { receive_id: chatId, msg_type: 'file', content, uuid },
280
+ };
281
+ const send = () => {
282
+ const pending = replyTo
283
+ ? this.#client.im.v1.message.reply(request)
284
+ : this.#client.im.v1.message.create(request);
285
+ trackOutboundArtifactProviderPromise(file, pending);
286
+ return pending;
287
+ };
288
+
289
+ let response;
290
+ try {
291
+ response = await waitForFileOperation(async (operationSignal) => {
292
+ operationSignal.throwIfAborted();
293
+ let result;
294
+ try {
295
+ result = await send();
296
+ } catch (error) {
297
+ if (providerErrorCode(error) !== 230049) throw error;
298
+ result = { code: 230049 };
299
+ }
300
+ operationSignal.throwIfAborted();
301
+
302
+ // Feishu documents 230049 as an uncertain asynchronous send result.
303
+ // Reuse the same file_key and UUID once so the provider can deduplicate.
304
+ if (Number(result?.code) === 230049) {
305
+ result = await send();
306
+ operationSignal.throwIfAborted();
307
+ }
308
+ return result;
309
+ }, {
310
+ signal,
311
+ timeoutMs: this.#fileMessageTimeoutMs,
312
+ stage: 'message send',
313
+ });
314
+ } catch (error) {
315
+ if (signal?.aborted) throw abortReason(signal);
316
+ throw fileDeliveryError('message send', error, undefined, { uncertain: true });
317
+ }
318
+ if (Number.isFinite(Number(response?.code)) && Number(response.code) !== 0) {
319
+ throw fileDeliveryError('message send', undefined, response.code, { uncertain: true });
320
+ }
321
+ const messageId = response?.data?.message_id;
322
+ if (typeof messageId !== 'string' || !messageId) {
323
+ throw fileDeliveryError('message send', undefined, undefined, { uncertain: true });
324
+ }
325
+ return createDeliveryReceipt({
326
+ deliveryId: file.deliveryKey,
327
+ presentation: 'feishu-file',
328
+ providerMessageIds: [messageId],
329
+ artifacts: [{
330
+ artifactId: file.artifactId,
331
+ outcome: 'sent',
332
+ }],
333
+ });
334
+ }
335
+
110
336
  async #sendCard(chatId, cardId, replyTo) {
111
337
  const content = JSON.stringify({ type: 'card', data: { card_id: cardId } });
112
338
  const response = replyTo
@@ -9,6 +9,7 @@ export const REQUIRED_TENANT_SCOPES = Object.freeze([
9
9
  'im:message:send_as_bot',
10
10
  'im:message.reactions:write_only',
11
11
  'im:message:recall',
12
+ 'im:resource',
12
13
  'cardkit:card:write',
13
14
  ]);
14
15