Skip to content

Commit da92615

Browse files
authored
feat(telegram): send rich messages as rich html (#93286)
* feat(telegram): render rich messages through rich html * docs(telegram): teach agents rich formatting * fix(telegram): bound rich draft payloads (#93286) * fix(telegram): narrow rich draft payload type (#93286) * fix(telegram): preserve rich table cell formatting (#93286) * fix(telegram): honor rich table mode config (#93286) * fix(telegram): default rich markdown tables (#93286) * fix(telegram): gate rich table block mode (#93286) * fix(telegram): normalize raw rich html limits (#93286) * fix(telegram): preserve link preview suppression (#93286) * fix(telegram): preserve rich markdown headings (#93286) * fix(telegram): reject unsupported rich media sources (#93286) * fix(telegram): honor link preview off in rich chunks (#93286) * fix(telegram): avoid double escaping markdown media (#93286) * fix(telegram): render markdown media via placeholders (#93286) * fix(telegram): preserve table text in prompt context (#93286)
1 parent 767e828 commit da92615

25 files changed

Lines changed: 1020 additions & 387 deletions

docs/channels/telegram.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -403,11 +403,11 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
403403
<Accordion title="Rich message formatting">
404404
Outbound text uses Telegram rich messages.
405405

406-
- Markdown text is sent as rich Markdown without converting it to HTML.
407-
- Explicit HTML payloads are sent as rich HTML.
406+
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
407+
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
408408
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
409409

410-
Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
410+
This keeps model text away from Telegram Rich Markdown sigils, so currency like `$400-600K` is not parsed as math. Long rich text is split automatically across Telegram's rich text and rich block limits. Tables over Telegram's column limit are sent as code blocks.
411411

412412
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
413413

extensions/telegram/src/bot-message-dispatch.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -882,10 +882,14 @@ export const dispatchTelegramMessage = async ({
882882
cfg,
883883
channel: "telegram",
884884
accountId: route.accountId,
885+
supportsBlockTables: true,
885886
});
886887
const renderStreamText = (text: string) => ({
887888
text,
888-
richMessage: buildTelegramRichMarkdown(text),
889+
richMessage: buildTelegramRichMarkdown(text, {
890+
tableMode,
891+
skipEntityDetection: telegramCfg.linkPreview === false,
892+
}),
889893
});
890894
const accountBlockStreamingEnabled =
891895
resolveChannelStreamingBlockEnabled(telegramCfg) ??

extensions/telegram/src/bot-native-commands.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -951,6 +951,7 @@ export const registerTelegramNativeCommands = ({
951951
cfg: runtimeCfg,
952952
channel: "telegram",
953953
accountId: route.accountId,
954+
supportsBlockTables: true,
954955
});
955956
const chunkMode = nativeCommandRuntime.resolveChunkMode(
956957
runtimeCfg,

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

Lines changed: 28 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,11 @@ import { resolveTelegramInlineButtons, type TelegramInlineButtons } from "../but
3333
import { splitTelegramCaption } from "../caption.js";
3434
import { renderTelegramHtmlText } from "../format.js";
3535
import { resolveTelegramInteractiveTextFallback } from "../interactive-fallback.js";
36-
import { splitTelegramRichMarkdownChunks, TELEGRAM_RICH_TEXT_LIMIT } from "../rich-message.js";
36+
import {
37+
splitTelegramRichMessageTextChunks,
38+
TELEGRAM_RICH_TEXT_LIMIT,
39+
type TelegramRichTextChunk,
40+
} from "../rich-message.js";
3741
import { buildInlineKeyboard } from "../send.js";
3842
import { resolveTelegramVoiceSend } from "../voice.js";
3943
import {
@@ -71,23 +75,23 @@ type TelegramReplyQuoteForSend = {
7175
entities?: unknown[];
7276
};
7377

74-
type TelegramTextChunk = {
75-
text: string;
76-
};
77-
78-
type ChunkTextFn = (markdown: string) => TelegramTextChunk[];
78+
type ChunkTextFn = (markdown: string) => TelegramRichTextChunk[];
7979

8080
function buildChunkTextResolver(params: {
8181
textLimit: number;
8282
chunkMode: ChunkMode;
8383
tableMode?: MarkdownTableMode;
84+
skipEntityDetection?: boolean;
8485
}): ChunkTextFn {
8586
return (markdown: string) => {
86-
return splitTelegramRichMarkdownChunks(markdown, params.textLimit, params.chunkMode).map(
87-
(text) => ({
88-
text,
89-
}),
90-
);
87+
return splitTelegramRichMessageTextChunks({
88+
text: markdown,
89+
textLimit: params.textLimit,
90+
textMode: "markdown",
91+
chunkMode: params.chunkMode,
92+
tableMode: params.tableMode,
93+
skipEntityDetection: params.skipEntityDetection,
94+
});
9195
};
9296
}
9397

@@ -156,6 +160,7 @@ async function deliverTextReply(params: {
156160
replyQuoteEntities?: unknown[];
157161
linkPreview?: boolean;
158162
silent?: boolean;
163+
tableMode?: MarkdownTableMode;
159164
replyToId?: number;
160165
replyToMode: ReplyToMode;
161166
progress: DeliveryProgress;
@@ -183,8 +188,9 @@ async function deliverTextReply(params: {
183188
replyQuotePosition: params.replyQuotePosition,
184189
replyQuoteEntities: params.replyQuoteEntities,
185190
thread: params.thread,
186-
textMode: "markdown",
191+
textMode: chunk.textMode,
187192
linkPreview: params.linkPreview,
193+
tableMode: params.tableMode,
188194
silent: params.silent,
189195
replyMarkup,
190196
},
@@ -207,6 +213,7 @@ async function sendPendingFollowUpText(params: {
207213
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
208214
linkPreview?: boolean;
209215
silent?: boolean;
216+
tableMode?: MarkdownTableMode;
210217
replyToId?: number;
211218
replyToMode: ReplyToMode;
212219
progress: DeliveryProgress;
@@ -223,8 +230,9 @@ async function sendPendingFollowUpText(params: {
223230
await sendTelegramText(params.bot, params.chatId, chunk.text, params.runtime, {
224231
replyToMessageId,
225232
thread: params.thread,
226-
textMode: "markdown",
233+
textMode: chunk.textMode,
227234
linkPreview: params.linkPreview,
235+
tableMode: params.tableMode,
228236
silent: params.silent,
229237
replyMarkup,
230238
});
@@ -261,14 +269,15 @@ async function sendTelegramVoiceFallbackText(opts: {
261269
chatId: string;
262270
runtime: RuntimeEnv;
263271
text: string;
264-
chunkText: (markdown: string) => TelegramTextChunk[];
272+
chunkText: ChunkTextFn;
265273
replyToId?: number;
266274
replyQuoteMessageId?: number;
267275
replyQuotePosition?: number;
268276
replyQuoteEntities?: unknown[];
269277
thread?: TelegramThreadSpec | null;
270278
linkPreview?: boolean;
271279
silent?: boolean;
280+
tableMode?: MarkdownTableMode;
272281
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
273282
replyQuoteText?: string;
274283
}): Promise<number | undefined> {
@@ -286,8 +295,9 @@ async function sendTelegramVoiceFallbackText(opts: {
286295
replyQuotePosition: applyQuoteForChunk ? opts.replyQuotePosition : undefined,
287296
replyQuoteEntities: applyQuoteForChunk ? opts.replyQuoteEntities : undefined,
288297
thread: opts.thread,
289-
textMode: "markdown",
298+
textMode: chunk.textMode,
290299
linkPreview: opts.linkPreview,
300+
tableMode: opts.tableMode,
291301
silent: opts.silent,
292302
replyMarkup: !appliedReplyTo ? opts.replyMarkup : undefined,
293303
});
@@ -552,6 +562,7 @@ async function deliverMediaReply(params: {
552562
replyMarkup: params.replyMarkup,
553563
linkPreview: params.linkPreview,
554564
silent: params.silent,
565+
tableMode: params.tableMode,
555566
replyToId: params.replyToId,
556567
replyToMode: params.replyToMode,
557568
progress: params.progress,
@@ -717,6 +728,7 @@ export async function deliverReplies(params: {
717728
textLimit: Math.min(params.textLimit, TELEGRAM_RICH_TEXT_LIMIT),
718729
chunkMode: params.chunkMode ?? "length",
719730
tableMode: params.tableMode,
731+
skipEntityDetection: params.linkPreview === false,
720732
});
721733
const candidateReplies: ReplyPayload[] = [];
722734
for (const reply of params.replies) {
@@ -837,6 +849,7 @@ export async function deliverReplies(params: {
837849
replyQuoteEntities: replyQuote.entities,
838850
linkPreview: params.linkPreview,
839851
silent: params.silent,
852+
tableMode: params.tableMode,
840853
replyToId,
841854
replyToMode: params.replyToMode,
842855
progress,

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
// Telegram plugin module implements delivery.send behavior.
22
import { type Bot, GrammyError } from "grammy";
3+
import type { MarkdownTableMode } from "openclaw/plugin-sdk/config-contracts";
34
import { createTelegramRetryRunner } from "openclaw/plugin-sdk/retry-runtime";
45
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime-env";
56
import { formatErrorMessage } from "openclaw/plugin-sdk/ssrf-runtime";
@@ -100,6 +101,7 @@ export async function sendTelegramText(
100101
thread?: TelegramThreadSpec | null;
101102
textMode?: "markdown" | "html";
102103
linkPreview?: boolean;
104+
tableMode?: MarkdownTableMode;
103105
silent?: boolean;
104106
replyMarkup?: ReturnType<typeof buildInlineKeyboard>;
105107
},
@@ -117,6 +119,7 @@ export async function sendTelegramText(
117119
const textMode = opts?.textMode ?? "markdown";
118120
const richMessage = buildTelegramRichMessage(text, textMode, {
119121
skipEntityDetection: opts?.linkPreview === false,
122+
tableMode: opts?.tableMode,
120123
});
121124
const richRawApi = getTelegramRichRawApi(bot.api);
122125

extensions/telegram/src/channel.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -790,6 +790,7 @@ export const telegramPlugin = createChatChannelPlugin({
790790
},
791791
},
792792
messaging: {
793+
defaultMarkdownTableMode: "block",
793794
targetPrefixes: ["telegram", "tg"],
794795
normalizeTarget: normalizeTelegramMessagingTarget,
795796
resolveInboundConversation: ({ to, conversationId, threadId }) =>

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

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import type { Bot } from "grammy";
33
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
44
import { createTelegramDraftStream } from "./draft-stream.js";
5+
import { markdownToTelegramRichHtml } from "./format.js";
56

67
type TelegramDraftStreamParams = Parameters<typeof createTelegramDraftStream>[0];
78

@@ -48,7 +49,7 @@ async function expectInitialForumSend(
4849
await vi.waitFor(() =>
4950
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
5051
chat_id: 123,
51-
rich_message: { markdown: text },
52+
rich_message: { html: markdownToTelegramRichHtml(text) },
5253
message_thread_id: 99,
5354
}),
5455
);
@@ -61,7 +62,7 @@ function expectRichSend(
6162
) {
6263
expect(api.raw.sendRichMessage).toHaveBeenCalledWith({
6364
chat_id: 123,
64-
rich_message: { markdown: text },
65+
rich_message: { html: markdownToTelegramRichHtml(text) },
6566
...params,
6667
});
6768
}
@@ -74,7 +75,7 @@ function expectNthRichSend(
7475
) {
7576
expect(api.raw.sendRichMessage).toHaveBeenNthCalledWith(call, {
7677
chat_id: 123,
77-
rich_message: { markdown: text },
78+
rich_message: { html: markdownToTelegramRichHtml(text) },
7879
...params,
7980
});
8081
}
@@ -83,7 +84,7 @@ function expectRichEdit(api: ReturnType<typeof createMockDraftApi>, text: string
8384
expect(api.raw.editMessageText).toHaveBeenCalledWith({
8485
chat_id: 123,
8586
message_id: 17,
86-
rich_message: { markdown: text },
87+
rich_message: { html: markdownToTelegramRichHtml(text) },
8788
});
8889
}
8990

@@ -396,7 +397,7 @@ describe("createTelegramDraftStream", () => {
396397
expect(api.raw.editMessageText).not.toHaveBeenCalledWith({
397398
chat_id: 123,
398399
message_id: 17,
399-
rich_message: { markdown: "Message B partial" },
400+
rich_message: { html: markdownToTelegramRichHtml("Message B partial") },
400401
});
401402
});
402403

@@ -471,7 +472,7 @@ describe("createTelegramDraftStream", () => {
471472
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
472473
chat_id: 123,
473474
message_id: 17,
474-
rich_message: { markdown: "Hello more" },
475+
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
475476
});
476477
expect(warn).not.toHaveBeenCalled();
477478
});
@@ -498,7 +499,7 @@ describe("createTelegramDraftStream", () => {
498499
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
499500
chat_id: 123,
500501
message_id: 17,
501-
rich_message: { markdown: "Hello again" },
502+
rich_message: { html: markdownToTelegramRichHtml("Hello again") },
502503
});
503504
expect(stream.lastDeliveredText?.()).toBe("Hello again");
504505
});
@@ -530,7 +531,7 @@ describe("createTelegramDraftStream", () => {
530531
expect(api.raw.editMessageText).toHaveBeenLastCalledWith({
531532
chat_id: 123,
532533
message_id: 17,
533-
rich_message: { markdown: "Hello more" },
534+
rich_message: { html: markdownToTelegramRichHtml("Hello more") },
534535
});
535536
} finally {
536537
vi.useRealTimers();
@@ -637,7 +638,7 @@ describe("createTelegramDraftStream", () => {
637638
chatId: 123,
638639
renderText: (value) => ({
639640
text: value,
640-
richMessage: { markdown: value },
641+
richMessage: { html: markdownToTelegramRichHtml(value) },
641642
}),
642643
});
643644

@@ -646,7 +647,7 @@ describe("createTelegramDraftStream", () => {
646647

647648
expect(richApi.sendRichMessage).toHaveBeenCalledWith({
648649
chat_id: 123,
649-
rich_message: { markdown: text.trimEnd() },
650+
rich_message: { html: markdownToTelegramRichHtml(text.trimEnd()) },
650651
});
651652
expect(api.sendMessage).not.toHaveBeenCalled();
652653
});
@@ -797,7 +798,7 @@ describe("createTelegramDraftStream", () => {
797798
chatId: 123,
798799
maxChars: 100,
799800
renderText: () => ({
800-
text: `<b>${"<".repeat(120)}</b>`,
801+
text: "short raw text",
801802
richMessage: { html: `<b>${"<".repeat(120)}</b>` },
802803
}),
803804
warn,

extensions/telegram/src/draft-stream.ts

Lines changed: 13 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -79,6 +79,11 @@ function telegramDraftPreviewKey(preview: TelegramDraftPreview): string {
7979
return JSON.stringify(preview.richMessage);
8080
}
8181

82+
function telegramDraftPreviewPayloadLength(preview: TelegramDraftPreview): number {
83+
const richMessage = preview.richMessage;
84+
return richMessage.html !== undefined ? richMessage.html.length : richMessage.markdown.length;
85+
}
86+
8287
function findTelegramDraftChunkLength(
8388
text: string,
8489
maxChars: number,
@@ -89,8 +94,8 @@ function findTelegramDraftChunkLength(
8994
let high = text.length;
9095
while (low <= high) {
9196
const mid = Math.floor((low + high) / 2);
92-
const renderedText = renderTelegramDraftPreview(text.slice(0, mid), renderText).text.trimEnd();
93-
if (renderedText && renderedText.length <= maxChars) {
97+
const preview = renderTelegramDraftPreview(text.slice(0, mid), renderText);
98+
if (preview.text.trimEnd() && telegramDraftPreviewPayloadLength(preview) <= maxChars) {
9499
best = mid;
95100
low = mid + 1;
96101
} else {
@@ -206,11 +211,9 @@ export function createTelegramDraftStream(params: {
206211
streamVisibleSinceMs = visibleSinceMs;
207212
return true;
208213
};
209-
const stopOversizedPreview = (renderedText: string): false => {
214+
const stopOversizedPreview = (payloadLength: number): false => {
210215
streamState.stopped = true;
211-
params.warn?.(
212-
`telegram stream preview stopped (text length ${renderedText.length} > ${maxChars})`,
213-
);
216+
params.warn?.(`telegram stream preview stopped (text length ${payloadLength} > ${maxChars})`);
214217
return false;
215218
};
216219

@@ -239,18 +242,19 @@ export function createTelegramDraftStream(params: {
239242
const renderedText = rendered.text.trimEnd();
240243
const renderedPreview = { ...rendered, text: renderedText };
241244
const renderedPreviewKey = telegramDraftPreviewKey(renderedPreview);
245+
const renderedPayloadLength = telegramDraftPreviewPayloadLength(renderedPreview);
242246
if (!renderedText) {
243247
return false;
244248
}
245-
if (renderedText.length > maxChars) {
249+
if (renderedPayloadLength > maxChars) {
246250
const chunkLength = findTelegramDraftChunkLength(currentText, maxChars, params.renderText);
247251
if (!streamState.final) {
248252
if (chunkLength > 0) {
249253
return await sendOrEditStreamMessage(
250254
trimmed.slice(0, deliveredTextOffset) + currentText.slice(0, chunkLength),
251255
);
252256
}
253-
return stopOversizedPreview(renderedText);
257+
return stopOversizedPreview(renderedPayloadLength);
254258
}
255259
if (lastDeliveredText.length > deliveredTextOffset) {
256260
const supersededMessageId = streamMessageId;
@@ -277,7 +281,7 @@ export function createTelegramDraftStream(params: {
277281
}
278282
return await sendOrEditStreamMessage(trimmed);
279283
}
280-
return stopOversizedPreview(renderedText);
284+
return stopOversizedPreview(renderedPayloadLength);
281285
}
282286
if (renderedPreviewKey === lastSentPreviewKey) {
283287
return true;

0 commit comments

Comments
 (0)