diff --git a/extensions/feishu/src/media.test.ts b/extensions/feishu/src/media.test.ts index 5b5d5d97a64..552369c01ed 100644 --- a/extensions/feishu/src/media.test.ts +++ b/extensions/feishu/src/media.test.ts @@ -12,6 +12,7 @@ const normalizeFeishuTargetMock = vi.hoisted(() => vi.fn()); const resolveReceiveIdTypeMock = vi.hoisted(() => vi.fn()); const loadWebMediaMock = vi.hoisted(() => vi.fn()); const runFfmpegMock = vi.hoisted(() => vi.fn()); +const runFfprobeMock = vi.hoisted(() => vi.fn()); const fileCreateMock = vi.hoisted(() => vi.fn()); const imageCreateMock = vi.hoisted(() => vi.fn()); @@ -49,6 +50,7 @@ vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => { return { ...actual, runFfmpeg: runFfmpegMock, + runFfprobe: runFfprobeMock, }; }); @@ -189,6 +191,7 @@ describe("sendMediaFeishu msg_type routing", () => { await fs.writeFile(args.at(-1) ?? "", Buffer.from("opus-output")); return ""; }); + runFfprobeMock.mockResolvedValue("1.234\n"); }); it("suppresses reply text only for voice-intent or native voice media", () => { @@ -234,6 +237,53 @@ describe("sendMediaFeishu msg_type routing", () => { expect(callData<{ msg_type?: string }>(messageCreateMock).msg_type).toBe("audio"); }); + it("includes audio duration in the Feishu file upload", async () => { + const audio = Buffer.from("opus"); + runFfprobeMock.mockResolvedValueOnce("2.345\n"); + + await sendMediaFeishu({ + cfg: emptyConfig, + to: "user:ou_target", + mediaBuffer: audio, + fileName: "reply.ogg", + }); + + expect(runFfprobeMock).toHaveBeenCalledTimes(1); + const ffprobeArgs = mockCallArg(runFfprobeMock, 0, 0); + expect(ffprobeArgs.slice(0, -1)).toEqual([ + "-v", + "error", + "-show_entries", + "format=duration", + "-of", + "csv=p=0", + ]); + expect(ffprobeArgs.at(-1)).toMatch(/input\.ogg$/); + expect(mockCallArg(runFfprobeMock, 0, 1)).toEqual({ timeoutMs: 5_000 }); + expect(callData<{ duration?: number }>(fileCreateMock).duration).toBe(2345); + const messageData = callData<{ content?: string; msg_type?: string }>(messageCreateMock); + expect(messageData.msg_type).toBe("audio"); + expect(JSON.parse(messageData.content ?? "{}")).toEqual({ + file_key: "file_key_1", + }); + }); + + it("omits audio duration when probing fails", async () => { + runFfprobeMock.mockRejectedValueOnce(new Error("ffprobe missing")); + + await sendMediaFeishu({ + cfg: emptyConfig, + to: "user:ou_target", + mediaBuffer: Buffer.from("opus"), + fileName: "reply.ogg", + }); + + expect(callData<{ duration?: number }>(fileCreateMock)).not.toHaveProperty("duration"); + expect(JSON.parse(callData<{ content?: string }>(messageCreateMock).content ?? "{}")).toEqual({ + file_key: "file_key_1", + }); + }); + it("uses msg_type=file for documents", async () => { await sendMediaFeishu({ cfg: emptyConfig, diff --git a/extensions/feishu/src/media.ts b/extensions/feishu/src/media.ts index d16810bd7b2..8beb03e5c2b 100644 --- a/extensions/feishu/src/media.ts +++ b/extensions/feishu/src/media.ts @@ -5,7 +5,11 @@ import { Readable } from "node:stream"; import type * as Lark from "@larksuiteoapi/node-sdk"; import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound"; import { mediaKindFromMime } from "openclaw/plugin-sdk/media-mime"; -import { MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, runFfmpeg } from "openclaw/plugin-sdk/media-runtime"; +import { + MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS, + runFfmpeg, + runFfprobe, +} from "openclaw/plugin-sdk/media-runtime"; import { saveMediaBuffer, saveMediaStream, type SavedMedia } from "openclaw/plugin-sdk/media-store"; import { readRegularFile, writeExternalFileWithinRoot } from "openclaw/plugin-sdk/security-runtime"; import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime"; @@ -471,7 +475,7 @@ export async function uploadFileFeishu(params: { file: Buffer | string; // Buffer or file path fileName: string; fileType: "opus" | "mp4" | "pdf" | "doc" | "xls" | "ppt" | "stream"; - duration?: number; // Required for audio/video files, in milliseconds + duration?: number; // Audio/video duration, in milliseconds. accountId?: string; }): Promise { const { cfg, file, fileName, fileType, duration, accountId } = params; @@ -492,7 +496,7 @@ export async function uploadFileFeishu(params: { file_type: fileType, file_name: safeFileName, file: fileData, - ...(duration !== undefined && { duration }), + ...(duration !== undefined ? { duration } : {}), }, }), "Feishu file upload failed", @@ -818,6 +822,29 @@ async function prepareFeishuVoiceMedia(params: { } } +async function probeAudioDurationMs(buffer: Buffer): Promise { + try { + return await withTempWorkspace( + { rootDir: resolvePreferredOpenClawTmpDir(), prefix: "feishu-audio-probe-" }, + async (workspace) => { + const inputPath = await workspace.write("input.ogg", buffer); + const stdout = await runFfprobe( + ["-v", "error", "-show_entries", "format=duration", "-of", "csv=p=0", inputPath], + { timeoutMs: 5_000 }, + ); + const seconds = Number.parseFloat(stdout.trim()); + if (!Number.isFinite(seconds) || seconds <= 0) { + return undefined; + } + return Math.max(1, Math.round(seconds * 1000)); + }, + ); + } catch (err) { + console.warn("[feishu] failed to probe audio duration; voice bubble will omit it:", err); + return undefined; + } +} + /** * Upload and send media (image or file) from URL, local path, or buffer. * When mediaUrl is a local path, mediaLocalRoots (from core outbound context) @@ -903,11 +930,13 @@ export async function sendMediaFeishu(params: { ...(voiceIntentDegradedToFile ? { voiceIntentDegradedToFile: true } : {}), }; } + const durationMs = routing.msgType === "audio" ? await probeAudioDurationMs(buffer) : undefined; const { fileKey } = await uploadFileFeishu({ cfg, file: buffer, fileName: name, fileType: routing.fileType ?? "stream", + ...(durationMs !== undefined ? { duration: durationMs } : {}), accountId, }); const result = await sendFileFeishu({