import type { Bot } from "grammy"; import { beforeEach, describe, expect, it, vi } from "vitest"; import type { RuntimeEnv } from "../../../../src/runtime.js"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); const triggerInternalHook = vi.hoisted(() => vi.fn(async () => {})); const messageHookRunner = vi.hoisted(() => ({ hasHooks: vi.fn<(name: string) => boolean>(() => false), runMessageSending: vi.fn(), runMessageSent: vi.fn(), })); const baseDeliveryParams = { chatId: "123", token: "tok", replyToMode: "off", textLimit: 4000, } as const; type DeliverRepliesParams = Parameters[0]; type DeliverWithParams = Omit< DeliverRepliesParams, "chatId" | "token" | "replyToMode" | "textLimit" > & Partial>; type RuntimeStub = Pick; vi.mock("../../../whatsapp/src/media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), })); vi.mock("../../../../src/plugins/hook-runner-global.js", () => ({ getGlobalHookRunner: () => messageHookRunner, })); vi.mock("../../../../src/hooks/internal-hooks.js", async () => { const actual = await vi.importActual( "../../../../src/hooks/internal-hooks.js", ); return { ...actual, triggerInternalHook, }; }); vi.mock("grammy", () => ({ InputFile: class { constructor( public buffer: Buffer, public fileName?: string, ) {} }, GrammyError: class GrammyError extends Error { description = ""; }, })); function createRuntime(withLog = true): RuntimeStub { return { error: vi.fn(), log: withLog ? vi.fn() : vi.fn(), exit: vi.fn(), }; } function createBot(api: Record = {}): Bot { return { api } as unknown as Bot; } async function deliverWith(params: DeliverWithParams) { await deliverReplies({ ...baseDeliveryParams, ...params, }); } function mockMediaLoad(fileName: string, contentType: string, data: string) { loadWebMedia.mockResolvedValueOnce({ buffer: Buffer.from(data), contentType, fileName, }); } function createSendMessageHarness(messageId = 4) { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: messageId, chat: { id: "123" }, }); const bot = createBot({ sendMessage }); return { runtime, sendMessage, bot }; } function createVoiceMessagesForbiddenError() { return new Error( "GrammyError: Call to 'sendVoice' failed! (400: Bad Request: VOICE_MESSAGES_FORBIDDEN)", ); } function createThreadNotFoundError(operation = "sendMessage") { return new Error( `GrammyError: Call to '${operation}' failed! (400: Bad Request: message thread not found)`, ); } function createVoiceFailureHarness(params: { voiceError: Error; sendMessageResult?: { message_id: number; chat: { id: string } }; }) { const runtime = createRuntime(); const sendVoice = vi.fn().mockRejectedValue(params.voiceError); const sendMessage = params.sendMessageResult ? vi.fn().mockResolvedValue(params.sendMessageResult) : vi.fn(); const bot = createBot({ sendVoice, sendMessage }); return { runtime, sendVoice, sendMessage, bot }; } describe("deliverReplies", () => { beforeEach(() => { loadWebMedia.mockClear(); triggerInternalHook.mockReset(); messageHookRunner.hasHooks.mockReset(); messageHookRunner.hasHooks.mockReturnValue(false); messageHookRunner.runMessageSending.mockReset(); messageHookRunner.runMessageSent.mockReset(); }); it("skips audioAsVoice-only payloads without logging an error", async () => { const runtime = createRuntime(false); await deliverWith({ replies: [{ audioAsVoice: true }], runtime, bot: createBot(), }); expect(runtime.error).not.toHaveBeenCalled(); }); it("skips malformed replies and continues with valid entries", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "123" } }); const bot = createBot({ sendMessage }); await deliverWith({ replies: [undefined, { text: "hello" }] as unknown as DeliverRepliesParams["replies"], runtime, bot, }); expect(runtime.error).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage.mock.calls[0]?.[1]).toBe("hello"); }); it("reports message_sent success=false when hooks blank out a text-only reply", async () => { messageHookRunner.hasHooks.mockImplementation( (name: string) => name === "message_sending" || name === "message_sent", ); messageHookRunner.runMessageSending.mockResolvedValue({ content: "" }); const runtime = createRuntime(false); const sendMessage = vi.fn(); const bot = createBot({ sendMessage }); await deliverWith({ replies: [{ text: "hello" }], runtime, bot, }); expect(sendMessage).not.toHaveBeenCalled(); expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith( expect.objectContaining({ success: false, content: "" }), expect.objectContaining({ channelId: "telegram", conversationId: "123" }), ); }); it("passes accountId into message hooks", async () => { messageHookRunner.hasHooks.mockImplementation( (name: string) => name === "message_sending" || name === "message_sent", ); const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); const bot = createBot({ sendMessage }); await deliverWith({ accountId: "work", replies: [{ text: "hello" }], runtime, bot, }); expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith( expect.any(Object), expect.objectContaining({ channelId: "telegram", accountId: "work", conversationId: "123", }), ); expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith( expect.objectContaining({ success: true }), expect.objectContaining({ channelId: "telegram", accountId: "work", conversationId: "123", }), ); }); it("emits internal message:sent when session hook context is available", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 9, chat: { id: "123" } }); const bot = createBot({ sendMessage }); await deliverWith({ sessionKeyForInternalHooks: "agent:test:telegram:123", mirrorIsGroup: true, mirrorGroupId: "123", replies: [{ text: "hello" }], runtime, bot, }); expect(triggerInternalHook).toHaveBeenCalledWith( expect.objectContaining({ type: "message", action: "sent", sessionKey: "agent:test:telegram:123", context: expect.objectContaining({ to: "123", content: "hello", success: true, channelId: "telegram", conversationId: "123", messageId: "9", isGroup: true, groupId: "123", }), }), ); }); it("does not emit internal message:sent without a session key", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockResolvedValue({ message_id: 11, chat: { id: "123" } }); const bot = createBot({ sendMessage }); await deliverWith({ replies: [{ text: "hello" }], runtime, bot, }); expect(triggerInternalHook).not.toHaveBeenCalled(); }); it("emits internal message:sent with success=false on delivery failure", async () => { const runtime = createRuntime(false); const sendMessage = vi.fn().mockRejectedValue(new Error("network error")); const bot = createBot({ sendMessage }); await expect( deliverWith({ sessionKeyForInternalHooks: "agent:test:telegram:123", replies: [{ text: "hello" }], runtime, bot, }), ).rejects.toThrow("network error"); expect(triggerInternalHook).toHaveBeenCalledWith( expect.objectContaining({ type: "message", action: "sent", sessionKey: "agent:test:telegram:123", context: expect.objectContaining({ to: "123", content: "hello", success: false, error: "network error", channelId: "telegram", conversationId: "123", }), }), ); }); it("passes media metadata to message_sending hooks", async () => { messageHookRunner.hasHooks.mockImplementation((name: string) => name === "message_sending"); const runtime = createRuntime(false); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" } }); const bot = createBot({ sendPhoto }); mockMediaLoad("photo.jpg", "image/jpeg", "image"); await deliverWith({ replies: [{ text: "caption", mediaUrl: "https://example.com/photo.jpg" }], runtime, bot, }); expect(messageHookRunner.runMessageSending).toHaveBeenCalledWith( expect.objectContaining({ to: "123", content: "caption", metadata: expect.objectContaining({ channel: "telegram", mediaUrls: ["https://example.com/photo.jpg"], }), }), expect.objectContaining({ channelId: "telegram", conversationId: "123" }), ); }); it("invokes onVoiceRecording before sending a voice note", async () => { const events: string[] = []; const runtime = createRuntime(false); const sendVoice = vi.fn(async () => { events.push("sendVoice"); return { message_id: 1, chat: { id: "123" } }; }); const bot = createBot({ sendVoice }); const onVoiceRecording = vi.fn(async () => { events.push("recordVoice"); }); mockMediaLoad("note.ogg", "audio/ogg", "voice"); await deliverWith({ replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], runtime, bot, onVoiceRecording, }); expect(onVoiceRecording).toHaveBeenCalledTimes(1); expect(sendVoice).toHaveBeenCalledTimes(1); expect(events).toEqual(["recordVoice", "sendVoice"]); }); it("renders markdown in media captions", async () => { const runtime = createRuntime(); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 2, chat: { id: "123" }, }); const bot = createBot({ sendPhoto }); mockMediaLoad("photo.jpg", "image/jpeg", "image"); await deliverWith({ replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "hi **boss**" }], runtime, bot, }); expect(sendPhoto).toHaveBeenCalledWith( "123", expect.anything(), expect.objectContaining({ caption: "hi boss", parse_mode: "HTML", }), ); }); it("passes mediaLocalRoots to media loading", async () => { const runtime = createRuntime(); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 12, chat: { id: "123" }, }); const bot = createBot({ sendPhoto }); const mediaLocalRoots = ["/tmp/workspace-work"]; mockMediaLoad("photo.jpg", "image/jpeg", "image"); await deliverWith({ replies: [{ mediaUrl: "/tmp/workspace-work/photo.jpg" }], runtime, bot, mediaLocalRoots, }); expect(loadWebMedia).toHaveBeenCalledWith("/tmp/workspace-work/photo.jpg", { localRoots: mediaLocalRoots, }); }); it("passes Telegram transport to remote media loading", async () => { const runtime = createRuntime(); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 13, chat: { id: "123" }, }); const bot = createBot({ sendPhoto }); const sourceFetch = vi.fn() as unknown as typeof fetch; const telegramTransport = { fetch: sourceFetch, sourceFetch, pinnedDispatcherPolicy: { mode: "explicit-proxy", proxyUrl: "http://proxy.test:8080", } as const, fallbackPinnedDispatcherPolicy: { mode: "direct" } as const, }; mockMediaLoad("photo.jpg", "image/jpeg", "image"); await deliverWith({ replies: [{ mediaUrl: "https://example.com/photo.jpg" }], runtime, bot, telegramTransport, }); expect(loadWebMedia).toHaveBeenCalledWith( "https://example.com/photo.jpg", expect.objectContaining({ fetchImpl: sourceFetch, dispatcherPolicy: telegramTransport.pinnedDispatcherPolicy, fallbackDispatcherPolicy: telegramTransport.fallbackPinnedDispatcherPolicy, shouldRetryFetchError: expect.any(Function), }), ); }); it("includes link_preview_options when linkPreview is false", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 3, chat: { id: "123" }, }); const bot = createBot({ sendMessage }); await deliverWith({ replies: [{ text: "Check https://example.com" }], runtime, bot, linkPreview: false, }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.objectContaining({ link_preview_options: { is_disabled: true }, }), ); }); it("includes message_thread_id for DM topics", async () => { const { runtime, sendMessage, bot } = createSendMessageHarness(); await deliverWith({ replies: [{ text: "Hello" }], runtime, bot, thread: { id: 42, scope: "dm" }, }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.objectContaining({ message_thread_id: 42, }), ); }); it("retries DM topic sends without message_thread_id when thread is missing", async () => { const runtime = createRuntime(); const sendMessage = vi .fn() .mockRejectedValueOnce(createThreadNotFoundError("sendMessage")) .mockResolvedValueOnce({ message_id: 7, chat: { id: "123" }, }); const bot = createBot({ sendMessage }); await deliverWith({ replies: [{ text: "hello" }], runtime, bot, thread: { id: 42, scope: "dm" }, }); expect(sendMessage).toHaveBeenCalledTimes(2); expect(sendMessage.mock.calls[0]?.[2]).toEqual( expect.objectContaining({ message_thread_id: 42, }), ); expect(sendMessage.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id"); expect(runtime.error).not.toHaveBeenCalled(); }); it("does not retry forum sends without message_thread_id", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockRejectedValue(createThreadNotFoundError("sendMessage")); const bot = createBot({ sendMessage }); await expect( deliverWith({ replies: [{ text: "hello" }], runtime, bot, thread: { id: 42, scope: "forum" }, }), ).rejects.toThrow("message thread not found"); expect(sendMessage).toHaveBeenCalledTimes(1); expect(runtime.error).toHaveBeenCalledTimes(1); }); it("retries media sends without message_thread_id for DM topics", async () => { const runtime = createRuntime(); const sendPhoto = vi .fn() .mockRejectedValueOnce(createThreadNotFoundError("sendPhoto")) .mockResolvedValueOnce({ message_id: 8, chat: { id: "123" }, }); const bot = createBot({ sendPhoto }); mockMediaLoad("photo.jpg", "image/jpeg", "image"); await deliverWith({ replies: [{ mediaUrl: "https://example.com/photo.jpg", text: "caption" }], runtime, bot, thread: { id: 42, scope: "dm" }, }); expect(sendPhoto).toHaveBeenCalledTimes(2); expect(sendPhoto.mock.calls[0]?.[2]).toEqual( expect.objectContaining({ message_thread_id: 42, }), ); expect(sendPhoto.mock.calls[1]?.[2]).not.toHaveProperty("message_thread_id"); expect(runtime.error).not.toHaveBeenCalled(); }); it("does not include link_preview_options when linkPreview is true", async () => { const { runtime, sendMessage, bot } = createSendMessageHarness(); await deliverWith({ replies: [{ text: "Check https://example.com" }], runtime, bot, linkPreview: true, }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.not.objectContaining({ link_preview_options: expect.anything(), }), ); }); it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => { const runtime = createRuntime(); const sendMessage = vi.fn(async (_chatId: string, text: string) => { if (text === "") { throw new Error("400: Bad Request: message text is empty"); } return { message_id: 6, chat: { id: "123" }, }; }); const bot = { api: { sendMessage } } as unknown as Bot; await deliverReplies({ replies: [{ text: ">" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, thread: { id: 42, scope: "forum" }, }); expect(sendMessage).toHaveBeenCalledTimes(1); expect(sendMessage).toHaveBeenCalledWith( "123", ">", expect.objectContaining({ message_thread_id: 42, }), ); }); it("throws when formatted and plain fallback text are both empty", async () => { const runtime = createRuntime(); const sendMessage = vi.fn(); const bot = { api: { sendMessage } } as unknown as Bot; await expect( deliverReplies({ replies: [{ text: " " }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 4000, }), ).rejects.toThrow("empty formatted text and empty plain fallback"); expect(sendMessage).not.toHaveBeenCalled(); }); it("uses reply_to_message_id when quote text is provided", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 10, chat: { id: "123" }, }); const bot = createBot({ sendMessage }); await deliverWith({ replies: [{ text: "Hello there", replyToId: "500" }], runtime, bot, replyToMode: "all", replyQuoteText: "quoted text", }); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.objectContaining({ reply_to_message_id: 500, }), ); expect(sendMessage).toHaveBeenCalledWith( "123", expect.any(String), expect.not.objectContaining({ reply_parameters: expect.anything(), }), ); }); it("falls back to text when sendVoice fails with VOICE_MESSAGES_FORBIDDEN", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), sendMessageResult: { message_id: 5, chat: { id: "123" }, }, }); mockMediaLoad("note.ogg", "audio/ogg", "voice"); await deliverWith({ replies: [ { mediaUrl: "https://example.com/note.ogg", text: "Hello there", audioAsVoice: true }, ], runtime, bot, }); // 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("voice fallback applies reply-to only on first chunk when replyToMode is first", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), sendMessageResult: { message_id: 6, chat: { id: "123" }, }, }); mockMediaLoad("note.ogg", "audio/ogg", "voice"); await deliverWith({ replies: [ { mediaUrl: "https://example.com/note.ogg", text: "chunk-one\n\nchunk-two", replyToId: "77", audioAsVoice: true, channelData: { telegram: { buttons: [[{ text: "Ack", callback_data: "ack" }]], }, }, }, ], runtime, bot, replyToMode: "first", replyQuoteText: "quoted context", textLimit: 12, }); expect(sendVoice).toHaveBeenCalledTimes(1); expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); expect(sendMessage.mock.calls[0][2]).toEqual( expect.objectContaining({ reply_to_message_id: 77, reply_markup: { inline_keyboard: [[{ text: "Ack", callback_data: "ack" }]], }, }), ); expect(sendMessage.mock.calls[1][2]).not.toEqual( expect.objectContaining({ reply_to_message_id: 77 }), ); expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_parameters"); expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_markup"); }); it("rethrows non-VOICE_MESSAGES_FORBIDDEN errors from sendVoice", async () => { const runtime = createRuntime(); const sendVoice = vi.fn().mockRejectedValue(new Error("Network error")); const sendMessage = vi.fn(); const bot = createBot({ sendVoice, sendMessage }); mockMediaLoad("note.ogg", "audio/ogg", "voice"); await expect( deliverWith({ replies: [{ mediaUrl: "https://example.com/note.ogg", text: "Hello", audioAsVoice: true }], runtime, bot, }), ).rejects.toThrow("Network error"); expect(sendVoice).toHaveBeenCalledTimes(1); // Text fallback should NOT be attempted for other errors expect(sendMessage).not.toHaveBeenCalled(); }); it("replyToMode 'first' only applies reply-to to the first text chunk", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 20, chat: { id: "123" }, }); const bot = createBot({ sendMessage }); // Use a small textLimit to force multiple chunks await deliverReplies({ replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "700" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "first", textLimit: 12, }); expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); // First chunk should have reply_to_message_id expect(sendMessage.mock.calls[0][2]).toEqual( expect.objectContaining({ reply_to_message_id: 700 }), ); // Second chunk should NOT have reply_to_message_id expect(sendMessage.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id"); }); it("replyToMode 'all' applies reply-to to every text chunk", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 21, chat: { id: "123" }, }); const bot = createBot({ sendMessage }); await deliverReplies({ replies: [{ text: "chunk-one\n\nchunk-two", replyToId: "800" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "all", textLimit: 12, }); expect(sendMessage.mock.calls.length).toBeGreaterThanOrEqual(2); // Both chunks should have reply_to_message_id for (const call of sendMessage.mock.calls) { expect(call[2]).toEqual(expect.objectContaining({ reply_to_message_id: 800 })); } }); it("replyToMode 'first' only applies reply-to to first media item", async () => { const runtime = createRuntime(); const sendPhoto = vi.fn().mockResolvedValue({ message_id: 30, chat: { id: "123" }, }); const bot = createBot({ sendPhoto }); mockMediaLoad("a.jpg", "image/jpeg", "img1"); mockMediaLoad("b.jpg", "image/jpeg", "img2"); await deliverReplies({ replies: [{ mediaUrls: ["https://a.jpg", "https://b.jpg"], replyToId: "900" }], chatId: "123", token: "tok", runtime, bot, replyToMode: "first", textLimit: 4000, }); expect(sendPhoto).toHaveBeenCalledTimes(2); // First media should have reply_to_message_id expect(sendPhoto.mock.calls[0][2]).toEqual( expect.objectContaining({ reply_to_message_id: 900 }), ); // Second media should NOT have reply_to_message_id expect(sendPhoto.mock.calls[1][2]).not.toHaveProperty("reply_to_message_id"); }); it("pins the first delivered text message when telegram pin is requested", async () => { const runtime = createRuntime(); const sendMessage = vi .fn() .mockResolvedValueOnce({ message_id: 101, chat: { id: "123" } }) .mockResolvedValueOnce({ message_id: 102, chat: { id: "123" } }); const pinChatMessage = vi.fn().mockResolvedValue(true); const bot = createBot({ sendMessage, pinChatMessage }); await deliverReplies({ replies: [{ text: "chunk-one\n\nchunk-two", channelData: { telegram: { pin: true } } }], chatId: "123", token: "tok", runtime, bot, replyToMode: "off", textLimit: 12, }); expect(pinChatMessage).toHaveBeenCalledTimes(1); expect(pinChatMessage).toHaveBeenCalledWith("123", 101, { disable_notification: true }); }); it("continues when pinning fails", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ message_id: 201, chat: { id: "123" } }); const pinChatMessage = vi.fn().mockRejectedValue(new Error("pin failed")); const bot = createBot({ sendMessage, pinChatMessage }); await deliverWith({ replies: [{ text: "hello", channelData: { telegram: { pin: true } } }], runtime, bot, }); expect(sendMessage).toHaveBeenCalledTimes(1); expect(pinChatMessage).toHaveBeenCalledTimes(1); }); it("rethrows VOICE_MESSAGES_FORBIDDEN when no text fallback is available", async () => { const { runtime, sendVoice, sendMessage, bot } = createVoiceFailureHarness({ voiceError: createVoiceMessagesForbiddenError(), }); mockMediaLoad("note.ogg", "audio/ogg", "voice"); await expect( deliverWith({ replies: [{ mediaUrl: "https://example.com/note.ogg", audioAsVoice: true }], runtime, bot, }), ).rejects.toThrow("VOICE_MESSAGES_FORBIDDEN"); expect(sendVoice).toHaveBeenCalledTimes(1); expect(sendMessage).not.toHaveBeenCalled(); }); });