Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion .changeset/config.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,5 +7,9 @@
"access": "public",
"baseBranch": "main",
"updateInternalDependencies": "patch",
"ignore": ["example-nextjs-chat", "@chat-adapter/integration-tests"]
"ignore": [
"example-nextjs-chat",
"example-telegram-chat",
"@chat-adapter/integration-tests"
]
}
10 changes: 10 additions & 0 deletions .changeset/fix-telegram-markdownv2.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
"@chat-adapter/telegram": patch
"chat": minor
---

Switch Telegram adapter's outbound `parse_mode` from legacy `Markdown` to `MarkdownV2`, and replace the standard-markdown passthrough renderer with a proper AST → MarkdownV2 renderer. Standard markdown (`**bold**`) and legacy `Markdown` (`*bold*`) use different syntaxes and have no shared escape rules, so any message containing `.`, `!`, `(`, `)`, `-`, `_` in regular text — which is virtually every LLM-generated message — was being rejected with `can't parse entities`. The new renderer walks the mdast tree and emits MarkdownV2 with context-aware escaping (normal text vs. code blocks vs. link URLs), uniformly applies MarkdownV2 `parse_mode` to every format-converter output (including AST messages, which previously shipped without `parse_mode` and rendered asterisks literally), and escapes card fallback text.

Also fix silent message truncation that the MarkdownV2 migration widened from a rare bug into a reliable 400. The previous truncator sliced messages at 4096/1024 chars and appended literal `...`, but in MarkdownV2 `.` is a reserved character that must be escaped, the slice can leave an orphan trailing `\`, and it can cut through a paired entity (`*bold*`, `` `code` ``) leaving it unclosed — all of which cause `can't parse entities`. The two truncate methods are unified into `truncateForTelegram(text, limit, parseMode)`, which appends an escaped `\.\.\.` for MarkdownV2 and walks back past unbalanced entity delimiters or orphan backslashes before appending. Plain-text messages keep literal `...`.

Internal typing hardening: `renderMarkdownV2` is now typed exhaustively on mdast's `Nodes` union with a `never` assertion, so new mdast node types fail the build rather than silently falling through. Introduce `TelegramParseMode = "MarkdownV2" | "plain"` replacing the previous `string | undefined` at call sites, with `toBotApiParseMode` mapping to the Bot API wire format at the boundary. The `chat` package gains a re-export of mdast's `Nodes` union so adapters can build exhaustively typed renderers without importing mdast directly.
42 changes: 42 additions & 0 deletions examples/telegram-chat/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
# 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](https://t.me/BotFather)

## Run

From the repo root:

```bash
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`.

## Related

- [`packages/adapter-telegram`](../../packages/adapter-telegram) — adapter source and README
- [`examples/nextjs-chat`](../nextjs-chat) — full multi-platform example with AI integration, Redis state, and webhooks
20 changes: 20 additions & 0 deletions examples/telegram-chat/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
{
"name": "example-telegram-chat",
"version": "0.1.0",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@chat-adapter/state-memory": "workspace:*",
"@chat-adapter/telegram": "workspace:*",
"chat": "workspace:*"
},
"devDependencies": {
"@types/node": "^22.10.2",
"tsx": "^4.19.2",
"typescript": "^5.7.2"
}
}
232 changes: 232 additions & 0 deletions examples/telegram-chat/src/demos/cards.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,232 @@
/**
* Card demos — interactive approval, callback-data size probe, link button.
*
* The approval card is edited in-place on button press: the
* on-action handler calls adapter.editMessage(threadId, messageId, newCard)
* to replace the original buttons with a decision status.
*
* The size-probe card deliberately includes one button whose callback_data
* is well under the limit and one that exceeds Telegram's 64-byte cap,
* teaching the constraint without hiding it.
*/

import {
Actions,
Button,
Card,
type CardElement,
CardText,
Divider,
Field,
Fields,
LinkButton,
Section,
type Thread,
toCardElement,
} from "chat";
import { encode } from "../lib/callbacks";

type AnyThread = Thread<unknown>;

export const APPROVAL_DEMO_ID = "card.approval";
const SIZE_PROBE_DEMO_ID = "card.size";
const LINK_DEMO_ID = "card.link";

const PENDING_APPROVAL_CARD = (
<Card title="Order #1234">
<Section>
<CardText>**Approval needed** for order #1234.</CardText>
<Fields>
<Field label="Amount" value="$450.00" />
<Field label="Customer" value="Alice Johnson" />
<Field label="Submitted" value="Apr 20 · 14:30" />
</Fields>
</Section>
<Divider />
<Actions>
<Button
id={encode({ kind: "act", demo: APPROVAL_DEMO_ID, arg: "approve" })}
style="primary"
>
Approve
</Button>
<Button
id={encode({ kind: "act", demo: APPROVAL_DEMO_ID, arg: "reject" })}
style="danger"
>
Reject
</Button>
</Actions>
</Card>
);

const TELEGRAM_CALLBACK_DATA_LIMIT = 64;

const SAFE_BUTTON_ID = encode({
kind: "act",
demo: SIZE_PROBE_DEMO_ID,
arg: "ok",
});
// What Telegram actually sees on the wire: the adapter wraps each button
// id in `chat:{"a":"..."}` before shipping, so the effective budget is
// ~13 bytes less than the raw Telegram cap.
const SAFE_PAYLOAD_WIRE_BYTES = `chat:{"a":"${SAFE_BUTTON_ID}"}`.length;

const OVERSIZE_ARG = "this-id-intentionally-long-to-exceed-the-64-byte-limit";
const OVERSIZE_BUTTON_ID = encode({
kind: "act",
demo: SIZE_PROBE_DEMO_ID,
arg: OVERSIZE_ARG,
});
const OVERSIZE_PAYLOAD_WIRE_BYTES = `chat:{"a":"${OVERSIZE_BUTTON_ID}"}`.length;

const SAFE_SIZE_CARD = (
<Card title="Size probe — safe payload">
<Section>
<CardText>
Telegram caps `callback_data` at **{TELEGRAM_CALLBACK_DATA_LIMIT}
bytes**. This button's id fits the budget (including the adapter's
`chat:{"{}"}` envelope).
</CardText>
<Fields>
<Field label="Button id" value={SAFE_BUTTON_ID} />
<Field
label="Wire bytes"
value={`${SAFE_PAYLOAD_WIRE_BYTES} / ${TELEGRAM_CALLBACK_DATA_LIMIT}`}
/>
</Fields>
</Section>
<Divider />
<Actions>
<Button id={SAFE_BUTTON_ID}>Tap to confirm</Button>
</Actions>
</Card>
);

const OVERSIZE_SIZE_CARD = (
<Card title="Size probe — oversize payload">
<Section>
<CardText>
Same encoding, longer arg, cap exceeded. This card will NOT post — the
SDK throws `ValidationError` at post time so the bug surfaces at the
line that constructed the button, not later at runtime.
</CardText>
<Fields>
<Field label="Button id" value={OVERSIZE_BUTTON_ID} />
<Field
label="Wire bytes"
value={`${OVERSIZE_PAYLOAD_WIRE_BYTES} / ${TELEGRAM_CALLBACK_DATA_LIMIT} (over by ${OVERSIZE_PAYLOAD_WIRE_BYTES - TELEGRAM_CALLBACK_DATA_LIMIT})`}
/>
</Fields>
</Section>
<Divider />
<Actions>
<Button id={OVERSIZE_BUTTON_ID}>Won't render</Button>
</Actions>
</Card>
);

const LINK_CARD = (
<Card title="Link buttons">
<Section>
<CardText>
{"`<LinkButton>` opens a URL directly. No callback handler runs."}
</CardText>
</Section>
<Actions>
<LinkButton url="https://github.com/vercel/chat">
View on GitHub
</LinkButton>
<LinkButton url="https://vercel.com">Visit Vercel</LinkButton>
</Actions>
</Card>
);

export const CARD_DEMOS: {
id: string;
label: string;
run: (thread: AnyThread) => Promise<void>;
}[] = [
{
id: APPROVAL_DEMO_ID,
label: "Interactive approval card",
run: async (thread) => {
await thread.post(PENDING_APPROVAL_CARD);
},
},
{
id: SIZE_PROBE_DEMO_ID,
label: "Button-size probe (64 B)",
run: async (thread) => {
// First: show the working case with bytecounts.
await thread.post(SAFE_SIZE_CARD);

// Then: attempt the oversize case. Expected to throw at post time —
// that's the teaching moment, not a failure.
try {
await thread.post(OVERSIZE_SIZE_CARD);
await thread.post(
"⚠️ Unexpected: oversize card posted without error. Did the adapter limit change?"
);
} catch (err) {
const msg = (err as Error).message ?? String(err);
await thread.post({
markdown: [
"📎 **Expected ValidationError caught.**",
"",
`> ${msg}`,
"",
"The SDK refuses to ship malformed `callback_data` to Telegram. Alternatives the SDK could have chosen:",
"",
"- Silently truncate → button clicks would echo a truncated id that doesn't match any handler; silent runtime bug.",
"- Hash + server-side lookup → needs stateful bookkeeping that survives bot restarts; higher ops cost.",
"- **Throw at post time** → developer sees the failure at the line that caused it. (Chosen here.)",
"",
"Lesson: treat `callback_data` as a short routing key, never as app state. Store data elsewhere, keyed by a short id.",
].join("\n"),
});
}
},
},
{
id: LINK_DEMO_ID,
label: "LinkButton card",
run: async (thread) => {
await thread.post(LINK_CARD);
},
},
];

export function buildDecidedCard(
decision: "approve" | "reject",
user: string,
when: Date
): CardElement {
const label = decision === "approve" ? "✅ Approved" : "🚫 Rejected";
const time = `${when.getHours().toString().padStart(2, "0")}:${when
.getMinutes()
.toString()
.padStart(2, "0")}`;
const jsx = (
<Card title="Order #1234">
<Section>
<CardText>
{label} by @{user} at {time}.
</CardText>
<Fields>
<Field label="Amount" value="$450.00" />
<Field label="Customer" value="Alice Johnson" />
<Field
label="Decision"
value={decision === "approve" ? "Approved" : "Rejected"}
/>
</Fields>
</Section>
</Card>
);
const card = toCardElement(jsx);
if (!card) {
throw new Error("buildDecidedCard: toCardElement returned null");
}
return card;
}
Loading