fix(telegram): probe video dimensions through sdk

Fix Telegram portrait video distortion by probing video dimensions through the shared media helper and passing width/height to sendVideo.

Validation:
- Targeted Telegram/media tests passed locally.
- Plugin SDK API baseline check passed locally.
- Formatter and git diff whitespace checks passed locally.

CI note: current boundary drift observed on prior run came from existing src/plugin-sdk/discord.ts and src/plugin-sdk/telegram-account.ts, not this PR diff.
This commit is contained in:
peter
2026-04-29 02:58:25 -04:00
committed by GitHub
parent 0bbbc99980
commit e71d7d48fb
14 changed files with 288 additions and 12 deletions

View File

@@ -18,6 +18,7 @@ Docs: https://docs.openclaw.ai
- Agents/tools: clamp `process.poll` waits to 30 seconds, advertise that cap in the tool schema, and honor abort signals while waiting, so long command polls cannot pin agent responsiveness after cancellation. Thanks @vincentkoc.
- Plugin SDK: add tracked Discord component-message helpers and a Telegram account-resolution compatibility facade, so existing plugins using those subpaths resolve while new plugins stay on generic channel SDK contracts. Thanks @vincentkoc.
- Shared labels: preserve Unicode combining marks and NFC-equivalent accented text in group/channel slug normalization so non-Latin labels no longer lose meaningful characters. Fixes #58932; carries forward #58942 and #58995. Thanks @fengqing-git, @Starhappysh, and @koen666.
- Channels/Telegram: include probed video width and height when sending regular Telegram videos, so portrait clips render with the correct orientation instead of being stretched by clients. (#18915) Thanks @storyarcade.
- Docs/Hetzner: clarify that SSH tunnel access requires `AllowTcpForwarding local` before running `ssh -L`, so hardened VPS sshd configs do not block loopback Gateway access. Fixes #54557; carries forward #54564; refs #54954. Thanks @satishkc7, @blackstrype, and @Aftabbs.
- Gateway/shutdown: report structured shutdown warnings and HTTP close timeout warnings through `ShutdownResult` while preserving lifecycle hook hardening. Carries forward #41296. Thanks @edenfunf.
- Plugins/QA: prebuild the private QA channel runtime before plugin gauntlet source runs so wrapper CPU/RSS measurements are not polluted by private QA dist rebuild work. Thanks @vincentkoc.

View File

@@ -1,2 +1,2 @@
21c1ddb7b6ab3da24d51971aca47b76044acf62229351dafc10ec1c0fc9ae1ff plugin-sdk-api-baseline.json
b4e011edd075864353ad238b8eeef0f6837a65f1500f21836aad7547c0c4507c plugin-sdk-api-baseline.jsonl
244286f93cd42484f81061b672437d7a769a61864c72eb3795f9e7abc739f60b plugin-sdk-api-baseline.json
5b7e45d83a0a7862f26f59a32647cdb04289609419e61473284568dd3adf9736 plugin-sdk-api-baseline.jsonl

View File

@@ -489,7 +489,7 @@ releases.
| `plugin-sdk/provider-stream` | Provider stream wrapper helpers | `ProviderStreamFamily`, `buildProviderStreamFamilyHooks`, `composeProviderStreamWrappers`, stream wrapper types, and shared Anthropic/Bedrock/DeepSeek V4/Google/Kilocode/Moonshot/OpenAI/OpenRouter/Z.A.I/MiniMax/Copilot wrapper helpers |
| `plugin-sdk/provider-transport-runtime` | Provider transport helpers | Native provider transport helpers such as guarded fetch, transport message transforms, and writable transport event streams |
| `plugin-sdk/keyed-async-queue` | Ordered async queue | `KeyedAsyncQueue` |
| `plugin-sdk/media-runtime` | Shared media helpers | Media fetch/transform/store helpers plus media payload builders |
| `plugin-sdk/media-runtime` | Shared media helpers | Media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
| `plugin-sdk/media-generation-runtime` | Shared media-generation helpers | Shared failover helpers, candidate selection, and missing-model messaging for image/video/music generation |
| `plugin-sdk/media-understanding` | Media-understanding helpers | Media understanding provider types plus provider-facing image/audio helper exports |
| `plugin-sdk/text-runtime` | Shared text helpers | Assistant-visible-text stripping, markdown render/chunking/table helpers, redaction helpers, directive-tag helpers, safe-text utilities, and related text/logging helpers |

View File

@@ -258,7 +258,7 @@ For the plugin authoring guide, see [Plugin SDK overview](/plugins/sdk-overview)
<Accordion title="Capability and testing subpaths">
| Subpath | Key exports |
| --- | --- |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers plus media payload builders |
| `plugin-sdk/media-runtime` | Shared media fetch/transform/store helpers, ffprobe-backed video dimension probing, and media payload builders |
| `plugin-sdk/media-store` | Narrow media store helpers such as `saveMediaBuffer` |
| `plugin-sdk/media-generation-runtime` | Shared media-generation failover helpers, candidate selection, and missing-model messaging |
| `plugin-sdk/media-understanding` | Media understanding provider types plus provider-facing image/audio helper exports |

View File

@@ -10,8 +10,12 @@ import {
toPluginMessageSentEvent,
} from "openclaw/plugin-sdk/hook-runtime";
import type { ReplyPayloadDelivery } from "openclaw/plugin-sdk/interactive-runtime";
import { buildOutboundMediaLoadOptions } from "openclaw/plugin-sdk/media-runtime";
import { isGifMedia, kindFromMime } from "openclaw/plugin-sdk/media-runtime";
import {
buildOutboundMediaLoadOptions,
isGifMedia,
kindFromMime,
probeVideoDimensions,
} from "openclaw/plugin-sdk/media-runtime";
import {
createOutboundPayloadPlan,
projectOutboundPayloadPlanForDelivery,
@@ -361,10 +365,12 @@ async function deliverMediaReply(params: {
progress: params.progress,
});
const shouldAttachButtonsToMedia = isFirstMedia && params.replyMarkup && !followUpText;
const videoDimensions = kind === "video" ? await probeVideoDimensions(media.buffer) : undefined;
const mediaParams: Record<string, unknown> = {
caption: htmlCaption,
...(htmlCaption ? { parse_mode: "HTML" } : {}),
...(shouldAttachButtonsToMedia ? { reply_markup: params.replyMarkup } : {}),
...(videoDimensions ? { width: videoDimensions.width, height: videoDimensions.height } : {}),
...buildTelegramSendParams({
replyToMessageId,
replyQuoteMessageId: params.replyQuoteMessageId,

View File

@@ -4,6 +4,9 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
const { loadWebMedia } = vi.hoisted(() => ({
loadWebMedia: vi.fn(),
}));
const { probeVideoDimensions } = vi.hoisted(() => ({
probeVideoDimensions: vi.fn(),
}));
const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {}));
const messageHookRunner = vi.hoisted(() => ({
hasHooks: vi.fn<(name: string) => boolean>(() => false),
@@ -28,6 +31,14 @@ vi.mock("openclaw/plugin-sdk/web-media", () => ({
loadWebMedia: (...args: unknown[]) => loadWebMedia(...args),
}));
vi.mock("openclaw/plugin-sdk/media-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/media-runtime")>();
return {
...actual,
probeVideoDimensions,
};
});
vi.mock("openclaw/plugin-sdk/hook-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/hook-runtime")>();
return {
@@ -135,6 +146,8 @@ function createVoiceFailureHarness(params: {
describe("deliverReplies", () => {
beforeEach(() => {
loadWebMedia.mockClear();
probeVideoDimensions.mockReset();
probeVideoDimensions.mockResolvedValue(undefined);
triggerInternalHook.mockReset();
messageHookRunner.hasHooks.mockReset();
messageHookRunner.hasHooks.mockReturnValue(false);
@@ -489,6 +502,63 @@ describe("deliverReplies", () => {
);
});
it("passes probed dimensions to video reply sends", async () => {
const runtime = createRuntime();
const sendVideo = vi.fn().mockResolvedValue({
message_id: 22,
chat: { id: "123" },
});
const bot = createBot({ sendVideo });
probeVideoDimensions.mockResolvedValueOnce({ width: 720, height: 1280 });
mockMediaLoad("video.mp4", "video/mp4", "video");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/video.mp4", text: "hi **boss**" }],
runtime,
bot,
});
expect(probeVideoDimensions).toHaveBeenCalledWith(Buffer.from("video"));
expect(sendVideo).toHaveBeenCalledWith(
"123",
expect.anything(),
expect.objectContaining({
caption: "hi <b>boss</b>",
parse_mode: "HTML",
width: 720,
height: 1280,
}),
);
});
it("does not probe GIF reply animations", async () => {
const runtime = createRuntime();
const sendAnimation = vi.fn().mockResolvedValue({
message_id: 23,
chat: { id: "123" },
});
const bot = createBot({ sendAnimation });
mockMediaLoad("fun.gif", "image/gif", "GIF89a");
await deliverWith({
replies: [{ mediaUrl: "https://example.com/fun.gif", text: "gif" }],
runtime,
bot,
});
expect(probeVideoDimensions).not.toHaveBeenCalled();
expect(sendAnimation).toHaveBeenCalledWith(
"123",
expect.anything(),
expect.not.objectContaining({
width: expect.any(Number),
height: expect.any(Number),
}),
);
});
it("passes mediaLocalRoots to media loading", async () => {
const runtime = createRuntime();
const sendPhoto = vi.fn().mockResolvedValue({

View File

@@ -8,5 +8,6 @@ export {
isGifMedia,
kindFromMime,
normalizePollInput,
probeVideoDimensions,
} from "openclaw/plugin-sdk/media-runtime";
export { loadWebMedia } from "openclaw/plugin-sdk/web-media";

View File

@@ -42,6 +42,10 @@ const { imageMetadata } = vi.hoisted(() => ({
},
}));
const { probeVideoDimensions } = vi.hoisted(() => ({
probeVideoDimensions: vi.fn(),
}));
const { loadConfig, resolveStorePath } = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
resolveStorePath: vi.fn(
@@ -90,6 +94,7 @@ type TelegramSendTestMocks = {
loadWebMedia: MockFn;
maybePersistResolvedTelegramTarget: MockFn;
imageMetadata: { width: number | undefined; height: number | undefined };
probeVideoDimensions: MockFn;
};
vi.mock("openclaw/plugin-sdk/web-media", () => ({
@@ -153,6 +158,7 @@ vi.mock("./send.runtime.js", () => ({
loadConfig,
loadWebMedia,
normalizePollInput,
probeVideoDimensions,
requireRuntimeConfig: vi.fn((cfg: unknown) => cfg ?? loadConfig()),
resolveMarkdownTableMode,
resolveStorePath,
@@ -171,6 +177,7 @@ export function getTelegramSendTestMocks(): TelegramSendTestMocks {
loadWebMedia,
maybePersistResolvedTelegramTarget,
imageMetadata,
probeVideoDimensions,
};
}
@@ -179,6 +186,8 @@ export function installTelegramSendTestHooks() {
loadConfig.mockReturnValue({});
resolveStorePath.mockReturnValue("/tmp/openclaw-telegram-send-tests.json");
loadWebMedia.mockReset();
probeVideoDimensions.mockReset();
probeVideoDimensions.mockResolvedValue(undefined);
imageMetadata.width = 1200;
imageMetadata.height = 800;
maybePersistResolvedTelegramTarget.mockReset();

View File

@@ -23,6 +23,7 @@ const {
loadConfig,
loadWebMedia,
maybePersistResolvedTelegramTarget,
probeVideoDimensions,
} = getTelegramSendTestMocks();
const {
buildInlineKeyboard,
@@ -978,6 +979,73 @@ describe("sendMessageTelegram", () => {
}
});
it("passes probed dimensions to regular video sends", async () => {
const chatId = "123";
const videoBuffer = Buffer.from("fake-video");
const sendVideo = vi.fn().mockResolvedValue({
message_id: 201,
chat: { id: chatId },
});
const api = { sendVideo } as unknown as {
sendVideo: typeof sendVideo;
};
probeVideoDimensions.mockResolvedValueOnce({ width: 720, height: 1280 });
mockLoadedMedia({
buffer: videoBuffer,
contentType: "video/mp4",
fileName: "video.mp4",
});
await sendMessageTelegram(chatId, "my caption", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
});
expect(probeVideoDimensions).toHaveBeenCalledWith(videoBuffer);
expect(sendVideo).toHaveBeenCalledWith(chatId, expect.anything(), {
caption: "my caption",
parse_mode: "HTML",
width: 720,
height: 1280,
});
});
it("does not probe video dimensions for video notes", async () => {
const chatId = "123";
const sendVideoNote = vi.fn().mockResolvedValue({
message_id: 101,
chat: { id: chatId },
});
const sendMessage = vi.fn().mockResolvedValue({
message_id: 102,
chat: { id: chatId },
});
const api = { sendVideoNote, sendMessage } as unknown as {
sendVideoNote: typeof sendVideoNote;
sendMessage: typeof sendMessage;
};
mockLoadedMedia({
buffer: Buffer.from("fake-video"),
contentType: "video/mp4",
fileName: "video.mp4",
});
await sendMessageTelegram(chatId, "ignored caption context", {
cfg: TELEGRAM_TEST_CFG,
token: "tok",
api,
mediaUrl: "https://example.com/video.mp4",
asVideoNote: true,
});
expect(probeVideoDimensions).not.toHaveBeenCalled();
expect(sendVideoNote).toHaveBeenCalledWith(chatId, expect.anything(), {});
});
it("applies reply markup and thread options to split video-note sends", async () => {
const chatId = "123";
const cases: Array<{
@@ -1195,6 +1263,7 @@ describe("sendMessageTelegram", () => {
caption: "caption",
parse_mode: "HTML",
});
expect(probeVideoDimensions).not.toHaveBeenCalled();
expect(res.messageId).toBe("9");
});

View File

@@ -36,6 +36,7 @@ import {
loadWebMedia,
type MediaKind,
normalizePollInput,
probeVideoDimensions,
type OpenClawConfig,
type PollInput,
requireRuntimeConfig,
@@ -821,10 +822,13 @@ export async function sendMessageTelegram(
...(hasThreadParams ? threadParams : {}),
...(!needsSeparateText && replyMarkup ? { reply_markup: replyMarkup } : {}),
};
const videoDimensions =
kind === "video" && !isVideoNote ? await probeVideoDimensions(media.buffer) : undefined;
const mediaParams = {
...(htmlCaption ? { caption: htmlCaption, parse_mode: "HTML" as const } : {}),
...baseMediaParams,
...(opts.silent === true ? { disable_notification: true } : {}),
...(videoDimensions ? { width: videoDimensions.width, height: videoDimensions.height } : {}),
};
const sendMedia = async (
label: string,

View File

@@ -13,6 +13,7 @@ const execFileAsync = promisify(execFile);
export type MediaExecOptions = {
timeoutMs?: number;
maxBufferBytes?: number;
input?: Buffer | string;
};
function resolveExecOptions(
@@ -41,12 +42,22 @@ function requireSystemBin(name: string): string {
}
export async function runFfprobe(args: string[], options?: MediaExecOptions): Promise<string> {
const { stdout } = await execFileAsync(
requireSystemBin("ffprobe"),
args,
resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options),
);
return stdout.toString();
const execOptions = resolveExecOptions(MEDIA_FFPROBE_TIMEOUT_MS, options);
if (options?.input == null) {
const { stdout } = await execFileAsync(requireSystemBin("ffprobe"), args, execOptions);
return stdout.toString();
}
return await new Promise<string>((resolve, reject) => {
const proc = execFile(requireSystemBin("ffprobe"), args, execOptions, (err, stdout) => {
if (err) {
reject(err);
return;
}
resolve(stdout.toString());
});
proc.stdin?.end(options.input);
});
}
export async function runFfmpeg(args: string[], options?: MediaExecOptions): Promise<string> {

View File

@@ -0,0 +1,61 @@
import { describe, expect, it, vi } from "vitest";
const { runFfprobe } = vi.hoisted(() => ({
runFfprobe: vi.fn(),
}));
vi.mock("./ffmpeg-exec.js", () => ({
runFfprobe,
}));
const { parseFfprobeVideoDimensions, probeVideoDimensions } = await import("./video-dimensions.js");
describe("parseFfprobeVideoDimensions", () => {
it("returns positive integer dimensions from ffprobe JSON", () => {
expect(
parseFfprobeVideoDimensions(JSON.stringify({ streams: [{ width: 720, height: 1280 }] })),
).toEqual({ width: 720, height: 1280 });
});
it("ignores missing or invalid dimensions", () => {
expect(parseFfprobeVideoDimensions(JSON.stringify({ streams: [] }))).toBeUndefined();
expect(
parseFfprobeVideoDimensions(JSON.stringify({ streams: [{ width: 0, height: 1280 }] })),
).toBeUndefined();
expect(
parseFfprobeVideoDimensions(JSON.stringify({ streams: [{ width: 720.5, height: 1280 }] })),
).toBeUndefined();
});
});
describe("probeVideoDimensions", () => {
it("probes video dimensions through ffprobe stdin", async () => {
const buffer = Buffer.from("video");
runFfprobe.mockResolvedValueOnce(JSON.stringify({ streams: [{ width: 720, height: 1280 }] }));
await expect(probeVideoDimensions(buffer)).resolves.toEqual({ width: 720, height: 1280 });
expect(runFfprobe).toHaveBeenCalledWith(
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
"pipe:0",
],
{ input: buffer },
);
});
it("falls back when ffprobe fails or returns malformed output", async () => {
runFfprobe.mockRejectedValueOnce(new Error("missing ffprobe"));
await expect(probeVideoDimensions(Buffer.from("video"))).resolves.toBeUndefined();
runFfprobe.mockResolvedValueOnce("{");
await expect(probeVideoDimensions(Buffer.from("video"))).resolves.toBeUndefined();
});
});

View File

@@ -0,0 +1,43 @@
import { runFfprobe } from "./ffmpeg-exec.js";
export type VideoDimensions = {
width: number;
height: number;
};
function parsePositiveDimension(value: unknown): number | undefined {
if (typeof value !== "number" || !Number.isInteger(value) || value <= 0) {
return undefined;
}
return value;
}
export function parseFfprobeVideoDimensions(stdout: string): VideoDimensions | undefined {
const parsed = JSON.parse(stdout) as { streams?: Array<{ width?: unknown; height?: unknown }> };
const stream = parsed.streams?.[0];
const width = parsePositiveDimension(stream?.width);
const height = parsePositiveDimension(stream?.height);
return width && height ? { width, height } : undefined;
}
export async function probeVideoDimensions(buffer: Buffer): Promise<VideoDimensions | undefined> {
try {
const stdout = await runFfprobe(
[
"-v",
"error",
"-select_streams",
"v:0",
"-show_entries",
"stream=width,height",
"-of",
"json",
"pipe:0",
],
{ input: buffer },
);
return parseFfprobeVideoDimensions(stdout);
} catch {
return undefined;
}
}

View File

@@ -20,6 +20,7 @@ export * from "../media/qr-terminal.ts";
export * from "../media/read-response-with-limit.js";
export * from "../media/store.js";
export * from "../media/temp-files.js";
export * from "../media/video-dimensions.js";
export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
export * from "./agent-media-payload.js";
export * from "../media-understanding/audio-preflight.ts";