diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c5321870..18c4d2801 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,7 @@ Docs: https://docs.molt.bot Status: beta. ### Changes +- Telegram: skip empty HTML chunks from thematic breaks to prevent delivery loop abort. (#3011) Thanks @AlexAnys. - Rebrand: rename the npm package/CLI to `moltbot`, add a `moltbot` compatibility shim, and move extensions to the `@moltbot/*` scope. - Commands: group /help and /commands output with Telegram paging. (#2504) Thanks @hougangdev. - macOS: limit project-local `node_modules/.bin` PATH preference to debug builds (reduce PATH hijacking risk). diff --git a/src/telegram/bot/delivery.test.ts b/src/telegram/bot/delivery.test.ts index 3cf1b2534..f8fb3910d 100644 --- a/src/telegram/bot/delivery.test.ts +++ b/src/telegram/bot/delivery.test.ts @@ -305,4 +305,32 @@ describe("deliverReplies", () => { expect(sendVoice).toHaveBeenCalledTimes(1); expect(sendMessage).not.toHaveBeenCalled(); }); + + it("skips empty chunks from thematic breaks without crashing (#3011)", async () => { + // A horizontal rule (---) in markdown produces empty HTML after conversion. + // This must not cause a Telegram "message text is empty" 400 error or + // abort delivery of subsequent chunks. + const runtime = { error: vi.fn(), log: vi.fn() }; + const sendMessage = vi.fn().mockResolvedValue({ message_id: 1, chat: { id: "c" } }); + const bot = { api: { sendMessage } } as unknown as Bot; + + await deliverReplies({ + replies: [{ text: "Before\n\n---\n\nAfter" }], + chatId: "c", + token: "tok", + runtime, + bot, + replyToMode: "off", + textLimit: 4000, + }); + + // All sendMessage calls should have non-empty text + for (const call of sendMessage.mock.calls) { + const text = call[1] as string; + expect(text.trim().length).toBeGreaterThan(0); + } + // "After" should not be silently dropped + const allTexts = sendMessage.mock.calls.map((c: unknown[]) => c[1] as string).join(" "); + expect(allTexts).toContain("After"); + }); }); diff --git a/src/telegram/bot/delivery.ts b/src/telegram/bot/delivery.ts index 669340b20..678d0f6a4 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -511,6 +511,15 @@ async function sendTelegramText( const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const textMode = opts?.textMode ?? "markdown"; const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); + // Skip empty HTML chunks — thematic breaks (---/***/___) and empty + // block-level elements produce zero visible output after markdown→HTML + // conversion. Sending an empty string to the Telegram API triggers a + // "400: message text is empty" error which aborts the entire delivery + // loop, silently dropping all subsequent chunks. See #3011. + if (!htmlText || !htmlText.trim()) { + logVerbose("telegram sendTelegramText: skipping empty HTML chunk"); + return undefined; + } try { const res = await withTelegramApiErrorLogging({ operation: "sendMessage", diff --git a/src/telegram/format.test.ts b/src/telegram/format.test.ts index 831782815..7713623a8 100644 --- a/src/telegram/format.test.ts +++ b/src/telegram/format.test.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from "vitest"; -import { markdownToTelegramHtml } from "./format.js"; +import { markdownToTelegramChunks, markdownToTelegramHtml } from "./format.js"; describe("markdownToTelegramHtml", () => { it("renders basic inline formatting", () => { @@ -47,4 +47,39 @@ describe("markdownToTelegramHtml", () => { const res = markdownToTelegramHtml("```js\nconst x = 1;\n```"); expect(res).toBe("
const x = 1;\n
"); }); + + it("returns empty string for thematic break (---)", () => { + const res = markdownToTelegramHtml("---"); + expect(res.trim()).toBe(""); + }); +}); + +describe("markdownToTelegramChunks", () => { + it("filters out empty chunks from thematic breaks", () => { + // A thematic break (---) produces empty HTML; it must be filtered + // out so Telegram doesn't receive an empty message. See #3011. + const chunks = markdownToTelegramChunks("First paragraph\n\n---\n\nSecond paragraph", 4000); + // Every returned chunk must have non-empty html + for (const chunk of chunks) { + expect(chunk.html.trim().length).toBeGreaterThan(0); + } + // Both paragraphs should still be present + const combined = chunks.map((c) => c.html).join(""); + expect(combined).toContain("First paragraph"); + expect(combined).toContain("Second paragraph"); + }); + + it("filters out empty chunks from *** and ___", () => { + const chunksAsterisk = markdownToTelegramChunks("Above\n\n***\n\nBelow", 4000); + const chunksUnderscore = markdownToTelegramChunks("Above\n\n___\n\nBelow", 4000); + for (const chunk of [...chunksAsterisk, ...chunksUnderscore]) { + expect(chunk.html.trim().length).toBeGreaterThan(0); + } + }); + + it("preserves normal chunks without filtering", () => { + const chunks = markdownToTelegramChunks("hello **world**", 4000); + expect(chunks.length).toBeGreaterThan(0); + expect(chunks[0].html).toContain("hello"); + }); }); diff --git a/src/telegram/format.ts b/src/telegram/format.ts index 472fc1f43..93307ff5d 100644 --- a/src/telegram/format.ts +++ b/src/telegram/format.ts @@ -81,10 +81,18 @@ export function markdownToTelegramChunks( tableMode: options.tableMode, }); const chunks = chunkMarkdownIR(ir, limit); - return chunks.map((chunk) => ({ - html: renderTelegramHtml(chunk), - text: chunk.text, - })); + return ( + chunks + .map((chunk) => ({ + html: renderTelegramHtml(chunk), + text: chunk.text, + })) + // Filter out empty chunks produced by thematic breaks (---/***/___), + // empty blockquotes, or headings without text. These render to empty + // HTML and would cause Telegram API "message text is empty" errors. + // See #3011. + .filter((chunk) => chunk.html.trim().length > 0) + ); } export function markdownToTelegramHtmlChunks(markdown: string, limit: number): string[] {