@kodelyth/feishu 2026.5.42 → 2026.6.2

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 (192) hide show
  1. package/klaw.plugin.json +1712 -47
  2. package/package.json +19 -6
  3. package/api.ts +0 -32
  4. package/channel-entry.ts +0 -20
  5. package/channel-plugin-api.ts +0 -1
  6. package/contract-api.ts +0 -16
  7. package/index.ts +0 -82
  8. package/runtime-api.ts +0 -52
  9. package/secret-contract-api.ts +0 -5
  10. package/security-contract-api.ts +0 -1
  11. package/session-key-api.ts +0 -1
  12. package/setup-api.ts +0 -3
  13. package/setup-entry.test.ts +0 -19
  14. package/setup-entry.ts +0 -13
  15. package/src/accounts.test.ts +0 -480
  16. package/src/accounts.ts +0 -333
  17. package/src/agent-config.ts +0 -21
  18. package/src/app-registration.ts +0 -331
  19. package/src/approval-auth.test.ts +0 -24
  20. package/src/approval-auth.ts +0 -25
  21. package/src/async.test.ts +0 -35
  22. package/src/async.ts +0 -104
  23. package/src/audio-preflight.runtime.ts +0 -9
  24. package/src/bitable.test.ts +0 -136
  25. package/src/bitable.ts +0 -762
  26. package/src/bot-content.ts +0 -485
  27. package/src/bot-group-name.test.ts +0 -116
  28. package/src/bot-runtime-api.ts +0 -12
  29. package/src/bot-sender-name.ts +0 -125
  30. package/src/bot.broadcast.test.ts +0 -523
  31. package/src/bot.card-action.test.ts +0 -552
  32. package/src/bot.checkBotMentioned.test.ts +0 -265
  33. package/src/bot.helpers.test.ts +0 -135
  34. package/src/bot.stripBotMention.test.ts +0 -126
  35. package/src/bot.test.ts +0 -3671
  36. package/src/bot.ts +0 -1703
  37. package/src/card-action.ts +0 -447
  38. package/src/card-interaction.test.ts +0 -131
  39. package/src/card-interaction.ts +0 -159
  40. package/src/card-test-helpers.ts +0 -54
  41. package/src/card-ux-approval.ts +0 -65
  42. package/src/card-ux-launcher.test.ts +0 -106
  43. package/src/card-ux-launcher.ts +0 -121
  44. package/src/card-ux-shared.ts +0 -33
  45. package/src/channel-runtime-api.ts +0 -16
  46. package/src/channel.runtime.ts +0 -47
  47. package/src/channel.test.ts +0 -1151
  48. package/src/channel.ts +0 -1423
  49. package/src/chat-schema.ts +0 -25
  50. package/src/chat.test.ts +0 -240
  51. package/src/chat.ts +0 -188
  52. package/src/client-timeout.ts +0 -42
  53. package/src/client.test.ts +0 -447
  54. package/src/client.ts +0 -262
  55. package/src/comment-dispatcher-runtime-api.ts +0 -6
  56. package/src/comment-dispatcher.test.ts +0 -185
  57. package/src/comment-dispatcher.ts +0 -107
  58. package/src/comment-handler-runtime-api.ts +0 -3
  59. package/src/comment-handler.test.ts +0 -592
  60. package/src/comment-handler.ts +0 -303
  61. package/src/comment-reaction.test.ts +0 -138
  62. package/src/comment-reaction.ts +0 -259
  63. package/src/comment-shared.test.ts +0 -183
  64. package/src/comment-shared.ts +0 -406
  65. package/src/comment-target.ts +0 -44
  66. package/src/config-schema.test.ts +0 -326
  67. package/src/config-schema.ts +0 -335
  68. package/src/conversation-id.test.ts +0 -18
  69. package/src/conversation-id.ts +0 -199
  70. package/src/dedup-runtime-api.ts +0 -1
  71. package/src/dedup.ts +0 -141
  72. package/src/dedupe-key.ts +0 -72
  73. package/src/directory.static.ts +0 -61
  74. package/src/directory.test.ts +0 -141
  75. package/src/directory.ts +0 -124
  76. package/src/doc-schema.ts +0 -182
  77. package/src/docx-batch-insert.test.ts +0 -116
  78. package/src/docx-batch-insert.ts +0 -223
  79. package/src/docx-color-text.ts +0 -154
  80. package/src/docx-table-ops.test.ts +0 -53
  81. package/src/docx-table-ops.ts +0 -316
  82. package/src/docx-types.ts +0 -38
  83. package/src/docx.account-selection.test.ts +0 -95
  84. package/src/docx.test.ts +0 -701
  85. package/src/docx.ts +0 -1596
  86. package/src/drive-schema.ts +0 -92
  87. package/src/drive.test.ts +0 -1237
  88. package/src/drive.ts +0 -829
  89. package/src/dynamic-agent.test.ts +0 -155
  90. package/src/dynamic-agent.ts +0 -143
  91. package/src/event-types.ts +0 -45
  92. package/src/external-keys.test.ts +0 -20
  93. package/src/external-keys.ts +0 -19
  94. package/src/lifecycle.test-support.ts +0 -220
  95. package/src/media.test.ts +0 -955
  96. package/src/media.ts +0 -1105
  97. package/src/mention-target.types.ts +0 -5
  98. package/src/mention.ts +0 -114
  99. package/src/message-action-contract.ts +0 -13
  100. package/src/monitor-state-runtime-api.ts +0 -7
  101. package/src/monitor-transport-runtime-api.ts +0 -10
  102. package/src/monitor.account.ts +0 -492
  103. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +0 -219
  104. package/src/monitor.bot-identity.ts +0 -86
  105. package/src/monitor.bot-menu-handler.ts +0 -165
  106. package/src/monitor.bot-menu.lifecycle.test-support.ts +0 -224
  107. package/src/monitor.bot-menu.test.ts +0 -188
  108. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +0 -264
  109. package/src/monitor.card-action.lifecycle.test-support.ts +0 -421
  110. package/src/monitor.cleanup.test.ts +0 -383
  111. package/src/monitor.comment-notice-handler.ts +0 -105
  112. package/src/monitor.comment.test.ts +0 -967
  113. package/src/monitor.comment.ts +0 -1386
  114. package/src/monitor.lifecycle.test.ts +0 -4
  115. package/src/monitor.message-handler.ts +0 -350
  116. package/src/monitor.reaction.lifecycle.test-support.ts +0 -68
  117. package/src/monitor.reaction.test.ts +0 -739
  118. package/src/monitor.startup.test.ts +0 -213
  119. package/src/monitor.startup.ts +0 -74
  120. package/src/monitor.state.defaults.test.ts +0 -46
  121. package/src/monitor.state.ts +0 -170
  122. package/src/monitor.synthetic-error.ts +0 -18
  123. package/src/monitor.test-mocks.ts +0 -46
  124. package/src/monitor.transport.ts +0 -451
  125. package/src/monitor.ts +0 -100
  126. package/src/monitor.webhook-e2e.test.ts +0 -279
  127. package/src/monitor.webhook-security.test.ts +0 -389
  128. package/src/monitor.webhook.test-helpers.ts +0 -116
  129. package/src/outbound-runtime-api.ts +0 -1
  130. package/src/outbound.test.ts +0 -1118
  131. package/src/outbound.ts +0 -785
  132. package/src/perm-schema.ts +0 -52
  133. package/src/perm.ts +0 -170
  134. package/src/pins.ts +0 -108
  135. package/src/policy.test.ts +0 -223
  136. package/src/policy.ts +0 -318
  137. package/src/post.test.ts +0 -105
  138. package/src/post.ts +0 -275
  139. package/src/probe.test.ts +0 -283
  140. package/src/probe.ts +0 -166
  141. package/src/processing-claims.ts +0 -59
  142. package/src/qr-terminal.ts +0 -1
  143. package/src/reactions.ts +0 -123
  144. package/src/reasoning-preview.test.ts +0 -113
  145. package/src/reasoning-preview.ts +0 -28
  146. package/src/reply-dispatcher-runtime-api.ts +0 -7
  147. package/src/reply-dispatcher.test.ts +0 -1513
  148. package/src/reply-dispatcher.ts +0 -748
  149. package/src/runtime.ts +0 -9
  150. package/src/secret-contract.ts +0 -145
  151. package/src/secret-input.ts +0 -1
  152. package/src/security-audit-shared.ts +0 -69
  153. package/src/security-audit.test.ts +0 -59
  154. package/src/security-audit.ts +0 -1
  155. package/src/send-result.ts +0 -80
  156. package/src/send-target.test.ts +0 -86
  157. package/src/send-target.ts +0 -35
  158. package/src/send.reply-fallback.test.ts +0 -417
  159. package/src/send.test.ts +0 -621
  160. package/src/send.ts +0 -861
  161. package/src/sequential-key.test.ts +0 -72
  162. package/src/sequential-key.ts +0 -25
  163. package/src/sequential-queue.test.ts +0 -165
  164. package/src/sequential-queue.ts +0 -86
  165. package/src/session-conversation.ts +0 -42
  166. package/src/session-route.ts +0 -48
  167. package/src/setup-core.ts +0 -51
  168. package/src/setup-surface.test.ts +0 -484
  169. package/src/setup-surface.ts +0 -618
  170. package/src/streaming-card.test.ts +0 -397
  171. package/src/streaming-card.ts +0 -571
  172. package/src/subagent-hooks.test.ts +0 -627
  173. package/src/subagent-hooks.ts +0 -413
  174. package/src/targets.ts +0 -97
  175. package/src/test-support/lifecycle-test-support.ts +0 -454
  176. package/src/thread-bindings.test.ts +0 -180
  177. package/src/thread-bindings.ts +0 -331
  178. package/src/tool-account-routing.test.ts +0 -250
  179. package/src/tool-account.test.ts +0 -44
  180. package/src/tool-account.ts +0 -93
  181. package/src/tool-factory-test-harness.ts +0 -79
  182. package/src/tool-result.test.ts +0 -32
  183. package/src/tool-result.ts +0 -16
  184. package/src/tools-config.test.ts +0 -21
  185. package/src/tools-config.ts +0 -22
  186. package/src/types.ts +0 -106
  187. package/src/typing.test.ts +0 -144
  188. package/src/typing.ts +0 -214
  189. package/src/wiki-schema.ts +0 -69
  190. package/src/wiki.ts +0 -270
  191. package/subagent-hooks-api.ts +0 -31
  192. package/tsconfig.json +0 -16
package/src/media.ts DELETED
@@ -1,1105 +0,0 @@
1
- import fs from "node:fs";
2
- import path from "node:path";
3
- import { Readable } from "node:stream";
4
- import type * as Lark from "@larksuiteoapi/node-sdk";
5
- import type { MessageReceipt } from "klaw/plugin-sdk/channel-message";
6
- import { mediaKindFromMime } from "klaw/plugin-sdk/media-mime";
7
- import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "klaw/plugin-sdk/media-runtime";
8
- import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "klaw/plugin-sdk/media-store";
9
- import { readByteStreamWithLimit } from "klaw/plugin-sdk/response-limit-runtime";
10
- import { readRegularFile, writeExternalFileWithinRoot } from "klaw/plugin-sdk/security-runtime";
11
- import { normalizeLowercaseStringOrEmpty } from "klaw/plugin-sdk/string-coerce-runtime";
12
- import {
13
- resolvePreferredKlawTmpDir,
14
- withTempWorkspace,
15
- withTempDownloadPath,
16
- } from "klaw/plugin-sdk/temp-path";
17
- import type { ClawdbotConfig } from "../runtime-api.js";
18
- import { resolveFeishuRuntimeAccount } from "./accounts.js";
19
- import { createFeishuClient } from "./client.js";
20
- import { requestFeishuApi } from "./comment-shared.js";
21
- import { normalizeFeishuExternalKey } from "./external-keys.js";
22
- import { getFeishuRuntime } from "./runtime.js";
23
- import {
24
- assertFeishuMessageApiSuccess,
25
- resolveFeishuReceiptKind,
26
- toFeishuSendResult,
27
- } from "./send-result.js";
28
- import { resolveFeishuSendTarget } from "./send-target.js";
29
-
30
- const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
31
- const FEISHU_VOICE_FILE_NAME = "voice.ogg";
32
- const FEISHU_VOICE_SAMPLE_RATE_HZ = 48_000;
33
- const FEISHU_VOICE_BITRATE = "64k";
34
-
35
- const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
36
- ".aac",
37
- ".aiff",
38
- ".alac",
39
- ".amr",
40
- ".caf",
41
- ".flac",
42
- ".m4a",
43
- ".mp3",
44
- ".oga",
45
- ".wav",
46
- ".webm",
47
- ".wma",
48
- ]);
49
-
50
- export type DownloadImageResult = {
51
- buffer: Buffer;
52
- contentType?: string;
53
- };
54
-
55
- export type DownloadMessageResourceResult = {
56
- buffer: Buffer;
57
- contentType?: string;
58
- fileName?: string;
59
- };
60
-
61
- export type SaveMessageResourceResult = {
62
- saved: SavedMedia;
63
- contentType?: string;
64
- fileName?: string;
65
- };
66
-
67
- function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
68
- account: ReturnType<typeof resolveFeishuRuntimeAccount>;
69
- client: ReturnType<typeof createFeishuClient>;
70
- } {
71
- const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
72
- if (!account.configured) {
73
- throw new Error(`Feishu account "${account.accountId}" not configured`);
74
- }
75
-
76
- return {
77
- account,
78
- client: createFeishuClient({
79
- ...account,
80
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
81
- }),
82
- };
83
- }
84
-
85
- type FeishuUploadResponse =
86
- | Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
87
- | Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
88
-
89
- type FeishuDownloadResponse =
90
- | Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
91
- | Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
92
- | Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
93
-
94
- type FeishuHeaderMap = Record<string, string | string[]>;
95
- type FeishuMessageResourceDownloadType = "image" | "file" | "media";
96
-
97
- function asHeaderMap(value: object | undefined): FeishuHeaderMap | undefined {
98
- if (!value) {
99
- return undefined;
100
- }
101
- const entries = Object.entries(value);
102
- if (entries.every(([, entry]) => typeof entry === "string" || Array.isArray(entry))) {
103
- return Object.fromEntries(entries) as FeishuHeaderMap;
104
- }
105
- return undefined;
106
- }
107
-
108
- function extractFeishuUploadKey(
109
- response: FeishuUploadResponse,
110
- params: {
111
- key: "image_key" | "file_key";
112
- errorPrefix: string;
113
- },
114
- ): string {
115
- if (!response) {
116
- throw new Error(`${params.errorPrefix}: empty response`);
117
- }
118
-
119
- const wrappedResponse = response as {
120
- image_key?: string;
121
- file_key?: string;
122
- code?: number;
123
- msg?: string;
124
- data?: Partial<Record<"image_key" | "file_key", string>>;
125
- };
126
- if (wrappedResponse.code !== undefined && wrappedResponse.code !== 0) {
127
- throw new Error(
128
- `${params.errorPrefix}: ${wrappedResponse.msg || `code ${wrappedResponse.code}`}`,
129
- );
130
- }
131
-
132
- const key =
133
- params.key === "image_key"
134
- ? (wrappedResponse.image_key ?? wrappedResponse.data?.image_key)
135
- : (wrappedResponse.file_key ?? wrappedResponse.data?.file_key);
136
- if (!key) {
137
- throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
138
- }
139
- return key;
140
- }
141
-
142
- function readHeaderValue(
143
- headers: Record<string, unknown> | undefined,
144
- name: string,
145
- ): string | undefined {
146
- if (!headers) {
147
- return undefined;
148
- }
149
- for (const [key, value] of Object.entries(headers)) {
150
- if (normalizeLowercaseStringOrEmpty(key) !== normalizeLowercaseStringOrEmpty(name)) {
151
- continue;
152
- }
153
- if (typeof value === "string" && value.trim()) {
154
- return value.trim();
155
- }
156
- if (Array.isArray(value)) {
157
- const first = value.find((entry) => typeof entry === "string" && entry.trim());
158
- if (typeof first === "string") {
159
- return first.trim();
160
- }
161
- }
162
- }
163
- return undefined;
164
- }
165
-
166
- function readHttpStatusFromError(error: unknown): number | undefined {
167
- if (!error || typeof error !== "object") {
168
- return undefined;
169
- }
170
-
171
- const response = (error as { response?: unknown }).response;
172
- if (response && typeof response === "object") {
173
- const status = (response as { status?: unknown }).status;
174
- if (typeof status === "number") {
175
- return status;
176
- }
177
- }
178
-
179
- const status = (error as { status?: unknown }).status;
180
- return typeof status === "number" ? status : undefined;
181
- }
182
-
183
- function isHttpStatusError(error: unknown, status: number): boolean {
184
- return readHttpStatusFromError(error) === status;
185
- }
186
-
187
- function containsEastAsianScript(value: string): boolean {
188
- return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value);
189
- }
190
-
191
- function recoverUtf8FileNameFromLatin1Header(value: string): string {
192
- const recovered = Buffer.from(value, "latin1").toString("utf8");
193
- if (recovered !== value && !recovered.includes("\uFFFD") && containsEastAsianScript(recovered)) {
194
- return recovered;
195
- }
196
- return value;
197
- }
198
-
199
- function decodeDispositionFileName(value: string): string | undefined {
200
- const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
201
- if (utf8Match?.[1]) {
202
- try {
203
- return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1"));
204
- } catch {
205
- return utf8Match[1].trim().replace(/^"(.*)"$/, "$1");
206
- }
207
- }
208
-
209
- const plainMatch = value.match(/filename="?([^";]+)"?/i);
210
- const plainFileName = plainMatch?.[1]?.trim();
211
- return plainFileName ? recoverUtf8FileNameFromLatin1Header(plainFileName) : undefined;
212
- }
213
-
214
- function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
215
- contentType?: string;
216
- fileName?: string;
217
- } {
218
- const responseWithOptionalFields = response as FeishuDownloadResponse & {
219
- header?: object;
220
- contentType?: string;
221
- mime_type?: string;
222
- data?: {
223
- contentType?: string;
224
- mime_type?: string;
225
- file_name?: string;
226
- fileName?: string;
227
- };
228
- file_name?: string;
229
- fileName?: string;
230
- };
231
- const headers =
232
- asHeaderMap(responseWithOptionalFields.headers) ??
233
- asHeaderMap(responseWithOptionalFields.header);
234
-
235
- const contentType =
236
- readHeaderValue(headers, "content-type") ??
237
- responseWithOptionalFields.contentType ??
238
- responseWithOptionalFields.mime_type ??
239
- responseWithOptionalFields.data?.contentType ??
240
- responseWithOptionalFields.data?.mime_type;
241
-
242
- const disposition = readHeaderValue(headers, "content-disposition");
243
- const fileName =
244
- (disposition ? decodeDispositionFileName(disposition) : undefined) ??
245
- responseWithOptionalFields.file_name ??
246
- responseWithOptionalFields.fileName ??
247
- responseWithOptionalFields.data?.file_name ??
248
- responseWithOptionalFields.data?.fileName;
249
-
250
- return { contentType, fileName };
251
- }
252
-
253
- function mediaLimitError(maxBytes: number): Error {
254
- return new Error(`Media exceeds ${Math.round(maxBytes / (1024 * 1024))}MB limit`);
255
- }
256
-
257
- function assertBufferWithinLimit(buffer: Buffer, maxBytes: number): Buffer {
258
- if (buffer.byteLength > maxBytes) {
259
- throw mediaLimitError(maxBytes);
260
- }
261
- return buffer;
262
- }
263
-
264
- async function readFeishuResponseBuffer(params: {
265
- response: FeishuDownloadResponse;
266
- tmpDirPrefix: string;
267
- errorPrefix: string;
268
- maxBytes: number;
269
- }): Promise<Buffer> {
270
- const { response, maxBytes } = params;
271
- if (Buffer.isBuffer(response)) {
272
- return assertBufferWithinLimit(response, maxBytes);
273
- }
274
- if (response instanceof ArrayBuffer) {
275
- return assertBufferWithinLimit(Buffer.from(response), maxBytes);
276
- }
277
- const responseWithOptionalFields = response as FeishuDownloadResponse & {
278
- code?: number;
279
- msg?: string;
280
- data?: Buffer | ArrayBuffer;
281
- [Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
282
- };
283
- if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
284
- throw new Error(
285
- `${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
286
- );
287
- }
288
-
289
- if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
290
- return assertBufferWithinLimit(responseWithOptionalFields.data, maxBytes);
291
- }
292
- if (responseWithOptionalFields.data instanceof ArrayBuffer) {
293
- return assertBufferWithinLimit(Buffer.from(responseWithOptionalFields.data), maxBytes);
294
- }
295
- if (typeof response.getReadableStream === "function") {
296
- return readByteStreamWithLimit(response.getReadableStream(), {
297
- maxBytes,
298
- onOverflow: () => mediaLimitError(maxBytes),
299
- });
300
- }
301
- if (typeof response.writeFile === "function") {
302
- return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
303
- await response.writeFile(tmpPath);
304
- const stat = await fs.promises.stat(tmpPath);
305
- if (stat.size > maxBytes) {
306
- throw mediaLimitError(maxBytes);
307
- }
308
- return await fs.promises.readFile(tmpPath);
309
- });
310
- }
311
- if (responseWithOptionalFields[Symbol.asyncIterator]) {
312
- const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
313
- return readByteStreamWithLimit(asyncIterable, {
314
- maxBytes,
315
- onOverflow: () => mediaLimitError(maxBytes),
316
- });
317
- }
318
- if (response instanceof Readable) {
319
- return readByteStreamWithLimit(response, {
320
- maxBytes,
321
- onOverflow: () => mediaLimitError(maxBytes),
322
- });
323
- }
324
-
325
- const keys = Object.keys(response as object);
326
- throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
327
- }
328
-
329
- async function saveFeishuResponseMedia(params: {
330
- response: FeishuDownloadResponse;
331
- tmpDirPrefix: string;
332
- errorPrefix: string;
333
- maxBytes: number;
334
- contentType?: string;
335
- fileName?: string;
336
- }): Promise<SavedMedia> {
337
- const { response, maxBytes, contentType, fileName } = params;
338
- if (Buffer.isBuffer(response)) {
339
- return saveMediaBuffer(response, contentType, "inbound", maxBytes, fileName);
340
- }
341
- if (response instanceof ArrayBuffer) {
342
- return saveMediaBuffer(Buffer.from(response), contentType, "inbound", maxBytes, fileName);
343
- }
344
- const responseWithOptionalFields = response as FeishuDownloadResponse & {
345
- code?: number;
346
- msg?: string;
347
- data?: Buffer | ArrayBuffer;
348
- [Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
349
- };
350
- if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
351
- throw new Error(
352
- `${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
353
- );
354
- }
355
-
356
- if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
357
- return saveMediaBuffer(
358
- responseWithOptionalFields.data,
359
- contentType,
360
- "inbound",
361
- maxBytes,
362
- fileName,
363
- );
364
- }
365
- if (responseWithOptionalFields.data instanceof ArrayBuffer) {
366
- return saveMediaBuffer(
367
- Buffer.from(responseWithOptionalFields.data),
368
- contentType,
369
- "inbound",
370
- maxBytes,
371
- fileName,
372
- );
373
- }
374
- if (typeof response.getReadableStream === "function") {
375
- return saveMediaStream(
376
- response.getReadableStream(),
377
- contentType,
378
- "inbound",
379
- maxBytes,
380
- fileName,
381
- );
382
- }
383
- if (typeof response.writeFile === "function") {
384
- return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
385
- await response.writeFile(tmpPath);
386
- const stat = await fs.promises.stat(tmpPath);
387
- if (stat.size > maxBytes) {
388
- throw mediaLimitError(maxBytes);
389
- }
390
- return await saveMediaStream(
391
- fs.createReadStream(tmpPath),
392
- contentType,
393
- "inbound",
394
- maxBytes,
395
- fileName,
396
- );
397
- });
398
- }
399
- if (responseWithOptionalFields[Symbol.asyncIterator]) {
400
- const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
401
- return saveMediaStream(asyncIterable, contentType, "inbound", maxBytes, fileName);
402
- }
403
- if (response instanceof Readable) {
404
- return saveMediaStream(response, contentType, "inbound", maxBytes, fileName);
405
- }
406
-
407
- const keys = Object.keys(response as object);
408
- throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
409
- }
410
-
411
- /**
412
- * Download an image from Feishu using image_key.
413
- * Used for downloading images sent in messages.
414
- */
415
- export async function downloadImageFeishu(params: {
416
- cfg: ClawdbotConfig;
417
- imageKey: string;
418
- accountId?: string;
419
- maxBytes?: number;
420
- }): Promise<DownloadImageResult> {
421
- const { cfg, imageKey, accountId, maxBytes = 30 * 1024 * 1024 } = params;
422
- const normalizedImageKey = normalizeFeishuExternalKey(imageKey);
423
- if (!normalizedImageKey) {
424
- throw new Error("Feishu image download failed: invalid image_key");
425
- }
426
- const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
427
-
428
- const response = await client.im.image.get({
429
- path: { image_key: normalizedImageKey },
430
- });
431
-
432
- const buffer = await readFeishuResponseBuffer({
433
- response,
434
- tmpDirPrefix: "klaw-feishu-img-",
435
- errorPrefix: "Feishu image download failed",
436
- maxBytes,
437
- });
438
- const meta = extractFeishuDownloadMetadata(response);
439
- return { buffer, contentType: meta.contentType };
440
- }
441
-
442
- async function downloadMessageResourceWithType(params: {
443
- client: ReturnType<typeof createFeishuClient>;
444
- messageId: string;
445
- fileKey: string;
446
- type: FeishuMessageResourceDownloadType;
447
- maxBytes: number;
448
- }): Promise<DownloadMessageResourceResult> {
449
- const response = await params.client.im.messageResource.get({
450
- path: { message_id: params.messageId, file_key: params.fileKey },
451
- params: { type: params.type },
452
- });
453
-
454
- const buffer = await readFeishuResponseBuffer({
455
- response,
456
- tmpDirPrefix: "klaw-feishu-resource-",
457
- errorPrefix: "Feishu message resource download failed",
458
- maxBytes: params.maxBytes,
459
- });
460
- return { buffer, ...extractFeishuDownloadMetadata(response) };
461
- }
462
-
463
- async function saveMessageResourceWithType(params: {
464
- client: ReturnType<typeof createFeishuClient>;
465
- messageId: string;
466
- fileKey: string;
467
- type: FeishuMessageResourceDownloadType;
468
- maxBytes: number;
469
- originalFilename?: string;
470
- }): Promise<SaveMessageResourceResult> {
471
- const response = await params.client.im.messageResource.get({
472
- path: { message_id: params.messageId, file_key: params.fileKey },
473
- params: { type: params.type },
474
- });
475
- const meta = extractFeishuDownloadMetadata(response);
476
- const saved = await saveFeishuResponseMedia({
477
- response,
478
- tmpDirPrefix: "klaw-feishu-resource-",
479
- errorPrefix: "Feishu message resource download failed",
480
- maxBytes: params.maxBytes,
481
- contentType: meta.contentType,
482
- fileName: meta.fileName ?? params.originalFilename,
483
- });
484
- return { saved, ...meta };
485
- }
486
-
487
- /**
488
- * Download a message resource (file/image/audio/video) from Feishu.
489
- * Used for downloading files, audio, and video from messages.
490
- */
491
- export async function downloadMessageResourceFeishu(params: {
492
- cfg: ClawdbotConfig;
493
- messageId: string;
494
- fileKey: string;
495
- type: "image" | "file";
496
- accountId?: string;
497
- maxBytes?: number;
498
- }): Promise<DownloadMessageResourceResult> {
499
- const { cfg, messageId, fileKey, type, accountId, maxBytes = 30 * 1024 * 1024 } = params;
500
- const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
501
- if (!normalizedFileKey) {
502
- throw new Error("Feishu message resource download failed: invalid file_key");
503
- }
504
- const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
505
-
506
- try {
507
- return await downloadMessageResourceWithType({
508
- client,
509
- messageId,
510
- fileKey: normalizedFileKey,
511
- type,
512
- maxBytes,
513
- });
514
- } catch (err) {
515
- if (type !== "file" || !isHttpStatusError(err, 502)) {
516
- throw err;
517
- }
518
- try {
519
- return await downloadMessageResourceWithType({
520
- client,
521
- messageId,
522
- fileKey: normalizedFileKey,
523
- type: "media",
524
- maxBytes,
525
- });
526
- } catch {
527
- throw err;
528
- }
529
- }
530
- }
531
-
532
- export async function saveMessageResourceFeishu(params: {
533
- cfg: ClawdbotConfig;
534
- messageId: string;
535
- fileKey: string;
536
- type: "image" | "file";
537
- accountId?: string;
538
- maxBytes: number;
539
- originalFilename?: string;
540
- }): Promise<SaveMessageResourceResult> {
541
- const { cfg, messageId, fileKey, type, accountId, maxBytes, originalFilename } = params;
542
- const normalizedFileKey = normalizeFeishuExternalKey(fileKey);
543
- if (!normalizedFileKey) {
544
- throw new Error("Feishu message resource download failed: invalid file_key");
545
- }
546
- const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
547
-
548
- try {
549
- return await saveMessageResourceWithType({
550
- client,
551
- messageId,
552
- fileKey: normalizedFileKey,
553
- type,
554
- maxBytes,
555
- originalFilename,
556
- });
557
- } catch (err) {
558
- if (type !== "file" || !isHttpStatusError(err, 502)) {
559
- throw err;
560
- }
561
- try {
562
- return await saveMessageResourceWithType({
563
- client,
564
- messageId,
565
- fileKey: normalizedFileKey,
566
- type: "media",
567
- maxBytes,
568
- originalFilename,
569
- });
570
- } catch {
571
- throw err;
572
- }
573
- }
574
- }
575
-
576
- export type UploadImageResult = {
577
- imageKey: string;
578
- };
579
-
580
- export type UploadFileResult = {
581
- fileKey: string;
582
- };
583
-
584
- export type SendMediaResult = {
585
- messageId: string;
586
- chatId: string;
587
- receipt: MessageReceipt;
588
- voiceIntentDegradedToFile?: boolean;
589
- };
590
-
591
- /**
592
- * Upload an image to Feishu and get an image_key for sending.
593
- * Supports: JPEG, PNG, WEBP, GIF, TIFF, BMP, ICO
594
- */
595
- export async function uploadImageFeishu(params: {
596
- cfg: ClawdbotConfig;
597
- image: Buffer | string; // Buffer or file path
598
- imageType?: "message" | "avatar";
599
- accountId?: string;
600
- }): Promise<UploadImageResult> {
601
- const { cfg, image, imageType = "message", accountId } = params;
602
- const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
603
-
604
- // SDK accepts Buffer directly. Keep string path support on this helper, but
605
- // verify the path as a regular local file before uploading it.
606
- // See: https://github.com/larksuite/node-sdk/issues/121
607
- const imageData =
608
- typeof image === "string" ? (await readRegularFile({ filePath: image })).buffer : image;
609
-
610
- const response = await requestFeishuApi(
611
- () =>
612
- client.im.image.create({
613
- data: {
614
- image_type: imageType,
615
- image: imageData,
616
- },
617
- }),
618
- "Feishu image upload failed",
619
- { includeNestedErrorLogId: true },
620
- );
621
-
622
- return {
623
- imageKey: extractFeishuUploadKey(response, {
624
- key: "image_key",
625
- errorPrefix: "Feishu image upload failed",
626
- }),
627
- };
628
- }
629
-
630
- /**
631
- * Sanitize a filename for safe use in Feishu multipart/form-data uploads.
632
- * Strips control characters and multipart-injection vectors (CWE-93) while
633
- * preserving the original UTF-8 display name (Chinese, emoji, etc.).
634
- *
635
- * Previous versions percent-encoded non-ASCII characters, but the Feishu
636
- * `im.file.create` API uses `file_name` as a literal display name — it does
637
- * NOT decode percent-encoding — so encoded filenames appeared as garbled text
638
- * in chat (regression in v2026.3.2).
639
- */
640
- export function sanitizeFileNameForUpload(fileName: string): string {
641
- return fileName.replace(/[\p{Cc}"\\]/gu, "_");
642
- }
643
-
644
- /**
645
- * Upload a file to Feishu and get a file_key for sending.
646
- * Max file size: 30MB
647
- */
648
- export async function uploadFileFeishu(params: {
649
- cfg: ClawdbotConfig;
650
- file: Buffer | string; // Buffer or file path
651
- fileName: string;
652
- fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
653
- duration?: number; // Required for audio/video files, in milliseconds
654
- accountId?: string;
655
- }): Promise<UploadFileResult> {
656
- const { cfg, file, fileName, fileType, duration, accountId } = params;
657
- const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
658
-
659
- // SDK accepts Buffer directly. Keep string path support on this helper, but
660
- // verify the path as a regular local file before uploading it.
661
- // See: https://github.com/larksuite/node-sdk/issues/121
662
- const fileData =
663
- typeof file === "string" ? (await readRegularFile({ filePath: file })).buffer : file;
664
-
665
- const safeFileName = sanitizeFileNameForUpload(fileName);
666
-
667
- const response = await requestFeishuApi(
668
- () =>
669
- client.im.file.create({
670
- data: {
671
- file_type: fileType,
672
- file_name: safeFileName,
673
- file: fileData,
674
- ...(duration !== undefined && { duration }),
675
- },
676
- }),
677
- "Feishu file upload failed",
678
- { includeNestedErrorLogId: true },
679
- );
680
-
681
- return {
682
- fileKey: extractFeishuUploadKey(response, {
683
- key: "file_key",
684
- errorPrefix: "Feishu file upload failed",
685
- }),
686
- };
687
- }
688
-
689
- /**
690
- * Send an image message using an image_key
691
- */
692
- export async function sendImageFeishu(params: {
693
- cfg: ClawdbotConfig;
694
- to: string;
695
- imageKey: string;
696
- replyToMessageId?: string;
697
- replyInThread?: boolean;
698
- accountId?: string;
699
- }): Promise<SendMediaResult> {
700
- const { cfg, to, imageKey, replyToMessageId, replyInThread, accountId } = params;
701
- const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
702
- cfg,
703
- to,
704
- accountId,
705
- });
706
- const content = JSON.stringify({ image_key: imageKey });
707
-
708
- if (replyToMessageId) {
709
- const response = await requestFeishuApi(
710
- () =>
711
- client.im.message.reply({
712
- path: { message_id: replyToMessageId },
713
- data: {
714
- content,
715
- msg_type: "image",
716
- ...(replyInThread ? { reply_in_thread: true } : {}),
717
- },
718
- }),
719
- "Feishu image reply failed",
720
- { includeNestedErrorLogId: true },
721
- );
722
- assertFeishuMessageApiSuccess(response, "Feishu image reply failed");
723
- return toFeishuSendResult(response, receiveId, "media");
724
- }
725
-
726
- const response = await requestFeishuApi(
727
- () =>
728
- client.im.message.create({
729
- params: { receive_id_type: receiveIdType },
730
- data: {
731
- receive_id: receiveId,
732
- content,
733
- msg_type: "image",
734
- },
735
- }),
736
- "Feishu image send failed",
737
- { includeNestedErrorLogId: true },
738
- );
739
- assertFeishuMessageApiSuccess(response, "Feishu image send failed");
740
- return toFeishuSendResult(response, receiveId, "media");
741
- }
742
-
743
- /**
744
- * Send a file message using a file_key
745
- */
746
- export async function sendFileFeishu(params: {
747
- cfg: ClawdbotConfig;
748
- to: string;
749
- fileKey: string;
750
- /** Use "audio" for audio, "media" for video (mp4), "file" for documents */
751
- msgType?: "file" | "audio" | "media";
752
- replyToMessageId?: string;
753
- replyInThread?: boolean;
754
- accountId?: string;
755
- }): Promise<SendMediaResult> {
756
- const { cfg, to, fileKey, replyToMessageId, replyInThread, accountId } = params;
757
- const msgType = params.msgType ?? "file";
758
- const { client, receiveId, receiveIdType } = resolveFeishuSendTarget({
759
- cfg,
760
- to,
761
- accountId,
762
- });
763
- const content = JSON.stringify({ file_key: fileKey });
764
-
765
- if (replyToMessageId) {
766
- const response = await requestFeishuApi(
767
- () =>
768
- client.im.message.reply({
769
- path: { message_id: replyToMessageId },
770
- data: {
771
- content,
772
- msg_type: msgType,
773
- ...(replyInThread ? { reply_in_thread: true } : {}),
774
- },
775
- }),
776
- "Feishu file reply failed",
777
- { includeNestedErrorLogId: true },
778
- );
779
- assertFeishuMessageApiSuccess(response, "Feishu file reply failed");
780
- return toFeishuSendResult(response, receiveId, resolveFeishuReceiptKind(msgType));
781
- }
782
-
783
- const response = await requestFeishuApi(
784
- () =>
785
- client.im.message.create({
786
- params: { receive_id_type: receiveIdType },
787
- data: {
788
- receive_id: receiveId,
789
- content,
790
- msg_type: msgType,
791
- },
792
- }),
793
- "Feishu file send failed",
794
- { includeNestedErrorLogId: true },
795
- );
796
- assertFeishuMessageApiSuccess(response, "Feishu file send failed");
797
- return toFeishuSendResult(response, receiveId, resolveFeishuReceiptKind(msgType));
798
- }
799
-
800
- /**
801
- * Helper to detect file type from extension
802
- */
803
- export function detectFileType(
804
- fileName: string,
805
- ): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
806
- const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
807
- switch (ext) {
808
- case ".opus":
809
- case ".ogg":
810
- return "opus";
811
- case ".mp4":
812
- case ".mov":
813
- case ".avi":
814
- return "mp4";
815
- case ".pdf":
816
- return "pdf";
817
- case ".doc":
818
- case ".docx":
819
- return "doc";
820
- case ".xls":
821
- case ".xlsx":
822
- return "xls";
823
- case ".ppt":
824
- case ".pptx":
825
- return "ppt";
826
- default:
827
- return "stream";
828
- }
829
- }
830
-
831
- function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): {
832
- fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
833
- msgType: "image" | "file" | "audio" | "media";
834
- } {
835
- const { fileName, contentType } = params;
836
- const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
837
- const mimeKind = mediaKindFromMime(contentType);
838
-
839
- const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
840
- ext,
841
- );
842
- if (isImageExt || mimeKind === "image") {
843
- return { msgType: "image" };
844
- }
845
-
846
- if (
847
- ext === ".opus" ||
848
- ext === ".ogg" ||
849
- contentType === "audio/ogg" ||
850
- contentType === "audio/opus"
851
- ) {
852
- return { fileType: "opus", msgType: "audio" };
853
- }
854
-
855
- if (
856
- [".mp4", ".mov", ".avi"].includes(ext) ||
857
- contentType === "video/mp4" ||
858
- contentType === "video/quicktime" ||
859
- contentType === "video/x-msvideo"
860
- ) {
861
- return { fileType: "mp4", msgType: "media" };
862
- }
863
-
864
- const fileType = detectFileType(fileName);
865
- return {
866
- fileType,
867
- msgType:
868
- fileType === "stream"
869
- ? "file"
870
- : fileType === "opus"
871
- ? "audio"
872
- : fileType === "mp4"
873
- ? "media"
874
- : "file",
875
- };
876
- }
877
-
878
- function isFeishuNativeVoiceAudio(params: { fileName: string; contentType?: string }): boolean {
879
- const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
880
- const contentType = normalizeLowercaseStringOrEmpty(params.contentType);
881
- return (
882
- ext === ".opus" || ext === ".ogg" || contentType === "audio/ogg" || contentType === "audio/opus"
883
- );
884
- }
885
-
886
- function normalizeMediaNameForExtension(raw: string): string {
887
- try {
888
- return new URL(raw).pathname;
889
- } catch {
890
- return raw.split(/[?#]/, 1)[0] ?? raw;
891
- }
892
- }
893
-
894
- export function shouldSuppressFeishuTextForVoiceMedia(params: {
895
- mediaUrl?: string;
896
- fileName?: string;
897
- contentType?: string;
898
- audioAsVoice?: boolean;
899
- }): boolean {
900
- if (params.audioAsVoice === true) {
901
- return true;
902
- }
903
- if (
904
- params.fileName &&
905
- isFeishuNativeVoiceAudio({
906
- fileName: params.fileName,
907
- contentType: params.contentType,
908
- })
909
- ) {
910
- return true;
911
- }
912
- if (!params.mediaUrl) {
913
- return false;
914
- }
915
- return isFeishuNativeVoiceAudio({
916
- fileName: normalizeMediaNameForExtension(params.mediaUrl),
917
- contentType: params.contentType,
918
- });
919
- }
920
-
921
- function isLikelyTranscodableAudio(params: { fileName: string; contentType?: string }): boolean {
922
- const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
923
- const contentType = normalizeLowercaseStringOrEmpty(params.contentType);
924
- return FEISHU_TRANSCODABLE_AUDIO_EXTS.has(ext) || mediaKindFromMime(contentType) === "audio";
925
- }
926
-
927
- async function transcodeToFeishuVoiceOpus(params: {
928
- buffer: Buffer;
929
- fileName: string;
930
- contentType?: string;
931
- }): Promise<{ buffer: Buffer; fileName: string; contentType: string }> {
932
- return await withTempWorkspace(
933
- { rootDir: resolvePreferredKlawTmpDir(), prefix: "feishu-voice-" },
934
- async (workspace) => {
935
- const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
936
- const inputExt = ext && ext.length <= 12 ? ext : ".audio";
937
- const inputPath = await workspace.write(`input${inputExt}`, params.buffer);
938
- await writeExternalFileWithinRoot({
939
- rootDir: workspace.dir,
940
- path: FEISHU_VOICE_FILE_NAME,
941
- write: async (outputPath) => {
942
- await runFfmpeg([
943
- "-hide_banner",
944
- "-loglevel",
945
- "error",
946
- "-y",
947
- "-i",
948
- inputPath,
949
- "-vn",
950
- "-sn",
951
- "-dn",
952
- "-t",
953
- String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
954
- "-ar",
955
- String(FEISHU_VOICE_SAMPLE_RATE_HZ),
956
- "-ac",
957
- "1",
958
- "-c:a",
959
- "libopus",
960
- "-b:a",
961
- FEISHU_VOICE_BITRATE,
962
- "-f",
963
- "ogg",
964
- outputPath,
965
- ]);
966
- },
967
- });
968
- return {
969
- buffer: await workspace.read(FEISHU_VOICE_FILE_NAME),
970
- fileName: FEISHU_VOICE_FILE_NAME,
971
- contentType: "audio/ogg",
972
- };
973
- },
974
- );
975
- }
976
-
977
- async function prepareFeishuVoiceMedia(params: {
978
- buffer: Buffer;
979
- fileName: string;
980
- contentType?: string;
981
- audioAsVoice?: boolean;
982
- }): Promise<{ buffer: Buffer; fileName: string; contentType?: string }> {
983
- if (isFeishuNativeVoiceAudio(params)) {
984
- return params;
985
- }
986
- if (params.audioAsVoice !== true || !isLikelyTranscodableAudio(params)) {
987
- return params;
988
- }
989
- try {
990
- return await transcodeToFeishuVoiceOpus(params);
991
- } catch (err) {
992
- console.warn(
993
- `[feishu] audioAsVoice transcode failed; sending ${params.fileName} as a file attachment:`,
994
- err,
995
- );
996
- return params;
997
- }
998
- }
999
-
1000
- /**
1001
- * Upload and send media (image or file) from URL, local path, or buffer.
1002
- * When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
1003
- * must be passed so loadWebMedia allows the path (post CVE-2026-26321).
1004
- */
1005
- export async function sendMediaFeishu(params: {
1006
- cfg: ClawdbotConfig;
1007
- to: string;
1008
- mediaUrl?: string;
1009
- mediaBuffer?: Buffer;
1010
- fileName?: string;
1011
- replyToMessageId?: string;
1012
- replyInThread?: boolean;
1013
- accountId?: string;
1014
- /** Allowed roots for local path reads; required for local filePath to work. */
1015
- mediaLocalRoots?: readonly string[];
1016
- /** When true, transcode compatible audio to Feishu native Ogg/Opus voice bubbles. */
1017
- audioAsVoice?: boolean;
1018
- }): Promise<SendMediaResult> {
1019
- const {
1020
- cfg,
1021
- to,
1022
- mediaUrl,
1023
- mediaBuffer,
1024
- fileName,
1025
- replyToMessageId,
1026
- replyInThread,
1027
- accountId,
1028
- mediaLocalRoots,
1029
- audioAsVoice,
1030
- } = params;
1031
- const account = resolveFeishuRuntimeAccount({ cfg, accountId });
1032
- if (!account.configured) {
1033
- throw new Error(`Feishu account "${account.accountId}" not configured`);
1034
- }
1035
- const mediaMaxBytes = (account.config?.mediaMaxMb ?? 30) * 1024 * 1024;
1036
-
1037
- let buffer: Buffer;
1038
- let name: string;
1039
- let contentType: string | undefined;
1040
-
1041
- if (mediaBuffer) {
1042
- buffer = mediaBuffer;
1043
- name = fileName ?? "file";
1044
- } else if (mediaUrl) {
1045
- const loaded = await getFeishuRuntime().media.loadWebMedia(mediaUrl, {
1046
- maxBytes: mediaMaxBytes,
1047
- optimizeImages: false,
1048
- localRoots: mediaLocalRoots?.length ? mediaLocalRoots : undefined,
1049
- });
1050
- buffer = loaded.buffer;
1051
- name = fileName ?? loaded.fileName ?? "file";
1052
- contentType = loaded.contentType;
1053
- } else {
1054
- throw new Error("Either mediaUrl or mediaBuffer must be provided");
1055
- }
1056
-
1057
- const prepared = await prepareFeishuVoiceMedia({
1058
- buffer,
1059
- fileName: name,
1060
- contentType,
1061
- audioAsVoice,
1062
- });
1063
- buffer = prepared.buffer;
1064
- name = prepared.fileName;
1065
- contentType = prepared.contentType;
1066
-
1067
- const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType });
1068
- const voiceIntentDegradedToFile = audioAsVoice === true && routing.msgType !== "audio";
1069
-
1070
- if (routing.msgType === "image") {
1071
- const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
1072
- const result = await sendImageFeishu({
1073
- cfg,
1074
- to,
1075
- imageKey,
1076
- replyToMessageId,
1077
- replyInThread,
1078
- accountId,
1079
- });
1080
- return {
1081
- ...result,
1082
- ...(voiceIntentDegradedToFile ? { voiceIntentDegradedToFile: true } : {}),
1083
- };
1084
- }
1085
- const { fileKey } = await uploadFileFeishu({
1086
- cfg,
1087
- file: buffer,
1088
- fileName: name,
1089
- fileType: routing.fileType ?? "stream",
1090
- accountId,
1091
- });
1092
- const result = await sendFileFeishu({
1093
- cfg,
1094
- to,
1095
- fileKey,
1096
- msgType: routing.msgType,
1097
- replyToMessageId,
1098
- replyInThread,
1099
- accountId,
1100
- });
1101
- return {
1102
- ...result,
1103
- ...(voiceIntentDegradedToFile ? { voiceIntentDegradedToFile: true } : {}),
1104
- };
1105
- }