Skip to content

Commit f48ff25

Browse files
committed
fix(telegram): unify rich plain fallback
1 parent 5c83b74 commit f48ff25

9 files changed

Lines changed: 402 additions & 143 deletions

File tree

extensions/telegram/src/bot/delivery.replies.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ import {
3939
} from "../format.js";
4040
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
4141
import { splitTelegramRichMessageTextChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
42-
import { isTelegramHtmlParseError } from "../send-error-predicates.js";
42+
import { isTelegramHtmlParseError } from "../rich-plain-fallback.js";
4343
import { buildInlineKeyboard, reactMessageTelegram } from "../send.js";
4444
import { resolveTelegramVoiceSend } from "../voice.js";
4545
import {

extensions/telegram/src/bot/delivery.send.ts

Lines changed: 20 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime";
55
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
66
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
77
import { withTelegramApiErrorLogging } from "../api-logging.js";
8-
import { markdownToTelegramHtml, telegramHtmlToPlainTextFallback } from "../format.js";
8+
import { markdownToTelegramHtml } from "../format.js";
99
import { isSafeToRetrySendError, isTelegramRateLimitError } from "../network-errors.js";
1010
import {
1111
buildTelegramSendParams,
@@ -15,15 +15,16 @@ import {
1515
} from "../reply-parameters.js";
1616
import { TELEGRAM_OUTBOUND_RETRY_AFTER_CAP_MS } from "../retry-after.js";
1717
import {
18-
buildTelegramRichMessage,
18+
buildTelegramRichMessagePlan,
1919
getTelegramRichRawApi,
2020
removeTelegramRichNativeQuoteParam,
2121
toTelegramRichMessageContextParams,
2222
} from "../rich-message.js";
2323
import {
24+
buildTelegramPlainFallbackPlan,
2425
isTelegramHtmlParseError,
25-
isTelegramRichEntityInvalidError,
26-
} from "../send-error-predicates.js";
26+
warnTelegramRichHtmlDegradations,
27+
} from "../rich-plain-fallback.js";
2728
import { buildInlineKeyboard } from "../send.js";
2829
import type { TelegramThreadSpec } from "./helpers.js";
2930

@@ -140,10 +141,15 @@ export async function sendTelegramText(
140141
};
141142

142143
if (opts?.richMessages === true) {
143-
const richMessage = buildTelegramRichMessage(text, textMode, {
144+
const richPlan = buildTelegramRichMessagePlan(text, textMode, {
144145
skipEntityDetection: opts.linkPreview === false,
145146
tableMode: opts.tableMode,
146147
});
148+
warnTelegramRichHtmlDegradations({
149+
context: "sendRichMessage",
150+
reasons: richPlan.degradationReasons,
151+
warn: (message) => runtime.log?.(message),
152+
});
147153
try {
148154
const res = await sendTelegramWithThreadFallback({
149155
operation: "sendRichMessage",
@@ -154,24 +160,24 @@ export async function sendTelegramText(
154160
send: (effectiveParams) =>
155161
getTelegramRichRawApi(bot.api).sendRichMessage({
156162
chat_id: chatId,
157-
rich_message: richMessage,
163+
rich_message: richPlan.richMessage,
158164
...(opts.replyMarkup ? { reply_markup: opts.replyMarkup } : {}),
159165
...effectiveParams,
160166
}),
161167
});
162168
runtime.log?.(`telegram sendRichMessage ok chat=${chatId} message=${res.message_id}`);
163169
return res.message_id;
164170
} catch (err) {
165-
if (!isTelegramRichEntityInvalidError(err) || !hasFallbackText) {
171+
const fallbackPlan = buildTelegramPlainFallbackPlan({
172+
html: richPlan.richMessage.html,
173+
err,
174+
context: "sendRichMessage",
175+
warn: (message) => runtime.log?.(message),
176+
});
177+
if (!fallbackPlan || !hasFallbackText) {
166178
throw err;
167179
}
168-
const errText = formatErrorMessage(err);
169-
const richFallbackText =
170-
opts?.plainText ?? (textMode === "html" ? telegramHtmlToPlainTextFallback(text) : text);
171-
runtime.log?.(
172-
`telegram sendRichMessage rejected invalid entity; falling back to plain text: ${errText}`,
173-
);
174-
return await sendPlainFallback(richFallbackText);
180+
return await sendPlainFallback(fallbackPlan.plainText);
175181
}
176182
}
177183

extensions/telegram/src/bot/delivery.test.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1333,6 +1333,32 @@ describe("deliverReplies", () => {
13331333
expect(mockCallArg(sendMessage, 0, 2)).not.toHaveProperty("parse_mode");
13341334
});
13351335

1336+
it("uses table-aware plain text when rich reply fallback sends", async () => {
1337+
const runtime = createRuntime();
1338+
const sendMessage = vi.fn().mockResolvedValue({
1339+
message_id: 12,
1340+
chat: { id: "123" },
1341+
});
1342+
const bot = createBot({ sendMessage });
1343+
(bot.api.raw as unknown as { sendRichMessage: ReturnType<typeof vi.fn> }).sendRichMessage = vi
1344+
.fn()
1345+
.mockRejectedValue(createRichEntityInvalidError("URL"));
1346+
const text = "| Rank | Model | Score |\n| --- | --- | --- |\n| 4 | Claude Opus | 78.16% |";
1347+
1348+
await deliverWith({
1349+
replies: [{ text }],
1350+
runtime,
1351+
bot,
1352+
richMessages: true,
1353+
});
1354+
1355+
expect(sendMessage).toHaveBeenCalledTimes(1);
1356+
expect(firstMockCallArg(sendMessage, 1)).toBe("Rank | Model | Score\n4 | Claude Opus | 78.16%");
1357+
expect(runtime.log).toHaveBeenCalledWith(
1358+
expect.stringContaining("rich-degrade=plain-fallback:rich-entity-invalid"),
1359+
);
1360+
});
1361+
13361362
it("falls back to plain text for other invalid rich entity validation errors", async () => {
13371363
const runtime = createRuntime();
13381364
const sendMessage = vi.fn().mockResolvedValue({

extensions/telegram/src/draft-stream.test.ts

Lines changed: 32 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -884,7 +884,9 @@ describe("createTelegramDraftStream", () => {
884884

885885
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
886886
chat_id: 123,
887-
rich_message: { html: "<h2>Plan</h2><table><tr><td>A</td></tr></table>" },
887+
rich_message: {
888+
html: "<h2>Plan</h2><table bordered striped><thead><tr><th>A</th></tr></thead></table>",
889+
},
888890
});
889891
expect(api.sendMessage).not.toHaveBeenCalled();
890892

@@ -897,11 +899,39 @@ describe("createTelegramDraftStream", () => {
897899
expect(api.raw.editMessageText).toHaveBeenCalledWith({
898900
chat_id: 123,
899901
message_id: 17,
900-
rich_message: { html: "<h2>Plan updated</h2><table><tr><td>B</td></tr></table>" },
902+
rich_message: {
903+
html: "<h2>Plan updated</h2><table bordered striped><thead><tr><th>B</th></tr></thead></table>",
904+
},
901905
});
902906
expect(api.editMessageText).not.toHaveBeenCalled();
903907
});
904908

909+
it("uses table-aware plain text when rich preview fallback sends", async () => {
910+
const api = createMockDraftApi();
911+
api.raw.sendRichMessage.mockRejectedValueOnce(
912+
new Error("400: Bad Request: RICH_MESSAGE_URL_INVALID"),
913+
);
914+
const warn = vi.fn();
915+
const stream = createDraftStream(api, { richMessages: true, warn });
916+
917+
stream.updatePreview({
918+
text: "Plan",
919+
richMessage: {
920+
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>",
921+
},
922+
});
923+
await stream.flush();
924+
925+
expect(api.sendMessage).toHaveBeenCalledWith(
926+
123,
927+
"Rank | Model | Score\n4 | Claude Opus | 78.16%",
928+
{},
929+
);
930+
expect(warn).toHaveBeenCalledWith(
931+
expect.stringContaining("rich-degrade=plain-fallback:rich-entity-invalid"),
932+
);
933+
});
934+
905935
it("skips rich entity detection for draft text with provider-prefixed email addresses", async () => {
906936
const api = createMockDraftApi();
907937
const stream = createDraftStream(api, { richMessages: true });

extensions/telegram/src/draft-stream.ts

Lines changed: 75 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,22 @@ import {
1919
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
2020
import { normalizeTelegramReplyToMessageId } from "./outbound-params.js";
2121
import {
22-
buildTelegramRichMarkdown,
22+
buildTelegramRichHtmlPlan,
23+
buildTelegramRichMarkdownPlan,
2324
getTelegramRichRawApi,
2425
isTelegramRichMessageWithinStructuralLimits,
2526
TELEGRAM_RICH_TEXT_LIMIT,
2627
type TelegramInputRichMessage,
2728
type TelegramSendRichMessageParams,
2829
} from "./rich-message.js";
30+
import {
31+
buildTelegramPlainFallbackPlan,
32+
isTelegramHtmlParseError,
33+
warnTelegramRichHtmlDegradations,
34+
} from "./rich-plain-fallback.js";
2935

3036
const TELEGRAM_STREAM_MAX_CHARS = TELEGRAM_TEXT_CHUNK_LIMIT;
3137
const DEFAULT_THROTTLE_MS = 1000;
32-
const TELEGRAM_PARSE_ERR_RE = /can't parse entities|parse entities|find end of the entity/i;
3338
// Retryable preview failures keep the latest text pending for the next throttle
3439
// tick; cap consecutive misses so a persistent outage stops the preview instead
3540
// of warn-spamming for the rest of the run.
@@ -106,10 +111,6 @@ function renderTelegramDraftPreview(
106111
return renderText?.(trimmed) ?? { text: trimmed };
107112
}
108113

109-
function isTelegramHtmlParseError(err: unknown): boolean {
110-
return TELEGRAM_PARSE_ERR_RE.test(formatErrorMessage(err));
111-
}
112-
113114
function telegramRichHtmlToParseModeHtml(html: string): string {
114115
return html.replace(/<br\s*\/?>/giu, "\n");
115116
}
@@ -160,10 +161,25 @@ function telegramDraftRichPayloadLength(preview: TelegramDraftPreview): number {
160161
if (!isTelegramRichMessageWithinStructuralLimits(sourceMessage)) {
161162
return TELEGRAM_RICH_TEXT_LIMIT + 1;
162163
}
163-
const richMessage = preview.richMessage ?? buildTelegramRichMarkdown(preview.text);
164+
const richMessage =
165+
preview.richMessage ?? buildTelegramRichMarkdownPlan(preview.text).richMessage;
164166
return richMessage.html?.length ?? richMessage.markdown?.length ?? 0;
165167
}
166168

169+
function buildTelegramDraftRichPlan(preview: TelegramDraftPreview) {
170+
if (preview.richMessage?.html !== undefined) {
171+
return buildTelegramRichHtmlPlan(preview.richMessage.html, {
172+
skipEntityDetection: preview.richMessage.skip_entity_detection === true,
173+
});
174+
}
175+
if (preview.richMessage?.markdown !== undefined) {
176+
return buildTelegramRichMarkdownPlan(preview.richMessage.markdown, {
177+
skipEntityDetection: preview.richMessage.skip_entity_detection === true,
178+
});
179+
}
180+
return buildTelegramRichMarkdownPlan(preview.text);
181+
}
182+
167183
function resolveTelegramDraftRenderedText(
168184
preview: TelegramDraftPreview,
169185
richMessages: boolean,
@@ -267,11 +283,30 @@ export function createTelegramDraftStream(params: {
267283
};
268284
const sendRenderedMessage = async (preview: TelegramDraftPreview) => {
269285
if (richMessages) {
270-
return await getTelegramRichRawApi(params.api).sendRichMessage({
271-
chat_id: chatId,
272-
rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text),
273-
...richMessageParams,
286+
const richPlan = buildTelegramDraftRichPlan(preview);
287+
warnTelegramRichHtmlDegradations({
288+
context: "stream preview",
289+
reasons: richPlan.degradationReasons,
290+
warn: (message) => params.warn?.(message),
274291
});
292+
try {
293+
return await getTelegramRichRawApi(params.api).sendRichMessage({
294+
chat_id: chatId,
295+
rich_message: richPlan.richMessage,
296+
...richMessageParams,
297+
});
298+
} catch (err) {
299+
const fallbackPlan = buildTelegramPlainFallbackPlan({
300+
html: richPlan.richMessage.html,
301+
err,
302+
context: "stream preview",
303+
warn: (message) => params.warn?.(message),
304+
});
305+
if (!fallbackPlan) {
306+
throw err;
307+
}
308+
return await params.api.sendMessage(chatId, fallbackPlan.plainText, sendMessageParams);
309+
}
275310
}
276311
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
277312
const sendPlain = async () =>
@@ -298,11 +333,30 @@ export function createTelegramDraftStream(params: {
298333
if (typeof streamMessageId === "number") {
299334
streamVisibleSinceMs ??= Date.now();
300335
if (richMessages) {
301-
await getTelegramRichRawApi(params.api).editMessageText({
302-
chat_id: chatId,
303-
message_id: streamMessageId,
304-
rich_message: preview.richMessage ?? buildTelegramRichMarkdown(preview.text),
336+
const richPlan = buildTelegramDraftRichPlan(preview);
337+
warnTelegramRichHtmlDegradations({
338+
context: "stream preview edit",
339+
reasons: richPlan.degradationReasons,
340+
warn: (message) => params.warn?.(message),
305341
});
342+
try {
343+
await getTelegramRichRawApi(params.api).editMessageText({
344+
chat_id: chatId,
345+
message_id: streamMessageId,
346+
rich_message: richPlan.richMessage,
347+
});
348+
} catch (err) {
349+
const fallbackPlan = buildTelegramPlainFallbackPlan({
350+
html: richPlan.richMessage.html,
351+
err,
352+
context: "stream preview edit",
353+
warn: (message) => params.warn?.(message),
354+
});
355+
if (!fallbackPlan) {
356+
throw err;
357+
}
358+
await params.api.editMessageText(chatId, streamMessageId, fallbackPlan.plainText);
359+
}
306360
return true;
307361
}
308362
const transportPreview = normalizeTelegramDraftTransportPreview(preview);
@@ -632,7 +686,11 @@ export function createTelegramDraftStream(params: {
632686
// Rewind WITHOUT deleting; the old id is captured above.
633687
resetStreamToNewMessage();
634688
if (typeof supersededMessageId === "number" && Number.isFinite(supersededMessageId)) {
635-
scheduleDetachedDelete(supersededMessageId, supersededVisibleSince, REPOSITION_DELETE_DELAY_MS);
689+
scheduleDetachedDelete(
690+
supersededMessageId,
691+
supersededVisibleSince,
692+
REPOSITION_DELETE_DELAY_MS,
693+
);
636694
return supersededMessageId;
637695
}
638696
return undefined;
@@ -651,9 +709,7 @@ export function createTelegramDraftStream(params: {
651709
return streamMessageId;
652710
};
653711

654-
const finalizeToPreview = async (
655-
preview: TelegramDraftPreview,
656-
): Promise<number | undefined> => {
712+
const finalizeToPreview = async (preview: TelegramDraftPreview): Promise<number | undefined> => {
657713
const text = preview.text.trimEnd();
658714
if (!text) {
659715
return undefined;

0 commit comments

Comments
 (0)