Skip to content

Commit a375d6c

Browse files
authored
fix(telegram): gate rich messages behind opt-in (#93279)
Restore readable standard Telegram text delivery by default after Bot API 10.1 rich messages rendered as unsupported in current clients. Keep native rich tables and structured messages available through the account-level richMessages opt-in, with account-aware capability advertising and documented structural limits. Fixes #93263.
1 parent 9dbf8f7 commit a375d6c

25 files changed

Lines changed: 1574 additions & 680 deletions

docs/channels/telegram.md

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -418,14 +418,28 @@ curl "https://api.telegram.org/bot<bot_token>/getUpdates"
418418
</Accordion>
419419

420420
<Accordion title="Rich message formatting">
421-
Outbound text uses Telegram rich messages.
421+
Outbound text uses standard Telegram HTML messages by default so replies remain readable across current Telegram clients.
422+
423+
Set `channels.telegram.richMessages: true` to opt into Bot API 10.1 rich messages:
424+
425+
```json5
426+
{
427+
channels: {
428+
telegram: {
429+
richMessages: true,
430+
},
431+
},
432+
}
433+
```
422434

423435
- Markdown text is rendered through OpenClaw's Markdown IR and sent as Telegram rich HTML.
424436
- Explicit rich HTML payloads preserve supported Bot API 10.1 tags such as headings, tables, details, rich media, and formulas.
425437
- Media captions still use Telegram HTML captions because rich messages do not replace captions.
426438

427439
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.
428440

441+
Rich messages require compatible Telegram clients. Some current Desktop, Web, Android, and third-party clients display accepted rich messages as unsupported, so keep this option disabled unless every client used with the bot can render them.
442+
429443
Link previews are enabled by default. `channels.telegram.linkPreview: false` skips automatic entity detection for rich text.
430444

431445
</Accordion>
@@ -1081,7 +1095,7 @@ Primary reference: [Configuration reference - Telegram](/gateway/config-channels
10811095
- command/menu: `commands.native`, `commands.nativeSkills`, `customCommands`
10821096
- threading/replies: `replyToMode`
10831097
- streaming: `streaming` (preview), `streaming.preview.toolProgress`, `blockStreaming`
1084-
- formatting/delivery: `textChunkLimit`, `chunkMode`, `linkPreview`, `responsePrefix`
1098+
- formatting/delivery: `textChunkLimit`, `chunkMode`, `richMessages`, `linkPreview`, `responsePrefix`
10851099
- media/network: `mediaMaxMb`, `mediaGroupFlushMs`, `timeoutSeconds`, `pollingStallThresholdMs`, `retry`, `network.autoSelectFamily`, `network.dangerouslyAllowPrivateNetwork`, `proxy`
10861100
- custom API root: `apiRoot` (Bot API root only; do not include `/bot<TOKEN>`)
10871101
- webhook: `webhookUrl`, `webhookSecret`, `webhookPath`, `webhookHost`

extensions/telegram/src/bot-core.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ import {
4949
resolveTelegramOutboundClientTimeoutFloorSeconds,
5050
} from "./client-fetch.js";
5151
import { resolveTelegramTransport } from "./fetch.js";
52+
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
5253
import { stringifyTelegramRawUpdateForLog } from "./raw-update-log.js";
5354
import { TELEGRAM_RICH_TEXT_LIMIT } from "./rich-message.js";
5455
import { createTelegramSendChatActionHandler } from "./sendchataction-401-backoff.js";
@@ -290,11 +291,13 @@ export function createTelegramBotCore(
290291
DEFAULT_GROUP_HISTORY_LIMIT,
291292
);
292293
const groupHistories = new Map<string, HistoryEntry[]>();
294+
const telegramTextLimit =
295+
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT;
293296
const textLimit = Math.min(
294297
resolveTextChunkLimit(cfg, "telegram", account.accountId, {
295-
fallbackLimit: TELEGRAM_RICH_TEXT_LIMIT,
298+
fallbackLimit: telegramTextLimit,
296299
}),
297-
TELEGRAM_RICH_TEXT_LIMIT,
300+
telegramTextLimit,
298301
);
299302
const dmPolicy = telegramCfg.dmPolicy ?? "pairing";
300303
const allowFrom = opts.allowFrom ?? telegramCfg.allowFrom;

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

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,61 @@ describe("dispatchTelegramMessage draft streaming", () => {
649649
expect(draftStream.clear).toHaveBeenCalledTimes(1);
650650
});
651651

652+
it("renders default draft previews with standard Telegram HTML", async () => {
653+
const draftStream = createDraftStream();
654+
createTelegramDraftStream.mockReturnValue(draftStream);
655+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
656+
async ({ dispatcherOptions, replyOptions }) => {
657+
await replyOptions?.onPartialReply?.({ text: "# Heading" });
658+
await dispatcherOptions.deliver({ text: "# Heading" }, { kind: "final" });
659+
return { queuedFinal: true };
660+
},
661+
);
662+
deliverReplies.mockResolvedValue({ delivered: true });
663+
664+
await dispatchWithContext({ context: createContext() });
665+
666+
const params = expectDraftStreamParams({});
667+
const renderText = params.renderText as ((text: string) => Record<string, unknown>) | undefined;
668+
expect(renderText?.("# Heading")).toEqual({
669+
text: "Heading",
670+
parseMode: "HTML",
671+
});
672+
});
673+
674+
it("renders rich draft previews only when enabled", async () => {
675+
resolveMarkdownTableMode.mockReturnValueOnce("block");
676+
const draftStream = createDraftStream();
677+
createTelegramDraftStream.mockReturnValue(draftStream);
678+
dispatchReplyWithBufferedBlockDispatcher.mockImplementation(
679+
async ({ dispatcherOptions, replyOptions }) => {
680+
await replyOptions?.onPartialReply?.({
681+
text: "| A | B |\n| --- | --- |\n| 1 | 2 |",
682+
});
683+
await dispatcherOptions.deliver(
684+
{ text: "| A | B |\n| --- | --- |\n| 1 | 2 |" },
685+
{ kind: "final" },
686+
);
687+
return { queuedFinal: true };
688+
},
689+
);
690+
deliverReplies.mockResolvedValue({ delivered: true });
691+
692+
await dispatchWithContext({
693+
context: createContext(),
694+
telegramCfg: { richMessages: true },
695+
});
696+
697+
const params = expectDraftStreamParams({ richMessages: true });
698+
const renderText = params.renderText as ((text: string) => Record<string, unknown>) | undefined;
699+
const preview = renderText?.("| A | B |\n| --- | --- |\n| 1 | 2 |");
700+
expect(preview?.richMessage).toEqual(
701+
expect.objectContaining({
702+
html: expect.stringContaining("<table>"),
703+
}),
704+
);
705+
});
706+
652707
it("recovers forum thread context from a topic-scoped session key", async () => {
653708
const recordInboundSession = vi.fn(async () => undefined);
654709
const oldHistoryKey = "-1003774691294:topic:1";
@@ -1521,7 +1576,7 @@ describe("dispatchTelegramMessage draft streaming", () => {
15211576
telegramCfg: { streaming: { mode: "partial" } },
15221577
});
15231578

1524-
expectDraftStreamParams({ maxChars: 4096 });
1579+
expectDraftStreamParams({ maxChars: 4000 });
15251580
});
15261581

15271582
it("streams text-only finals into the answer message", async () => {

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

Lines changed: 22 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -107,6 +107,7 @@ import {
107107
shouldSuppressTelegramError,
108108
} from "./error-policy.js";
109109
import { shouldSuppressLocalTelegramExecApprovalPrompt } from "./exec-approvals.js";
110+
import { renderTelegramHtmlText } from "./format.js";
110111
import { includesRecentTelegramGroupHistoryContext } from "./group-history-context.js";
111112
import { beginTelegramInboundEventDeliveryCorrelation } from "./inbound-event-delivery.js";
112113
import {
@@ -116,6 +117,7 @@ import {
116117
type LaneDeliveryResult,
117118
type LaneName,
118119
} from "./lane-delivery.js";
120+
import { TELEGRAM_TEXT_CHUNK_LIMIT } from "./outbound-adapter.js";
119121
import { recordOutboundMessageForPromptContext } from "./outbound-message-context.js";
120122
import {
121123
createTelegramReasoningStepState,
@@ -891,20 +893,29 @@ export const dispatchTelegramMessage = async ({
891893
const draftMaxChars =
892894
streamMode === "block"
893895
? Math.min(resolveTelegramDraftStreamingChunking(cfg, route.accountId).maxChars, textLimit)
894-
: Math.min(textLimit, TELEGRAM_RICH_TEXT_LIMIT);
896+
: Math.min(
897+
textLimit,
898+
telegramCfg.richMessages === true ? TELEGRAM_RICH_TEXT_LIMIT : TELEGRAM_TEXT_CHUNK_LIMIT,
899+
);
895900
const tableMode = resolveMarkdownTableMode({
896901
cfg,
897902
channel: "telegram",
898903
accountId: route.accountId,
899-
supportsBlockTables: true,
900-
});
901-
const renderStreamText = (text: string) => ({
902-
text,
903-
richMessage: buildTelegramRichMarkdown(text, {
904-
tableMode,
905-
skipEntityDetection: telegramCfg.linkPreview === false,
906-
}),
904+
supportsBlockTables: telegramCfg.richMessages === true,
907905
});
906+
const renderStreamText = (text: string): TelegramDraftPreview =>
907+
telegramCfg.richMessages === true
908+
? {
909+
text,
910+
richMessage: buildTelegramRichMarkdown(text, {
911+
tableMode,
912+
skipEntityDetection: telegramCfg.linkPreview === false,
913+
}),
914+
}
915+
: {
916+
text: renderTelegramHtmlText(text, { tableMode }),
917+
parseMode: "HTML",
918+
};
908919
const accountBlockStreamingEnabled =
909920
resolveChannelStreamingBlockEnabled(telegramCfg) ??
910921
cfg.agents?.defaults?.blockStreamingDefault === "on";
@@ -988,6 +999,7 @@ export const dispatchTelegramMessage = async ({
988999
maxChars: draftMaxChars,
9891000
thread: threadSpec,
9901001
replyToMessageId: draftReplyToMessageId,
1002+
richMessages: telegramCfg.richMessages,
9911003
minInitialChars: draftMinInitialChars,
9921004
renderText: renderStreamText,
9931005
onSupersededPreview: (superseded) => {
@@ -1507,6 +1519,7 @@ export const dispatchTelegramMessage = async ({
15071519
thread: threadSpec,
15081520
tableMode,
15091521
chunkMode,
1522+
richMessages: telegramCfg.richMessages,
15101523
linkPreview: telegramCfg.linkPreview,
15111524
replyQuoteMessageId,
15121525
replyQuoteText,

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -703,6 +703,25 @@ describe("registerTelegramNativeCommands", () => {
703703
expect(replyAt(deliverParams).isError).toBe(true);
704704
});
705705

706+
it("uses rich messages for plugin command replies when enabled", async () => {
707+
const { handler } = registerPlugCommand({
708+
cfg: {
709+
channels: {
710+
telegram: {
711+
richMessages: true,
712+
},
713+
},
714+
},
715+
registerOverrides: {
716+
telegramCfg: { richMessages: true } as TelegramAccountConfig,
717+
},
718+
});
719+
720+
await handler(createPrivateCommandContext());
721+
722+
expect(firstDeliverRepliesParams().richMessages).toBe(true);
723+
});
724+
706725
it("forwards topic-scoped binding context to Telegram plugin commands", async () => {
707726
const { handler } = registerPlugCommand();
708727

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

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -973,6 +973,7 @@ export const registerTelegramNativeCommands = ({
973973
tableMode: ReturnType<typeof resolveMarkdownTableMode>;
974974
chunkMode: TelegramChunkMode;
975975
linkPreview?: boolean;
976+
richMessages?: boolean;
976977
}) => ({
977978
cfg: params.cfg,
978979
chatId: String(params.chatId),
@@ -992,6 +993,7 @@ export const registerTelegramNativeCommands = ({
992993
tableMode: params.tableMode,
993994
chunkMode: params.chunkMode,
994995
linkPreview: params.linkPreview,
996+
richMessages: params.richMessages,
995997
});
996998
const resolveCommandTargetSessionKey = (params: {
997999
runtimeCfg: OpenClawConfig;
@@ -1209,6 +1211,7 @@ export const registerTelegramNativeCommands = ({
12091211
tableMode,
12101212
chunkMode,
12111213
linkPreview: runtimeTelegramCfg.linkPreview,
1214+
richMessages: runtimeTelegramCfg.richMessages,
12121215
});
12131216
let topicName: string | undefined;
12141217
if (isForum && resolvedThreadId != null) {
@@ -1431,6 +1434,7 @@ export const registerTelegramNativeCommands = ({
14311434
tableMode,
14321435
chunkMode,
14331436
linkPreview: runtimeTelegramCfg.linkPreview,
1437+
richMessages: runtimeTelegramCfg.richMessages,
14341438
});
14351439
const from = isGroup ? buildTelegramGroupFrom(chatId, threadSpec.id) : `telegram:${chatId}`;
14361440
const to = `telegram:${chatId}`;

0 commit comments

Comments
 (0)