@openclaw/feishu 2026.3.12 → 2026.5.1-beta.1

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 (188) hide show
  1. package/api.ts +31 -0
  2. package/channel-entry.ts +20 -0
  3. package/channel-plugin-api.ts +1 -0
  4. package/contract-api.ts +16 -0
  5. package/index.ts +70 -53
  6. package/openclaw.plugin.json +1653 -4
  7. package/package.json +32 -7
  8. package/runtime-api.ts +55 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/security-contract-api.ts +1 -0
  11. package/session-key-api.ts +1 -0
  12. package/setup-api.ts +3 -0
  13. package/setup-entry.test.ts +14 -0
  14. package/setup-entry.ts +13 -0
  15. package/src/accounts.test.ts +115 -22
  16. package/src/accounts.ts +199 -117
  17. package/src/app-registration.ts +331 -0
  18. package/src/approval-auth.test.ts +24 -0
  19. package/src/approval-auth.ts +25 -0
  20. package/src/async.test.ts +35 -0
  21. package/src/async.ts +43 -1
  22. package/src/audio-preflight.runtime.ts +9 -0
  23. package/src/bitable.test.ts +131 -0
  24. package/src/bitable.ts +59 -22
  25. package/src/bot-content.ts +474 -0
  26. package/src/bot-group-name.test.ts +108 -0
  27. package/src/bot-runtime-api.ts +12 -0
  28. package/src/bot-sender-name.ts +125 -0
  29. package/src/bot.broadcast.test.ts +463 -0
  30. package/src/bot.card-action.test.ts +519 -5
  31. package/src/bot.checkBotMentioned.test.ts +92 -20
  32. package/src/bot.helpers.test.ts +118 -0
  33. package/src/bot.stripBotMention.test.ts +13 -21
  34. package/src/bot.test.ts +1334 -401
  35. package/src/bot.ts +798 -786
  36. package/src/card-action.ts +408 -40
  37. package/src/card-interaction.test.ts +129 -0
  38. package/src/card-interaction.ts +159 -0
  39. package/src/card-test-helpers.ts +47 -0
  40. package/src/card-ux-approval.ts +65 -0
  41. package/src/card-ux-launcher.test.ts +99 -0
  42. package/src/card-ux-launcher.ts +121 -0
  43. package/src/card-ux-shared.ts +33 -0
  44. package/src/channel-runtime-api.ts +16 -0
  45. package/src/channel.runtime.ts +47 -0
  46. package/src/channel.test.ts +914 -3
  47. package/src/channel.ts +1252 -309
  48. package/src/chat-schema.ts +5 -4
  49. package/src/chat.test.ts +84 -28
  50. package/src/chat.ts +68 -10
  51. package/src/client.test.ts +212 -103
  52. package/src/client.ts +115 -21
  53. package/src/comment-dispatcher-runtime-api.ts +6 -0
  54. package/src/comment-dispatcher.test.ts +169 -0
  55. package/src/comment-dispatcher.ts +107 -0
  56. package/src/comment-handler-runtime-api.ts +3 -0
  57. package/src/comment-handler.test.ts +486 -0
  58. package/src/comment-handler.ts +309 -0
  59. package/src/comment-reaction.test.ts +166 -0
  60. package/src/comment-reaction.ts +259 -0
  61. package/src/comment-shared.test.ts +182 -0
  62. package/src/comment-shared.ts +365 -0
  63. package/src/comment-target.ts +44 -0
  64. package/src/config-schema.test.ts +77 -25
  65. package/src/config-schema.ts +31 -4
  66. package/src/conversation-id.test.ts +18 -0
  67. package/src/conversation-id.ts +199 -0
  68. package/src/dedup-runtime-api.ts +1 -0
  69. package/src/dedup.ts +76 -35
  70. package/src/directory.static.ts +61 -0
  71. package/src/directory.test.ts +119 -20
  72. package/src/directory.ts +61 -91
  73. package/src/doc-schema.ts +1 -1
  74. package/src/docx-batch-insert.test.ts +39 -38
  75. package/src/docx-batch-insert.ts +55 -19
  76. package/src/docx-color-text.ts +9 -4
  77. package/src/docx-table-ops.test.ts +53 -0
  78. package/src/docx-table-ops.ts +52 -34
  79. package/src/docx-types.ts +38 -0
  80. package/src/docx.account-selection.test.ts +12 -3
  81. package/src/docx.test.ts +314 -74
  82. package/src/docx.ts +278 -122
  83. package/src/drive-schema.ts +47 -1
  84. package/src/drive.test.ts +1219 -0
  85. package/src/drive.ts +614 -13
  86. package/src/dynamic-agent.ts +10 -4
  87. package/src/event-types.ts +45 -0
  88. package/src/external-keys.ts +1 -1
  89. package/src/lifecycle.test-support.ts +220 -0
  90. package/src/media.test.ts +413 -87
  91. package/src/media.ts +488 -154
  92. package/src/mention-target.types.ts +5 -0
  93. package/src/mention.ts +32 -51
  94. package/src/message-action-contract.ts +13 -0
  95. package/src/monitor-state-runtime-api.ts +7 -0
  96. package/src/monitor-transport-runtime-api.ts +7 -0
  97. package/src/monitor.account.ts +220 -313
  98. package/src/monitor.acp-init-failure.lifecycle.test-support.ts +219 -0
  99. package/src/monitor.bot-identity.ts +86 -0
  100. package/src/monitor.bot-menu-handler.ts +165 -0
  101. package/src/monitor.bot-menu.lifecycle.test-support.ts +224 -0
  102. package/src/monitor.bot-menu.test.ts +178 -0
  103. package/src/monitor.broadcast.reply-once.lifecycle.test-support.ts +264 -0
  104. package/src/monitor.card-action.lifecycle.test-support.ts +373 -0
  105. package/src/monitor.cleanup.test.ts +376 -0
  106. package/src/monitor.comment-notice-handler.ts +105 -0
  107. package/src/monitor.comment.test.ts +937 -0
  108. package/src/monitor.comment.ts +1386 -0
  109. package/src/monitor.lifecycle.test.ts +4 -0
  110. package/src/monitor.message-handler.ts +339 -0
  111. package/src/monitor.reaction.lifecycle.test-support.ts +68 -0
  112. package/src/monitor.reaction.test.ts +194 -92
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +24 -36
  115. package/src/monitor.startup.ts +26 -16
  116. package/src/monitor.state.ts +20 -5
  117. package/src/monitor.synthetic-error.ts +18 -0
  118. package/src/monitor.test-mocks.ts +2 -2
  119. package/src/monitor.transport.ts +297 -39
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +272 -0
  122. package/src/monitor.webhook-security.test.ts +125 -91
  123. package/src/monitor.webhook.test-helpers.ts +116 -0
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +627 -53
  126. package/src/outbound.ts +623 -81
  127. package/src/perm-schema.ts +1 -1
  128. package/src/perm.ts +1 -7
  129. package/src/pins.ts +108 -0
  130. package/src/policy.test.ts +297 -117
  131. package/src/policy.ts +142 -29
  132. package/src/post.ts +7 -6
  133. package/src/probe.test.ts +122 -118
  134. package/src/probe.ts +26 -16
  135. package/src/processing-claims.ts +59 -0
  136. package/src/qr-terminal.ts +1 -0
  137. package/src/reactions.ts +23 -60
  138. package/src/reasoning-preview.test.ts +59 -0
  139. package/src/reasoning-preview.ts +20 -0
  140. package/src/reply-dispatcher-runtime-api.ts +7 -0
  141. package/src/reply-dispatcher.test.ts +721 -168
  142. package/src/reply-dispatcher.ts +422 -172
  143. package/src/runtime.ts +6 -3
  144. package/src/secret-contract.ts +145 -0
  145. package/src/secret-input.ts +1 -13
  146. package/src/security-audit-shared.ts +69 -0
  147. package/src/security-audit.test.ts +61 -0
  148. package/src/security-audit.ts +1 -0
  149. package/src/send-result.ts +1 -1
  150. package/src/send-target.test.ts +9 -3
  151. package/src/send-target.ts +10 -4
  152. package/src/send.reply-fallback.test.ts +127 -42
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +486 -164
  155. package/src/sequential-key.test.ts +72 -0
  156. package/src/sequential-key.ts +28 -0
  157. package/src/sequential-queue.test.ts +92 -0
  158. package/src/sequential-queue.ts +16 -0
  159. package/src/session-conversation.ts +42 -0
  160. package/src/session-route.ts +48 -0
  161. package/src/setup-core.ts +51 -0
  162. package/src/{onboarding.test.ts → setup-surface.test.ts} +52 -21
  163. package/src/setup-surface.ts +581 -0
  164. package/src/streaming-card.test.ts +138 -2
  165. package/src/streaming-card.ts +134 -18
  166. package/src/subagent-hooks.test.ts +603 -0
  167. package/src/subagent-hooks.ts +397 -0
  168. package/src/targets.ts +3 -13
  169. package/src/test-support/lifecycle-test-support.ts +479 -0
  170. package/src/thread-bindings.test.ts +143 -0
  171. package/src/thread-bindings.ts +330 -0
  172. package/src/tool-account-routing.test.ts +66 -8
  173. package/src/tool-account.test.ts +44 -0
  174. package/src/tool-account.ts +40 -17
  175. package/src/tool-factory-test-harness.ts +11 -8
  176. package/src/tool-result.ts +3 -1
  177. package/src/tools-config.ts +1 -1
  178. package/src/types.ts +16 -15
  179. package/src/typing.ts +10 -6
  180. package/src/wiki-schema.ts +1 -1
  181. package/src/wiki.ts +1 -7
  182. package/subagent-hooks-api.ts +31 -0
  183. package/tsconfig.json +16 -0
  184. package/src/feishu-command-handler.ts +0 -59
  185. package/src/onboarding.status.test.ts +0 -25
  186. package/src/onboarding.ts +0 -489
  187. package/src/send-message.ts +0 -71
  188. package/src/targets.test.ts +0 -70
package/src/media.ts CHANGED
@@ -1,8 +1,16 @@
1
- import fs from "fs";
2
- import path from "path";
3
- import { Readable } from "stream";
4
- import { withTempDownloadPath, type ClawdbotConfig } from "openclaw/plugin-sdk/feishu";
5
- import { resolveFeishuAccount } from "./accounts.js";
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 { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime";
6
+ import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime";
7
+ import {
8
+ resolvePreferredOpenClawTmpDir,
9
+ withTempDownloadPath,
10
+ } from "openclaw/plugin-sdk/temp-path";
11
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
12
+ import type { ClawdbotConfig } from "../runtime-api.js";
13
+ import { resolveFeishuRuntimeAccount } from "./accounts.js";
6
14
  import { createFeishuClient } from "./client.js";
7
15
  import { normalizeFeishuExternalKey } from "./external-keys.js";
8
16
  import { getFeishuRuntime } from "./runtime.js";
@@ -10,6 +18,24 @@ import { assertFeishuMessageApiSuccess, toFeishuSendResult } from "./send-result
10
18
  import { resolveFeishuSendTarget } from "./send-target.js";
11
19
 
12
20
  const FEISHU_MEDIA_HTTP_TIMEOUT_MS = 120_000;
21
+ const FEISHU_VOICE_FILE_NAME = "voice.ogg";
22
+ const FEISHU_VOICE_SAMPLE_RATE_HZ = 48_000;
23
+ const FEISHU_VOICE_BITRATE = "64k";
24
+
25
+ const FEISHU_TRANSCODABLE_AUDIO_EXTS = new Set([
26
+ ".aac",
27
+ ".aiff",
28
+ ".alac",
29
+ ".amr",
30
+ ".caf",
31
+ ".flac",
32
+ ".m4a",
33
+ ".mp3",
34
+ ".oga",
35
+ ".wav",
36
+ ".webm",
37
+ ".wma",
38
+ ]);
13
39
 
14
40
  export type DownloadImageResult = {
15
41
  buffer: Buffer;
@@ -22,62 +48,253 @@ export type DownloadMessageResourceResult = {
22
48
  fileName?: string;
23
49
  };
24
50
 
51
+ function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
52
+ account: ReturnType<typeof resolveFeishuRuntimeAccount>;
53
+ client: ReturnType<typeof createFeishuClient>;
54
+ } {
55
+ const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
56
+ if (!account.configured) {
57
+ throw new Error(`Feishu account "${account.accountId}" not configured`);
58
+ }
59
+
60
+ return {
61
+ account,
62
+ client: createFeishuClient({
63
+ ...account,
64
+ httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
65
+ }),
66
+ };
67
+ }
68
+
69
+ type FeishuUploadResponse =
70
+ | Awaited<ReturnType<Lark.Client["im"]["image"]["create"]>>
71
+ | Awaited<ReturnType<Lark.Client["im"]["file"]["create"]>>;
72
+
73
+ type FeishuDownloadResponse =
74
+ | Awaited<ReturnType<Lark.Client["im"]["image"]["get"]>>
75
+ | Awaited<ReturnType<Lark.Client["im"]["file"]["get"]>>
76
+ | Awaited<ReturnType<Lark.Client["im"]["messageResource"]["get"]>>;
77
+
78
+ type FeishuHeaderMap = Record<string, string | string[]>;
79
+ type FeishuMessageResourceDownloadType = "image" | "file" | "media";
80
+
81
+ function asHeaderMap(value: object | undefined): FeishuHeaderMap | undefined {
82
+ if (!value) {
83
+ return undefined;
84
+ }
85
+ const entries = Object.entries(value);
86
+ if (entries.every(([, entry]) => typeof entry === "string" || Array.isArray(entry))) {
87
+ return Object.fromEntries(entries) as FeishuHeaderMap;
88
+ }
89
+ return undefined;
90
+ }
91
+
92
+ function extractFeishuUploadKey(
93
+ response: FeishuUploadResponse,
94
+ params: {
95
+ key: "image_key" | "file_key";
96
+ errorPrefix: string;
97
+ },
98
+ ): string {
99
+ if (!response) {
100
+ throw new Error(`${params.errorPrefix}: empty response`);
101
+ }
102
+
103
+ const wrappedResponse = response as {
104
+ image_key?: string;
105
+ file_key?: string;
106
+ code?: number;
107
+ msg?: string;
108
+ data?: Partial<Record<"image_key" | "file_key", string>>;
109
+ };
110
+ if (wrappedResponse.code !== undefined && wrappedResponse.code !== 0) {
111
+ throw new Error(
112
+ `${params.errorPrefix}: ${wrappedResponse.msg || `code ${wrappedResponse.code}`}`,
113
+ );
114
+ }
115
+
116
+ const key =
117
+ params.key === "image_key"
118
+ ? (wrappedResponse.image_key ?? wrappedResponse.data?.image_key)
119
+ : (wrappedResponse.file_key ?? wrappedResponse.data?.file_key);
120
+ if (!key) {
121
+ throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
122
+ }
123
+ return key;
124
+ }
125
+
126
+ function readHeaderValue(
127
+ headers: Record<string, unknown> | undefined,
128
+ name: string,
129
+ ): string | undefined {
130
+ if (!headers) {
131
+ return undefined;
132
+ }
133
+ for (const [key, value] of Object.entries(headers)) {
134
+ if (normalizeLowercaseStringOrEmpty(key) !== normalizeLowercaseStringOrEmpty(name)) {
135
+ continue;
136
+ }
137
+ if (typeof value === "string" && value.trim()) {
138
+ return value.trim();
139
+ }
140
+ if (Array.isArray(value)) {
141
+ const first = value.find((entry) => typeof entry === "string" && entry.trim());
142
+ if (typeof first === "string") {
143
+ return first.trim();
144
+ }
145
+ }
146
+ }
147
+ return undefined;
148
+ }
149
+
150
+ function readHttpStatusFromError(error: unknown): number | undefined {
151
+ if (!error || typeof error !== "object") {
152
+ return undefined;
153
+ }
154
+
155
+ const response = (error as { response?: unknown }).response;
156
+ if (response && typeof response === "object") {
157
+ const status = (response as { status?: unknown }).status;
158
+ if (typeof status === "number") {
159
+ return status;
160
+ }
161
+ }
162
+
163
+ const status = (error as { status?: unknown }).status;
164
+ return typeof status === "number" ? status : undefined;
165
+ }
166
+
167
+ function isHttpStatusError(error: unknown, status: number): boolean {
168
+ return readHttpStatusFromError(error) === status;
169
+ }
170
+
171
+ function containsEastAsianScript(value: string): boolean {
172
+ return /[\p{Script=Han}\p{Script=Hiragana}\p{Script=Katakana}\p{Script=Hangul}]/u.test(value);
173
+ }
174
+
175
+ function recoverUtf8FileNameFromLatin1Header(value: string): string {
176
+ const recovered = Buffer.from(value, "latin1").toString("utf8");
177
+ if (recovered !== value && !recovered.includes("\uFFFD") && containsEastAsianScript(recovered)) {
178
+ return recovered;
179
+ }
180
+ return value;
181
+ }
182
+
183
+ function decodeDispositionFileName(value: string): string | undefined {
184
+ const utf8Match = value.match(/filename\*=UTF-8''([^;]+)/i);
185
+ if (utf8Match?.[1]) {
186
+ try {
187
+ return decodeURIComponent(utf8Match[1].trim().replace(/^"(.*)"$/, "$1"));
188
+ } catch {
189
+ return utf8Match[1].trim().replace(/^"(.*)"$/, "$1");
190
+ }
191
+ }
192
+
193
+ const plainMatch = value.match(/filename="?([^";]+)"?/i);
194
+ const plainFileName = plainMatch?.[1]?.trim();
195
+ return plainFileName ? recoverUtf8FileNameFromLatin1Header(plainFileName) : undefined;
196
+ }
197
+
198
+ function extractFeishuDownloadMetadata(response: FeishuDownloadResponse): {
199
+ contentType?: string;
200
+ fileName?: string;
201
+ } {
202
+ const responseWithOptionalFields = response as FeishuDownloadResponse & {
203
+ header?: object;
204
+ contentType?: string;
205
+ mime_type?: string;
206
+ data?: {
207
+ contentType?: string;
208
+ mime_type?: string;
209
+ file_name?: string;
210
+ fileName?: string;
211
+ };
212
+ file_name?: string;
213
+ fileName?: string;
214
+ };
215
+ const headers =
216
+ asHeaderMap(responseWithOptionalFields.headers) ??
217
+ asHeaderMap(responseWithOptionalFields.header);
218
+
219
+ const contentType =
220
+ readHeaderValue(headers, "content-type") ??
221
+ responseWithOptionalFields.contentType ??
222
+ responseWithOptionalFields.mime_type ??
223
+ responseWithOptionalFields.data?.contentType ??
224
+ responseWithOptionalFields.data?.mime_type;
225
+
226
+ const disposition = readHeaderValue(headers, "content-disposition");
227
+ const fileName =
228
+ (disposition ? decodeDispositionFileName(disposition) : undefined) ??
229
+ responseWithOptionalFields.file_name ??
230
+ responseWithOptionalFields.fileName ??
231
+ responseWithOptionalFields.data?.file_name ??
232
+ responseWithOptionalFields.data?.fileName;
233
+
234
+ return { contentType, fileName };
235
+ }
236
+
237
+ async function readReadableBuffer(stream: Readable): Promise<Buffer> {
238
+ const chunks: Buffer[] = [];
239
+ for await (const chunk of stream) {
240
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
241
+ }
242
+ return Buffer.concat(chunks);
243
+ }
244
+
25
245
  async function readFeishuResponseBuffer(params: {
26
- response: unknown;
246
+ response: FeishuDownloadResponse;
27
247
  tmpDirPrefix: string;
28
248
  errorPrefix: string;
29
249
  }): Promise<Buffer> {
30
250
  const { response } = params;
31
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
32
- const responseAny = response as any;
33
- if (responseAny.code !== undefined && responseAny.code !== 0) {
34
- throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
35
- }
36
-
37
251
  if (Buffer.isBuffer(response)) {
38
252
  return response;
39
253
  }
40
254
  if (response instanceof ArrayBuffer) {
41
255
  return Buffer.from(response);
42
256
  }
43
- if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
44
- return responseAny.data;
257
+ const responseWithOptionalFields = response as FeishuDownloadResponse & {
258
+ code?: number;
259
+ msg?: string;
260
+ data?: Buffer | ArrayBuffer;
261
+ [Symbol.asyncIterator]?: () => AsyncIterator<Buffer | Uint8Array | string>;
262
+ };
263
+ if (responseWithOptionalFields.code !== undefined && responseWithOptionalFields.code !== 0) {
264
+ throw new Error(
265
+ `${params.errorPrefix}: ${responseWithOptionalFields.msg || `code ${responseWithOptionalFields.code}`}`,
266
+ );
45
267
  }
46
- if (responseAny.data instanceof ArrayBuffer) {
47
- return Buffer.from(responseAny.data);
268
+
269
+ if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
270
+ return responseWithOptionalFields.data;
48
271
  }
49
- if (typeof responseAny.getReadableStream === "function") {
50
- const stream = responseAny.getReadableStream();
51
- const chunks: Buffer[] = [];
52
- for await (const chunk of stream) {
53
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
54
- }
55
- return Buffer.concat(chunks);
272
+ if (responseWithOptionalFields.data instanceof ArrayBuffer) {
273
+ return Buffer.from(responseWithOptionalFields.data);
274
+ }
275
+ if (typeof response.getReadableStream === "function") {
276
+ return readReadableBuffer(response.getReadableStream());
56
277
  }
57
- if (typeof responseAny.writeFile === "function") {
278
+ if (typeof response.writeFile === "function") {
58
279
  return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
59
- await responseAny.writeFile(tmpPath);
280
+ await response.writeFile(tmpPath);
60
281
  return await fs.promises.readFile(tmpPath);
61
282
  });
62
283
  }
63
- if (typeof responseAny[Symbol.asyncIterator] === "function") {
284
+ if (responseWithOptionalFields[Symbol.asyncIterator]) {
285
+ const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
64
286
  const chunks: Buffer[] = [];
65
- for await (const chunk of responseAny) {
287
+ for await (const chunk of asyncIterable) {
66
288
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
67
289
  }
68
290
  return Buffer.concat(chunks);
69
291
  }
70
- if (typeof responseAny.read === "function") {
71
- const chunks: Buffer[] = [];
72
- for await (const chunk of responseAny as Readable) {
73
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
74
- }
75
- return Buffer.concat(chunks);
292
+ if (response instanceof Readable) {
293
+ return readReadableBuffer(response);
76
294
  }
77
295
 
78
- const keys = Object.keys(responseAny);
79
- const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
80
- throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${types}]`);
296
+ const keys = Object.keys(response as object);
297
+ throw new Error(`${params.errorPrefix}: unexpected response format. Keys: [${keys.join(", ")}]`);
81
298
  }
82
299
 
83
300
  /**
@@ -94,15 +311,7 @@ export async function downloadImageFeishu(params: {
94
311
  if (!normalizedImageKey) {
95
312
  throw new Error("Feishu image download failed: invalid image_key");
96
313
  }
97
- const account = resolveFeishuAccount({ cfg, accountId });
98
- if (!account.configured) {
99
- throw new Error(`Feishu account "${account.accountId}" not configured`);
100
- }
101
-
102
- const client = createFeishuClient({
103
- ...account,
104
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
105
- });
314
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
106
315
 
107
316
  const response = await client.im.image.get({
108
317
  path: { image_key: normalizedImageKey },
@@ -113,7 +322,27 @@ export async function downloadImageFeishu(params: {
113
322
  tmpDirPrefix: "openclaw-feishu-img-",
114
323
  errorPrefix: "Feishu image download failed",
115
324
  });
116
- return { buffer };
325
+ const meta = extractFeishuDownloadMetadata(response);
326
+ return { buffer, contentType: meta.contentType };
327
+ }
328
+
329
+ async function downloadMessageResourceWithType(params: {
330
+ client: ReturnType<typeof createFeishuClient>;
331
+ messageId: string;
332
+ fileKey: string;
333
+ type: FeishuMessageResourceDownloadType;
334
+ }): Promise<DownloadMessageResourceResult> {
335
+ const response = await params.client.im.messageResource.get({
336
+ path: { message_id: params.messageId, file_key: params.fileKey },
337
+ params: { type: params.type },
338
+ });
339
+
340
+ const buffer = await readFeishuResponseBuffer({
341
+ response,
342
+ tmpDirPrefix: "openclaw-feishu-resource-",
343
+ errorPrefix: "Feishu message resource download failed",
344
+ });
345
+ return { buffer, ...extractFeishuDownloadMetadata(response) };
117
346
  }
118
347
 
119
348
  /**
@@ -132,27 +361,30 @@ export async function downloadMessageResourceFeishu(params: {
132
361
  if (!normalizedFileKey) {
133
362
  throw new Error("Feishu message resource download failed: invalid file_key");
134
363
  }
135
- const account = resolveFeishuAccount({ cfg, accountId });
136
- if (!account.configured) {
137
- throw new Error(`Feishu account "${account.accountId}" not configured`);
364
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
365
+
366
+ try {
367
+ return await downloadMessageResourceWithType({
368
+ client,
369
+ messageId,
370
+ fileKey: normalizedFileKey,
371
+ type,
372
+ });
373
+ } catch (err) {
374
+ if (type !== "file" || !isHttpStatusError(err, 502)) {
375
+ throw err;
376
+ }
377
+ try {
378
+ return await downloadMessageResourceWithType({
379
+ client,
380
+ messageId,
381
+ fileKey: normalizedFileKey,
382
+ type: "media",
383
+ });
384
+ } catch {
385
+ throw err;
386
+ }
138
387
  }
139
-
140
- const client = createFeishuClient({
141
- ...account,
142
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
143
- });
144
-
145
- const response = await client.im.messageResource.get({
146
- path: { message_id: messageId, file_key: normalizedFileKey },
147
- params: { type },
148
- });
149
-
150
- const buffer = await readFeishuResponseBuffer({
151
- response,
152
- tmpDirPrefix: "openclaw-feishu-resource-",
153
- errorPrefix: "Feishu message resource download failed",
154
- });
155
- return { buffer };
156
388
  }
157
389
 
158
390
  export type UploadImageResult = {
@@ -179,15 +411,7 @@ export async function uploadImageFeishu(params: {
179
411
  accountId?: string;
180
412
  }): Promise<UploadImageResult> {
181
413
  const { cfg, image, imageType = "message", accountId } = params;
182
- const account = resolveFeishuAccount({ cfg, accountId });
183
- if (!account.configured) {
184
- throw new Error(`Feishu account "${account.accountId}" not configured`);
185
- }
186
-
187
- const client = createFeishuClient({
188
- ...account,
189
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
190
- });
414
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
191
415
 
192
416
  // SDK accepts Buffer directly or fs.ReadStream for file paths
193
417
  // Using Readable.from(buffer) causes issues with form-data library
@@ -197,43 +421,30 @@ export async function uploadImageFeishu(params: {
197
421
  const response = await client.im.image.create({
198
422
  data: {
199
423
  image_type: imageType,
200
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
201
- image: imageData as any,
424
+ image: imageData,
202
425
  },
203
426
  });
204
427
 
205
- // SDK v1.30+ returns data directly without code wrapper on success
206
- // On error, it throws or returns { code, msg }
207
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
208
- const responseAny = response as any;
209
- if (responseAny.code !== undefined && responseAny.code !== 0) {
210
- throw new Error(`Feishu image upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
211
- }
212
-
213
- const imageKey = responseAny.image_key ?? responseAny.data?.image_key;
214
- if (!imageKey) {
215
- throw new Error("Feishu image upload failed: no image_key returned");
216
- }
217
-
218
- return { imageKey };
428
+ return {
429
+ imageKey: extractFeishuUploadKey(response, {
430
+ key: "image_key",
431
+ errorPrefix: "Feishu image upload failed",
432
+ }),
433
+ };
219
434
  }
220
435
 
221
436
  /**
222
- * Encode a filename for safe use in Feishu multipart/form-data uploads.
223
- * Non-ASCII characters (Chinese, em-dash, full-width brackets, etc.) cause
224
- * the upload to silently fail when passed raw through the SDK's form-data
225
- * serialization. RFC 5987 percent-encoding keeps headers 7-bit clean while
226
- * Feishu's server decodes and preserves the original display name.
437
+ * Sanitize a filename for safe use in Feishu multipart/form-data uploads.
438
+ * Strips control characters and multipart-injection vectors (CWE-93) while
439
+ * preserving the original UTF-8 display name (Chinese, emoji, etc.).
440
+ *
441
+ * Previous versions percent-encoded non-ASCII characters, but the Feishu
442
+ * `im.file.create` API uses `file_name` as a literal display name — it does
443
+ * NOT decode percent-encoding — so encoded filenames appeared as garbled text
444
+ * in chat (regression in v2026.3.2).
227
445
  */
228
446
  export function sanitizeFileNameForUpload(fileName: string): string {
229
- const ASCII_ONLY = /^[\x20-\x7E]+$/;
230
- if (ASCII_ONLY.test(fileName)) {
231
- return fileName;
232
- }
233
- return encodeURIComponent(fileName)
234
- .replace(/'/g, "%27")
235
- .replace(/\(/g, "%28")
236
- .replace(/\)/g, "%29");
447
+ return fileName.replace(/[\p{Cc}"\\]/gu, "_");
237
448
  }
238
449
 
239
450
  /**
@@ -249,15 +460,7 @@ export async function uploadFileFeishu(params: {
249
460
  accountId?: string;
250
461
  }): Promise<UploadFileResult> {
251
462
  const { cfg, file, fileName, fileType, duration, accountId } = params;
252
- const account = resolveFeishuAccount({ cfg, accountId });
253
- if (!account.configured) {
254
- throw new Error(`Feishu account "${account.accountId}" not configured`);
255
- }
256
-
257
- const client = createFeishuClient({
258
- ...account,
259
- httpTimeoutMs: FEISHU_MEDIA_HTTP_TIMEOUT_MS,
260
- });
463
+ const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
261
464
 
262
465
  // SDK accepts Buffer directly or fs.ReadStream for file paths
263
466
  // Using Readable.from(buffer) causes issues with form-data library
@@ -270,25 +473,17 @@ export async function uploadFileFeishu(params: {
270
473
  data: {
271
474
  file_type: fileType,
272
475
  file_name: safeFileName,
273
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
274
- file: fileData as any,
476
+ file: fileData,
275
477
  ...(duration !== undefined && { duration }),
276
478
  },
277
479
  });
278
480
 
279
- // SDK v1.30+ returns data directly without code wrapper on success
280
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
281
- const responseAny = response as any;
282
- if (responseAny.code !== undefined && responseAny.code !== 0) {
283
- throw new Error(`Feishu file upload failed: ${responseAny.msg || `code ${responseAny.code}`}`);
284
- }
285
-
286
- const fileKey = responseAny.file_key ?? responseAny.data?.file_key;
287
- if (!fileKey) {
288
- throw new Error("Feishu file upload failed: no file_key returned");
289
- }
290
-
291
- return { fileKey };
481
+ return {
482
+ fileKey: extractFeishuUploadKey(response, {
483
+ key: "file_key",
484
+ errorPrefix: "Feishu file upload failed",
485
+ }),
486
+ };
292
487
  }
293
488
 
294
489
  /**
@@ -388,7 +583,7 @@ export async function sendFileFeishu(params: {
388
583
  export function detectFileType(
389
584
  fileName: string,
390
585
  ): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
391
- const ext = path.extname(fileName).toLowerCase();
586
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
392
587
  switch (ext) {
393
588
  case ".opus":
394
589
  case ".ogg":
@@ -413,6 +608,136 @@ export function detectFileType(
413
608
  }
414
609
  }
415
610
 
611
+ function resolveFeishuOutboundMediaKind(params: { fileName: string; contentType?: string }): {
612
+ fileType?: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream";
613
+ msgType: "image" | "file" | "audio" | "media";
614
+ } {
615
+ const { fileName, contentType } = params;
616
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
617
+ const mimeKind = mediaKindFromMime(contentType);
618
+
619
+ const isImageExt = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(
620
+ ext,
621
+ );
622
+ if (isImageExt || mimeKind === "image") {
623
+ return { msgType: "image" };
624
+ }
625
+
626
+ if (
627
+ ext === ".opus" ||
628
+ ext === ".ogg" ||
629
+ contentType === "audio/ogg" ||
630
+ contentType === "audio/opus"
631
+ ) {
632
+ return { fileType: "opus", msgType: "audio" };
633
+ }
634
+
635
+ if (
636
+ [".mp4", ".mov", ".avi"].includes(ext) ||
637
+ contentType === "video/mp4" ||
638
+ contentType === "video/quicktime" ||
639
+ contentType === "video/x-msvideo"
640
+ ) {
641
+ return { fileType: "mp4", msgType: "media" };
642
+ }
643
+
644
+ const fileType = detectFileType(fileName);
645
+ return {
646
+ fileType,
647
+ msgType:
648
+ fileType === "stream"
649
+ ? "file"
650
+ : fileType === "opus"
651
+ ? "audio"
652
+ : fileType === "mp4"
653
+ ? "media"
654
+ : "file",
655
+ };
656
+ }
657
+
658
+ function isFeishuNativeVoiceAudio(params: { fileName: string; contentType?: string }): boolean {
659
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
660
+ const contentType = normalizeLowercaseStringOrEmpty(params.contentType);
661
+ return (
662
+ ext === ".opus" || ext === ".ogg" || contentType === "audio/ogg" || contentType === "audio/opus"
663
+ );
664
+ }
665
+
666
+ function isLikelyTranscodableAudio(params: { fileName: string; contentType?: string }): boolean {
667
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
668
+ const contentType = normalizeLowercaseStringOrEmpty(params.contentType);
669
+ return FEISHU_TRANSCODABLE_AUDIO_EXTS.has(ext) || mediaKindFromMime(contentType) === "audio";
670
+ }
671
+
672
+ async function transcodeToFeishuVoiceOpus(params: {
673
+ buffer: Buffer;
674
+ fileName: string;
675
+ contentType?: string;
676
+ }): Promise<{ buffer: Buffer; fileName: string; contentType: string }> {
677
+ const tempRoot = resolvePreferredOpenClawTmpDir();
678
+ await fs.promises.mkdir(tempRoot, { recursive: true, mode: 0o700 });
679
+ const tempDir = await fs.promises.mkdtemp(path.join(tempRoot, "feishu-voice-"));
680
+ try {
681
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(params.fileName));
682
+ const inputExt = ext && ext.length <= 12 ? ext : ".audio";
683
+ const inputPath = path.join(tempDir, `input${inputExt}`);
684
+ const outputPath = path.join(tempDir, FEISHU_VOICE_FILE_NAME);
685
+ await fs.promises.writeFile(inputPath, params.buffer, { mode: 0o600 });
686
+ await runFfmpeg([
687
+ "-hide_banner",
688
+ "-loglevel",
689
+ "error",
690
+ "-y",
691
+ "-i",
692
+ inputPath,
693
+ "-vn",
694
+ "-sn",
695
+ "-dn",
696
+ "-t",
697
+ String(MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS),
698
+ "-ar",
699
+ String(FEISHU_VOICE_SAMPLE_RATE_HZ),
700
+ "-ac",
701
+ "1",
702
+ "-c:a",
703
+ "libopus",
704
+ "-b:a",
705
+ FEISHU_VOICE_BITRATE,
706
+ outputPath,
707
+ ]);
708
+ return {
709
+ buffer: await fs.promises.readFile(outputPath),
710
+ fileName: FEISHU_VOICE_FILE_NAME,
711
+ contentType: "audio/ogg",
712
+ };
713
+ } finally {
714
+ await fs.promises.rm(tempDir, { recursive: true, force: true });
715
+ }
716
+ }
717
+
718
+ async function prepareFeishuVoiceMedia(params: {
719
+ buffer: Buffer;
720
+ fileName: string;
721
+ contentType?: string;
722
+ audioAsVoice?: boolean;
723
+ }): Promise<{ buffer: Buffer; fileName: string; contentType?: string }> {
724
+ if (isFeishuNativeVoiceAudio(params)) {
725
+ return params;
726
+ }
727
+ if (params.audioAsVoice !== true || !isLikelyTranscodableAudio(params)) {
728
+ return params;
729
+ }
730
+ try {
731
+ return await transcodeToFeishuVoiceOpus(params);
732
+ } catch (err) {
733
+ console.warn(
734
+ `[feishu] audioAsVoice transcode failed; sending ${params.fileName} as a file attachment:`,
735
+ err,
736
+ );
737
+ return params;
738
+ }
739
+ }
740
+
416
741
  /**
417
742
  * Upload and send media (image or file) from URL, local path, or buffer.
418
743
  * When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
@@ -429,6 +754,8 @@ export async function sendMediaFeishu(params: {
429
754
  accountId?: string;
430
755
  /** Allowed roots for local path reads; required for local filePath to work. */
431
756
  mediaLocalRoots?: readonly string[];
757
+ /** When true, transcode compatible audio to Feishu native Ogg/Opus voice bubbles. */
758
+ audioAsVoice?: boolean;
432
759
  }): Promise<SendMediaResult> {
433
760
  const {
434
761
  cfg,
@@ -440,8 +767,9 @@ export async function sendMediaFeishu(params: {
440
767
  replyInThread,
441
768
  accountId,
442
769
  mediaLocalRoots,
770
+ audioAsVoice,
443
771
  } = params;
444
- const account = resolveFeishuAccount({ cfg, accountId });
772
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
445
773
  if (!account.configured) {
446
774
  throw new Error(`Feishu account "${account.accountId}" not configured`);
447
775
  }
@@ -449,6 +777,7 @@ export async function sendMediaFeishu(params: {
449
777
 
450
778
  let buffer: Buffer;
451
779
  let name: string;
780
+ let contentType: string | undefined;
452
781
 
453
782
  if (mediaBuffer) {
454
783
  buffer = mediaBuffer;
@@ -461,36 +790,41 @@ export async function sendMediaFeishu(params: {
461
790
  });
462
791
  buffer = loaded.buffer;
463
792
  name = fileName ?? loaded.fileName ?? "file";
793
+ contentType = loaded.contentType;
464
794
  } else {
465
795
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
466
796
  }
467
797
 
468
- // Determine if it's an image based on extension
469
- const ext = path.extname(name).toLowerCase();
470
- const isImage = [".jpg", ".jpeg", ".png", ".gif", ".webp", ".bmp", ".ico", ".tiff"].includes(ext);
798
+ const prepared = await prepareFeishuVoiceMedia({
799
+ buffer,
800
+ fileName: name,
801
+ contentType,
802
+ audioAsVoice,
803
+ });
804
+ buffer = prepared.buffer;
805
+ name = prepared.fileName;
806
+ contentType = prepared.contentType;
807
+
808
+ const routing = resolveFeishuOutboundMediaKind({ fileName: name, contentType });
471
809
 
472
- if (isImage) {
810
+ if (routing.msgType === "image") {
473
811
  const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
474
812
  return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
475
- } else {
476
- const fileType = detectFileType(name);
477
- const { fileKey } = await uploadFileFeishu({
478
- cfg,
479
- file: buffer,
480
- fileName: name,
481
- fileType,
482
- accountId,
483
- });
484
- // Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file"
485
- const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
486
- return sendFileFeishu({
487
- cfg,
488
- to,
489
- fileKey,
490
- msgType,
491
- replyToMessageId,
492
- replyInThread,
493
- accountId,
494
- });
495
813
  }
814
+ const { fileKey } = await uploadFileFeishu({
815
+ cfg,
816
+ file: buffer,
817
+ fileName: name,
818
+ fileType: routing.fileType ?? "stream",
819
+ accountId,
820
+ });
821
+ return sendFileFeishu({
822
+ cfg,
823
+ to,
824
+ fileKey,
825
+ msgType: routing.msgType,
826
+ replyToMessageId,
827
+ replyInThread,
828
+ accountId,
829
+ });
496
830
  }