lark-remote 0.1.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 (201) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/LICENSE +21 -0
  3. package/README.en.md +162 -0
  4. package/README.md +161 -0
  5. package/dist/binder.d.ts +57 -0
  6. package/dist/binder.js +105 -0
  7. package/dist/bridge/index.d.ts +338 -0
  8. package/dist/bridge/index.js +1281 -0
  9. package/dist/bridge/queue-manager.d.ts +277 -0
  10. package/dist/bridge/queue-manager.js +729 -0
  11. package/dist/card/bash-card-session.d.ts +78 -0
  12. package/dist/card/bash-card-session.js +174 -0
  13. package/dist/card/bash-renderer.d.ts +24 -0
  14. package/dist/card/bash-renderer.js +187 -0
  15. package/dist/card/card-budget.d.ts +31 -0
  16. package/dist/card/card-budget.js +280 -0
  17. package/dist/card/card-session.d.ts +83 -0
  18. package/dist/card/card-session.js +185 -0
  19. package/dist/card/card-shared.d.ts +28 -0
  20. package/dist/card/card-shared.js +99 -0
  21. package/dist/card/collapsible.d.ts +26 -0
  22. package/dist/card/collapsible.js +87 -0
  23. package/dist/card/run-card-session.d.ts +78 -0
  24. package/dist/card/run-card-session.js +165 -0
  25. package/dist/card/run-renderer.d.ts +66 -0
  26. package/dist/card/run-renderer.js +841 -0
  27. package/dist/card/run-state.d.ts +103 -0
  28. package/dist/card/run-state.js +313 -0
  29. package/dist/card/text-truncate.d.ts +56 -0
  30. package/dist/card/text-truncate.js +184 -0
  31. package/dist/card/time.d.ts +1 -0
  32. package/dist/card/time.js +14 -0
  33. package/dist/card/tool-render.d.ts +25 -0
  34. package/dist/card/tool-render.js +176 -0
  35. package/dist/cli.d.ts +28 -0
  36. package/dist/cli.js +89 -0
  37. package/dist/common/guards.d.ts +37 -0
  38. package/dist/common/guards.js +65 -0
  39. package/dist/config/codex-config.d.ts +113 -0
  40. package/dist/config/codex-config.js +428 -0
  41. package/dist/config/dir.d.ts +17 -0
  42. package/dist/config/dir.js +81 -0
  43. package/dist/config/index.d.ts +219 -0
  44. package/dist/config/index.js +388 -0
  45. package/dist/config/kimi-config.d.ts +33 -0
  46. package/dist/config/kimi-config.js +111 -0
  47. package/dist/config/opencode-config.d.ts +20 -0
  48. package/dist/config/opencode-config.js +124 -0
  49. package/dist/config/pi-config.d.ts +49 -0
  50. package/dist/config/pi-config.js +151 -0
  51. package/dist/config/wizard.d.ts +12 -0
  52. package/dist/config/wizard.js +84 -0
  53. package/dist/connector/file-limits.d.ts +7 -0
  54. package/dist/connector/file-limits.js +8 -0
  55. package/dist/connector/index.d.ts +86 -0
  56. package/dist/connector/index.js +340 -0
  57. package/dist/error-classification.d.ts +15 -0
  58. package/dist/error-classification.js +24 -0
  59. package/dist/index.d.ts +1 -0
  60. package/dist/index.js +580 -0
  61. package/dist/instance-lock.d.ts +30 -0
  62. package/dist/instance-lock.js +184 -0
  63. package/dist/logger/index.d.ts +57 -0
  64. package/dist/logger/index.js +178 -0
  65. package/dist/order/index.d.ts +27 -0
  66. package/dist/order/index.js +90 -0
  67. package/dist/persistence/atomic-write.d.ts +10 -0
  68. package/dist/persistence/atomic-write.js +65 -0
  69. package/dist/persistence/load-json-file.d.ts +9 -0
  70. package/dist/persistence/load-json-file.js +23 -0
  71. package/dist/restart.d.ts +24 -0
  72. package/dist/restart.js +73 -0
  73. package/dist/router/card-helpers.d.ts +28 -0
  74. package/dist/router/card-helpers.js +65 -0
  75. package/dist/router/config/claude.d.ts +14 -0
  76. package/dist/router/config/claude.js +72 -0
  77. package/dist/router/config/codex.d.ts +22 -0
  78. package/dist/router/config/codex.js +100 -0
  79. package/dist/router/config/common/render.d.ts +16 -0
  80. package/dist/router/config/common/render.js +158 -0
  81. package/dist/router/config/index.d.ts +18 -0
  82. package/dist/router/config/index.js +38 -0
  83. package/dist/router/config/kimi.d.ts +21 -0
  84. package/dist/router/config/kimi.js +63 -0
  85. package/dist/router/config/opencode.d.ts +12 -0
  86. package/dist/router/config/opencode.js +44 -0
  87. package/dist/router/config/pi.d.ts +12 -0
  88. package/dist/router/config/pi.js +50 -0
  89. package/dist/router/config/types.d.ts +43 -0
  90. package/dist/router/config/types.js +8 -0
  91. package/dist/router/index.d.ts +318 -0
  92. package/dist/router/index.js +2715 -0
  93. package/dist/router/order-exec-dispatch.d.ts +30 -0
  94. package/dist/router/order-exec-dispatch.js +48 -0
  95. package/dist/router/utils.d.ts +44 -0
  96. package/dist/router/utils.js +116 -0
  97. package/dist/runner/bash/index.d.ts +4 -0
  98. package/dist/runner/bash/index.js +5 -0
  99. package/dist/runner/bash/runner.d.ts +55 -0
  100. package/dist/runner/bash/runner.js +213 -0
  101. package/dist/runner/claude/index.d.ts +4 -0
  102. package/dist/runner/claude/index.js +5 -0
  103. package/dist/runner/claude/runner.d.ts +29 -0
  104. package/dist/runner/claude/runner.js +83 -0
  105. package/dist/runner/codex/argv.d.ts +33 -0
  106. package/dist/runner/codex/argv.js +40 -0
  107. package/dist/runner/codex/index.d.ts +1 -0
  108. package/dist/runner/codex/index.js +2 -0
  109. package/dist/runner/codex/jsonl.d.ts +37 -0
  110. package/dist/runner/codex/jsonl.js +221 -0
  111. package/dist/runner/codex/runner.d.ts +65 -0
  112. package/dist/runner/codex/runner.js +124 -0
  113. package/dist/runner/common/exec-translator.d.ts +73 -0
  114. package/dist/runner/common/exec-translator.js +105 -0
  115. package/dist/runner/common/jsonl-stream.d.ts +39 -0
  116. package/dist/runner/common/jsonl-stream.js +203 -0
  117. package/dist/runner/common/process-stopper.d.ts +28 -0
  118. package/dist/runner/common/process-stopper.js +79 -0
  119. package/dist/runner/common/runner-utils.d.ts +21 -0
  120. package/dist/runner/common/runner-utils.js +65 -0
  121. package/dist/runner/common/spawn-heartbeat.d.ts +33 -0
  122. package/dist/runner/common/spawn-heartbeat.js +51 -0
  123. package/dist/runner/common/spawning-runner.d.ts +321 -0
  124. package/dist/runner/common/spawning-runner.js +634 -0
  125. package/dist/runner/common/usage.d.ts +31 -0
  126. package/dist/runner/common/usage.js +29 -0
  127. package/dist/runner/index.d.ts +19 -0
  128. package/dist/runner/index.js +24 -0
  129. package/dist/runner/kimi/index.d.ts +1 -0
  130. package/dist/runner/kimi/index.js +2 -0
  131. package/dist/runner/kimi/jsonl.d.ts +28 -0
  132. package/dist/runner/kimi/jsonl.js +143 -0
  133. package/dist/runner/kimi/runner.d.ts +50 -0
  134. package/dist/runner/kimi/runner.js +105 -0
  135. package/dist/runner/opencode/argv.d.ts +32 -0
  136. package/dist/runner/opencode/argv.js +35 -0
  137. package/dist/runner/opencode/index.d.ts +1 -0
  138. package/dist/runner/opencode/index.js +2 -0
  139. package/dist/runner/opencode/jsonl.d.ts +68 -0
  140. package/dist/runner/opencode/jsonl.js +295 -0
  141. package/dist/runner/opencode/runner.d.ts +63 -0
  142. package/dist/runner/opencode/runner.js +93 -0
  143. package/dist/runner/pi/index.d.ts +1 -0
  144. package/dist/runner/pi/index.js +2 -0
  145. package/dist/runner/pi/jsonl.d.ts +128 -0
  146. package/dist/runner/pi/jsonl.js +249 -0
  147. package/dist/runner/pi/runner.d.ts +37 -0
  148. package/dist/runner/pi/runner.js +76 -0
  149. package/dist/runner/registry.d.ts +54 -0
  150. package/dist/runner/registry.js +75 -0
  151. package/dist/runner/resolve-agent-choices.d.ts +7 -0
  152. package/dist/runner/resolve-agent-choices.js +76 -0
  153. package/dist/runner/sync-agent-choices.d.ts +6 -0
  154. package/dist/runner/sync-agent-choices.js +73 -0
  155. package/dist/runner/types.d.ts +250 -0
  156. package/dist/runner/types.js +8 -0
  157. package/dist/session/claude/index.d.ts +1 -0
  158. package/dist/session/claude/index.js +2 -0
  159. package/dist/session/claude/session-reader.d.ts +34 -0
  160. package/dist/session/claude/session-reader.js +46 -0
  161. package/dist/session/claude/sessions.d.ts +53 -0
  162. package/dist/session/claude/sessions.js +528 -0
  163. package/dist/session/codex/index.d.ts +1 -0
  164. package/dist/session/codex/index.js +2 -0
  165. package/dist/session/codex/rollout-reader.d.ts +88 -0
  166. package/dist/session/codex/rollout-reader.js +473 -0
  167. package/dist/session/codex/sessions.d.ts +21 -0
  168. package/dist/session/codex/sessions.js +40 -0
  169. package/dist/session/common/catch-up.d.ts +12 -0
  170. package/dist/session/common/catch-up.js +31 -0
  171. package/dist/session/common/constants.d.ts +11 -0
  172. package/dist/session/common/constants.js +12 -0
  173. package/dist/session/common/content-blocks.d.ts +37 -0
  174. package/dist/session/common/content-blocks.js +68 -0
  175. package/dist/session/common/jsonl.d.ts +77 -0
  176. package/dist/session/common/jsonl.js +351 -0
  177. package/dist/session/common/usage-accumulator.d.ts +58 -0
  178. package/dist/session/common/usage-accumulator.js +72 -0
  179. package/dist/session/index.d.ts +7 -0
  180. package/dist/session/index.js +11 -0
  181. package/dist/session/kimi/index.d.ts +1 -0
  182. package/dist/session/kimi/index.js +2 -0
  183. package/dist/session/kimi/sessions.d.ts +81 -0
  184. package/dist/session/kimi/sessions.js +527 -0
  185. package/dist/session/opencode/index.d.ts +1 -0
  186. package/dist/session/opencode/index.js +2 -0
  187. package/dist/session/opencode/sessions.d.ts +81 -0
  188. package/dist/session/opencode/sessions.js +388 -0
  189. package/dist/session/pi/index.d.ts +1 -0
  190. package/dist/session/pi/index.js +2 -0
  191. package/dist/session/pi/sessions.d.ts +27 -0
  192. package/dist/session/pi/sessions.js +399 -0
  193. package/dist/session/registry.d.ts +12 -0
  194. package/dist/session/registry.js +20 -0
  195. package/dist/session/session-store.d.ts +86 -0
  196. package/dist/session/session-store.js +450 -0
  197. package/dist/startup-contact.d.ts +18 -0
  198. package/dist/startup-contact.js +51 -0
  199. package/dist/workspace/index.d.ts +19 -0
  200. package/dist/workspace/index.js +53 -0
  201. package/package.json +93 -0
@@ -0,0 +1,340 @@
1
+ import { createLarkChannel, } from '@larksuite/channel';
2
+ import { getLogger } from '../logger/index.js';
3
+ import { MAX_FILE_UPLOAD_SIZE } from './file-limits.js';
4
+ import axios from 'axios';
5
+ import fs from 'node:fs';
6
+ import FormData from 'form-data';
7
+ /**
8
+ * dedup 缓存 TTL(毫秒)。控制飞书事件去重窗口,全局作用于 message + cardAction。
9
+ *
10
+ * 必须远小于用户连击间隔,否则同一按钮的连续点击会被当成"重复事件"丢弃:
11
+ * - SDK 的 cardAction dedup eventId = `card:{messageId}:{operator.openId}:{actionId}`
12
+ * - actionId = `tag|name|option|JSON.stringify(value)`,不含时间戳/事件序号
13
+ * - config 卡片原地更新(updateCardInPlace),用户在同一张卡上连点 toggle:
14
+ * messageId / operator / value 三段都相同 → eventId 完全相同 → 第二次点击被
15
+ * seenCache drop,toggle 不可逆("显示工具结果"等开关第二次点击静默失效)。
16
+ *
17
+ * SDK 默认 12h 太长;60s 仍会误伤连击。300ms 挡飞书瞬时重投递(<100ms 级),
18
+ * 放过用户连击(慢点两次通常 >500ms)。代价:削弱 message 秒级重投递防护——
19
+ * 飞书 WS 正常连接不重发,重连补发延迟常 >60s 本就挡不住,串行队列(§9.6)
20
+ * 还兜底防并发,影响可接受。
21
+ *
22
+ * 此 bug 无法在 stub connector 测试中复现(测试绕过 SDK safety 层)。
23
+ * 详见 design.md §9.8。原地更新逻辑新增按钮时参考该节去重风险分类。
24
+ */
25
+ export const DEDUP_TTL_MS = 300;
26
+ /**
27
+ * 判断 send 失败是否值得重试一次(§9.5 限流重试口径,§P1-3 修正)。
28
+ *
29
+ * @larksuite/channel@0.3.0 的 classifyError 把飞书业务码 99991400/99991401
30
+ * (频率控制)归类为 `code='permission_denied'`(SDK 源码实证),并保留原始
31
+ * axios 错误在 `cause` 链上(`cause.response.data.code`)。因此仅判
32
+ * `code === 'rate_limited'` 会让这条重试路径对设计目标完全死亡。判定覆盖:
33
+ * 1. code === 'rate_limited'(HTTP 429,SDK 已内置退避重试,这里作为兜底保留);
34
+ * 2. context.feishuCode / cause.code / cause.data.code / cause.response.data.code
35
+ * 命中 99991400 或 99991401;
36
+ * 3. message 内含 99991400/99991401(低版本 SDK 把业务码拼进 msg 的兜底)。
37
+ * 注意:普通 permission_denied(如缺 scope 的 99991663)不重试——重试无意义且
38
+ * 会放大无效出站。
39
+ */
40
+ function shouldRetrySendError(err) {
41
+ const e = err;
42
+ if (e?.code === 'rate_limited')
43
+ return true;
44
+ const feishuCodes = [
45
+ e?.context?.feishuCode,
46
+ e?.cause?.code,
47
+ e?.cause?.data?.code,
48
+ e?.cause?.response?.data?.code,
49
+ ];
50
+ if (feishuCodes.some((c) => c === 99991400 || c === 99991401))
51
+ return true;
52
+ if (typeof e?.message === 'string' && /9999140[01]/.test(e.message))
53
+ return true;
54
+ return false;
55
+ }
56
+ export class FeishuConnector {
57
+ channel;
58
+ onMessage;
59
+ onCardAction;
60
+ isConnected = false;
61
+ appId;
62
+ appSecret;
63
+ constructor(config) {
64
+ this.appId = config.feishu.appId;
65
+ this.appSecret = config.feishu.appSecret;
66
+ this.channel = createLarkChannel({
67
+ appId: config.feishu.appId,
68
+ appSecret: config.feishu.appSecret,
69
+ // Attach raw Feishu event on every cardAction: the SDK normalizer drops
70
+ // `input_value` (CardKit 2.0 input submit-icon payload), so handlers
71
+ // (queue.edit input) read it from action.raw.
72
+ includeRawEvent: true,
73
+ policy: {
74
+ dmMode: 'open',
75
+ requireMention: false,
76
+ },
77
+ safety: {
78
+ dedup: {
79
+ ttl: DEDUP_TTL_MS,
80
+ maxEntries: 1000,
81
+ },
82
+ },
83
+ });
84
+ this.channel.on('message', (msg) => {
85
+ if (msg.chatType !== 'p2p')
86
+ return;
87
+ this.onMessage?.({
88
+ userId: msg.senderId,
89
+ messageId: msg.messageId,
90
+ chatId: msg.chatId,
91
+ content: msg.content,
92
+ });
93
+ });
94
+ this.channel.on('cardAction', (action) => {
95
+ // Return the handler's response (toast / in-place card update) so the
96
+ // SDK passes it back to Feishu as the button-click callback response.
97
+ return this.onCardAction?.(action);
98
+ });
99
+ this.channel.on('error', (err) => {
100
+ getLogger().error('[feishu] channel error:', err.code, err.message);
101
+ });
102
+ this.channel.on('reconnecting', () => {
103
+ getLogger().info('[feishu] reconnecting...');
104
+ this.isConnected = false;
105
+ });
106
+ this.channel.on('reconnected', () => {
107
+ getLogger().info('[feishu] reconnected');
108
+ this.isConnected = true;
109
+ });
110
+ }
111
+ get connected() {
112
+ return this.isConnected;
113
+ }
114
+ setMessageHandler(handler) {
115
+ this.onMessage = handler;
116
+ }
117
+ setCardActionHandler(handler) {
118
+ this.onCardAction = handler;
119
+ }
120
+ async connect() {
121
+ try {
122
+ await this.channel.connect();
123
+ this.isConnected = true;
124
+ getLogger().info('[feishu] connected via WebSocket');
125
+ }
126
+ catch (err) {
127
+ getLogger().error('[feishu] connection failed:', err);
128
+ throw err;
129
+ }
130
+ }
131
+ async disconnect() {
132
+ try {
133
+ await this.channel.disconnect();
134
+ this.isConnected = false;
135
+ getLogger().info('[feishu] disconnected');
136
+ }
137
+ catch (err) {
138
+ getLogger().warn('[feishu] disconnect error:', err);
139
+ // Still mark as disconnected even if there was an error
140
+ this.isConnected = false;
141
+ }
142
+ }
143
+ async reconnect() {
144
+ // disconnect() swallows its own errors internally, so no try/catch needed
145
+ // here — it always resolves.
146
+ await this.disconnect();
147
+ await this.connect();
148
+ }
149
+ /**
150
+ * Send a message with automatic rate-limit retry (§9.5).
151
+ * Encounters rate_limited / feishuCode 99991400|99991401(SDK 归类为
152
+ * permission_denied)→ sleep 200ms → retry once.
153
+ */
154
+ async sendWithRetry(chatId, input, opts) {
155
+ try {
156
+ const result = await this.channel.send(chatId, input, { replyTo: opts?.replyTo });
157
+ return result.messageId;
158
+ }
159
+ catch (err) {
160
+ if (shouldRetrySendError(err)) {
161
+ getLogger().warn('[feishu] rate limited, retrying in 200ms...');
162
+ await new Promise((r) => setTimeout(r, 200));
163
+ try {
164
+ const result = await this.channel.send(chatId, input, { replyTo: opts?.replyTo });
165
+ return result.messageId;
166
+ }
167
+ catch (retryErr) {
168
+ // P2-17: throw the RETRY error, not the outer `err`. Previously
169
+ // `throw err` here re-threw the first (rate-limit) failure, hiding
170
+ // the real reason the retry attempt failed (e.g. auth invalid).
171
+ getLogger().warn('[feishu] retry after rate limit still failed');
172
+ throw retryErr;
173
+ }
174
+ }
175
+ throw err;
176
+ }
177
+ }
178
+ async streamCard(chatId, initial, producer, opts) {
179
+ try {
180
+ const result = await this.channel.stream(chatId, { card: { initial, producer } }, { replyTo: opts?.replyTo });
181
+ return result.messageId;
182
+ }
183
+ catch (err) {
184
+ // Log the error but don't throw - this prevents unhandled rejection
185
+ const errorInfo = this.formatError(err);
186
+ getLogger().error('[feishu] streamCard failed:', errorInfo);
187
+ // Throw a plain error to avoid axios error serialization issues
188
+ throw new Error(`streamCard failed: ${errorInfo}`, { cause: err });
189
+ }
190
+ }
191
+ async updateCard(messageId, card) {
192
+ try {
193
+ await this.channel.updateCard(messageId, card);
194
+ }
195
+ catch (err) {
196
+ // Log the error but don't throw - this prevents unhandled rejection
197
+ const errorInfo = this.formatError(err);
198
+ getLogger().error('[feishu] updateCard failed:', errorInfo);
199
+ // Throw a plain error to avoid axios error serialization issues
200
+ throw new Error(`updateCard failed: ${errorInfo}`, { cause: err });
201
+ }
202
+ }
203
+ /** Format error for logging without causing circular serialization */
204
+ formatError(err) {
205
+ if (err instanceof Error) {
206
+ // For axios errors, extract useful info without circular refs
207
+ const axiosErr = err;
208
+ if (axiosErr.response?.status) {
209
+ return `${err.message} (HTTP ${axiosErr.response.status})`;
210
+ }
211
+ if (axiosErr.code) {
212
+ return `${err.message} (code: ${axiosErr.code})`;
213
+ }
214
+ return err.message;
215
+ }
216
+ return String(err);
217
+ }
218
+ async addReaction(messageId, emoji) {
219
+ try {
220
+ await this.channel.addReaction(messageId, emoji);
221
+ }
222
+ catch (err) {
223
+ getLogger().error('[feishu] addReaction failed:', this.formatError(err));
224
+ }
225
+ }
226
+ /**
227
+ * Upload a file to Feishu and send it to the specified chat.
228
+ * Uses the im/v1/files upload API.
229
+ */
230
+ async sendFile(chatId, filePath) {
231
+ // Check file existence first with proper error handling
232
+ let stat;
233
+ try {
234
+ stat = fs.statSync(filePath);
235
+ }
236
+ catch (err) {
237
+ const code = err.code;
238
+ if (code === 'ENOENT') {
239
+ throw new Error(`Cannot access file: file not found (${filePath})`, { cause: err });
240
+ }
241
+ throw new Error(`Cannot access file: ${err.message}`, { cause: err });
242
+ }
243
+ // Check file size limit (30MB, aligned with Feishu im/v1/files API)
244
+ if (stat.size > MAX_FILE_UPLOAD_SIZE) {
245
+ throw new Error(`File too large (${(stat.size / 1_000_000).toFixed(1)}MB), exceeds ${MAX_FILE_UPLOAD_SIZE / (1024 * 1024)}MB limit`);
246
+ }
247
+ const fileName = filePath.split('/').pop() ?? 'file';
248
+ // P2-18: capture the read stream so it can be destroyed on any failure
249
+ // path. Without this, a token/upload/send failure leaks the fd (axios
250
+ // only closes the stream when it fully consumes it on success).
251
+ let fileStream = null;
252
+ try {
253
+ // P2-18: tenant_access_token cache. Avoid re-fetching on every file send
254
+ // (token is valid ~2h); validate data.code before trusting the token.
255
+ const accessToken = await this.getTenantAccessToken();
256
+ // Upload file
257
+ const form = new FormData();
258
+ fileStream = fs.createReadStream(filePath);
259
+ form.append('file', fileStream);
260
+ form.append('file_name', fileName);
261
+ form.append('file_type', 'stream');
262
+ // P2-18: timeout on the upload (large file, slow link) — 120s.
263
+ const uploadResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/files', form, {
264
+ headers: {
265
+ ...form.getHeaders(),
266
+ Authorization: `Bearer ${accessToken}`,
267
+ },
268
+ timeout: 120000,
269
+ });
270
+ if (uploadResp.data.code !== 0) {
271
+ throw new Error(`File upload failed: ${uploadResp.data.msg}`);
272
+ }
273
+ const fileKey = uploadResp.data.data.file_key;
274
+ // Send file message. P2-18: timeout 30s.
275
+ const sendResp = await axios.post('https://open.feishu.cn/open-apis/im/v1/messages', {
276
+ receive_id: chatId,
277
+ msg_type: 'file',
278
+ content: JSON.stringify({ file_key: fileKey }),
279
+ }, {
280
+ headers: {
281
+ 'Content-Type': 'application/json; charset=utf-8',
282
+ Authorization: `Bearer ${accessToken}`,
283
+ },
284
+ params: { receive_id_type: 'chat_id' },
285
+ timeout: 30000,
286
+ });
287
+ if (sendResp.data.code !== 0) {
288
+ throw new Error(`File message failed: ${sendResp.data.msg}`);
289
+ }
290
+ return sendResp.data.data.message_id;
291
+ }
292
+ catch (err) {
293
+ // P2-18: destroy the read stream on failure so the fd is released.
294
+ if (fileStream) {
295
+ try {
296
+ fileStream.destroy();
297
+ }
298
+ catch {
299
+ /* already destroyed */
300
+ }
301
+ }
302
+ // Log the error but throw a formatted error to prevent unhandled rejection
303
+ const errorInfo = this.formatError(err);
304
+ getLogger().error('[feishu] sendFile failed:', errorInfo);
305
+ throw new Error(`sendFile failed: ${errorInfo}`, { cause: err });
306
+ }
307
+ }
308
+ /**
309
+ * Cached tenant_access_token for sendFile (P2-18). The token is valid ~2h;
310
+ * fetching it on every file send wastes a round-trip and an unguarded fetch
311
+ * could return data.code != 0 with an undefined token. Cache with expiry and
312
+ * validate the response code.
313
+ */
314
+ cachedToken = null;
315
+ cachedTokenExpireAt = 0;
316
+ async getTenantAccessToken() {
317
+ const now = Date.now();
318
+ if (this.cachedToken && now < this.cachedTokenExpireAt) {
319
+ return this.cachedToken;
320
+ }
321
+ // P2-18: timeout 30s on the token request.
322
+ const tokenResp = await axios.post('https://open.feishu.cn/open-apis/auth/v3/tenant_access_token/internal', {
323
+ app_id: this.appId,
324
+ app_secret: this.appSecret,
325
+ }, {
326
+ headers: { 'Content-Type': 'application/json; charset=utf-8' },
327
+ timeout: 30000,
328
+ });
329
+ if (tokenResp.data.code !== 0) {
330
+ throw new Error(`Failed to get tenant_access_token: ${tokenResp.data.msg ?? 'unknown error'}`);
331
+ }
332
+ const token = tokenResp.data.tenant_access_token;
333
+ // Expire is in seconds; refresh 5min early as a safety margin.
334
+ const expire = (tokenResp.data.expire ?? 7200);
335
+ this.cachedToken = token;
336
+ this.cachedTokenExpireAt = now + (expire - 300) * 1000;
337
+ return token;
338
+ }
339
+ }
340
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,15 @@
1
+ /**
2
+ * 进程级未捕获 rejection 的分类。
3
+ *
4
+ * 飞书 SDK @larksuite/channel 的流式卡片 patch 走 throttle + setTimeout 异步触发,
5
+ * patchCard 失败时 rejection 是一条脱离调用方 await 链的 detached Promise ——
6
+ * `RunCardSession.update()` 的 try-catch 只包住 `controller.update()` 返回的 Promise,
7
+ * 而后者在调完 `throttle.note()` 后立即 resolve,不等真正的 patch,所以挡不住
8
+ * 延迟 fire 的 patch rejection。该 rejection 最终冒泡到 `process.unhandledRejection`。
9
+ *
10
+ * 该分类决定 detach 逃逸的 patch rejection 是「尽力而为的展示层失败」
11
+ * (recoverable,记日志不 exit)还是「进程状态损坏」(fatal,exit)。
12
+ */
13
+ type RejectionVerdict = 'recoverable' | 'fatal';
14
+ export declare function classifyRejection(reason: unknown): RejectionVerdict;
15
+ export {};
@@ -0,0 +1,24 @@
1
+ export function classifyRejection(reason) {
2
+ // Guard against null/undefined/primitive reasons: the `as` cast below is
3
+ // TS-only (erased at runtime), so `null.response` would throw a TypeError
4
+ // synchronously inside the unhandledRejection callback — Node then re-emits
5
+ // it as uncaughtException, causing a misleading fatal exit and losing the
6
+ // original rejection.
7
+ if (reason == null || typeof reason !== 'object')
8
+ return 'fatal';
9
+ const err = reason;
10
+ const status = err.response?.status;
11
+ if (status !== undefined && status >= 500 && status < 600)
12
+ return 'recoverable';
13
+ if (err.code === 'ETIMEDOUT' || err.code === 'ECONNRESET')
14
+ return 'recoverable';
15
+ // 飞书 API 业务错误(如 230027 外部会话无权限)—— 经 SDK throttle detach 路径逃逸的
16
+ // patch rejection。流式卡片是尽力而为的展示层,飞书侧业务拒绝不应击穿进程。
17
+ // 识别条件:4xx + 带飞书业务 code(区别于请求构造错误的纯 400)。
18
+ const feishuCode = err.response?.data?.code;
19
+ if (status !== undefined && status >= 400 && status < 500 && typeof feishuCode === 'number') {
20
+ return 'recoverable';
21
+ }
22
+ return 'fatal';
23
+ }
24
+ //# sourceMappingURL=error-classification.js.map
@@ -0,0 +1 @@
1
+ export {};