diff --git a/CHANGELOG.md b/CHANGELOG.md index 5909c9899..1acd4dece 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 4f45f9997..0e170f86e 100644 --- a/src/telegram/bot/delivery.ts +++ b/src/telegram/bot/delivery.ts @@ -476,6 +476,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[] {