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
This commit is contained in:
Robin Waslander 2026-03-28 22:27:56 +01:00 committed by GitHub
parent eec290e68d
commit 4d6c8edd74
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
2 changed files with 15 additions and 7 deletions

View File

@ -92,6 +92,12 @@ function markDelivered(progress: DeliveryProgress): void {
progress.deliveredCount += 1;
}
function filterEmptyTelegramTextChunks<T extends { text: string }>(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<number | undefined> {
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<void> {
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<number | undefined> {
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];

View File

@ -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();
});