mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-25 21:09:34 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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({
|
||||
|
||||
Reference in New Issue
Block a user