Skip to content

fix(adapter-telegram): support MarkdownV2#405

Closed
fuxingloh wants to merge 2 commits intovercel:mainfrom
fuxingloh:chat-adapter-telegram
Closed

fix(adapter-telegram): support MarkdownV2#405
fuxingloh wants to merge 2 commits intovercel:mainfrom
fuxingloh:chat-adapter-telegram

Conversation

@fuxingloh
Copy link
Copy Markdown

Generated end-to-end with Claude Code. Tested via the patch below on 4.25.0.

chat-adapter-telegram@4.25.0.patch — drop-in patch for local testing
diff --git a/dist/index.d.ts b/dist/index.d.ts
--- a/dist/index.d.ts
+++ b/dist/index.d.ts
@@ -226,15 +226,24 @@
 /**
  * Telegram format conversion.
  *
- * Telegram supports Markdown/HTML parse modes, but to avoid
- * platform-specific escaping pitfalls this adapter emits normalized
- * markdown text as plain message text.
+ * Telegram's `MarkdownV2` parse mode requires every occurrence of the
+ * reserved characters `_ * [ ] ( ) ~ ` > # + - = | { } . !` to be
+ * escaped with a preceding `\` outside of formatting entities. The
+ * plain markdown produced by `remark-stringify` does not satisfy this
+ * rule, which made Telegram reject messages that contained perfectly
+ * ordinary punctuation (periods, parentheses, dashes, pipes, …).
+ *
+ * This converter walks the mdast AST directly and emits MarkdownV2
+ * with context-aware escaping so the resulting string is always safe
+ * to send with `parse_mode: "MarkdownV2"`.
  */

 declare class TelegramFormatConverter extends BaseFormatConverter {
     fromAst(ast: Root): string;
     toAst(text: string): Root;
     renderPostable(message: AdapterPostableMessage): string;
+    private nodeToMarkdownV2;
+    private renderMarkdownV2List;
 }

 type TelegramRuntimeMode = "webhook" | "polling";
diff --git a/dist/index.js b/dist/index.js
--- a/dist/index.js
+++ b/dist/index.js
@@ -113,32 +113,47 @@
 // src/markdown.ts
 import {
   BaseFormatConverter,
+  getNodeChildren,
+  isBlockquoteNode,
+  isCodeNode,
+  isDeleteNode,
+  isEmphasisNode,
+  isInlineCodeNode,
+  isLinkNode,
+  isListNode,
+  isParagraphNode,
+  isStrongNode,
   isTableNode,
+  isTextNode,
   parseMarkdown,
-  stringifyMarkdown,
-  tableToAscii,
-  walkAst
+  tableToAscii
 } from "chat";
+var MARKDOWN_V2_RESERVED = /[_*[\]()~`>#+\-=|{}.!]/g;
+var MARKDOWN_V2_CODE_RESERVED = /[`\\]/g;
+var MARKDOWN_V2_LINK_URL_RESERVED = /[)\\]/g;
+function escapeText(text) {
+  return text.replace(/\\/g, "\\\\").replace(MARKDOWN_V2_RESERVED, (char) => `\\${char}`);
+}
+function escapeCode(text) {
+  return text.replace(MARKDOWN_V2_CODE_RESERVED, (char) => `\\${char}`);
+}
+function escapeLinkUrl(url) {
+  return url.replace(MARKDOWN_V2_LINK_URL_RESERVED, (char) => `\\${char}`);
+}
 var TelegramFormatConverter = class extends BaseFormatConverter {
   fromAst(ast) {
-    const transformed = walkAst(structuredClone(ast), (node) => {
-      if (isTableNode(node)) {
-        return {
-          type: "code",
-          value: tableToAscii(node),
-          lang: void 0
-        };
-      }
-      return node;
-    });
-    return stringifyMarkdown(transformed).trim();
+    const parts = [];
+    for (const node of ast.children) {
+      parts.push(this.nodeToMarkdownV2(node));
+    }
+    return parts.join("\n\n").trim();
   }
   toAst(text) {
     return parseMarkdown(text);
   }
   renderPostable(message) {
     if (typeof message === "string") {
-      return message;
+      return escapeText(message);
     }
     if ("raw" in message) {
       return message.raw;
@@ -150,7 +165,98 @@
       return this.fromAst(message.ast);
     }
     return super.renderPostable(message);
+  }
+  nodeToMarkdownV2(node) {
+    /* …full AST walker, see diff in PR description… */
+  }
+  renderMarkdownV2List(node, depth) {
+    /* …list rendering with ordered/unordered prefix escaping… */
   }
 };

 // src/index.ts
@@ -159,7 +265,7 @@
 var TELEGRAM_CAPTION_LIMIT = 1024;
 var TELEGRAM_SECRET_TOKEN_HEADER = "x-telegram-bot-api-secret-token";
 var MESSAGE_ID_PATTERN = /^([^:]+):(\d+)$/;
-var TELEGRAM_MARKDOWN_PARSE_MODE = "Markdown";
+var TELEGRAM_MARKDOWN_PARSE_MODE = "MarkdownV2";

Problem

Telegram rejected most LLM replies with:

Bad Request: can't parse entities: character '.' is reserved and must be
escaped with the preceding '\'

., (, ), -, |, ! appear in ordinary prose, so this fired constantly. Downstream harnesses worked around it by wrapping thread.post(result.fullStream) in a try/catch that fell back to plain text — doubling latency and dropping the stream.

Root cause: the adapter sent parse_mode: "Markdown" (legacy) and passed through text from remark-stringify, which does not escape the 18 characters MarkdownV2 reserves.

Fix

Switch to parse_mode: "MarkdownV2" and replace TelegramFormatConverter.fromAst with an mdast walker that applies context-aware escaping per the Telegram spec.

Context Characters escaped
Regular text _ * [ ] ( ) ~ \ > # + - = | { } . !`
code / pre ` and \
Link URL (…) ) and \

Also:

  • Bold → *…*, italic → _…_, strike → ~…~, headings → bold.
  • Unordered list bullets emit as \-, ordered numerals as N\..
  • Thematic break emits as \-\-\-.
  • Tables render as ASCII inside a fenced code block (Telegram has no native table entity).
  • renderPostable(string) now escapes plain strings — safe default for raw LLM output. renderPostable({ raw }) still passes through unescaped (caller opt-in).

Migration

No API changes. Output format differs:

  • **bold***bold*
  • *italic*_italic_
  • ~~strike~~~strike~

Callers hand-crafting legacy-Markdown strings and passing them via { raw } will now render as literal text. Switch to { markdown } for proper conversion.

Sources

Testing

  • pnpm --filter @chat-adapter/telegram test — 103/103 pass (60 index, 9 cards, 34 markdown).
  • pnpm --filter @chat-adapter/telegram typecheck — clean.
  • pnpm --filter @chat-adapter/telegram build — clean.

New markdown.test.ts cases cover:

  • Plain text with ., (, ), /, ! (the exact trigger from the bug report).
  • Dashes at line start (list bullets).
  • Every reserved character escaped in regular text.
  • ` and \ escaped inside inline code.
  • ) escaped inside link URLs.
  • Bold/italic/strikethrough emit MarkdownV2 tokens (*, _, ~).
  • Headings render as bold.
  • Blockquotes prefix each line with >.

Updated index.test.ts assertions from parse_mode === "Markdown" to "MarkdownV2".

Changeset: .changeset/telegram-markdown-v2-escape.md (minor).

Test plan

  • Apply chat-adapter-telegram@4.25.0.patch locally and verify in a real chat for a week.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 20, 2026

@fuxingloh is attempting to deploy a commit to the Vercel Team on Vercel.

A member of the Team first needs to authorize it.

@fuxingloh fuxingloh marked this pull request as ready for review April 20, 2026 13:49
Copy link
Copy Markdown
Contributor

@vercel vercel Bot left a comment

Choose a reason for hiding this comment

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

Additional Suggestion:

Plain strings and AST messages are MarkdownV2-escaped by renderPostable but sent to Telegram without parse_mode: "MarkdownV2", causing literal backslashes and formatting markers to appear in messages.

Fix on Vercel

@dancer
Copy link
Copy Markdown
Contributor

dancer commented Apr 21, 2026

done in #407 but thank you ❤️

@dancer dancer closed this Apr 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants