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.
|
- 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: block unauthorized `/reset` and infer WhatsApp senders from E.164 inputs.
|
||||||
- Auto-reply: track compaction count in session status; verbose mode announces auto-compactions.
|
- 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
|
### Maintenance
|
||||||
- Deps: bump pi-* stack, Slack SDK, discord-api-types, file-type, zod, and Biome.
|
- 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()];
|
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(
|
export function imageMimeFromFormat(
|
||||||
format?: string | null,
|
format?: string | null,
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|||||||
@@ -2,6 +2,14 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import * as replyModule from "../auto-reply/reply.js";
|
import * as replyModule from "../auto-reply/reply.js";
|
||||||
import { createTelegramBot } from "./bot.js";
|
import { createTelegramBot } from "./bot.js";
|
||||||
|
|
||||||
|
const { loadWebMedia } = vi.hoisted(() => ({
|
||||||
|
loadWebMedia: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("../web/media.js", () => ({
|
||||||
|
loadWebMedia,
|
||||||
|
}));
|
||||||
|
|
||||||
const { loadConfig } = vi.hoisted(() => ({
|
const { loadConfig } = vi.hoisted(() => ({
|
||||||
loadConfig: vi.fn(() => ({})),
|
loadConfig: vi.fn(() => ({})),
|
||||||
}));
|
}));
|
||||||
@@ -18,15 +26,21 @@ const onSpy = vi.fn();
|
|||||||
const stopSpy = vi.fn();
|
const stopSpy = vi.fn();
|
||||||
const sendChatActionSpy = vi.fn();
|
const sendChatActionSpy = vi.fn();
|
||||||
const sendMessageSpy = vi.fn(async () => ({ message_id: 77 }));
|
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 = {
|
type ApiStub = {
|
||||||
config: { use: (arg: unknown) => void };
|
config: { use: (arg: unknown) => void };
|
||||||
sendChatAction: typeof sendChatActionSpy;
|
sendChatAction: typeof sendChatActionSpy;
|
||||||
sendMessage: typeof sendMessageSpy;
|
sendMessage: typeof sendMessageSpy;
|
||||||
|
sendAnimation: typeof sendAnimationSpy;
|
||||||
|
sendPhoto: typeof sendPhotoSpy;
|
||||||
};
|
};
|
||||||
const apiStub: ApiStub = {
|
const apiStub: ApiStub = {
|
||||||
config: { use: useSpy },
|
config: { use: useSpy },
|
||||||
sendChatAction: sendChatActionSpy,
|
sendChatAction: sendChatActionSpy,
|
||||||
sendMessage: sendMessageSpy,
|
sendMessage: sendMessageSpy,
|
||||||
|
sendAnimation: sendAnimationSpy,
|
||||||
|
sendPhoto: sendPhotoSpy,
|
||||||
};
|
};
|
||||||
|
|
||||||
vi.mock("grammy", () => ({
|
vi.mock("grammy", () => ({
|
||||||
@@ -57,6 +71,9 @@ vi.mock("../auto-reply/reply.js", () => {
|
|||||||
describe("createTelegramBot", () => {
|
describe("createTelegramBot", () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
loadConfig.mockReturnValue({});
|
loadConfig.mockReturnValue({});
|
||||||
|
loadWebMedia.mockReset();
|
||||||
|
sendAnimationSpy.mockReset();
|
||||||
|
sendPhotoSpy.mockReset();
|
||||||
});
|
});
|
||||||
|
|
||||||
it("installs grammY throttler", () => {
|
it("installs grammY throttler", () => {
|
||||||
@@ -511,4 +528,48 @@ describe("createTelegramBot", () => {
|
|||||||
|
|
||||||
expect(replySpy).toHaveBeenCalledTimes(1);
|
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 { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { getChildLogger } from "../logging.js";
|
import { getChildLogger } from "../logging.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.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 { saveMediaBuffer } from "../media/store.js";
|
||||||
import type { RuntimeEnv } from "../runtime.js";
|
import type { RuntimeEnv } from "../runtime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
@@ -176,9 +176,7 @@ export function createTelegramBot(opts: TelegramBotOptions) {
|
|||||||
).trim();
|
).trim();
|
||||||
if (!rawBody) return;
|
if (!rawBody) return;
|
||||||
const replySuffix = replyTarget
|
const replySuffix = replyTarget
|
||||||
? `\n\n[Replying to ${replyTarget.sender}${
|
? `\n\n[Replying to ${replyTarget.sender}${replyTarget.id ? ` id:${replyTarget.id}` : ""}]\n${replyTarget.body}\n[/Replying]`
|
||||||
replyTarget.id ? ` id:${replyTarget.id}` : ""
|
|
||||||
}]\n${replyTarget.body}\n[/Replying]`
|
|
||||||
: "";
|
: "";
|
||||||
const body = formatAgentEnvelope({
|
const body = formatAgentEnvelope({
|
||||||
surface: "Telegram",
|
surface: "Telegram",
|
||||||
@@ -336,14 +334,24 @@ async function deliverReplies(params: {
|
|||||||
for (const mediaUrl of mediaList) {
|
for (const mediaUrl of mediaList) {
|
||||||
const media = await loadWebMedia(mediaUrl);
|
const media = await loadWebMedia(mediaUrl);
|
||||||
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
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;
|
const caption = first ? (reply.text ?? undefined) : undefined;
|
||||||
first = false;
|
first = false;
|
||||||
const replyToMessageId =
|
const replyToMessageId =
|
||||||
replyToId && (replyToMode === "all" || !hasReplied)
|
replyToId && (replyToMode === "all" || !hasReplied)
|
||||||
? replyToId
|
? replyToId
|
||||||
: undefined;
|
: 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, {
|
await bot.api.sendPhoto(chatId, file, {
|
||||||
caption,
|
caption,
|
||||||
reply_to_message_id: replyToMessageId,
|
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";
|
import { sendMessageTelegram } from "./send.js";
|
||||||
|
|
||||||
describe("sendMessageTelegram", () => {
|
describe("sendMessageTelegram", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
loadWebMedia.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
it("falls back to plain text when Telegram rejects Markdown", async () => {
|
it("falls back to plain text when Telegram rejects Markdown", async () => {
|
||||||
const chatId = "123";
|
const chatId = "123";
|
||||||
const parseErr = new Error(
|
const parseErr = new Error(
|
||||||
@@ -67,4 +79,32 @@ describe("sendMessageTelegram", () => {
|
|||||||
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
|
sendMessageTelegram(chatId, "hi", { token: "tok", api }),
|
||||||
).rejects.toThrow(/chat_id=123/);
|
).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 { Bot, InputFile } from "grammy";
|
||||||
import { formatErrorMessage } from "../infra/errors.js";
|
import { formatErrorMessage } from "../infra/errors.js";
|
||||||
import { mediaKindFromMime } from "../media/constants.js";
|
import { mediaKindFromMime } from "../media/constants.js";
|
||||||
|
import { isGifMedia } from "../media/mime.js";
|
||||||
import { loadWebMedia } from "../web/media.js";
|
import { loadWebMedia } from "../web/media.js";
|
||||||
|
|
||||||
type TelegramSendOpts = {
|
type TelegramSendOpts = {
|
||||||
@@ -110,17 +111,30 @@ export async function sendMessageTelegram(
|
|||||||
if (mediaUrl) {
|
if (mediaUrl) {
|
||||||
const media = await loadWebMedia(mediaUrl, opts.maxBytes);
|
const media = await loadWebMedia(mediaUrl, opts.maxBytes);
|
||||||
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
const kind = mediaKindFromMime(media.contentType ?? undefined);
|
||||||
const file = new InputFile(
|
const isGif = isGifMedia({
|
||||||
media.buffer,
|
contentType: media.contentType,
|
||||||
media.fileName ?? inferFilename(kind) ?? "file",
|
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;
|
const caption = text?.trim() || undefined;
|
||||||
let result:
|
let result:
|
||||||
| Awaited<ReturnType<typeof api.sendPhoto>>
|
| Awaited<ReturnType<typeof api.sendPhoto>>
|
||||||
| Awaited<ReturnType<typeof api.sendVideo>>
|
| Awaited<ReturnType<typeof api.sendVideo>>
|
||||||
| Awaited<ReturnType<typeof api.sendAudio>>
|
| Awaited<ReturnType<typeof api.sendAudio>>
|
||||||
|
| Awaited<ReturnType<typeof api.sendAnimation>>
|
||||||
| Awaited<ReturnType<typeof api.sendDocument>>;
|
| 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(
|
result = await sendWithRetry(
|
||||||
() => api.sendPhoto(chatId, file, { caption }),
|
() => api.sendPhoto(chatId, file, { caption }),
|
||||||
"photo",
|
"photo",
|
||||||
|
|||||||
@@ -76,6 +76,34 @@ describe("web media loading", () => {
|
|||||||
fetchMock.mockRestore();
|
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 () => {
|
it("preserves GIF animation by skipping JPEG optimization", async () => {
|
||||||
// Create a minimal valid GIF (1x1 pixel)
|
// Create a minimal valid GIF (1x1 pixel)
|
||||||
// GIF89a header + minimal image data
|
// GIF89a header + minimal image data
|
||||||
|
|||||||
@@ -22,6 +22,29 @@ type WebMediaOptions = {
|
|||||||
optimizeImages?: boolean;
|
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(
|
async function loadWebMediaInternal(
|
||||||
mediaUrl: string,
|
mediaUrl: string,
|
||||||
options: WebMediaOptions = {},
|
options: WebMediaOptions = {},
|
||||||
@@ -54,11 +77,11 @@ async function loadWebMediaInternal(
|
|||||||
};
|
};
|
||||||
|
|
||||||
if (/^https?:\/\//i.test(mediaUrl)) {
|
if (/^https?:\/\//i.test(mediaUrl)) {
|
||||||
let fileName: string | undefined;
|
let fileNameFromUrl: string | undefined;
|
||||||
try {
|
try {
|
||||||
const url = new URL(mediaUrl);
|
const url = new URL(mediaUrl);
|
||||||
const base = path.basename(url.pathname);
|
const base = path.basename(url.pathname);
|
||||||
fileName = base || undefined;
|
fileNameFromUrl = base || undefined;
|
||||||
} catch {
|
} catch {
|
||||||
// ignore parse errors; leave undefined
|
// ignore parse errors; leave undefined
|
||||||
}
|
}
|
||||||
@@ -67,10 +90,18 @@ async function loadWebMediaInternal(
|
|||||||
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
throw new Error(`Failed to fetch media: HTTP ${res.status}`);
|
||||||
}
|
}
|
||||||
const array = Buffer.from(await res.arrayBuffer());
|
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({
|
const contentType = await detectMime({
|
||||||
buffer: array,
|
buffer: array,
|
||||||
headerMime: res.headers.get("content-type"),
|
headerMime: res.headers.get("content-type"),
|
||||||
filePath: mediaUrl,
|
filePath: filePathForMime,
|
||||||
});
|
});
|
||||||
if (fileName && !path.extname(fileName) && contentType) {
|
if (fileName && !path.extname(fileName) && contentType) {
|
||||||
const ext = extensionForMime(contentType);
|
const ext = extensionForMime(contentType);
|
||||||
|
|||||||
Reference in New Issue
Block a user