fix(telegram): MarkdownV2 rendering + telegram-chat reference example#407
Merged
dancer merged 7 commits intovercel:mainfrom Apr 21, 2026
Merged
fix(telegram): MarkdownV2 rendering + telegram-chat reference example#407dancer merged 7 commits intovercel:mainfrom
dancer merged 7 commits intovercel:mainfrom
Conversation
The Telegram adapter hardcoded `parse_mode: "Markdown"` (legacy) but
rendered messages via the SDK's generic `stringifyMarkdown()`, which
emits standard markdown. Two incompatible dialects glued together:
- Standard markdown uses `**bold**`, Telegram legacy uses `*bold*`
- Legacy Markdown has no escape rules — any message with `.`, `!`,
`(`, `)`, `-`, `_` in unexpected positions was rejected with
`can't parse entities`, which is virtually every LLM-generated
response
- Legacy Markdown is deprecated by Telegram and lacks support for
underline, strikethrough, spoiler, and blockquote
This commit:
- Switches TELEGRAM_MARKDOWN_PARSE_MODE to "MarkdownV2"
- Replaces fromAst() with a proper AST → MarkdownV2 renderer:
- Single `*bold*`, `_italic_`, `~strike~` markers
- Context-aware escaping: 20-char matrix for normal text, only
`` ` `` and `\` inside code blocks, only `)` and `\` inside link
URLs
- Headings rendered as bold (MarkdownV2 has no heading syntax)
- Ordered/unordered lists with escaped dashes and periods
- Blockquotes with per-line `>` prefix
- Tables pre-empted and rendered as ASCII code blocks
- Explicit handlers for reference-style links, images, HTML, and
definitions so nothing is silently dropped
- Routes card fallback text through `fromMarkdown` (not raw escape)
with `boldFormat: "**"` — @chat-adapter/shared's cardToFallbackText
defaults `boldFormat` to "*" (Slack mrkdwn), which would render as
italic on Telegram. Explicit "**" keeps the card title rendered as
real MarkdownV2 bold.
- Fixes resolveParseMode so every message routed through the format
converter (`{markdown}`, `{ast}`, cards, JSX) gets
`parse_mode: "MarkdownV2"`. Previously only `{markdown}` and cards
were covered, so `{ast}` messages shipped without parse_mode and
rendered asterisks literally.
- Documents inbound vs outbound dialects on applyTelegramEntities /
escapeMarkdownInEntity (inbound entities → standard markdown)
versus the new outbound MarkdownV2 renderer, so future
contributors don't confuse the two.
Tests: full 20-char MarkdownV2 escape matrix, context-escape tests
for code blocks and link URLs, nested-formatting tests, edge cases
(empty, whitespace-only, raw HTML), and an end-to-end LLM-output
corpus test that asserts MarkdownV2 validity (no unescaped special
chars outside entities or code blocks). Regression guards added in
index.test.ts for the AST / plain-string / raw parse_mode paths and
for card-title MarkdownV2 bold rendering.
Fixes vercel#226
Polling-mode Telegram bot that exercises the adapter end-to-end: MarkdownV2 rendering, interactive cards with inline keyboards, reactions, file uploads, and streaming edits. Runs with a single `pnpm --filter example-telegram-chat start`; no webhook, no public URL, no external API keys. Menu structure — three categorized sub-menus reached from any DM text: - Text & Markdown: plain, inline emphasis, code block, links, list+table, 20-char torture string, LLM-style corpus, streaming editMessage loop - Cards & Actions: interactive approval card (edits in-place on press), callback_data size probe demonstrating the 64-byte limit, LinkButton - Media & Reactions: on-demand reaction one-shot (briefly subscribes), generated 1×1 PNG upload, generated minimal PDF upload Zero new runtime deps. PNG/PDF are hand-rolled in memory (lib/png.ts, lib/pdf.ts) rather than pulled from a binary-processing library. Failure handling is consistent: every demo runner is try/catch-wrapped and posts an inline ❌ line with the error message. Excluded from npm release via .changeset/config.json.
Contributor
|
@serejke is attempting to deploy a commit to the Vercel Team on Vercel. A member of the Team first needs to authorize it. |
Contributor
Author
|
Review the following changes in direct dependencies. Learn more about Socket for GitHub.
|
The MarkdownV2 migration widened a latent truncation bug into a reliable 400. The previous truncator sliced at 4096/1024 chars and appended literal "..." — but in MarkdownV2 `.` is a reserved character, the slice can leave an orphan trailing `\`, and it can cut through a paired entity (`*bold*`, `` `code` ``) leaving it unclosed. Unify the two truncate methods into one `truncateForTelegram(text, limit, parseMode)` that appends `\.\.\.` for MarkdownV2 and walks back past unbalanced entity delimiters or orphan backslashes. Plain text keeps literal `...`. Adds 8 length-limit tests. Related cleanup: - Move MarkdownV2 string utilities and Bot API limits to markdown.ts. - Type renderMarkdownV2 exhaustively on mdast's `Nodes` union with a `never` assertion so new node kinds fail the build. Replaces the hand-rolled `AstNode` interface. Adds explicit cases for table / tableRow / tableCell (throw — preprocessed by fromAst), footnoteDefinition, footnoteReference, yaml. - Introduce `TelegramParseMode = "MarkdownV2" | "plain"` replacing `string | undefined`. `toBotApiParseMode` handles the wire mapping. - Re-export `Nodes` from the chat package; re-export `TelegramReactionType` from the adapter entry.
Three new menu entries exercise the MarkdownV2 truncation path that the prior commit fixed: - Long (5000 plain) — basic truncation, verifies escaped `\.\.\.` ellipsis - Long (bold crosses 4096) — entity-balancing heuristic for unclosed `*` - Long (code crosses 4096) — entity-balancing heuristic for unclosed `` ` `` Each button posts a message whose rendered length exceeds Telegram's 4096-char limit and would have produced `can't parse entities` 400s against the previous truncator. Serves as an interactive smoke test alongside the unit tests in packages/adapter-telegram.
Contributor
Author
|
While working on this I noticed the truncator was producing bad MarkdownV2 — unescaped dots, orphan Also had a look at the other adapters while I was here — Discord has the same truncation bug, WhatsApp silently splits into multiple messages, Slack/GChat/Teams don't check at all. Filed #408 to clean that up separately. |
dancer
approved these changes
Apr 21, 2026
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
Contributor
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Summary
Two things bundled in one PR:
**bold**) shipped withparse_mode: "Markdown"(legacy, single-asterisk) — so every LLM-generated message containing.,!,(,),-got rejected withcan't parse entities. Fixes Telegram adapter: parse_mode not set for markdown messages #226.examples/telegram-chat/reference bot that exercises the adapter end-to-end: MarkdownV2 rendering, interactive cards, reactions, file uploads, streaming edits. Doubles as a manual regression harness for adapter changes.The fix (
efae68f)Root cause
The adapter's
fromAst()delegated to the SDK's genericstringifyMarkdown(), which emits standard markdown (**bold**, no escaping). That output shipped withparse_mode: "Markdown"— Telegram's legacy parser that uses*bold*(single asterisk) and has no escape rules. Two incompatible dialects glued together.Changes to
packages/adapter-telegramTELEGRAM_MARKDOWN_PARSE_MODEto"MarkdownV2".fromAst()with a dedicated AST → MarkdownV2 renderer (markdown.ts):*bold*/_italic_/~strike~markers.`and\inside code blocks; only)and\inside link URLs.>prefix.linkReference,imageReference,definition,htmlso nothing is silently dropped.fromMarkdown(not raw escape), withboldFormat: "**"passed to@chat-adapter/shared'scardToFallbackText. DefaultboldFormatis"*"(Slack mrkdwn) which, fed back through a markdown parser, becomes italic — not bold — on Telegram.resolveParseModeso every message routed through the format converter ({markdown},{ast}, cards, JSX) getsparse_mode: "MarkdownV2". Previously only{markdown}and cards were covered, so{ast}messages shipped without parse_mode and rendered asterisks literally.applyTelegramEntities/escapeMarkdownInEntitynote they're the inbound path (Telegram entities → standard markdown forparseMarkdown) and are distinct from the new outbound MarkdownV2 renderer.Tests (74 → 148)
`,\), link URL (only),\).index.test.tsfor the AST / plain-string / rawparse_modepaths and for card-title MarkdownV2 bold rendering.Changeset
patchbump on@chat-adapter/telegram.The example (
12d5435)A polling-mode Telegram bot at
examples/telegram-chat/that exercises the adapter end-to-end. One command to run (pnpm --filter example-telegram-chat start), no webhook, no public URL, no external API keys.Menu structure — three categorized sub-menus, inline-keyboard navigation:
Zero new runtime deps. PNG/PDF are hand-rolled in memory (
lib/png.ts/lib/pdf.ts) rather than pulled from a binary-processing library.Excluded from npm release via
.changeset/config.json.Known limitation (not fixed in this PR)
The size-probe demo surfaces a DX gap worth flagging for a future PR:
@chat-adapter/shared's button encoder wraps everyButton.idin achat:{\"a\":\"<id>\",\"v\":\"<value>\"}JSON envelope before writingcallback_data.ValidationError: Callback payload too large for Telegram (max 64 bytes)— which is exactly what the size-probe demo teaches.Possible follow-ups in a separate PR: document the effective budget in the adapter README; consider a pluggable
CallbackEncoderhook so apps with short action ids can opt into a leaner encoding (dropchat:prefix, single-string instead of JSON) when they don't need round-tripvalue. Happy to propose this separately if maintainers agree.Test plan
pnpm --filter @chat-adapter/telegram test— 148/148 passpnpm --filter @chat-adapter/telegram typecheckcleanpnpm --filter example-telegram-chat typecheckcleanpnpm dlx ultracite checkon both packages cleanpnpm knipclean400 can't parse entities, approval card edits in-place on button press, size-probe shows safe card + teaching error for oversize, streaming demo visibly edits a single message through 8 framesFixes #226