Skip to content

feat: chat-adapter-telegram MarkdownV2#369

Draft
fuxingloh wants to merge 1 commit intovercel:mainfrom
fuxingloh:main
Draft

feat: chat-adapter-telegram MarkdownV2#369
fuxingloh wants to merge 1 commit intovercel:mainfrom
fuxingloh:main

Conversation

@fuxingloh
Copy link
Copy Markdown

@fuxingloh fuxingloh commented Apr 13, 2026

🤖 Generated with Claude Code — end to end —
I didn't write this manually, but I will be testing this using patches on 4.25.0.

chat-adapter-telegram@4.25.0.patch if anyone want to test it locally
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) {
+    if (isParagraphNode(node)) {
+      return getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("");
+    }
+    if (isTextNode(node)) {
+      return escapeText(node.value);
+    }
+    if (isStrongNode(node)) {
+      const content = getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("");
+      return `*${content}*`;
+    }
+    if (isEmphasisNode(node)) {
+      const content = getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("");
+      return `_${content}_`;
+    }
+    if (isDeleteNode(node)) {
+      const content = getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("");
+      return `~${content}~`;
+    }
+    if (isInlineCodeNode(node)) {
+      return `\`${escapeCode(node.value)}\``;
+    }
+    if (isCodeNode(node)) {
+      const lang = node.lang ?? "";
+      return `\`\`\`${lang}
+${escapeCode(node.value)}
+\`\`\``;
+    }
+    if (isLinkNode(node)) {
+      const linkText = getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("");
+      return `[${linkText}](${escapeLinkUrl(node.url)})`;
+    }
+    if (isBlockquoteNode(node)) {
+      return getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("\n").split("\n").map((line) => `>${line}`).join("\n");
+    }
+    if (isListNode(node)) {
+      return this.renderMarkdownV2List(node, 0);
+    }
+    if (node.type === "break") {
+      return "\n";
+    }
+    if (node.type === "thematicBreak") {
+      return "\\-\\-\\-";
+    }
+    if (isTableNode(node)) {
+      return `\`\`\`
+${escapeCode(tableToAscii(node))}
+\`\`\``;
+    }
+    if (node.type === "heading") {
+      const content = getNodeChildren(node).map((child) => this.nodeToMarkdownV2(child)).join("");
+      return `*${content}*`;
+    }
+    return escapeText(
+      this.defaultNodeToText(node, (child) => {
+        if (isTextNode(child)) {
+          return child.value;
+        }
+        return this.defaultNodeToText(child, () => "");
+      })
+    );
   }
+  renderMarkdownV2List(node, depth) {
+    if (!isListNode(node)) {
+      return "";
+    }
+    const indent = "  ".repeat(depth);
+    const start = node.start ?? 1;
+    const lines = [];
+    for (const [i, item] of getNodeChildren(node).entries()) {
+      const prefix = node.ordered ? `${start + i}\\.` : "\\-";
+      let isFirstContent = true;
+      for (const child of getNodeChildren(item)) {
+        if (isListNode(child)) {
+          lines.push(this.renderMarkdownV2List(child, depth + 1));
+          continue;
+        }
+        const rendered = this.nodeToMarkdownV2(child);
+        if (!rendered.trim()) {
+          continue;
+        }
+        if (isFirstContent) {
+          lines.push(`${indent}${prefix} ${rendered}`);
+          isFirstContent = false;
+        } else {
+          lines.push(`${indent}  ${rendered}`);
+        }
+      }
+    }
+    return lines.join("\n");
+  }
 };
 
 // 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";
 var trimTrailingSlashes = (url) => {
   let end = url.length;
   while (end > 0 && url[end - 1] === "/") {

Fix Telegram MarkdownV2 escaping (@chat-adapter/telegram)

Problem

Telegram was rejecting messages with:

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

This fired on almost every LLM reply because ., (, ), -, |, ! appear in ordinary prose. Downstream harnesses were forced to wrap 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 emitted text produced by remark-stringify, which does not escape the 18 characters MarkdownV2 reserves.

Fix

Switched to parse_mode: "MarkdownV2" and replaced the body of TelegramFormatConverter.fromAst with an mdast AST 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.
  • 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) escapes plain strings (safe default for raw LLM output). renderPostable({ raw }) still passes through unescaped — caller opts in.

Sources

Testing

  • pnpm --filter @chat-adapter/telegram test103/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 use MarkdownV2 tokens (*, _, ~).
    • Headings render as bold.
    • Blockquotes prefix each line with >.
  • Updated index.test.ts assertions from parse_mode === "Markdown" to "MarkdownV2".

Migration

None for consumers. Output format changes:

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

If a caller was hand-crafting legacy-Markdown strings and passing them via
{ raw }, they will now render as literal text (since raw is passed
through unescaped). Switch to { markdown } to get proper conversion.

Changeset

Minor — see .changeset/telegram-markdown-v2-escape.md.

Test plan

  • Send a message containing Hello (world). Path: src/foo.ts! — should render without falling back to plain text.
  • Apply chat-adapter-telegram@4.25.0.patch in my project and test it.

@vercel
Copy link
Copy Markdown
Contributor

vercel bot commented Apr 13, 2026

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

A member of the Team first needs to authorize it.

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.

1 participant