mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-12 21:53:00 -06:00
fix(telegram): unify rich plain fallback
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -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 });
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user