fix(feishu): show voice message duration via upload duration (#89172)

Voice/audio messages sent to Feishu (opus) play fine but show no duration
on the bubble. Feishu derives the voice-bubble duration from the `duration`
parameter of the file upload API (`im/v1/files`); the audio message content
only carries `{file_key}` and has no duration field, so the duration was
never set.

`sendMediaFeishu` now probes the outgoing audio with `ffprobe` and passes the
result as the upload `duration` (ms). It probes the buffer that is actually
sent (after the existing voice transcode, which caps length via
`MEDIA_FFMPEG_MAX_AUDIO_DURATION_SECS`), so the reported length matches what
is played. Probing is best-effort: on failure it logs and omits the duration,
and the message still sends. The audio message content is unchanged.

Fixes #53798

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
areslp
2026-06-23 03:13:14 +08:00
committed by GitHub
parent 8c366bfefd
commit bfbf25e234
2 changed files with 82 additions and 3 deletions

View File

@@ -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<string[]>(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,

View File

@@ -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<UploadFileResult> {
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<number | undefined> {
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({