Files
Sergei Patrikeev b9a1961aa5 fix(telegram): MarkdownV2 rendering + telegram-chat reference example (#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>
2026-04-21 05:27:47 -07:00
..

telegram-chat

A Telegram bot that exercises the Chat SDK end-to-end: MarkdownV2 rendering, cards with inline-keyboard actions, reactions, file uploads, and streaming edits. Runs in polling mode — no webhook, no public URL, no deploy.

Doubles as a reference example for developers learning the SDK and an interactive smoke-test harness for the @chat-adapter/telegram package.

Prerequisites

  • Node.js ≥ 20
  • A Telegram bot token from @BotFather

Run

From the repo root:

pnpm install
TELEGRAM_BOT_TOKEN=<your_token> pnpm --filter example-telegram-chat start

Optional: TELEGRAM_BOT_USERNAME=<handle> (defaults to telegramchatdemobot).

Then DM the bot — any message opens the main menu.

What you see

The bot replies with an inline keyboard with three categories:

  • Text & Markdown — 6 curated markdown demos plus a streaming edit loop
  • Cards & Actions — interactive approval card, callback-data size probe, link buttons
  • Media & Reactions — on-demand reactions, generated PNG and PDF uploads

Every sub-menu has a ← Back button. Sending any text at any time reopens the main menu.

Why it's stateless

No thread subscription, no persistence. Every button press is self-contained; memory state is used only because the SDK requires a state adapter. If you need a stateful reference, see examples/nextjs-chat.