From 4d6c8edd7484f72e058a882b7bf26c75ec9cfdbd Mon Sep 17 00:00:00 2001 From: Robin Waslander Date: Sat, 28 Mar 2026 22:27:56 +0100 Subject: [PATCH] fix(telegram): skip empty text replies instead of crashing with GrammyError 400 (#56620) Filter whitespace-only text chunks at the bot delivery fan-in before they reach sendTelegramText(). Covers normal text replies, follow-up text, and voice fallback text paths. Media-only replies are unaffected. message_sent hook still fires with success: false for suppressed empty replies. Fixes #37278 --- extensions/telegram/src/bot/delivery.replies.ts | 14 +++++++++++--- extensions/telegram/src/bot/delivery.test.ts | 8 ++++---- 2 files changed, 15 insertions(+), 7 deletions(-) diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 928af45607d..60fb1dbc720 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -92,6 +92,12 @@ function markDelivered(progress: DeliveryProgress): void { progress.deliveredCount += 1; } +function filterEmptyTelegramTextChunks(chunks: readonly T[]): T[] { + // Telegram rejects whitespace-only text payloads; drop them before sendMessage so + // hook-mutated or model-emitted empty replies become a no-op instead of a 400. + return chunks.filter((chunk) => chunk.text.trim().length > 0); +} + async function deliverTextReply(params: { bot: Bot; chatId: string; @@ -108,8 +114,9 @@ async function deliverTextReply(params: { progress: DeliveryProgress; }): Promise { let firstDeliveredMessageId: number | undefined; + const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.replyText)); await sendChunkedTelegramReplyText({ - chunks: params.chunkText(params.replyText), + chunks, progress: params.progress, replyToId: params.replyToId, replyToMode: params.replyToMode, @@ -155,8 +162,9 @@ async function sendPendingFollowUpText(params: { replyToMode: ReplyToMode; progress: DeliveryProgress; }): Promise { + const chunks = filterEmptyTelegramTextChunks(params.chunkText(params.text)); await sendChunkedTelegramReplyText({ - chunks: params.chunkText(params.text), + chunks, progress: params.progress, replyToId: params.replyToId, replyToMode: params.replyToMode, @@ -204,7 +212,7 @@ async function sendTelegramVoiceFallbackText(opts: { replyQuoteText?: string; }): Promise { let firstDeliveredMessageId: number | undefined; - const chunks = opts.chunkText(opts.text); + const chunks = filterEmptyTelegramTextChunks(opts.chunkText(opts.text)); let appliedReplyTo = false; for (let i = 0; i < chunks.length; i += 1) { const chunk = chunks[i]; diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index f583992e599..c9347aa2dbb 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -170,7 +170,7 @@ describe("deliverReplies", () => { messageHookRunner.hasHooks.mockImplementation( (name: string) => name === "message_sending" || name === "message_sent", ); - messageHookRunner.runMessageSending.mockResolvedValue({ content: "" }); + messageHookRunner.runMessageSending.mockResolvedValue({ content: " " }); const runtime = createRuntime(false); const sendMessage = vi.fn(); @@ -184,7 +184,7 @@ describe("deliverReplies", () => { expect(sendMessage).not.toHaveBeenCalled(); expect(messageHookRunner.runMessageSent).toHaveBeenCalledWith( - expect.objectContaining({ success: false, content: "" }), + expect.objectContaining({ success: false, content: " " }), expect.objectContaining({ channelId: "telegram", conversationId: "123" }), ); }); @@ -600,7 +600,7 @@ describe("deliverReplies", () => { ); }); - it("throws when formatted and plain fallback text are both empty", async () => { + it("skips whitespace-only text replies without calling Telegram", async () => { const runtime = createRuntime(); const sendMessage = vi.fn(); const bot = { api: { sendMessage } } as unknown as Bot; @@ -615,7 +615,7 @@ describe("deliverReplies", () => { replyToMode: "off", textLimit: 4000, }), - ).rejects.toThrow("empty formatted text and empty plain fallback"); + ).resolves.toEqual({ delivered: false }); expect(sendMessage).not.toHaveBeenCalled(); });