diff --git a/src/telegram/bot.media.test.ts b/src/telegram/bot.media.test.ts new file mode 100644 index 000000000..000e3ac28 --- /dev/null +++ b/src/telegram/bot.media.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, it, vi } from "vitest"; + +const useSpy = vi.fn(); +const onSpy = vi.fn(); +const stopSpy = vi.fn(); +const sendChatActionSpy = vi.fn(); + +type ApiStub = { + config: { use: (arg: unknown) => void }; + sendChatAction: typeof sendChatActionSpy; +}; + +const apiStub: ApiStub = { + config: { use: useSpy }, + sendChatAction: sendChatActionSpy, +}; + +vi.mock("grammy", () => ({ + Bot: class { + api = apiStub; + on = onSpy; + stop = stopSpy; + constructor(public token: string) {} + }, + InputFile: class {}, + webhookCallback: vi.fn(), +})); + +const throttlerSpy = vi.fn(() => "throttler"); +vi.mock("@grammyjs/transformer-throttler", () => ({ + apiThrottler: () => throttlerSpy(), +})); + +vi.mock("../config/config.js", () => ({ + loadConfig: () => ({}), +})); + +vi.mock("../auto-reply/reply.js", () => { + const replySpy = vi.fn(async (_ctx, opts) => { + await opts?.onReplyStart?.(); + return undefined; + }); + return { getReplyFromConfig: replySpy, __replySpy: replySpy }; +}); + +describe("telegram inbound media", () => { + it("downloads media via file_path (no file.download)", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + sendChatActionSpy.mockReset(); + + const runtimeLog = vi.fn(); + const runtimeError = vi.fn(); + createTelegramBot({ + token: "tok", + runtime: { + log: runtimeLog, + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + const fetchSpy = vi + .spyOn(globalThis, "fetch" as never) + .mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/jpeg" }, + arrayBuffer: async () => + new Uint8Array([0xff, 0xd8, 0xff, 0x00]).buffer, + } as Response); + + await handler({ + message: { + message_id: 1, + chat: { id: 1234, type: "private" }, + photo: [{ file_id: "fid" }], + date: 1736380800, // 2025-01-09T00:00:00Z + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ file_path: "photos/1.jpg" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(fetchSpy).toHaveBeenCalledWith( + "https://api.telegram.org/file/bottok/photos/1.jpg", + ); + expect(replySpy).toHaveBeenCalledTimes(1); + const payload = replySpy.mock.calls[0][0]; + expect(payload.Body).toContain(""); + + fetchSpy.mockRestore(); + }); + + it("prefers proxyFetch over global fetch", async () => { + const { createTelegramBot } = await import("./bot.js"); + + onSpy.mockReset(); + + const runtimeLog = vi.fn(); + const runtimeError = vi.fn(); + const globalFetchSpy = vi + .spyOn(globalThis, "fetch" as never) + .mockImplementation(() => { + throw new Error("global fetch should not be called"); + }); + const proxyFetch = vi.fn().mockResolvedValueOnce({ + ok: true, + status: 200, + statusText: "OK", + headers: { get: () => "image/jpeg" }, + arrayBuffer: async () => new Uint8Array([0xff, 0xd8, 0xff]).buffer, + } as Response); + + createTelegramBot({ + token: "tok", + proxyFetch: proxyFetch as unknown as typeof fetch, + runtime: { + log: runtimeLog, + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + message_id: 2, + chat: { id: 1234, type: "private" }, + photo: [{ file_id: "fid" }], + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({ file_path: "photos/2.jpg" }), + }); + + expect(runtimeError).not.toHaveBeenCalled(); + expect(proxyFetch).toHaveBeenCalledWith( + "https://api.telegram.org/file/bottok/photos/2.jpg", + ); + + globalFetchSpy.mockRestore(); + }); + + it("logs a handler error when getFile returns no file_path", async () => { + const { createTelegramBot } = await import("./bot.js"); + const replyModule = await import("../auto-reply/reply.js"); + const replySpy = replyModule.__replySpy as unknown as ReturnType< + typeof vi.fn + >; + + onSpy.mockReset(); + replySpy.mockReset(); + + const runtimeLog = vi.fn(); + const runtimeError = vi.fn(); + const fetchSpy = vi.spyOn(globalThis, "fetch" as never); + + createTelegramBot({ + token: "tok", + runtime: { + log: runtimeLog, + error: runtimeError, + exit: () => { + throw new Error("exit"); + }, + }, + }); + const handler = onSpy.mock.calls[0]?.[1] as ( + ctx: Record, + ) => Promise; + + await handler({ + message: { + message_id: 3, + chat: { id: 1234, type: "private" }, + photo: [{ file_id: "fid" }], + }, + me: { username: "clawdis_bot" }, + getFile: async () => ({}), + }); + + expect(fetchSpy).not.toHaveBeenCalled(); + expect(replySpy).not.toHaveBeenCalled(); + expect(runtimeError).toHaveBeenCalledTimes(1); + const msg = String(runtimeError.mock.calls[0]?.[0] ?? ""); + expect(msg).toContain("Telegram handler failed:"); + expect(msg).toContain("file_path"); + + fetchSpy.mockRestore(); + }); +});