fix(telegram): improve gif handling

This commit is contained in:
Peter Steinberger
2026-01-06 02:22:09 +00:00
parent 45c67a48af
commit cc0ef4d012
8 changed files with 209 additions and 15 deletions

View File

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

View File

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

View File

@@ -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<string, unknown>,
) => Promise<void>;
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();
});
});

View File

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

View File

@@ -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");
});
});

View File

@@ -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<ReturnType<typeof api.sendPhoto>>
| Awaited<ReturnType<typeof api.sendVideo>>
| Awaited<ReturnType<typeof api.sendAudio>>
| Awaited<ReturnType<typeof api.sendAnimation>>
| Awaited<ReturnType<typeof api.sendDocument>>;
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",

View File

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

View File

@@ -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);