fix(telegram): improve gif handling
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user