fix(telegram): preserve original filename from Telegram document/audio/video uploads

The downloadAndSaveTelegramFile inner function only used the server-side
file path (e.g. "documents/file_42.pdf") or the Content-Disposition
header (which Telegram doesn't send) to derive the saved filename.
The original filename provided by Telegram via msg.document.file_name,
msg.audio.file_name, msg.video.file_name, and msg.animation.file_name
was never passed through, causing all inbound files to lose their
user-provided names.

Now downloadAndSaveTelegramFile accepts an optional telegramFileName
parameter that takes priority over the fetched/server-side name.
The resolveMedia call site extracts the original name from the message
and passes it through.

Closes #31768

Made-with: Cursor
This commit is contained in:
Kay-051
2026-03-02 22:36:34 +08:00
committed by Peter Steinberger
parent e45d26b9ed
commit 88783417f3
2 changed files with 180 additions and 6 deletions

View File

@@ -31,8 +31,9 @@ const MAX_MEDIA_BYTES = 10_000_000;
const BOT_TOKEN = "tok123";
function makeCtx(
mediaField: "voice" | "audio" | "photo" | "video",
mediaField: "voice" | "audio" | "photo" | "video" | "document" | "animation",
getFile: TelegramContext["getFile"],
opts?: { file_name?: string },
): TelegramContext {
const msg: Record<string, unknown> = {
message_id: 1,
@@ -43,13 +44,40 @@ function makeCtx(
msg.voice = { file_id: "v1", duration: 5, file_unique_id: "u1" };
}
if (mediaField === "audio") {
msg.audio = { file_id: "a1", duration: 5, file_unique_id: "u2" };
msg.audio = {
file_id: "a1",
duration: 5,
file_unique_id: "u2",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "photo") {
msg.photo = [{ file_id: "p1", width: 100, height: 100 }];
}
if (mediaField === "video") {
msg.video = { file_id: "vid1", duration: 10, file_unique_id: "u3" };
msg.video = {
file_id: "vid1",
duration: 10,
file_unique_id: "u3",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "document") {
msg.document = {
file_id: "d1",
file_unique_id: "u4",
...(opts?.file_name && { file_name: opts.file_name }),
};
}
if (mediaField === "animation") {
msg.animation = {
file_id: "an1",
duration: 3,
file_unique_id: "u5",
width: 200,
height: 200,
...(opts?.file_name && { file_name: opts.file_name }),
};
}
return {
message: msg as unknown as Message,
@@ -204,3 +232,140 @@ describe("resolveMedia getFile retry", () => {
expect(result).not.toBeNull();
});
});
describe("resolveMedia original filename preservation", () => {
beforeEach(() => {
vi.useFakeTimers();
fetchRemoteMedia.mockClear();
saveMediaBuffer.mockClear();
});
afterEach(() => {
vi.useRealTimers();
});
it("passes document.file_name to saveMediaBuffer instead of server-side path", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/business-plan---uuid.pdf",
contentType: "application/pdf",
});
const ctx = makeCtx("document", getFile, { file_name: "business-plan.pdf" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"business-plan.pdf",
);
expect(result).toEqual(expect.objectContaining({ path: "/tmp/business-plan---uuid.pdf" }));
});
it("passes audio.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "music/file_99.mp3" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("audio-data"),
contentType: "audio/mpeg",
fileName: "file_99.mp3",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/my-song---uuid.mp3",
contentType: "audio/mpeg",
});
const ctx = makeCtx("audio", getFile, { file_name: "my-song.mp3" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"audio/mpeg",
"inbound",
MAX_MEDIA_BYTES,
"my-song.mp3",
);
expect(result).not.toBeNull();
});
it("passes video.file_name to saveMediaBuffer", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "videos/file_55.mp4" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("video-data"),
contentType: "video/mp4",
fileName: "file_55.mp4",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/presentation---uuid.mp4",
contentType: "video/mp4",
});
const ctx = makeCtx("video", getFile, { file_name: "presentation.mp4" });
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"video/mp4",
"inbound",
MAX_MEDIA_BYTES,
"presentation.mp4",
);
expect(result).not.toBeNull();
});
it("falls back to fetched.fileName when telegram file_name is absent", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: "file_42.pdf",
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"file_42.pdf",
);
expect(result).not.toBeNull();
});
it("falls back to filePath when neither telegram nor fetched fileName is available", async () => {
const getFile = vi.fn().mockResolvedValue({ file_path: "documents/file_42.pdf" });
fetchRemoteMedia.mockResolvedValueOnce({
buffer: Buffer.from("pdf-data"),
contentType: "application/pdf",
fileName: undefined,
});
saveMediaBuffer.mockResolvedValueOnce({
path: "/tmp/file_42---uuid.pdf",
contentType: "application/pdf",
});
const ctx = makeCtx("document", getFile);
const result = await resolveMedia(ctx, MAX_MEDIA_BYTES, BOT_TOKEN);
expect(saveMediaBuffer).toHaveBeenCalledWith(
expect.any(Buffer),
"application/pdf",
"inbound",
MAX_MEDIA_BYTES,
"documents/file_42.pdf",
);
expect(result).not.toBeNull();
});
});

View File

@@ -53,7 +53,11 @@ export async function resolveMedia(
stickerMetadata?: StickerMetadata;
} | null> {
const msg = ctx.message;
const downloadAndSaveTelegramFile = async (filePath: string, fetchImpl: typeof fetch) => {
const downloadAndSaveTelegramFile = async (
filePath: string,
fetchImpl: typeof fetch,
telegramFileName?: string,
) => {
const url = `https://api.telegram.org/file/bot${token}/${filePath}`;
const fetched = await fetchRemoteMedia({
url,
@@ -62,7 +66,7 @@ export async function resolveMedia(
maxBytes,
ssrfPolicy: TELEGRAM_MEDIA_SSRF_POLICY,
});
const originalName = fetched.fileName ?? filePath;
const originalName = telegramFileName ?? fetched.fileName ?? filePath;
return saveMediaBuffer(fetched.buffer, fetched.contentType, "inbound", maxBytes, originalName);
};
@@ -184,7 +188,12 @@ export async function resolveMedia(
if (!fetchImpl) {
throw new Error("fetch is not available; set channels.telegram.proxy in config");
}
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl);
const telegramFileName =
msg.document?.file_name ??
msg.audio?.file_name ??
msg.video?.file_name ??
msg.animation?.file_name;
const saved = await downloadAndSaveTelegramFile(file.file_path, fetchImpl, telegramFileName);
const placeholder = resolveTelegramMediaPlaceholder(msg) ?? "<media:document>";
return { path: saved.path, contentType: saved.contentType, placeholder };
}