diff --git a/extensions/telegram/src/bot/delivery.replies.ts b/extensions/telegram/src/bot/delivery.replies.ts index 96b0ab023974..fcfefd550db0 100644 --- a/extensions/telegram/src/bot/delivery.replies.ts +++ b/extensions/telegram/src/bot/delivery.replies.ts @@ -39,7 +39,7 @@ import { } from "../format.js"; import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js"; import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js"; -import { isTelegramHtmlParseError } from "../send-error-predicates.js"; +import { isTelegramHtmlParseError } from "../rich-plain-fallback.js"; import { buildInlineKeyboard, reactMessageTelegram } from "../send.js"; import { resolveTelegramVoiceSend } from "../voice.js"; import { diff --git a/extensions/telegram/src/bot/delivery.send.ts b/extensions/telegram/src/bot/delivery.send.ts index 9fc30c7370d2..969da9bdb44d 100644 --- a/extensions/telegram/src/bot/delivery.send.ts +++ b/extensions/telegram/src/bot/delivery.send.ts @@ -5,7 +5,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime"; import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env"; import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; import { withTelegramApiErrorLogging } from "../api-logging.js"; -import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "../format.js"; +import { markdownToTelegramHtml } from "../format.js"; import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js"; import { buildTelegramSendParams, @@ -15,15 +15,16 @@ import { } from "../reply-parameters.js"; import { TELEGRAM_OUTBOUND_RETRY_AFTER_CAP_MS } from "../retry-after.js"; import { - buildTelegramRichMessage, + buildTelegramRichMessagePlan, getTelegramRichRawApi, removeTelegramRichNativeQuoteParam, toTelegramRichMessageContextParams, } from "../rich-message.js"; import { + buildTelegramPlainFallbackPlan, isTelegramHtmlParseError, - isTelegramRichEntityInvalidError, -} from "../send-error-predicates.js"; + warnTelegramRichHtmlDegradations, +} from "../rich-plain-fallback.js"; import { buildInlineKeyboard } from "../send.js"; import type { TelegramThreadSpec } from "./helpers.js"; @@ -140,10 +141,15 @@ export async function sendTelegramText( }; if (opts?.richMessages === true) { - const richMessage = buildTelegramRichMessage(text, textMode, { + const richPlan = buildTelegramRichMessagePlan(text, textMode, { skipEntityDetection: opts.linkPreview === false, tableMode: opts.tableMode, }); + warnTelegramRichHtmlDegradations({ + context: "sendRichMessage", + reasons: richPlan.degradationReasons, + warn: (message) => runtime.log?.(message), + }); try { const res = await sendTelegramWithThreadFallback({ operation: "sendRichMessage", @@ -154,7 +160,7 @@ export async function sendTelegramText( send: (effectiveParams) => getTelegramRichRawApi(bot.api).sendRichMessage({ chat_id: chatId, - rich_message: richMessage, + rich_message: richPlan.richMessage, ...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}), ...effectiveParams, }), @@ -162,16 +168,16 @@ export async function sendTelegramText( runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`); return res.message_id; } catch (err) { - if (!isTelegramRichEntityInvalidError(err) || !hasFallbackText) { + const fallbackPlan = buildTelegramPlainFallbackPlan({ + html: richPlan.richMessage.html, + err, + context: "sendRichMessage", + warn: (message) => runtime.log?.(message), + }); + if (!fallbackPlan || !hasFallbackText) { throw err; } - const errText = formatErrorMessage(err); - const richFallbackText = - opts?.plainText ?? (textMode === "html" ? telegramHtmlToPlainTextFallback(text) : text); - runtime.log?.( - `telegram sendRichMessage rejected invalid entity; falling back to plain text: ${errText}`, - ); - return await sendPlainFallback(richFallbackText); + return await sendPlainFallback(fallbackPlan.plainText); } } diff --git a/extensions/telegram/src/bot/delivery.test.ts b/extensions/telegram/src/bot/delivery.test.ts index de541e67a1db..086b1c46a78f 100644 --- a/extensions/telegram/src/bot/delivery.test.ts +++ b/extensions/telegram/src/bot/delivery.test.ts @@ -1333,6 +1333,32 @@ describe("deliverReplies", () => { expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("parse_mode"); }); + it("uses table-aware plain text when rich reply fallback sends", async () => { + const runtime = createRuntime(); + const sendMessage = vi.fn().mockResolvedValue({ + message_id: 12, + chat: { id: "123" }, + }); + const bot = createBot({ sendMessage }); + (bot.api.raw as unknown as { sendRichMessage: ReturnType }).sendRichMessage = vi + .fn() + .mockRejectedValue(createRichEntityInvalidError("URL")); + const text = "| Rank | Model | Score |\n| --- | --- | --- |\n| 4 | Claude Opus | 78.16% |"; + + await deliverWith({ + replies: [{ text }], + runtime, + bot, + richMessages: true, + }); + + expect(sendMessage).toHaveBeenCalledTimes(1); + expect(firstMockCallArg(sendMessage, 1)).toBe("Rank | Model | Score\n4 | Claude Opus | 78.16%"); + expect(runtime.log).toHaveBeenCalledWith( + expect.stringContaining("rich-degrade=plain-fallback:rich-entity-invalid"), + ); + }); + it("falls back to plain text for other invalid rich entity validation errors", async () => { const runtime = createRuntime(); const sendMessage = vi.fn().mockResolvedValue({ diff --git a/extensions/telegram/src/draft-stream.test.ts b/extensions/telegram/src/draft-stream.test.ts index ccb7019c52d1..6789f2a1093a 100644 --- a/extensions/telegram/src/draft-stream.test.ts +++ b/extensions/telegram/src/draft-stream.test.ts @@ -884,7 +884,9 @@ describe("createTelegramDraftStream", () => { expect(api.raw.sendRichMessage).toHaveBeenCalledWith({ chat_id: 123, - rich_message: { html: "

Plan

A
" }, + rich_message: { + html: "

Plan

A
", + }, }); expect(api.sendMessage).not.toHaveBeenCalled(); @@ -897,11 +899,39 @@ describe("createTelegramDraftStream", () => { expect(api.raw.editMessageText).toHaveBeenCalledWith({ chat_id: 123, message_id: 17, - rich_message: { html: "

Plan updated

B
" }, + rich_message: { + html: "

Plan updated

B
", + }, }); expect(api.editMessageText).not.toHaveBeenCalled(); }); + it("uses table-aware plain text when rich preview fallback sends", async () => { + const api = createMockDraftApi(); + api.raw.sendRichMessage.mockRejectedValueOnce( + new Error("400: Bad Request: RICH_MESSAGE_URL_INVALID"), + ); + const warn = vi.fn(); + const stream = createDraftStream(api, { richMessages: true, warn }); + + stream.updatePreview({ + text: "Plan", + richMessage: { + html: "
RankModelScore
4Claude Opus78.16%
", + }, + }); + await stream.flush(); + + expect(api.sendMessage).toHaveBeenCalledWith( + 123, + "Rank | Model | Score\n4 | Claude Opus | 78.16%", + {}, + ); + expect(warn).toHaveBeenCalledWith( + expect.stringContaining("rich-degrade=plain-fallback:rich-entity-invalid"), + ); + }); + it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => { const api = createMockDraftApi(); const stream = createDraftStream(api, { richMessages: true }); diff --git a/extensions/telegram/src/draft-stream.ts b/extensions/telegram/src/draft-stream.ts index f7bf45fe1a9e..f66e820b46e3 100644 --- a/extensions/telegram/src/draft-stream.ts +++ b/extensions/telegram/src/draft-stream.ts @@ -19,17 +19,22 @@ import { import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js"; import { normalizeTelegramReplyToMessageId } from "./outbound-params.js"; import { - buildTelegramRichMarkdown, + buildTelegramRichHtmlPlan, + buildTelegramRichMarkdownPlan, getTelegramRichRawApi, isTelegramRichMessageWithinStructuralLimits, TELEGRAM_RICH_TEXT_LIMIT, type TelegramInputRichMessage, type TelegramSendRichMessageParams, } from "./rich-message.js"; +import { + buildTelegramPlainFallbackPlan, + isTelegramHtmlParseError, + warnTelegramRichHtmlDegradations, +} from "./rich-plain-fallback.js"; const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_TEXT_CHUNK_LIMIT; const DEFAULT_THROTTLE_MS = 1000; -const TELEGRAM_PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; // Retryable preview failures keep the latest text pending for the next throttle // tick; cap consecutive misses so a persistent outage stops the preview instead // of warn-spamming for the rest of the run. @@ -106,10 +111,6 @@ function renderTelegramDraftPreview( return renderText?.(trimmed) ?? { text: trimmed }; } -function isTelegramHtmlParseError(err: unknown): boolean { - return TELEGRAM_PARSE_ERR_RE.test(formatErrorMessage(err)); -} - function telegramRichHtmlToParseModeHtml(html: string): string { return html.replace(//giu, "\n"); } @@ -160,10 +161,25 @@ function telegramDraftRichPayloadLength(preview: TelegramDraftPreview): number { if (!isTelegramRichMessageWithinStructuralLimits(sourceMessage)) { return TELEGRAM_RICH_TEXT_LIMIT + 1; } - const richMessage = preview.richMessage ?? buildTelegramRichMarkdown(preview.text); + const richMessage = + preview.richMessage ?? buildTelegramRichMarkdownPlan(preview.text).richMessage; return richMessage.html?.length ?? richMessage.markdown?.length ?? 0; } +function buildTelegramDraftRichPlan(preview: TelegramDraftPreview) { + if (preview.richMessage?.html !== undefined) { + return buildTelegramRichHtmlPlan(preview.richMessage.html, { + skipEntityDetection: preview.richMessage.skip_entity_detection === true, + }); + } + if (preview.richMessage?.markdown !== undefined) { + return buildTelegramRichMarkdownPlan(preview.richMessage.markdown, { + skipEntityDetection: preview.richMessage.skip_entity_detection === true, + }); + } + return buildTelegramRichMarkdownPlan(preview.text); +} + function resolveTelegramDraftRenderedText( preview: TelegramDraftPreview, richMessages: boolean, @@ -267,11 +283,30 @@ export function createTelegramDraftStream(params: { }; const sendRenderedMessage = async (preview: TelegramDraftPreview) => { if (richMessages) { - return await getTelegramRichRawApi(params.api).sendRichMessage({ - chat_id: chatId, - rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text), - ...richMessageParams, + const richPlan = buildTelegramDraftRichPlan(preview); + warnTelegramRichHtmlDegradations({ + context: "stream preview", + reasons: richPlan.degradationReasons, + warn: (message) => params.warn?.(message), }); + try { + return await getTelegramRichRawApi(params.api).sendRichMessage({ + chat_id: chatId, + rich_message: richPlan.richMessage, + ...richMessageParams, + }); + } catch (err) { + const fallbackPlan = buildTelegramPlainFallbackPlan({ + html: richPlan.richMessage.html, + err, + context: "stream preview", + warn: (message) => params.warn?.(message), + }); + if (!fallbackPlan) { + throw err; + } + return await params.api.sendMessage(chatId, fallbackPlan.plainText, sendMessageParams); + } } const transportPreview = normalizeTelegramDraftTransportPreview(preview); const sendPlain = async () => @@ -298,11 +333,30 @@ export function createTelegramDraftStream(params: { if (typeof streamMessageId === "number") { streamVisibleSinceMs ??= Date.now(); if (richMessages) { - await getTelegramRichRawApi(params.api).editMessageText({ - chat_id: chatId, - message_id: streamMessageId, - rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text), + const richPlan = buildTelegramDraftRichPlan(preview); + warnTelegramRichHtmlDegradations({ + context: "stream preview edit", + reasons: richPlan.degradationReasons, + warn: (message) => params.warn?.(message), }); + try { + await getTelegramRichRawApi(params.api).editMessageText({ + chat_id: chatId, + message_id: streamMessageId, + rich_message: richPlan.richMessage, + }); + } catch (err) { + const fallbackPlan = buildTelegramPlainFallbackPlan({ + html: richPlan.richMessage.html, + err, + context: "stream preview edit", + warn: (message) => params.warn?.(message), + }); + if (!fallbackPlan) { + throw err; + } + await params.api.editMessageText(chatId, streamMessageId, fallbackPlan.plainText); + } return true; } const transportPreview = normalizeTelegramDraftTransportPreview(preview); @@ -632,7 +686,11 @@ export function createTelegramDraftStream(params: { // Rewind WITHOUT deleting; the old id is captured above. resetStreamToNewMessage(); if (typeof supersededMessageId === "number" && Number.isFinite(supersededMessageId)) { - scheduleDetachedDelete(supersededMessageId, supersededVisibleSince, REPOSITION_DELETE_DELAY_MS); + scheduleDetachedDelete( + supersededMessageId, + supersededVisibleSince, + REPOSITION_DELETE_DELAY_MS, + ); return supersededMessageId; } return undefined; @@ -651,9 +709,7 @@ export function createTelegramDraftStream(params: { return streamMessageId; }; - const finalizeToPreview = async ( - preview: TelegramDraftPreview, - ): Promise => { + const finalizeToPreview = async (preview: TelegramDraftPreview): Promise => { const text = preview.text.trimEnd(); if (!text) { return undefined; diff --git a/extensions/telegram/src/rich-plain-fallback.ts b/extensions/telegram/src/rich-plain-fallback.ts new file mode 100644 index 000000000000..0db001092f55 --- /dev/null +++ b/extensions/telegram/src/rich-plain-fallback.ts @@ -0,0 +1,132 @@ +// Telegram rich/plain fallback policy is shared by durable sends, final replies, +// and draft previews. A second copy reintroduces silent drift in parse failures. +import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; +import { + telegramHtmlToPlainTextFallback, + type TelegramRichHtmlDegradationReason, +} from "./format.js"; + +const RICH_ENTITY_INVALID_RE = + /RICH_MESSAGE_(?:EMAIL|URL|MENTION|HASHTAG|CASHTAG|BOT_COMMAND|PHONE|BANK_CARD)_INVALID/i; +const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; + +export type TelegramPlainFallbackTrigger = "rich-entity-invalid" | "html-parse"; + +export type TelegramPlainFallbackPlan = { + plainText: string; + chunks: string[]; +}; + +export function isTelegramRichEntityInvalidError(err: unknown): boolean { + return RICH_ENTITY_INVALID_RE.test(formatErrorMessage(err)); +} + +export function isTelegramHtmlParseError(err: unknown): boolean { + return PARSE_ERR_RE.test(formatErrorMessage(err)); +} + +export function getTelegramPlainFallbackTrigger( + err: unknown, +): TelegramPlainFallbackTrigger | undefined { + if (isTelegramRichEntityInvalidError(err)) { + return "rich-entity-invalid"; + } + if (isTelegramHtmlParseError(err)) { + return "html-parse"; + } + return undefined; +} + +function surrogateSafeChunkEnd(text: string, end: number, start: number): number { + const high = text.charCodeAt(end - 1); + const low = text.charCodeAt(end); + const splitsPair = end > 0 && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff; + if (!splitsPair) { + return end; + } + const clamped = end - 1; + return clamped > start ? clamped : start + 2; +} + +export function splitTelegramPlainTextChunks(text: string, limit: number): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const chunks: string[] = []; + let start = 0; + while (start < text.length) { + const end = surrogateSafeChunkEnd(text, start + normalizedLimit, start); + chunks.push(text.slice(start, end)); + start = end; + } + return chunks; +} + +export function splitTelegramPlainTextFallback( + text: string, + chunkCount: number, + limit: number, +): string[] { + if (!text) { + return []; + } + const normalizedLimit = Math.max(1, Math.floor(limit)); + const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit); + if (chunkCount <= 1 || fixedChunks.length >= chunkCount) { + return fixedChunks; + } + const chunks: string[] = []; + let offset = 0; + for (let index = 0; index < chunkCount; index += 1) { + const remainingChars = text.length - offset; + const remainingChunks = chunkCount - index; + const nextChunkLength = + remainingChunks === 1 + ? remainingChars + : Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks)); + const end = surrogateSafeChunkEnd(text, offset + nextChunkLength, offset); + chunks.push(text.slice(offset, end)); + offset = end; + } + return chunks; +} + +export function buildTelegramPlainFallbackPlan(params: { + html: string; + err: unknown; + context: string; + warn: (message: string) => void; + limit?: number; + chunkCount?: number; +}): TelegramPlainFallbackPlan | undefined { + const trigger = getTelegramPlainFallbackTrigger(params.err); + if (!trigger) { + return undefined; + } + const plainText = telegramHtmlToPlainTextFallback(params.html); + const limit = params.limit ?? 4000; + const chunks = + params.chunkCount === undefined + ? splitTelegramPlainTextChunks(plainText, limit) + : splitTelegramPlainTextFallback(plainText, params.chunkCount, limit); + params.warn( + `telegram ${params.context} rich-degrade=plain-fallback:${trigger}: ${formatErrorMessage( + params.err, + )}`, + ); + return { + plainText, + chunks, + }; +} + +export function warnTelegramRichHtmlDegradations(params: { + context: string; + reasons: readonly TelegramRichHtmlDegradationReason[]; + warn: (message: string) => void; +}): void { + for (const reason of new Set(params.reasons)) { + params.warn(`telegram ${params.context} rich-degrade=${reason}`); + } +} diff --git a/extensions/telegram/src/send-error-predicates.ts b/extensions/telegram/src/send-error-predicates.ts deleted file mode 100644 index b481f0cf017e..000000000000 --- a/extensions/telegram/src/send-error-predicates.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Telegram API rejection predicates shared by durable and streaming send funnels. -import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime"; - -const RICH_ENTITY_INVALID_RE = - /RICH_MESSAGE_(?:EMAIL|URL|MENTION|HASHTAG|CASHTAG|BOT_COMMAND|PHONE|BANK_CARD)_INVALID/i; -const PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i; - -export function isTelegramRichEntityInvalidError(err: unknown): boolean { - return RICH_ENTITY_INVALID_RE.test(formatErrorMessage(err)); -} - -export function isTelegramHtmlParseError(err: unknown): boolean { - return PARSE_ERR_RE.test(formatErrorMessage(err)); -} diff --git a/extensions/telegram/src/send.test.ts b/extensions/telegram/src/send.test.ts index 7e3f992ba7e6..b3cf20681ecf 100644 --- a/extensions/telegram/src/send.test.ts +++ b/extensions/telegram/src/send.test.ts @@ -1021,6 +1021,40 @@ describe("sendMessageTelegram", () => { expect(richMessage?.html).toContain(""); }); + it("normalizes raw rich HTML tables before durable rich sends", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const html = + '
RankModelScore
4Claude Opus78.16%
'; + + await sendMessageTelegram("123", html, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + textMode: "html", + }); + + expect(botRawApi.sendRichMessage).toHaveBeenCalledTimes(1); + const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message; + expect(richMessage?.html).toBe( + "
RankModelScore
4Claude Opus78.16%
", + ); + }); + + it("warns when raw rich HTML tables degrade to ASCII", async () => { + const logFile = captureInfoLogs(); + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const cells = Array.from({ length: 21 }, (_, index) => `C${index + 1}`).join(""); + + await sendMessageTelegram("123", `${cells}
`, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + textMode: "html", + }); + + const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message; + expect(richMessage?.html).toContain("
");
+    expect(capturedLogText(logFile)).toContain("rich-degrade=table-ascii");
+  });
+
   it("skips rich entity detection for provider-prefixed email text", async () => {
     botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
     const oauthProfileText =
@@ -1061,6 +1095,26 @@ describe("sendMessageTelegram", () => {
     expect(result).toEqual({ messageId: "46", chatId: "123" });
   });
 
+  it("uses table-aware plain text when durable rich sends fall back", async () => {
+    const logFile = captureInfoLogs();
+    const html =
+      "
RankModelScore
4Claude Opus78.16%
"; + botRawApi.sendRichMessage.mockRejectedValueOnce(createRichEntityInvalidError("URL")); + botApi.sendMessage.mockResolvedValueOnce({ message_id: 46, chat: { id: "123" } }); + + await sendMessageTelegram("123", html, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + textMode: "html", + }); + + expect(botApi.sendMessage).toHaveBeenCalledWith( + "123", + "Rank | Model | Score\n4 | Claude Opus | 78.16%", + ); + expect(capturedLogText(logFile)).toContain("rich-degrade=plain-fallback:rich-entity-invalid"); + }); + it("chunks long plain text when durable rich sends reject an invalid entity", async () => { const text = `Status includes openai:owner@example.com ${"A".repeat(5000)}`; botRawApi.sendRichMessage.mockRejectedValueOnce(createRichEntityInvalidError("EMAIL")); @@ -1125,6 +1179,28 @@ describe("sendMessageTelegram", () => { expect(htmlChunks.join("\n")).toContain(testCase.terminalText); }); + it("keeps rich entity detection skip scoped to the affected chunk", async () => { + botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); + const firstChunk = Array.from( + { length: 700 }, + (_, index) => `

link ${index}

`, + ) + .join("") + .trim(); + const text = `${firstChunk}

OAuth profile: openai:owner@example.com

`; + + await sendMessageTelegram("123", text, { + cfg: { channels: { telegram: { richMessages: true } } }, + token: "tok", + textMode: "html", + }); + + expect(botRawApi.sendRichMessage.mock.calls.length).toBeGreaterThan(1); + const richMessages = botRawApi.sendRichMessage.mock.calls.map((call) => call[0]?.rich_message); + expect(richMessages[0]).not.toHaveProperty("skip_entity_detection"); + expect(richMessages.at(-1)).toHaveProperty("skip_entity_detection", true); + }); + it("chunks rich media at Telegram's attachment limit", async () => { botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } }); const html = Array.from( diff --git a/extensions/telegram/src/send.ts b/extensions/telegram/src/send.ts index 69887ea8d62d..e44e6ff302a1 100644 --- a/extensions/telegram/src/send.ts +++ b/extensions/telegram/src/send.ts @@ -50,7 +50,7 @@ import { } from "./reply-parameters.js"; import { TELEGRAM_OUTBOUND_RETRY_AFTER_CAP_MS } from "./retry-after.js"; import { - buildTelegramRichMessage, + buildTelegramRichMessagePlan, getTelegramRichRawApi, removeTelegramRichNativeQuoteParam, splitTelegramRichMessageTextChunks, @@ -61,9 +61,12 @@ import { type TelegramRichTextChunk, } from "./rich-message.js"; import { + buildTelegramPlainFallbackPlan, isTelegramHtmlParseError, - isTelegramRichEntityInvalidError, -} from "./send-error-predicates.js"; + splitTelegramPlainTextChunks, + splitTelegramPlainTextFallback, + warnTelegramRichHtmlDegradations, +} from "./rich-plain-fallback.js"; import { buildOutboundMediaLoadOptions, getImageMetadata, @@ -202,69 +205,6 @@ function resolveTelegramMessageIdOrThrow( throw new Error(`Telegram ${context} returned no message_id`); } -// Pull a chunk end back off a UTF-16 surrogate pair so neither chunk carries a -// lone surrogate that re-encodes to U+FFFD. Mirrors the guard in -// bot/native-quote.ts `truncateUtf16Safe`; shared by both plain-text splitters. -// -// `start` is the beginning of the current chunk — the return value is -// guaranteed to be > start, so callers that loop on `start = end` always -// advance. When clamping would land on `start` (i.e. the surrogate pair begins -// exactly at `start`), we emit both surrogates together (end = start + 2) -// rather than emitting a lone surrogate or stalling. -function surrogateSafeChunkEnd(text: string, end: number, start: number): number { - const high = text.charCodeAt(end - 1); - const low = text.charCodeAt(end); - const splitsPair = end > 0 && high >= 0xd800 && high <= 0xdbff && low >= 0xdc00 && low <= 0xdfff; - if (!splitsPair) { - return end; - } - const clamped = end - 1; - // Guard: never return an index that would stall the loop. If clamped equals - // start the surrogate pair's high unit is the very first char of this chunk; - // emit both surrogates together instead of splitting or stalling. - return clamped > start ? clamped : start + 2; -} - -function splitTelegramPlainTextChunks(text: string, limit: number): string[] { - if (!text) { - return []; - } - const normalizedLimit = Math.max(1, Math.floor(limit)); - const chunks: string[] = []; - let start = 0; - while (start < text.length) { - const end = surrogateSafeChunkEnd(text, start + normalizedLimit, start); - chunks.push(text.slice(start, end)); - start = end; - } - return chunks; -} - -function splitTelegramPlainTextFallback(text: string, chunkCount: number, limit: number): string[] { - if (!text) { - return []; - } - const normalizedLimit = Math.max(1, Math.floor(limit)); - const fixedChunks = splitTelegramPlainTextChunks(text, normalizedLimit); - if (chunkCount <= 1 || fixedChunks.length >= chunkCount) { - return fixedChunks; - } - const chunks: string[] = []; - let offset = 0; - for (let index = 0; index < chunkCount; index += 1) { - const remainingChars = text.length - offset; - const remainingChunks = chunkCount - index; - const nextChunkLength = - remainingChunks === 1 - ? remainingChars - : Math.min(normalizedLimit, Math.ceil(remainingChars / remainingChunks)); - const end = surrogateSafeChunkEnd(text, offset + nextChunkLength, offset); - chunks.push(text.slice(offset, end)); - offset = end; - } - return chunks; -} - // Test-only handle: the plain-text splitter is internal, but its surrogate-safe // chunk boundary needs direct behavior coverage. export function splitTelegramPlainTextChunksForTests(text: string, limit: number): string[] { @@ -1080,6 +1020,11 @@ export async function sendMessageTelegram( let result: TelegramMessageLike; let recordedParams: TelegramThreadScopedParams | TelegramRichMessageContextParams | undefined; try { + warnTelegramRichHtmlDegradations({ + context: "richMessage", + reasons: chunk.degradationReasons, + warn: (message) => sendLogger.warn(message), + }); const richResult = await withTelegramNativeQuoteFallback({ label: "richMessage", requestParams: acceptedParams ?? {}, @@ -1089,10 +1034,9 @@ export async function sendMessageTelegram( () => richRawApi.sendRichMessage({ chat_id: chatId, - rich_message: buildTelegramRichMessage(chunk.text, chunk.textMode, { - skipEntityDetection: account.config.linkPreview === false, - tableMode, - }), + rich_message: chunk.skipEntityDetection + ? { html: chunk.text, skip_entity_detection: true } + : { html: chunk.text }, ...effectiveParams, ...(opts.silent === true ? { disable_notification: true } : {}), }), @@ -1102,17 +1046,16 @@ export async function sendMessageTelegram( result = richResult.result; recordedParams = toTelegramRichMessageContextParams(richResult.acceptedParams); } catch (err) { - if (!isTelegramRichEntityInvalidError(err)) { + const fallbackPlan = buildTelegramPlainFallbackPlan({ + html: chunk.text, + err, + context: "richMessage", + warn: (message) => sendLogger.warn(message), + }); + if (!fallbackPlan) { throw err; } - // Mirror delivery.send.ts plain-text fallback, but keep normal 4k - // sendMessage chunking because rich chunks may be much larger. - sendLogger.warn( - `telegram richMessage rejected invalid entity, retrying as plain text: ${formatErrorMessage( - err, - )}`, - ); - const fallbackChunks = splitTelegramPlainTextChunks(chunk.plainText, 4000); + const fallbackChunks = fallbackPlan.chunks; const fallbackReplyChunkCount = Math.max(chunks.length, fallbackChunks.length); for (let fallbackIndex = 0; fallbackIndex < fallbackChunks.length; fallbackIndex += 1) { const fallbackText = fallbackChunks[fallbackIndex] ?? ""; @@ -1872,8 +1815,8 @@ export async function editMessageTelegram( const htmlText = renderTelegramHtmlText(text, { textMode, tableMode }); const plainText = textMode === "html" ? telegramHtmlToPlainTextFallback(htmlText) : text; const richRawApi = useRichMessages ? getTelegramRichRawApi(api) : undefined; - const richMessage = useRichMessages - ? buildTelegramRichMessage(text, textMode, { + const richMessagePlan = useRichMessages + ? buildTelegramRichMessagePlan(text, textMode, { skipEntityDetection: opts.linkPreview === false, tableMode, }) @@ -1918,35 +1861,39 @@ export async function editMessageTelegram( } const performTextEdit = () => { - if (richRawApi && richMessage) { + if (richRawApi && richMessagePlan) { const richEditParams: Pick = replyMarkup === undefined ? {} : { reply_markup: replyMarkup }; + warnTelegramRichHtmlDegradations({ + context: "editMessage", + reasons: richMessagePlan.degradationReasons, + warn: (message) => sendLogger.warn(message), + }); return requestWithEditShouldLog( () => richRawApi.editMessageText({ chat_id: chatId, message_id: messageId, - rich_message: richMessage, + rich_message: richMessagePlan.richMessage, ...richEditParams, }), "editMessage", (err) => !isTelegramMessageNotModifiedError(err), ).catch((err: unknown) => { - if (!isTelegramRichEntityInvalidError(err)) { + const fallbackPlan = buildTelegramPlainFallbackPlan({ + html: richMessagePlan.richMessage.html, + err, + context: "editMessage", + warn: (message) => sendLogger.warn(message), + }); + if (!fallbackPlan) { throw err; } - // Mirror durable send fallback for edits: invalid rich entities degrade - // to the same plain text that normal HTML edit fallback would send. - sendLogger.warn( - `telegram editMessage rich entity rejected, retrying as plain text: ${formatErrorMessage( - err, - )}`, - ); return requestWithEditShouldLog( () => Object.keys(plainTextParams).length > 0 - ? api.editMessageText(chatId, messageId, plainText, plainTextParams) - : api.editMessageText(chatId, messageId, plainText), + ? api.editMessageText(chatId, messageId, fallbackPlan.plainText, plainTextParams) + : api.editMessageText(chatId, messageId, fallbackPlan.plainText), "editMessage-plain", (plainErr) => !isTelegramMessageNotModifiedError(plainErr), );