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