diff --git a/src/telegram/bot/delivery.replies.ts b/src/telegram/bot/delivery.replies.ts index 5573b2916f5..597cf1a5976 100644 --- a/src/telegram/bot/delivery.replies.ts +++ b/src/telegram/bot/delivery.replies.ts @@ -33,6 +33,7 @@ const CAPTION_TOO_LONG_RE = /caption is too long/i; type DeliveryProgress = { hasReplied: boolean; hasDelivered: boolean; + deliveredCount: number; }; type ChunkTextFn = (markdown: string) => ReturnType; @@ -85,6 +86,7 @@ function markReplyApplied(progress: DeliveryProgress, replyToId?: number): void function markDelivered(progress: DeliveryProgress): void { progress.hasDelivered = true; + progress.deliveredCount += 1; } async function deliverTextReply(params: { @@ -445,6 +447,7 @@ export async function deliverReplies(params: { const progress: DeliveryProgress = { hasReplied: false, hasDelivered: false, + deliveredCount: 0, }; const hookRunner = getGlobalHookRunner(); const hasMessageSendingHooks = hookRunner?.hasHooks("message_sending") ?? false; @@ -489,6 +492,7 @@ export async function deliverReplies(params: { const contentForSentHook = reply.text || ""; try { + const deliveredCountBeforeReply = progress.deliveredCount; const replyToId = params.replyToMode === "off" ? undefined : resolveTelegramReplyId(reply.replyToId); const mediaList = reply.mediaUrls?.length @@ -537,11 +541,12 @@ export async function deliverReplies(params: { } if (hasMessageSentHooks) { + const deliveredThisReply = progress.deliveredCount > deliveredCountBeforeReply; void hookRunner?.runMessageSent( { to: params.chatId, content: contentForSentHook, - success: true, + success: deliveredThisReply, }, { channelId: "telegram", diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 9fe98be8f39..26ccd4b006f 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -4,6 +4,11 @@ import type { RuntimeEnv } from "../../runtime.js"; import { deliverReplies } from "./delivery.js"; const loadWebMedia = vi.fn(); +const messageHookRunner = vi.hoisted(() => ({ + hasHooks: vi.fn<(name: string) => boolean>(() => false), + runMessageSending: vi.fn(), + runMessageSent: vi.fn(), +})); const baseDeliveryParams = { chatId: "123", token: "tok", @@ -22,6 +27,10 @@ vi.mock("../../web/media.js", () => ({ loadWebMedia: (...args: unknown[]) => loadWebMedia(...args), })); +vi.mock("../../plugins/hook-runner-global.js", () => ({ + getGlobalHookRunner: () => messageHookRunner, +})); + vi.mock("grammy", () => ({ InputFile: class { constructor( @@ -99,6 +108,10 @@ function createVoiceFailureHarness(params: { describe("deliverReplies", () => { beforeEach(() => { loadWebMedia.mockClear(); + messageHookRunner.hasHooks.mockReset(); + messageHookRunner.hasHooks.mockReturnValue(false); + messageHookRunner.runMessageSending.mockReset(); + messageHookRunner.runMessageSent.mockReset(); }); it("skips audioAsVoice-only payloads without logging an error", async () => { @@ -113,6 +126,29 @@ describe("deliverReplies", () => { expect(runtime.error).not.toHaveBeenCalled(); }); + 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("invokes onVoiceRecording before sending a voice note", async () => { const events: string[] = []; const runtime = createRuntime(false);