import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); vi.mock("../../web/media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), })); vi.mock("grammy", () => ({ InputFile: class { constructor( public buffer: Buffer, public fileName?: string, ) {} }, GrammyError: class GrammyError extends Error { description = ""; }, })); describe("deliverReplies", () => { beforeEach(() => { loadWebMedia.mockReset(); }); it("skips audioAsVoice-only payloads without logging an error", async () => { const runtime = { error: vi.fn() }; const bot = { api: {} } as unknown as Bot; await deliverReplies({ replies: [{ audioAsVoice: true }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, }); expect(runtime.error).not.toHaveBeenCalled(); }); it("invokes onVoiceRecording before sending a voice note", async () => { const events: string[] = []; const runtime = { error: vi.fn() }; const sendVoice = vi.fn(async () => { events.push("sendVoice"); return { message_id: 1, chat: { id: "123" } }; }); const bot = { api: { sendVoice } } as unknown as Bot; const onVoiceRecording = vi.fn(async () => { events.push("recordVoice"); }); loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("voice"), contentType: "audio/ogg", fileName: "note.ogg", }); await deliverReplies({ replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, onVoiceRecording, }); expect(onVoiceRecording).toHaveBeenCalledTimes(1); expect(sendVoice).toHaveBeenCalledTimes(1); expect(events).toEqual(["recordVoice", "sendVoice"]); }); it("renders markdown in media captions", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendPhoto = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" }, }); const bot = { api: { sendPhoto } } as unknown as Bot; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("image"), contentType: "image/jpeg", fileName: "photo.jpg", }); await deliverReplies({ replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, }); expect(sendPhoto).toHaveBeenCalledWith( "123", expect.anything(), expect.objectContaining({ caption: "hi boss", parse_mode: "HTML", }), ); }); it("includes link_preview_options when linkPreview is false", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" }, }); const bot = { api: { sendMessage } } as unknown as Bot; await deliverReplies({ replies: [{ text: "Check https://example.com" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, linkPreview: false, }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.objectContaining({ link_preview_options: { is_disabled: true }, }), ); }); it("does not include link_preview_options when linkPreview is true", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendMessage = vi.fn().mockResolvedValue({ message_id: 4, chat: { id: "123" }, }); const bot = { api: { sendMessage } } as unknown as Bot; await deliverReplies({ replies: [{ text: "Check https://example.com" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, linkPreview: true, }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.not.objectContaining({ link_preview_options: expect.anything(), }), ); }); it("uses reply_parameters when quote text is provided", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendMessage = vi.fn().mockResolvedValue({ message_id: 10, chat: { id: "123" }, }); const bot = { api: { sendMessage } } as unknown as Bot; await deliverReplies({ replies: [{ text: "Hello there", replyToId: "500" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "all", textLimit: 4000, replyQuoteText: "quoted text", }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.objectContaining({ reply_parameters: { message_id: 500, quote: "quoted text", }, }), ); }); it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendVoice = vi .fn() .mockRejectedValue( new Error( "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", ), ); const sendMessage = vi.fn().mockResolvedValue({ message_id: 5, chat: { id: "123" }, }); const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("voice"), contentType: "audio/ogg", fileName: "note.ogg", }); await deliverReplies({ replies: [ { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, ], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, }); // Voice was attempted but failed expect(sendVoice).toHaveBeenCalledTimes(1); // Fallback to text succeeded expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( "123", expect.stringContaining("Hello there"), expect.any(Object), ); }); it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); const sendMessage = vi.fn(); const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("voice"), contentType: "audio/ogg", fileName: "note.ogg", }); await expect( deliverReplies({ replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, }), ).rejects.toThrow("Network error"); expect(sendVoice).toHaveBeenCalledTimes(1); // Text fallback should NOT be attempted for other errors expect(sendMessage).not.toHaveBeenCalled(); }); it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => { const runtime = { error: vi.fn(), log: vi.fn() }; const sendVoice = vi .fn() .mockRejectedValue( new Error( "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", ), ); const sendMessage = vi.fn(); const bot = { api: { sendVoice, sendMessage } } as unknown as Bot; loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from("voice"), contentType: "audio/ogg", fileName: "note.ogg", }); await expect( deliverReplies({ replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, }), ).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN"); expect(sendVoice).toHaveBeenCalledTimes(1); expect(sendMessage).not.toHaveBeenCalled(); }); });