fix(telegram): MarkdownV2 rendering + telegram-chat reference example#407
fix(telegram): MarkdownV2 rendering + telegram-chat reference example#407dancer 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.
|
@serejke is attempting to deploy a commit to the Vercel Team on Vercel. A member of the Team first needs to authorize it. |
|
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.
|
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. |
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
…vercel#407) * fix(telegram): switch parse_mode from legacy Markdown to MarkdownV2 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 * feat(examples): add telegram-chat reference bot 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. * fix(telegram): produce valid MarkdownV2 when truncating long messages 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. * feat(examples): add length-limit demos to telegram-chat reference bot 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. * test(telegram): add unit tests for truncation helpers and MarkdownV2 boundary trimming * docs(telegram): update README to reflect MarkdownV2 parse mode * chore: unexport trimToMarkdownV2SafeBoundary to fix knip --------- Co-authored-by: dancer <josh@afterima.ge>
|
when would be the next release? can't wait! |
|
Please release this fix! |
…#407) * fix(telegram): switch parse_mode from legacy Markdown to MarkdownV2 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 #226 * feat(examples): add telegram-chat reference bot 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. * fix(telegram): produce valid MarkdownV2 when truncating long messages 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. * feat(examples): add length-limit demos to telegram-chat reference bot 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. * test(telegram): add unit tests for truncation helpers and MarkdownV2 boundary trimming * docs(telegram): update README to reflect MarkdownV2 parse mode * chore: unexport trimToMarkdownV2SafeBoundary to fix knip --------- Co-authored-by: dancer <josh@afterima.ge>

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