diff --git a/src/telegram/bot/delivery.resolve-media-retry.test.ts b/src/telegram/bot/delivery.resolve-media-retry.test.ts index d6f4e8fadc0..bc0774ff3b1 100644 --- a/src/telegram/bot/delivery.resolve-media-retry.test.ts +++ b/src/telegram/bot/delivery.resolve-media-retry.test.ts @@ -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 = { 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(); + }); +}); diff --git a/src/telegram/bot/delivery.resolve-media.ts b/src/telegram/bot/delivery.resolve-media.ts index 81cfabbdcf4..0d5ddec445c 100644 --- a/src/telegram/bot/delivery.resolve-media.ts +++ b/src/telegram/bot/delivery.resolve-media.ts @@ -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) ?? ""; return { path: saved.path, contentType: saved.contentType, placeholder }; }