fix(telegram): unify rich plain fallback

This commit is contained in:
Ayaan Zaidi
2026-07-03 23:53:26 -07:00
parent 5c83b74235
commit f48ff25b3b
9 changed files with 402 additions and 143 deletions
@@ -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 {
+20 -14
View File
@@ -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);
}
}
@@ -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<typeof vi.fn> }).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({
+32 -2
View File
@@ -884,7 +884,9 @@ describe("createTelegramDraftStream", () => {
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
chat_id: 123,
rich_message: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
rich_message: {
html: "<h2>Plan</h2><table bordered striped><thead><tr><th>A</th></tr></thead></table>",
},
});
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: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
rich_message: {
html: "<h2>Plan updated</h2><table bordered striped><thead><tr><th>B</th></tr></thead></table>",
},
});
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: "<table><tr><td>Rank</td><td>Model</td><td>Score</td></tr><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></table>",
},
});
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 });
+75 -19
View File
@@ -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(/<br\s*\/?>/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<number | undefined> => {
const finalizeToPreview = async (preview: TelegramDraftPreview): Promise<number | undefined> => {
const text = preview.text.trimEnd();
if (!text) {
return undefined;
@@ -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}`);
}
}
@@ -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));
}
+76
View File
@@ -1021,6 +1021,40 @@ describe("sendMessageTelegram", () => {
expect(richMessage?.html).toContain("<table bordered striped>");
});
it("normalizes raw rich HTML tables before durable rich sends", async () => {
botApi.sendMessage.mockResolvedValue({ message_id: 45, chat: { id: "123" } });
const html =
'<table data-source="model"><tr><td>Rank</td><td>Model</td><td>Score</td></tr><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></table>';
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(
"<table bordered striped><thead><tr><th>Rank</th><th>Model</th><th>Score</th></tr></thead><tbody><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></tbody></table>",
);
});
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) => `<td>C${index + 1}</td>`).join("");
await sendMessageTelegram("123", `<table><tr>${cells}</tr></table>`, {
cfg: { channels: { telegram: { richMessages: true } } },
token: "tok",
textMode: "html",
});
const richMessage = botRawApi.sendRichMessage.mock.calls[0]?.[0]?.rich_message;
expect(richMessage?.html).toContain("<pre><code>");
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 =
"<table><tr><td>Rank</td><td>Model</td><td>Score</td></tr><tr><td>4</td><td>Claude Opus</td><td>78.16%</td></tr></table>";
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) => `<p><a href="https://example.com/${index}">link ${index}</a></p>`,
)
.join("")
.trim();
const text = `${firstChunk}<p>OAuth profile: openai:owner@example.com</p>`;
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(
+40 -93
View File
@@ -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<TelegramMessageLike>({
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<TelegramEditRichMessageTextParams, "reply_markup"> =
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),
);