Skip to content
Draft
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
16 changes: 16 additions & 0 deletions .changeset/telegram-markdown-v2-escape.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
---
"@chat-adapter/telegram": minor
---

Fix Telegram rejecting messages with `Bad Request: can't parse entities` when the text contained reserved characters like `.`, `(`, `)`, `-`, `|`, `!`, `+`, `=`, `{`, `}`, `#`. This happened on almost every LLM reply because periods and parentheses appear in normal prose.

The adapter now uses `parse_mode: "MarkdownV2"` (the modern Telegram parse mode) and walks the mdast AST directly to emit properly escaped output:

- **Regular text** β€” escapes all 18 MarkdownV2 reserved characters: `_ * [ ] ( ) ~ \` > # + - = | { } . !`
- **Inline and fenced code** β€” escapes only `` ` `` and `\` (per spec)
- **Link URLs** β€” escapes only `)` and `\` inside the `(...)` portion
- **Formatting entities** β€” bold `*…*`, italic `_…_`, underline `__…__`, strikethrough `~…~`, headings render as bold
- **Lists** β€” bullets emitted as `\-`, ordered numerals as `N\.`
- **Thematic breaks** β€” emitted as `\-\-\-`

Reference: [Telegram Bot API β€” Formatting options](https://core.telegram.org/bots/api#formatting-options). See `packages/adapter-telegram/docs/markdown-v2.md` for the full rule set.
5 changes: 3 additions & 2 deletions packages/adapter-telegram/src/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -973,7 +973,7 @@ describe("TelegramAdapter", () => {
String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
) as { parse_mode?: string };

expect(sendMessageBody.parse_mode).toBe("Markdown");
expect(sendMessageBody.parse_mode).toBe("MarkdownV2");
});

it("posts cards with inline keyboard buttons", async () => {
Expand Down Expand Up @@ -1023,6 +1023,7 @@ describe("TelegramAdapter", () => {
const sendMessageBody = JSON.parse(
String((mockFetch.mock.calls[1]?.[1] as RequestInit).body)
) as {
parse_mode?: string;
reply_markup?: {
inline_keyboard: Array<
Array<{ text: string; callback_data?: string; url?: string }>
Expand All @@ -1032,7 +1033,7 @@ describe("TelegramAdapter", () => {

const row = sendMessageBody.reply_markup?.inline_keyboard[0];
expect(row).toBeDefined();
expect(sendMessageBody.parse_mode).toBe("Markdown");
expect(sendMessageBody.parse_mode).toBe("MarkdownV2");
expect(row?.[0]).toEqual({
text: "Approve",
callback_data: encodeTelegramCallbackData("approve", "request-123"),
Expand Down
3 changes: 2 additions & 1 deletion packages/adapter-telegram/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,7 @@ const TELEGRAM_MESSAGE_LIMIT = 4096;
const TELEGRAM_CAPTION_LIMIT = 1024;
const TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token";
const MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/;
const TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown";
const TELEGRAM_MARKDOWN_PARSE_MODE = "MarkdownV2";
const trimTrailingSlashes = (url: string): string => {
let end = url.length;
while (end > 0 && url[end - 1] === "/") {
Expand Down Expand Up @@ -1813,6 +1813,7 @@ export type {
TelegramMessage,
TelegramMessageReactionUpdated,
TelegramRawMessage,
TelegramReactionType,
TelegramThreadId,
TelegramUpdate,
TelegramUser,
Expand Down
121 changes: 66 additions & 55 deletions packages/adapter-telegram/src/markdown.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,48 +6,67 @@ const TABLE_PIPE_PATTERN = /\|.*Name.*\|/;
describe("TelegramFormatConverter", () => {
const converter = new TelegramFormatConverter();

describe("fromAst (AST -> markdown string)", () => {
describe("fromAst (AST -> MarkdownV2 string)", () => {
it("should convert a plain text paragraph", () => {
const ast = converter.toAst("Hello world");
const result = converter.fromAst(ast);
expect(result).toContain("Hello world");
expect(result).toBe("Hello world");
});

it("should escape reserved characters in plain text", () => {
const ast = converter.toAst("Hello (world). Path: src/foo.ts!");
const result = converter.fromAst(ast);
expect(result).toBe("Hello \\(world\\)\\. Path: src/foo\\.ts\\!");
});

it("should convert bold", () => {
it("should escape dashes at the start of a sentence", () => {
const ast = converter.toAst("- first\n- second");
const result = converter.fromAst(ast);
expect(result).toContain("\\- first");
expect(result).toContain("\\- second");
});

it("should convert bold using MarkdownV2 single asterisks", () => {
const ast = converter.toAst("**bold text**");
const result = converter.fromAst(ast);
expect(result).toContain("**bold text**");
expect(result).toBe("*bold text*");
});

it("should convert italic", () => {
it("should convert italic using MarkdownV2 underscores", () => {
const ast = converter.toAst("*italic text*");
const result = converter.fromAst(ast);
expect(result).toContain("*italic text*");
expect(result).toBe("_italic text_");
});

it("should convert strikethrough", () => {
it("should convert strikethrough using a single tilde", () => {
const ast = converter.toAst("~~strikethrough~~");
const result = converter.fromAst(ast);
expect(result).toContain("~~strikethrough~~");
expect(result).toBe("~strikethrough~");
});

it("should convert links", () => {
const ast = converter.toAst("[link text](https://example.com)");
it("should convert links and escape reserved URL chars", () => {
const ast = converter.toAst("[link text](https://example.com/a(b))");
const result = converter.fromAst(ast);
expect(result).toContain("[link text](https://example.com)");
expect(result).toBe("[link text](https://example.com/a(b\\))");
});

it("should preserve inline code", () => {
it("should preserve and escape inline code", () => {
const ast = converter.toAst("Use `const x = 1`");
const result = converter.fromAst(ast);
expect(result).toContain("`const x = 1`");
});

it("should handle code blocks", () => {
it("should escape backticks and backslashes inside inline code", () => {
const ast = converter.toAst("Run `echo \\`hi\\``");
const result = converter.fromAst(ast);
expect(result).toContain("\\`");
});

it("should handle fenced code blocks", () => {
const input = "```js\nconst x = 1;\n```";
const ast = converter.toAst(input);
const output = converter.fromAst(ast);
expect(output).toContain("```");
expect(output).toContain("```js");
expect(output).toContain("const x = 1;");
});

Expand All @@ -61,6 +80,18 @@ describe("TelegramFormatConverter", () => {
expect(result).toContain("Alice");
expect(result).not.toMatch(TABLE_PIPE_PATTERN);
});

it("should render headings as bold", () => {
const ast = converter.toAst("# Title");
const result = converter.fromAst(ast);
expect(result).toBe("*Title*");
});

it("should handle blockquotes with line prefixes", () => {
const ast = converter.toAst("> quoted line");
const result = converter.fromAst(ast);
expect(result).toBe(">quoted line");
});
});

describe("toAst (markdown -> AST)", () => {
Expand Down Expand Up @@ -90,38 +121,38 @@ describe("TelegramFormatConverter", () => {
});

describe("renderPostable", () => {
it("should return a plain string as-is", () => {
const result = converter.renderPostable("Hello world");
expect(result).toBe("Hello world");
it("should escape reserved chars in plain strings", () => {
const result = converter.renderPostable("Hello (world).");
expect(result).toBe("Hello \\(world\\)\\.");
});

it("should return an empty string unchanged", () => {
const result = converter.renderPostable("");
expect(result).toBe("");
});

it("should render a raw message directly", () => {
const result = converter.renderPostable({ raw: "raw content" });
expect(result).toBe("raw content");
it("should render a raw message directly without escaping", () => {
const result = converter.renderPostable({ raw: "raw (content)." });
expect(result).toBe("raw (content).");
});

it("should render a markdown message", () => {
const result = converter.renderPostable({ markdown: "**bold** text" });
expect(result).toContain("bold");
expect(result).toContain("*bold*");
});

it("should render an AST message", () => {
const ast = converter.toAst("Hello from AST");
const result = converter.renderPostable({ ast });
expect(result).toContain("Hello from AST");
expect(result).toBe("Hello from AST");
});

it("should render markdown with bold and italic", () => {
const result = converter.renderPostable({
markdown: "**bold** and *italic*",
});
expect(result).toContain("**bold**");
expect(result).toContain("*italic*");
expect(result).toContain("*bold*");
expect(result).toContain("_italic_");
});

it("should render markdown table as code block", () => {
Expand Down Expand Up @@ -185,37 +216,17 @@ describe("TelegramFormatConverter", () => {
});
});

describe("roundtrip", () => {
it("should preserve plain text through toAst -> fromAst", () => {
const input = "Hello world";
const result = converter.fromAst(converter.toAst(input));
expect(result).toContain("Hello world");
});

it("should preserve bold through toAst -> fromAst", () => {
const input = "**bold text**";
const result = converter.fromAst(converter.toAst(input));
expect(result).toContain("**bold text**");
});

it("should preserve links through toAst -> fromAst", () => {
const input = "[click here](https://example.com)";
const result = converter.fromAst(converter.toAst(input));
expect(result).toContain("[click here](https://example.com)");
});

it("should preserve code blocks through toAst -> fromAst", () => {
const input = "```\nconst x = 1;\n```";
const result = converter.fromAst(converter.toAst(input));
expect(result).toContain("const x = 1;");
});

it("should convert table to code block on roundtrip", () => {
const input = "| Col1 | Col2 |\n|------|------|\n| A | B |";
const result = converter.fromAst(converter.toAst(input));
expect(result).toContain("```");
expect(result).toContain("Col1");
expect(result).toContain("A");
describe("MarkdownV2 escape coverage", () => {
it("should escape every reserved character in regular text", () => {
// `[` and `]` are consumed by the markdown parser as link syntax, so
// they never reach the text converter unescaped. The remaining 18
// reserved characters must all be escaped.
const reserved = "_*()~`>#+-=|{}.!";
const ast = converter.toAst(`word ${reserved} word`);
const result = converter.fromAst(ast);
for (const char of reserved) {
expect(result).toContain(`\\${char}`);
}
});
});
});
Loading