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

@@ -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,