fix(telegram): fallback to plain text when threaded markdown renders empty

Minimal fix path for Telegram empty-text failures in threaded replies.

- fallback to plain text when formatted htmlText is empty
- retry plain text on parse/empty-text API errors
- add focused regression test for threaded mode case

Related: #25091
Supersedes alternative fix path in #17629 if maintainers prefer minimal scope.
This commit is contained in:
Glucksberg
2026-02-15 00:45:49 +00:00
committed by Ayaan Zaidi
parent 00de3ca833
commit 51b3e23680
2 changed files with 62 additions and 15 deletions

View File

@@ -244,6 +244,40 @@ describe("deliverReplies", () => {
); );
}); });
it("falls back to plain text when markdown renders to empty HTML in threaded mode", async () => {
const runtime = { error: vi.fn(), log: vi.fn() };
const sendMessage = vi.fn(async (_chatId: string, text: string) => {
if (text === "") {
throw new Error("400: Bad Request: message text is empty");
}
return {
message_id: 6,
chat: { id: "123" },
};
});
const bot = { api: { sendMessage } } as unknown as Bot;
await deliverReplies({
replies: [{ text: ">" }],
chatId: "123",
token: "tok",
runtime,
bot,
replyToMode: "off",
textLimit: 4000,
thread: { id: 42, scope: "forum" },
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"123",
">",
expect.objectContaining({
message_thread_id: 42,
}),
);
});
it("uses reply_to_message_id when quote text is provided", async () => { it("uses reply_to_message_id when quote text is provided", async () => {
const runtime = createRuntime(); const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({ const sendMessage = vi.fn().mockResolvedValue({

View File

@@ -41,6 +41,7 @@ const TELEGRAM_MEDIA_SSRF_POLICY = {
allowedHostnames: ["api.telegram.org"], allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true, allowRfc2544BenchmarkRange: true,
}; };
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
export async function deliverReplies(params: { export async function deliverReplies(params: {
replies: ReplyPayload[]; replies: ReplyPayload[];
@@ -553,6 +554,30 @@ async function sendTelegramText(
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true }; const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const textMode = opts?.textMode ?? "markdown"; const textMode = opts?.textMode ?? "markdown";
const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text); const htmlText = textMode === "html" ? text : markdownToTelegramHtml(text);
const fallbackText = opts?.plainText ?? text;
const hasFallbackText = fallbackText.trim().length > 0;
const sendPlainFallback = async () => {
if (!hasFallbackText) {
return undefined;
}
const res = await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
}),
});
return res.message_id;
};
// Markdown can occasionally render to empty HTML (for example syntax-only chunks).
// Telegram rejects those sends, so fall back to plain text early.
if (!htmlText.trim()) {
return await sendPlainFallback();
}
try { try {
const res = await withTelegramApiErrorLogging({ const res = await withTelegramApiErrorLogging({
operation: "sendMessage", operation: "sendMessage",
@@ -570,21 +595,9 @@ async function sendTelegramText(
return res.message_id; return res.message_id;
} catch (err) { } catch (err) {
const errText = formatErrorMessage(err); const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText)) { if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`); runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
const fallbackText = opts?.plainText ?? text; return await sendPlainFallback();
const res = await withTelegramApiErrorLogging({
operation: "sendMessage",
runtime,
fn: () =>
bot.api.sendMessage(chatId, fallbackText, {
...(linkPreviewOptions ? { link_preview_options: linkPreviewOptions } : {}),
...(opts?.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
...baseParams,
}),
});
runtime.log?.(`telegram sendMessage ok chat=${chatId} message=${res.message_id} (plain)`);
return res.message_id;
} }
throw err; throw err;
} }