@openclaw/feishu 2026.3.13 → 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 +95 -7
  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 +778 -775
  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 +63 -1
  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 +32 -94
  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 +375 -26
  91. package/src/media.ts +434 -88
  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 +218 -312
  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 +108 -48
  113. package/src/monitor.reply-once.lifecycle.test-support.ts +190 -0
  114. package/src/monitor.startup.test.ts +11 -9
  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 +220 -60
  120. package/src/monitor.ts +15 -10
  121. package/src/monitor.webhook-e2e.test.ts +65 -7
  122. package/src/monitor.webhook-security.test.ts +122 -0
  123. package/src/monitor.webhook.test-helpers.ts +44 -26
  124. package/src/outbound-runtime-api.ts +1 -0
  125. package/src/outbound.test.ts +616 -37
  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 +14 -9
  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 +4 -34
  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 +660 -29
  142. package/src/reply-dispatcher.ts +407 -154
  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 +77 -2
  153. package/src/send.test.ts +386 -4
  154. package/src/send.ts +399 -86
  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;
@@ -23,10 +49,10 @@ export type DownloadMessageResourceResult = {
23
49
  };
24
50
 
25
51
  function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accountId?: string }): {
26
- account: ReturnType<typeof resolveFeishuAccount>;
52
+ account: ReturnType<typeof resolveFeishuRuntimeAccount>;
27
53
  client: ReturnType<typeof createFeishuClient>;
28
54
  } {
29
- const account = resolveFeishuAccount({ cfg: params.cfg, accountId: params.accountId });
55
+ const account = resolveFeishuRuntimeAccount({ cfg: params.cfg, accountId: params.accountId });
30
56
  if (!account.configured) {
31
57
  throw new Error(`Feishu account "${account.accountId}" not configured`);
32
58
  }
@@ -40,83 +66,235 @@ function createConfiguredFeishuMediaClient(params: { cfg: ClawdbotConfig; accoun
40
66
  };
41
67
  }
42
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
+
43
92
  function extractFeishuUploadKey(
44
- response: unknown,
93
+ response: FeishuUploadResponse,
45
94
  params: {
46
95
  key: "image_key" | "file_key";
47
96
  errorPrefix: string;
48
97
  },
49
98
  ): string {
50
- // SDK v1.30+ returns data directly without code wrapper on success.
51
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
52
- const responseAny = response as any;
53
- if (responseAny.code !== undefined && responseAny.code !== 0) {
54
- throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
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
+ );
55
114
  }
56
115
 
57
- const key = responseAny[params.key] ?? responseAny.data?.[params.key];
116
+ const key =
117
+ params.key === "image_key"
118
+ ? (wrappedResponse.image_key ?? wrappedResponse.data?.image_key)
119
+ : (wrappedResponse.file_key ?? wrappedResponse.data?.file_key);
58
120
  if (!key) {
59
121
  throw new Error(`${params.errorPrefix}: no ${params.key} returned`);
60
122
  }
61
123
  return key;
62
124
  }
63
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
+
64
245
  async function readFeishuResponseBuffer(params: {
65
- response: unknown;
246
+ response: FeishuDownloadResponse;
66
247
  tmpDirPrefix: string;
67
248
  errorPrefix: string;
68
249
  }): Promise<Buffer> {
69
250
  const { response } = params;
70
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK response type
71
- const responseAny = response as any;
72
- if (responseAny.code !== undefined && responseAny.code !== 0) {
73
- throw new Error(`${params.errorPrefix}: ${responseAny.msg || `code ${responseAny.code}`}`);
74
- }
75
-
76
251
  if (Buffer.isBuffer(response)) {
77
252
  return response;
78
253
  }
79
254
  if (response instanceof ArrayBuffer) {
80
255
  return Buffer.from(response);
81
256
  }
82
- if (responseAny.data && Buffer.isBuffer(responseAny.data)) {
83
- 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
+ );
84
267
  }
85
- if (responseAny.data instanceof ArrayBuffer) {
86
- return Buffer.from(responseAny.data);
268
+
269
+ if (responseWithOptionalFields.data && Buffer.isBuffer(responseWithOptionalFields.data)) {
270
+ return responseWithOptionalFields.data;
87
271
  }
88
- if (typeof responseAny.getReadableStream === "function") {
89
- const stream = responseAny.getReadableStream();
90
- const chunks: Buffer[] = [];
91
- for await (const chunk of stream) {
92
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
93
- }
94
- 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());
95
277
  }
96
- if (typeof responseAny.writeFile === "function") {
278
+ if (typeof response.writeFile === "function") {
97
279
  return await withTempDownloadPath({ prefix: params.tmpDirPrefix }, async (tmpPath) => {
98
- await responseAny.writeFile(tmpPath);
280
+ await response.writeFile(tmpPath);
99
281
  return await fs.promises.readFile(tmpPath);
100
282
  });
101
283
  }
102
- if (typeof responseAny[Symbol.asyncIterator] === "function") {
284
+ if (responseWithOptionalFields[Symbol.asyncIterator]) {
285
+ const asyncIterable = responseWithOptionalFields as AsyncIterable<Buffer | Uint8Array | string>;
103
286
  const chunks: Buffer[] = [];
104
- for await (const chunk of responseAny) {
287
+ for await (const chunk of asyncIterable) {
105
288
  chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
106
289
  }
107
290
  return Buffer.concat(chunks);
108
291
  }
109
- if (typeof responseAny.read === "function") {
110
- const chunks: Buffer[] = [];
111
- for await (const chunk of responseAny as Readable) {
112
- chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
113
- }
114
- return Buffer.concat(chunks);
292
+ if (response instanceof Readable) {
293
+ return readReadableBuffer(response);
115
294
  }
116
295
 
117
- const keys = Object.keys(responseAny);
118
- const types = keys.map((k) => `${k}: ${typeof responseAny[k]}`).join(", ");
119
- 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(", ")}]`);
120
298
  }
121
299
 
122
300
  /**
@@ -144,7 +322,27 @@ export async function downloadImageFeishu(params: {
144
322
  tmpDirPrefix: "openclaw-feishu-img-",
145
323
  errorPrefix: "Feishu image download failed",
146
324
  });
147
- 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) };
148
346
  }
149
347
 
150
348
  /**
@@ -165,17 +363,28 @@ export async function downloadMessageResourceFeishu(params: {
165
363
  }
166
364
  const { client } = createConfiguredFeishuMediaClient({ cfg, accountId });
167
365
 
168
- const response = await client.im.messageResource.get({
169
- path: { message_id: messageId, file_key: normalizedFileKey },
170
- params: { type },
171
- });
172
-
173
- const buffer = await readFeishuResponseBuffer({
174
- response,
175
- tmpDirPrefix: "openclaw-feishu-resource-",
176
- errorPrefix: "Feishu message resource download failed",
177
- });
178
- return { buffer };
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
+ }
387
+ }
179
388
  }
180
389
 
181
390
  export type UploadImageResult = {
@@ -212,8 +421,7 @@ export async function uploadImageFeishu(params: {
212
421
  const response = await client.im.image.create({
213
422
  data: {
214
423
  image_type: imageType,
215
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
216
- image: imageData as any,
424
+ image: imageData,
217
425
  },
218
426
  });
219
427
 
@@ -236,7 +444,7 @@ export async function uploadImageFeishu(params: {
236
444
  * in chat (regression in v2026.3.2).
237
445
  */
238
446
  export function sanitizeFileNameForUpload(fileName: string): string {
239
- return fileName.replace(/[\x00-\x1F\x7F\r\n"\\]/g, "_");
447
+ return fileName.replace(/[\p{Cc}"\\]/gu, "_");
240
448
  }
241
449
 
242
450
  /**
@@ -265,8 +473,7 @@ export async function uploadFileFeishu(params: {
265
473
  data: {
266
474
  file_type: fileType,
267
475
  file_name: safeFileName,
268
- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- SDK accepts Buffer or ReadStream
269
- file: fileData as any,
476
+ file: fileData,
270
477
  ...(duration !== undefined && { duration }),
271
478
  },
272
479
  });
@@ -376,7 +583,7 @@ export async function sendFileFeishu(params: {
376
583
  export function detectFileType(
377
584
  fileName: string,
378
585
  ): "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream" {
379
- const ext = path.extname(fileName).toLowerCase();
586
+ const ext = normalizeLowercaseStringOrEmpty(path.extname(fileName));
380
587
  switch (ext) {
381
588
  case ".opus":
382
589
  case ".ogg":
@@ -401,6 +608,136 @@ export function detectFileType(
401
608
  }
402
609
  }
403
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
+
404
741
  /**
405
742
  * Upload and send media (image or file) from URL, local path, or buffer.
406
743
  * When mediaUrl is a local path, mediaLocalRoots (from core outbound context)
@@ -417,6 +754,8 @@ export async function sendMediaFeishu(params: {
417
754
  accountId?: string;
418
755
  /** Allowed roots for local path reads; required for local filePath to work. */
419
756
  mediaLocalRoots?: readonly string[];
757
+ /** When true, transcode compatible audio to Feishu native Ogg/Opus voice bubbles. */
758
+ audioAsVoice?: boolean;
420
759
  }): Promise<SendMediaResult> {
421
760
  const {
422
761
  cfg,
@@ -428,8 +767,9 @@ export async function sendMediaFeishu(params: {
428
767
  replyInThread,
429
768
  accountId,
430
769
  mediaLocalRoots,
770
+ audioAsVoice,
431
771
  } = params;
432
- const account = resolveFeishuAccount({ cfg, accountId });
772
+ const account = resolveFeishuRuntimeAccount({ cfg, accountId });
433
773
  if (!account.configured) {
434
774
  throw new Error(`Feishu account "${account.accountId}" not configured`);
435
775
  }
@@ -437,6 +777,7 @@ export async function sendMediaFeishu(params: {
437
777
 
438
778
  let buffer: Buffer;
439
779
  let name: string;
780
+ let contentType: string | undefined;
440
781
 
441
782
  if (mediaBuffer) {
442
783
  buffer = mediaBuffer;
@@ -449,36 +790,41 @@ export async function sendMediaFeishu(params: {
449
790
  });
450
791
  buffer = loaded.buffer;
451
792
  name = fileName ?? loaded.fileName ?? "file";
793
+ contentType = loaded.contentType;
452
794
  } else {
453
795
  throw new Error("Either mediaUrl or mediaBuffer must be provided");
454
796
  }
455
797
 
456
- // Determine if it's an image based on extension
457
- const ext = path.extname(name).toLowerCase();
458
- 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 });
459
809
 
460
- if (isImage) {
810
+ if (routing.msgType === "image") {
461
811
  const { imageKey } = await uploadImageFeishu({ cfg, image: buffer, accountId });
462
812
  return sendImageFeishu({ cfg, to, imageKey, replyToMessageId, replyInThread, accountId });
463
- } else {
464
- const fileType = detectFileType(name);
465
- const { fileKey } = await uploadFileFeishu({
466
- cfg,
467
- file: buffer,
468
- fileName: name,
469
- fileType,
470
- accountId,
471
- });
472
- // Feishu API: opus -> "audio", mp4/video -> "media" (playable), others -> "file"
473
- const msgType = fileType === "opus" ? "audio" : fileType === "mp4" ? "media" : "file";
474
- return sendFileFeishu({
475
- cfg,
476
- to,
477
- fileKey,
478
- msgType,
479
- replyToMessageId,
480
- replyInThread,
481
- accountId,
482
- });
483
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
+ });
484
830
  }
@@ -0,0 +1,5 @@
1
+ export type MentionTarget = {
2
+ openId: string;
3
+ name: string;
4
+ key: string; // Placeholder in original message, e.g. @_user_1
5
+ };