Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/telegram-rich-message-escaping.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@chat-adapter/telegram": patch
---

Rich messages now escape `$` outside code and send single line breaks as hard breaks. Telegram's rich-message parser reads `$...$` as inline math, so text such as `~$950 (-3.3% from $983.23)` rendered as a formula, and it joined lines separated by a single newline into one paragraph. Both `{ markdown }` and `{ ast }` posts, edits, and streamed rich messages now keep dollar amounts and line-per-item text as written. Inline code, code blocks, link URLs, and autolinks are unchanged. Markdown that relied on `$...$` to render a formula now shows the dollar signs as text.
2 changes: 1 addition & 1 deletion apps/docs/content/adapters/official/telegram.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -275,7 +275,7 @@ If Telegram rate limits the final edit, the adapter waits and retries when the r

## Markdown formatting

On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages. Standard markdown gains native headings, lists, tables, task lists, formulas, details, and separate media blocks where supported by Telegram.
On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages. Standard markdown gains native headings, lists, tables, task lists, details, and separate media blocks where supported by Telegram. The adapter escapes `$` outside code so Telegram doesn't read it as a formula delimiter, and turns single line breaks into hard breaks so each line stays on its own line, as it does in the MarkdownV2 path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: formulas aren't actually gone. Fenced ```math blocks are code nodes, so they pass through unescaped, and the Bot API renders them as formulas (as does inline <tg-math>). Consider keeping formulas in the list and saying they are written with a ```math block rather than $...$. Same for the README.


Plain strings, raw messages, cards, and media captions retain their existing lightweight message paths. Cards and captions use Telegram's `MarkdownV2` parse mode with context-aware escaping. Older or custom Bot API servers automatically fall back to this existing path when rich message methods are unavailable.

Expand Down
2 changes: 1 addition & 1 deletion packages/adapter-telegram/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -309,7 +309,7 @@ If Telegram rate limits the final edit, the adapter waits and retries when the r

## Markdown formatting

On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages, including native headings, lists, tables, task lists, formulas, details, and separate media blocks supported by the Bot API.
On Telegram Bot API 10.1 and newer, explicit `{ markdown }` and `{ ast }` messages use rich messages, including native headings, lists, tables, task lists, details, and separate media blocks supported by the Bot API. The adapter escapes `$` outside code so Telegram doesn't read it as a formula delimiter, and turns single line breaks into hard breaks so each line stays on its own line, as it does in the MarkdownV2 path.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same note as the docs page: formulas still work via ```math fenced blocks.


Plain strings, raw messages, cards, and media captions retain their existing lightweight message paths. Cards and captions use Telegram's `MarkdownV2` parse mode with context-aware escaping. If an older or custom Bot API server does not support rich message methods, the adapter automatically falls back to the existing MarkdownV2 path.

Expand Down
36 changes: 36 additions & 0 deletions packages/adapter-telegram/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3915,6 +3915,42 @@ describe("TelegramAdapter", () => {
expect(sendMessageBody.rich_message.markdown).toBe("**bold** and _italic_");
});

it("escapes dollar signs and line breaks in rich messages", async () => {
mockFetch
.mockResolvedValueOnce(
telegramOk({
id: 999,
is_bot: true,
first_name: "Bot",
username: "mybot",
})
)
.mockResolvedValueOnce(telegramOk(sampleMessage()));

const adapter = createTelegramAdapter({
botToken: "token",
mode: "webhook",
logger: mockLogger,
userName: "mybot",
});

await adapter.initialize(createMockChat());

await adapter.postMessage("telegram:123", {
markdown: "MU ~$950 (-3.3%)\nTSLA ~$403",
});

const sendMessageUrl = String(mockFetch.mock.calls[1]?.[0]);
const sendMessageBody = JSON.parse(
String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
) as { rich_message: { markdown: string } };

expect(sendMessageUrl).toContain("/sendRichMessage");
expect(sendMessageBody.rich_message.markdown).toBe(
"MU ~\\$950 (-3.3%)\\\nTSLA ~\\$403"
);
});

it("uses rich messages for AST messages", async () => {
mockFetch
.mockResolvedValueOnce(
Expand Down
18 changes: 12 additions & 6 deletions packages/adapter-telegram/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import {
truncateForTelegram,
} from "./markdown";
import {
escapeRichMarkdown,
fitsRichMessage,
richMessageMedia,
richMessageToMarkdown,
Expand Down Expand Up @@ -2470,7 +2471,7 @@ export class TelegramAdapter

let draftText: string;
if (streamUsesRich) {
draftText = truncateRichMarkdown(renderer.render());
draftText = truncateRichMarkdown(escapeRichMarkdown(renderer.render()));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Removing escapeRichMarkdown here keeps all 524 tests passing. Please cover the draft path, e.g. stream "MU ~$950\nTSLA ~$403" and assert the sendRichMessageDraft body's markdown is "MU ~\\$950\\\nTSLA ~\\$403".

} else if (streamUsesMarkdown) {
draftText = renderMarkdownText(renderer.render());
} else {
Expand Down Expand Up @@ -2510,7 +2511,7 @@ export class TelegramAdapter
await flushDraft();

if (streamUsesRich) {
const markdown = truncateRichMarkdown(finalMarkdown);
const markdown = truncateRichMarkdown(escapeRichMarkdown(finalMarkdown));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here: removing the escape on the final streamed sendRichMessage is not caught by any test. The same stream test can assert the final body too.

try {
const raw = await this.telegramFetch<TelegramMessage>(
"sendRichMessage",
Expand Down Expand Up @@ -3958,14 +3959,19 @@ export class TelegramAdapter

/**
* Render the untruncated rich-message markdown for a `{ markdown }` or
* `{ ast }` message, with emoji placeholders resolved.
* `{ ast }` message, with emoji placeholders resolved and text escaped for
* Telegram's rich-message parser.
*/
protected renderRichMarkdown(
message: { markdown: string } | { ast: Root }
): string {
return convertEmojiPlaceholders(
"markdown" in message ? message.markdown : stringifyMarkdown(message.ast),
"gchat"
return escapeRichMarkdown(
convertEmojiPlaceholders(
"markdown" in message
? message.markdown
: stringifyMarkdown(message.ast),
"gchat"
)
);
}

Expand Down
46 changes: 46 additions & 0 deletions packages/adapter-telegram/src/rich.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { parseMarkdown, toPlainText } from "chat";
import { describe, expect, it } from "vitest";
import {
escapeRichMarkdown,
fitsRichMessage,
richMessageToMarkdown,
richMessageToText,
Expand Down Expand Up @@ -289,3 +290,48 @@ describe("Telegram rich messages", () => {
expect(truncateRichMarkdown(markdown)).toContain("| tail |");
});
});

describe("escapeRichMarkdown", () => {
it("escapes dollar signs so Telegram does not render them as math", () => {
expect(
escapeRichMarkdown("MU ~$950 (-3.3% from $983.23). TSLA ~$403")
).toBe("MU ~\\$950 (-3.3% from \\$983.23). TSLA ~\\$403");
});

it("escapes dollar signs inside formatting and link text", () => {
expect(escapeRichMarkdown("**$5** and [$10](https://example.com/$x)")).toBe(
"**\\$5** and [\\$10](https://example.com/$x)"
);
});

it("keeps dollar signs that are already escaped", () => {
expect(escapeRichMarkdown("\\$5 and \\\\$6")).toBe("\\$5 and \\\\\\$6");
});

it("leaves inline code, code blocks, and autolinks unchanged", () => {
const markdown =
"Run `echo $HOME`\n\n```sh\nexport A=$B\necho $A\n```\n\n<https://example.com/$a>";
expect(escapeRichMarkdown(markdown)).toBe(markdown);
});

it("turns soft line breaks into hard breaks", () => {
expect(escapeRichMarkdown("Prices:\n• a\n• b")).toBe(
"Prices:\\\n• a\\\n• b"
);
});

it("keeps paragraph breaks and existing hard breaks", () => {
expect(escapeRichMarkdown("a\\\nb \nc\n\nd")).toBe("a\\\nb \nc\n\nd");
});

it("adds hard breaks inside blockquotes and list items", () => {
expect(escapeRichMarkdown("> a\n> b\n\n- c\n d")).toBe(
"> a\\\n> b\n\n- c\\\n d"
);
});

it("does not add hard breaks inside code blocks", () => {
const markdown = "```\none\ntwo\n```";
expect(escapeRichMarkdown(markdown)).toBe(markdown);
});
});
60 changes: 58 additions & 2 deletions packages/adapter-telegram/src/rich.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { Attachment } from "chat";
import { StreamingMarkdownRenderer } from "chat";
import type { Attachment, Nodes } from "chat";
import { parseMarkdown, StreamingMarkdownRenderer } from "chat";
import type {
TelegramFile,
TelegramRichBlock,
Expand All @@ -14,6 +14,7 @@ export const TELEGRAM_RICH_MESSAGE_LIMIT = 32_768;
const MARKDOWN_PUNCTUATION = /[!-/:-@[-`{-~]/g;
const LINE_BREAKS = /[\r\n]/g;
const BACKTICKS = /`+/g;
const RICH_TEXT_SPECIALS = /\\[\s\S]|\$|\r?\n/g;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: CRLF handling is untested (changing \r?\n to \n survives the suite), and a bare \r line ending, which CommonMark also treats as a line ending, isn't converted ("a\rb" comes back unchanged). Worth a "a\r\nb" test, and either handling \r or noting it's intentionally unsupported.


/**
* Whether `markdown` fits in one rich message, which Telegram measures in
Expand All @@ -27,6 +28,61 @@ export function fitsRichMessage(markdown: string): boolean {
return Array.from(markdown).length <= TELEGRAM_RICH_MESSAGE_LIMIT;
}

function collectTextRanges(
node: Nodes,
markdown: string,
ranges: [number, number][]
): void {
const start = node.position?.start.offset;
const end = node.position?.end.offset;
if (node.type === "text") {
if (start !== undefined && end !== undefined) {
ranges.push([start, end]);
}
return;
}
// Autolinks (`<https://...>`, bare URLs and emails) keep their text as is.
if (node.type === "link" && markdown[start ?? 0] !== "[") {
return;
}
if ("children" in node) {
for (const child of node.children) {
collectTextRanges(child, markdown, ranges);
Comment on lines +48 to +50

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Escaping dollar signs or soft newlines inside shortcut and collapsed reference-link labels changes their lookup identifiers, causing valid links to become literal text instead of resolving to their definitions.

Fix on Vercel

}
}
}

function escapeRichText(source: string): string {
return source.replace(RICH_TEXT_SPECIALS, (match) => {
if (match === "$") {
return "\\$";
}
if (match.endsWith("\n")) {
return `\\${match}`;
}
return match;
});
}

/**
* Adapt CommonMark to Telegram's rich-message parser. Telegram reads `$...$`
* as inline math and joins soft line breaks, so text outside code escapes
* `$` and ends each soft-wrapped line with a backslash hard break.
*/
export function escapeRichMarkdown(markdown: string): string {
const ranges: [number, number][] = [];
collectTextRanges(parseMarkdown(markdown), markdown, ranges);

let result = "";
let cursor = 0;
for (const [start, end] of ranges) {
result += markdown.slice(cursor, start);
result += escapeRichText(markdown.slice(start, end));
cursor = end;
}
return result + markdown.slice(cursor);
}

export function truncateRichMarkdown(markdown: string): string {
if (fitsRichMessage(markdown)) {
return markdown;
Expand Down