From cc0ef4d01200539153a103604d0c0710552a262c Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Tue, 6 Jan 2026 02:22:09 +0000 Subject: [PATCH] fix(telegram): improve gif handling --- CHANGELOG.md | 1 + src/media/mime.ts | 11 +++++++ src/telegram/bot.test.ts | 61 +++++++++++++++++++++++++++++++++++++++ src/telegram/bot.ts | 20 +++++++++---- src/telegram/send.test.ts | 42 ++++++++++++++++++++++++++- src/telegram/send.ts | 24 +++++++++++---- src/web/media.test.ts | 28 ++++++++++++++++++ src/web/media.ts | 37 ++++++++++++++++++++++-- 8 files changed, 209 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 98e4f9e34..9af4fe803 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -51,6 +51,7 @@ - Telegram: honor routing.groupChat.mentionPatterns for group mention gating. Thanks @regenrek for PR #242. - Auto-reply: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs. - Auto-reply: track compaction count in session status; verbose mode announces auto-compactions. +- Telegram: send GIF media as animations (auto-play) and improve filename sniffing. ### Maintenance - Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome. diff --git a/src/media/mime.ts b/src/media/mime.ts index d26cfb969..a53abdb23 100644 --- a/src/media/mime.ts +++ b/src/media/mime.ts @@ -107,6 +107,17 @@ export function extensionForMime(mime?: string | null): string | undefined { return EXT_BY_MIME[mime.toLowerCase()]; } +export function isGifMedia(opts: { + contentType?: string | null; + fileName?: string | null; +}): boolean { + if (opts.contentType?.toLowerCase() === "image/gif") return true; + const ext = opts.fileName + ? path.extname(opts.fileName).toLowerCase() + : undefined; + return ext === ".gif"; +} + export function imageMimeFromFormat( format?: string | null, ): string | undefined { diff --git a/src/telegram/bot.test.ts b/src/telegram/bot.test.ts index bee9bd560..b74a25a76 100644 --- a/src/telegram/bot.test.ts +++ b/src/telegram/bot.test.ts @@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import * as replyModule from "../auto-reply/reply.js"; import { createTelegramBot } from "./bot.js"; +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); + const { loadConfig } = vi.hoisted(() => ({ loadConfig: vi.fn(() => ({})), })); @@ -18,15 +26,21 @@ const onSpy = vi.fn(); const stopSpy = vi.fn(); const sendChatActionSpy = vi.fn(); const sendMessageSpy = vi.fn(async () => ({ message_id: 77 })); +const sendAnimationSpy = vi.fn(async () => ({ message_id: 78 })); +const sendPhotoSpy = vi.fn(async () => ({ message_id: 79 })); type ApiStub = { config: { use: (arg: unknown) => void }; sendChatAction: typeof sendChatActionSpy; sendMessage: typeof sendMessageSpy; + sendAnimation: typeof sendAnimationSpy; + sendPhoto: typeof sendPhotoSpy; }; const apiStub: ApiStub = { config: { use: useSpy }, sendChatAction: sendChatActionSpy, sendMessage: sendMessageSpy, + sendAnimation: sendAnimationSpy, + sendPhoto: sendPhotoSpy, }; vi.mock("grammy", () => ({ @@ -57,6 +71,9 @@ vi.mock("../auto-reply/reply.js", () => { describe("createTelegramBot", () => { beforeEach(() => { loadConfig.mockReturnValue({}); + loadWebMedia.mockReset(); + sendAnimationSpy.mockReset(); + sendPhotoSpy.mockReset(); }); it("installs grammY throttler", () => { @@ -511,4 +528,48 @@ describe("createTelegramBot", () => { expect(replySpy).toHaveBeenCalledTimes(1); }); + + it("sends GIF replies as animations", async () => { + onSpy.mockReset(); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + replySpy.mockReset(); + + replySpy.mockResolvedValueOnce({ + text: "caption", + mediaUrl: "https://example.com/fun", + }); + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + contentType: "image/gif", + fileName: "fun.gif", + }); + + createTelegramBot({ token: "tok" }); + const handler = onSpy.mock.calls[0][1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + chat: { id: 1234, type: "private" }, + text: "hello world", + date: 1736380800, + message_id: 5, + from: { first_name: "Ada" }, + }, + me: { username: "clawdbot_bot" }, + getFile: async () => ({ download: async () => new Uint8Array() }), + }); + + expect(sendAnimationSpy).toHaveBeenCalledTimes(1); + expect(sendAnimationSpy).toHaveBeenCalledWith( + "1234", + expect.anything(), + { caption: "caption", reply_to_message_id: undefined }, + ); + expect(sendPhotoSpy).not.toHaveBeenCalled(); + }); }); diff --git a/src/telegram/bot.ts b/src/telegram/bot.ts index f8022dc98..952550148 100644 --- a/src/telegram/bot.ts +++ b/src/telegram/bot.ts @@ -21,7 +21,7 @@ import { danger, logVerbose, shouldLogVerbose } from "../globals.js"; import { formatErrorMessage } from "../infra/errors.js"; import { getChildLogger } from "../logging.js"; import { mediaKindFromMime } from "../media/constants.js"; -import { detectMime } from "../media/mime.js"; +import { detectMime, isGifMedia } from "../media/mime.js"; import { saveMediaBuffer } from "../media/store.js"; import type { RuntimeEnv } from "../runtime.js"; import { loadWebMedia } from "../web/media.js"; @@ -176,9 +176,7 @@ export function createTelegramBot(opts: TelegramBotOptions) { ).trim(); if (!rawBody) return; const replySuffix = replyTarget - ? `\n\n[Replying to ${replyTarget.sender}${ - replyTarget.id ? ` id:${replyTarget.id}` : "" - }]\n${replyTarget.body}\n[/Replying]` + ? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]` : ""; const body = formatAgentEnvelope({ surface: "Telegram", @@ -336,14 +334,24 @@ async function deliverReplies(params: { for (const mediaUrl of mediaList) { const media = await loadWebMedia(mediaUrl); const kind = mediaKindFromMime(media.contentType ?? undefined); - const file = new InputFile(media.buffer, media.fileName ?? "file"); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + const fileName = media.fileName ?? (isGif ? "animation.gif" : "file"); + const file = new InputFile(media.buffer, fileName); const caption = first ? (reply.text ?? undefined) : undefined; first = false; const replyToMessageId = replyToId && (replyToMode === "all" || !hasReplied) ? replyToId : undefined; - if (kind === "image") { + if (isGif) { + await bot.api.sendAnimation(chatId, file, { + caption, + reply_to_message_id: replyToMessageId, + }); + } else if (kind === "image") { await bot.api.sendPhoto(chatId, file, { caption, reply_to_message_id: replyToMessageId, diff --git a/src/telegram/send.test.ts b/src/telegram/send.test.ts index 04a068cd7..a302c9f5a 100644 --- a/src/telegram/send.test.ts +++ b/src/telegram/send.test.ts @@ -1,8 +1,20 @@ -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; + +const { loadWebMedia } = vi.hoisted(() => ({ + loadWebMedia: vi.fn(), +})); + +vi.mock("../web/media.js", () => ({ + loadWebMedia, +})); import { sendMessageTelegram } from "./send.js"; describe("sendMessageTelegram", () => { + beforeEach(() => { + loadWebMedia.mockReset(); + }); + it("falls back to plain text when Telegram rejects Markdown", async () => { const chatId = "123"; const parseErr = new Error( @@ -67,4 +79,32 @@ describe("sendMessageTelegram", () => { sendMessageTelegram(chatId, "hi", { token: "tok", api }), ).rejects.toThrow(/chat_id=123/); }); + + it("sends GIF media as animation", async () => { + const chatId = "123"; + const sendAnimation = vi.fn().mockResolvedValue({ + message_id: 9, + chat: { id: chatId }, + }); + const api = { sendAnimation } as unknown as { + sendAnimation: typeof sendAnimation; + }; + + loadWebMedia.mockResolvedValueOnce({ + buffer: Buffer.from("GIF89a"), + fileName: "fun.gif", + }); + + const res = await sendMessageTelegram(chatId, "caption", { + token: "tok", + api, + mediaUrl: "https://example.com/fun", + }); + + expect(sendAnimation).toHaveBeenCalledTimes(1); + expect(sendAnimation).toHaveBeenCalledWith(chatId, expect.anything(), { + caption: "caption", + }); + expect(res.messageId).toBe("9"); + }); }); diff --git a/src/telegram/send.ts b/src/telegram/send.ts index 06b50da5b..39c063cc7 100644 --- a/src/telegram/send.ts +++ b/src/telegram/send.ts @@ -2,6 +2,7 @@ import { Bot, InputFile } from "grammy"; import { formatErrorMessage } from "../infra/errors.js"; import { mediaKindFromMime } from "../media/constants.js"; +import { isGifMedia } from "../media/mime.js"; import { loadWebMedia } from "../web/media.js"; type TelegramSendOpts = { @@ -110,17 +111,30 @@ export async function sendMessageTelegram( if (mediaUrl) { const media = await loadWebMedia(mediaUrl, opts.maxBytes); const kind = mediaKindFromMime(media.contentType ?? undefined); - const file = new InputFile( - media.buffer, - media.fileName ?? inferFilename(kind) ?? "file", - ); + const isGif = isGifMedia({ + contentType: media.contentType, + fileName: media.fileName, + }); + const fileName = + media.fileName ?? + (isGif ? "animation.gif" : inferFilename(kind)) ?? + "file"; + const file = new InputFile(media.buffer, fileName); const caption = text?.trim() || undefined; let result: | Awaited> | Awaited> | Awaited> + | Awaited> | Awaited>; - if (kind === "image") { + if (isGif) { + result = await sendWithRetry( + () => api.sendAnimation(chatId, file, { caption }), + "animation", + ).catch((err) => { + throw wrapChatNotFound(err); + }); + } else if (kind === "image") { result = await sendWithRetry( () => api.sendPhoto(chatId, file, { caption }), "photo", diff --git a/src/web/media.test.ts b/src/web/media.test.ts index 8cf90da8b..02377a46b 100644 --- a/src/web/media.test.ts +++ b/src/web/media.test.ts @@ -76,6 +76,34 @@ describe("web media loading", () => { fetchMock.mockRestore(); }); + it("uses content-disposition filename when available", async () => { + const fetchMock = vi.spyOn(globalThis, "fetch").mockResolvedValueOnce({ + ok: true, + body: true, + arrayBuffer: async () => Buffer.from("%PDF-1.4").buffer, + headers: { + get: (name: string) => { + if (name === "content-disposition") { + return 'attachment; filename="report.pdf"'; + } + if (name === "content-type") return "application/pdf"; + return null; + }, + }, + status: 200, + } as Response); + + const result = await loadWebMedia( + "https://example.com/download?id=1", + 1024 * 1024, + ); + + expect(result.kind).toBe("document"); + expect(result.fileName).toBe("report.pdf"); + + fetchMock.mockRestore(); + }); + it("preserves GIF animation by skipping JPEG optimization", async () => { // Create a minimal valid GIF (1x1 pixel) // GIF89a header + minimal image data diff --git a/src/web/media.ts b/src/web/media.ts index 97743f3d0..e1ba089e6 100644 --- a/src/web/media.ts +++ b/src/web/media.ts @@ -22,6 +22,29 @@ type WebMediaOptions = { optimizeImages?: boolean; }; +function stripQuotes(value: string): string { + return value.replace(/^["']|["']$/g, ""); +} + +function parseContentDispositionFileName( + header?: string | null, +): string | undefined { + if (!header) return undefined; + const starMatch = /filename\*\s*=\s*([^;]+)/i.exec(header); + if (starMatch?.[1]) { + const cleaned = stripQuotes(starMatch[1].trim()); + const encoded = cleaned.split("''").slice(1).join("''") || cleaned; + try { + return path.basename(decodeURIComponent(encoded)); + } catch { + return path.basename(encoded); + } + } + const match = /filename\s*=\s*([^;]+)/i.exec(header); + if (match?.[1]) return path.basename(stripQuotes(match[1].trim())); + return undefined; +} + async function loadWebMediaInternal( mediaUrl: string, options: WebMediaOptions = {}, @@ -54,11 +77,11 @@ async function loadWebMediaInternal( }; if (/^https?:\/\//i.test(mediaUrl)) { - let fileName: string | undefined; + let fileNameFromUrl: string | undefined; try { const url = new URL(mediaUrl); const base = path.basename(url.pathname); - fileName = base || undefined; + fileNameFromUrl = base || undefined; } catch { // ignore parse errors; leave undefined } @@ -67,10 +90,18 @@ async function loadWebMediaInternal( throw new Error(`Failed to fetch media: HTTP ${res.status}`); } const array = Buffer.from(await res.arrayBuffer()); + const headerFileName = parseContentDispositionFileName( + res.headers.get("content-disposition"), + ); + let fileName = headerFileName || fileNameFromUrl || undefined; + const filePathForMime = + headerFileName && path.extname(headerFileName) + ? headerFileName + : mediaUrl; const contentType = await detectMime({ buffer: array, headerMime: res.headers.get("content-type"), - filePath: mediaUrl, + filePath: filePathForMime, }); if (fileName && !path.extname(fileName) && contentType) { const ext = extensionForMime(contentType);