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 () => {
const runtime = createRuntime();
const sendMessage = vi.fn().mockResolvedValue({

View File

@ -41,6 +41,7 @@ const TELEGRAM_MEDIA_SSRF_POLICY = {
allowedHostnames: ["api.telegram.org"],
allowRfc2544BenchmarkRange: true,
};
const EMPTY_TEXT_ERR_RE = /message text is empty/i;
export async function deliverReplies(params: {
replies: ReplyPayload[];
@ -553,6 +554,30 @@ async function sendTelegramText(
const linkPreviewOptions = linkPreviewEnabled ? undefined : { is_disabled: true };
const textMode = opts?.textMode ?? "markdown";
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 {
const res = await withTelegramApiErrorLogging({
operation: "sendMessage",
@ -570,21 +595,9 @@ async function sendTelegramText(
return res.message_id;
} catch (err) {
const errText = formatErrorMessage(err);
if (PARSE_ERR_RE.test(errText)) {
runtime.log?.(`telegram HTML parse failed; retrying without formatting: ${errText}`);
const fallbackText = opts?.plainText ?? text;
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;
if (PARSE_ERR_RE.test(errText) || EMPTY_TEXT_ERR_RE.test(errText)) {
runtime.log?.(`telegram formatted send failed; retrying without formatting: ${errText}`);
return await sendPlainFallback();
}
throw err;
}