fix(telegram): skip empty HTML chunks from thematic breaks (#3011)

Thematic breaks (---, ***, ___) and empty block-level markdown elements
produce empty HTML after markdown→Telegram conversion.  When these empty
chunks are sent to the Telegram Bot API, it rejects with '400: message
text is empty', which aborts the delivery loop and silently drops all
subsequent message chunks.

Fix:
1. markdownToTelegramChunks() now filters out chunks with empty HTML
   before returning, preventing empty chunks from reaching the delivery
   pipeline at all.
2. sendTelegramText() adds a defensive guard that skips empty HTML,
   protecting against any other code path that might produce one.

Closes #3011
This commit is contained in:
Alex AN 2026-01-28 12:49:21 +08:00
parent 9688454a30
commit 84a60129be
5 changed files with 86 additions and 5 deletions

View File

@ -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).

View File

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

View File

@ -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",

View File

@ -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("<pre><code>const x = 1;\n</code></pre>");
});
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");
});
});

View File

@ -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[] {