Skip to content

feat: Telegram Bot API emulator#75

Open
serejke wants to merge 2 commits intovercel-labs:mainfrom
serejke:feat/telegram-emulator
Open

feat: Telegram Bot API emulator#75
serejke wants to merge 2 commits intovercel-labs:mainfrom
serejke:feat/telegram-emulator

Conversation

@serejke
Copy link
Copy Markdown

@serejke serejke commented Apr 21, 2026

Summary

  • New emulator: @emulators/telegram — a stateful, wire-compatible Telegram Bot API emulator so grammY / telegraf / @chat-adapter/telegram clients can run against http://localhost:4007 unmodified. Tests provision bots + chats, drive scripted user activity, assert on bot replies, inject faults, and inspect state against the same typed in-memory store the bot writes to.
  • End-to-end typed: every HTTP boundary is backed by a hand-authored type system under src/types/{wire,request,store,internal,validators}/. Route handlers receive pre-validated typed input via zod schemas; Dispatcher.enqueue is generic over UpdateType with PayloadFor<T>; TelegramUpdate.payload is a discriminated WireUpdate union. No dependency on grammY or telegraf types — self-contained.
  • Ships with a demo: examples/telegram-grammy/ is a production-shaped grammY bot whose parity test boots the emulator in-process, runs the bot against it, and asserts the round-trip behaves correctly across 9 scenarios.

What's implemented

Bot API methods:

  • Identity: getMe
  • Delivery: getUpdates (with offset / limit / timeout / allowed_updates, 409 on concurrent polls with takeover semantics), setWebhook (HTTPS-only + secret_token + allowed_updates), deleteWebhook, getWebhookInfo
  • Messaging: sendMessage, sendPhoto, sendDocument, sendVideo, sendAudio, sendVoice, sendAnimation, sendSticker, editMessageText, editMessageReplyMarkup, deleteMessage, sendChatAction
  • Files: getFile + GET /file/bot<token>/<file_path>; file_id preserved on re-send
  • Reactions: setMessageReaction (dispatches both message_reaction per-user and message_reaction_count anonymous aggregate)
  • Chats: getChat (returns ChatFullInfo), getChatMember, getChatAdministrators, getChatMemberCount
  • Forum topics: createForumTopic, editForumTopic, closeForumTopic, reopenForumTopic, deleteForumTopic
  • Commands: setMyCommands, getMyCommands
  • Callbacks: answerCallbackQuery
  • Streaming (emulator-only extension): sendMessageDraft for animated chunked replies

Formatting: parse_mode = MarkdownV2 / HTML / legacy Markdown on text + caption surfaces, including blockquote / expandable_blockquote, with UTF-16 entity offsets and exact Telegram-style 400 error wording on unescaped reserved chars.

Update types dispatched: message, edited_message, callback_query, my_chat_member, message_reaction, message_reaction_count, channel_post, edited_channel_post.

Chat types: private, group, supergroup (with forum topics via is_forum + message_thread_id), channel (with channel_post / edited_channel_post + sender_chat-only messages).

Non-obvious behaviour covered (the stuff a naive stub gets wrong):

  • Privacy Mode in groups: bare /command is dropped; only @bot_username mentions and /command@bot_username reach privacy-on bots.
  • Concurrent getUpdates → 409 "terminated by other getUpdates request" on the prior long-poll, new poll proceeds.
  • Webhook delivery: initial POST + up to 3 retries on 5xx with 1s/2s/4s backoff; terminal on 4xx; HTTPS-only.
  • Text > 4096 chars / caption > 1024 chars → 400 "message is too long" rather than truncate.
  • reply_to_message_id pointing at a missing message → 400 "message to be replied not found".
  • sendSticker with caption: silently strips (real Telegram behaviour).
  • Per-store counters — multiple stores in one process don't collide on bot/user/update IDs.

Plus a test control plane at /_emu/telegram/* exposing routes real Telegram never has: create bots/users/chats/supergroups/channels/forum-topics, simulate user messages/photos/media/callbacks/reactions/edits, inject faults (401/403/404/429 with retry_after), inspect drafts and callback answers. Mirrored in a typed TS client (@emulators/telegram/test).

And a read-only inspector UI at / showing bots, chats, message timelines with entity highlighting, media/reaction badges, streaming drafts, and the per-bot Update queue.

Architecture

Three orthogonal layers + control plane + inspector:

                         ┌─ /bot<token>/*    ─▶  bot-api routes  ─┐
  HTTP ingress (hono)   ─┼─ /file/bot<token> ─▶  file download    ├─▶ typed store ─┐
                         ├─ /_emu/telegram/* ─▶  control routes   ┘                │
                         └─ /                ─▶  inspector (HTML)                  │
                                                                                   ▼
                                                                              dispatcher
                                                                              (long-poll
                                                                               or webhook)

src/types/ splits wire shapes (Bot API emission), request shapes (inbound validated bodies), store rows (persistence), and internal types (dispatcher/service). The serializer in src/serializers.ts is the only place that maps store → wire. Validators in src/types/validators/* are the only place raw input is narrowed to typed input — one zod schema per Bot API method + control route.

Non-goals

Permanent: Payments, Games, Business API, Passport, TON wallets, real BotFather account management.

Deferred (lands when a concrete flow asks): custom reply keyboards + force_reply, deep-link ?start=payload, forwardMessage / copyMessage / sendMediaGroup, pinChatMessage, non-bot chat_member / chat_join_request Updates, inline mode, polls, stories, web apps.

Scale + deps

  • ~9,500 LOC of plugin + tests + demo + docs + types
  • 131 plugin tests passing (8 files), 9 grammY parity tests passing, ~60s total
  • Runtime deps: hono (inherited from @emulators/core), zod@^4 (~11 kB min+gz). Nothing else.
  • Default port: 4007 (next after AWS).

Verification

Verified by author on the squashed tree before opening:

  • pnpm --filter @emulators/telegram exec tsc --noEmit — typecheck clean
  • pnpm --filter @emulators/telegram test — 131/131 pass
  • pnpm --filter telegram-grammy-demo test — 9/9 grammY parity tests pass (~60s)
  • pnpm -r test — full monorepo test suite green across all emulators

Reasonable spot-checks for a reviewer:

  • pnpm -r build — production build succeeds
  • npx emulate --service telegram then open http://localhost:4007/ and click through a seeded chat in the inspector
  • Point a real SDK at http://localhost:4007 (e.g. grammY new Bot(token, { client: { apiRoot: "http://localhost:4007" } })) and confirm getMe / sendMessage / getUpdates against a seeded bot

See packages/@emulators/telegram/README.md for the full Test API, seed configuration, privacy-rules reference, and the complete "what is implemented" + "not implemented yet" breakdown.

@vercel
Copy link
Copy Markdown
Contributor

vercel Bot commented Apr 21, 2026

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

A member of the Team first needs to authorize it.

@socket-security
Copy link
Copy Markdown

socket-security Bot commented Apr 21, 2026

Review the following changes in direct dependencies. Learn more about Socket for GitHub.

Diff Package Supply Chain
Security
Vulnerability Quality Maintenance License
Addednpm/​tsx@​4.21.01001008185100
Addednpm/​grammy@​1.42.09510010092100

View full report

Comment thread packages/@emulators/telegram/src/test.ts Outdated
@serejke serejke force-pushed the feat/telegram-emulator branch from 7878996 to 8eec55e Compare April 21, 2026 15:07
`@emulators/telegram` — a stateful, wire-compatible emulation of the
Telegram Bot API so grammY, telegraf, and `@chat-adapter/telegram`
clients can run against `http://localhost:4007` unmodified. Test
harnesses provision bots and chats, drive scripted user activity,
assert on bot replies, inject faults, and inspect state — all against
the same typed in-memory store the bot writes to, without ever
touching api.telegram.org.

## Why

Cheaper alternatives miss real bugs:

- Mocking fetch / the SDK is unit-test scope — you verify the handler
  called `ctx.reply(x)`, not that the reply materialises in the shape
  Telegram would deliver, that the next user action arrives as a
  correctly-typed Update, or that the webhook endpoint is retried on 5xx.
- Recorded fixtures fossilise one path — they do not execute the
  polling loop, offset confirmation, Privacy Mode filtering, or any
  reaction to dynamic test input.
- MTProto user clients (gramjs, telethon) need a real phone number +
  SMS verification and break Telegram's ToS for most automation.
- A real BotFather token + ngrok needs a human clicking through and a
  public URL; not hermetic, not parallelisable, cannot run in CI.

The plugin pays for itself by covering the non-obvious Bot API
behaviours where a naive JSON stub returns plausible data but the
bot's handler silently no-ops:

- UTF-16 entity offsets on parsed MarkdownV2 / HTML / legacy Markdown,
  including blockquote and expandable_blockquote.
- Strict escape validation for the MarkdownV2 reserved set with the
  exact 400 "can't parse entities: character 'X' is reserved and must
  be escaped with the preceding '\'" wording.
- Privacy Mode in groups: non-privileged bots only see @-mentions and
  `/command@botname` bot_commands. Bare `/command` is dropped. Plain
  human chatter is never delivered.
- `file_id` identity preservation on re-send (cache-by-file_id
  behaviour).
- `my_chat_member`, `message_reaction`, `message_reaction_count`,
  `channel_post` and `edited_channel_post` dispatch shapes, including
  the `sender_chat`-with-no-`from` shape for channel self-posts.
- 429 bodies with `parameters.retry_after`; 401 / 403 / 404 /
  409 shapes matching real Telegram.
- Text / caption length caps that reject rather than truncate, so the
  adapter's own truncation stays observably necessary.
- Offset confirmation semantics in long polling; 409 on concurrent
  `getUpdates` with real-Telegram takeover semantics.
- Webhook retry with initial + up to 3 retries (1s / 2s / 4s backoff),
  terminal on 4xx, HTTPS-URL validation, and the
  `X-Telegram-Bot-Api-Secret-Token` header when configured.

Missing any of these = a handler path in the adapter stays untested
without anyone noticing.

## Architecture

Three layers, each orthogonal:

1. **Typed in-memory store** (`src/store.ts`, `src/types/store/*`).
   Per-store collections for bots, users, chats, messages, files,
   callback queries, update queue, draft snapshots, reactions, faults,
   forum topics. Built on the emulate framework's generic Store /
   Collection primitives with indexes by `bot_id` / `chat_id` /
   `token` / `file_id`. ID counters, chat-id allocators, and the
   per-bot update sequence live on the store's `data` map so multiple
   stores in one process never collide.

2. **Bot API HTTP surface** (`src/routes/bot-api*.ts`). Hono routes
   under `/bot<token>/<method>` translate JSON / multipart request
   bodies through zod validators into typed handler input, then into
   store operations. File download endpoint at
   `/file/bot<token>/<file_path>`. Split by resource group:
   `bot-api.ts` (messaging + media), `bot-api-chats.ts`,
   `bot-api-delivery.ts`, `bot-api-forum.ts`.

3. **Update dispatcher** (`src/dispatcher.ts`). When store mutations
   produce events a bot should see, constructs a typed `WireUpdate`
   and delivers it via the bot's chosen mode: POST to webhook URL
   with 5xx retry (initial + 3 retries, 1s / 2s / 4s backoff,
   terminal on 4xx, `X-Telegram-Bot-Api-Secret-Token` header when
   configured), or enqueue for long-polling drain via `getUpdates`
   with offset confirmation and 409 takeover semantics. `enqueue` is
   generic over `UpdateType` — `PayloadFor<T>` keys the payload
   shape to the update type.

Plus the **test control plane** (`src/routes/control.ts`,
`src/routes/control-diagnostics.ts`) — HTTP routes under
`/_emu/telegram/*` that real Telegram never exposes. Create bots,
users, chats, supergroups, channels, forum topics; simulate user
messages, photos, media, callbacks, reactions, edits, channel posts;
inject faults; inspect drafts and callback answers. Mirrored in a
typed TypeScript client (`src/test.ts`) so vitest suites can drive
the emulator without raw fetch.

Read-only **inspector UI** (`src/routes/inspector.ts`) at `/` shows
bots, chats, message timelines with entity highlighting, media
badges, reaction badges, edit and delete indicators, streaming draft
tables, and the per-bot Update queue.

## Bot API surface

- **Identity:** `getMe`
- **Delivery:** `getUpdates` (with `offset`, `limit`, `timeout`,
  `allowed_updates`, 409 on concurrent polls), `setWebhook`
  (HTTPS-only, with `secret_token` + `allowed_updates`),
  `deleteWebhook`, `getWebhookInfo`
- **Messaging:** `sendMessage`, `sendPhoto`, `sendDocument`,
  `sendVideo`, `sendAudio`, `sendVoice`, `sendAnimation`,
  `sendSticker`, `editMessageText`, `editMessageReplyMarkup`,
  `deleteMessage`, `sendChatAction`
- **Streaming:** `sendMessageDraft` — emulator-only extension for
  testing animated streamed replies (private chats only; each call
  appends a snapshot under `(chat_id, draft_id, bot_id)`)
- **Files:** `getFile`, `GET /file/bot<token>/<file_path>`; `file_id`
  preserved on re-send
- **Reactions:** `setMessageReaction`
- **Callbacks:** `answerCallbackQuery` (persists `text` /
  `show_alert` / `url` / `cache_time`)
- **Chats:** `getChat` (returns `ChatFullInfo` with
  `accent_color_id`, `max_reaction_count`, `permissions`,
  `pinned_message`, `bio`, `description`, `invite_link`, etc.),
  `getChatMember`, `getChatAdministrators`, `getChatMemberCount`
- **Forum topics:** `createForumTopic`, `editForumTopic`,
  `closeForumTopic`, `reopenForumTopic`, `deleteForumTopic`
- **Commands:** `setMyCommands`, `getMyCommands`

Formatting covers `parse_mode = MarkdownV2` / `HTML` / legacy
`Markdown` on text and caption surfaces, including blockquote,
expandable_blockquote, and every entity type the Bot API emits.

Auto-detected entities in free text: `bot_command`, `mention`, `url`,
`email`, `hashtag`, `cashtag`.

Update types dispatched: `message`, `edited_message`, `callback_query`,
`my_chat_member`, `message_reaction`, `message_reaction_count`,
`channel_post`, `edited_channel_post`.

Chat types: `private`, `group`, `supergroup` (with forum topics via
`is_forum` + `message_thread_id`), `channel` (with `channel_post` /
`edited_channel_post` + `sender_chat`-only messages).

## End-to-end typed

Every HTTP boundary is backed by a hand-authored type system under
`src/types/`. No dependency on grammY or telegraf types — the
emulator is self-contained.

```
src/types/
  wire/        — Bot API wire shapes (WireUser, WireChat,
                 WireChatFullInfo, WireChatMember discriminated on
                 status, WireMessage, WireUpdate discriminated on
                 UpdateType, WireReplyMarkup union, WirePhotoSize,
                 WireMediaField, WireCallbackQuery,
                 WireMessageReactionUpdated,
                 WireMessageReactionCountUpdated, WireChatMemberUpdated,
                 WireWebhookInfo, WireForumTopic, ...)
  request/     — inbound Bot API body types, derived via z.infer
  store/       — persisted row shapes (TelegramBot, TelegramChat,
                 TelegramMessage, TelegramFile, TelegramUpdate,
                 TelegramCallbackQuery, TelegramReaction,
                 TelegramForumTopic, TelegramDraftSnapshot,
                 TelegramFault)
  internal/    — dispatcher + service + handler-facing types
  validators/  — one zod schema per Bot API method + control route
                 (parseJsonBody helper + firstZodError normaliser
                 that maps zod issues to Telegram's 400 wording)
```

`Dispatcher.enqueue` is generic:
`enqueue<T extends UpdateType>(botId: number, type: T,
payload: PayloadFor<T>): TelegramUpdate`. `TelegramUpdate.payload`
is the discriminated `WireUpdate` union — no `Record<string,
unknown>` in the store. `getChatMember` / `getChatAdministrators`
return a `WireChatMember` discriminated on `status`
(creator / administrator / member / left). `serialize*` functions
all have named return types.

Route handlers receive already-validated typed input through
`parseJsonBody(c, schema)` — zero `typeof body.X === …` ladders, zero
`as unknown as` double casts, zero `body.X as Y` single casts. The
one sanctioned cast is centralised in `wrapPayload` where the
dynamic computed key `{ update_id, [type]: payload }` defeats TS
narrowing.

## Test client

`createTelegramTestClient(baseUrl, { fetchImpl? })` returns a typed
programmatic client for vitest / jest / playwright. Every method has
a matching `/_emu/telegram/*` HTTP route for cross-language drivers,
both sides going through a single `src/paths.ts` URL builder. The
`fetchImpl` override lets tests drive a Hono app in-process via
`app.request` without booting a real HTTP server.

Methods: `createBot`, `createUser`, `createPrivateChat`,
`createGroupChat` (with optional `creatorUserId` / `adminUserIds` /
`adminBotIds`), `createSupergroup` (with `isForum`), `createChannel`,
`createForumTopic`, `promoteChatMember`, `sendUserMessage`,
`sendUserPhoto`, `sendUserMedia`, `clickInlineButton`,
`editUserMessage`, `reactToMessage`, `postAsChannel`,
`editChannelPost`, `addBotToChat`, `removeBotFromChat`,
`injectFault`, `clearFaults`, `getCallbackAnswer`, `getDraftHistory`,
`getSentMessages`, `getAllMessages`, `reset`.

## Demo + parity tests

`examples/telegram-grammy/` ships a production-shaped grammY bot
with handlers for `/start`, `/echo`, `/menu` (inline keyboard +
callback_query → `answerCallbackQuery` + `editMessageReplyMarkup`),
`/stream` (`sendMessageDraft` animated streaming), `/revise`
(`editMessageText` in place), `/oops` (`deleteMessage`), photo
receive (`file_id` → `getFile` → bytes download → re-send by
`file_id`), plain-text fallback.

The parity test
(`examples/telegram-grammy/src/__tests__/parity.test.ts`) boots the
emulator in-process, starts the bot against it, drives scripted user
activity, and asserts the bot's replies land in the store as
expected. Nine cases, ~60s runtime.

## Seed configuration

```yaml
telegram:
  bots:
    - username: trip_bot
      first_name: Trip Bot
      token: "100001:SEEDED_TOKEN_TRIP_BOT"
      can_join_groups: true
      commands:
        - command: connect
          description: Connect this chat to a trip
  users:
    - first_name: Alice
      username: alice_tester
  chats:
    - type: private
      between: [trip_bot, alice_tester]
    - type: group
      title: Morocco Planning
      members: [alice_tester]
      bots: [trip_bot]
```

YAML `chats[].type` supports `private` and `group`; supergroups,
channels, and forum topics are created at runtime via the control
plane (`createSupergroup`, `createChannel`, `createForumTopic`).

## Retention

The dispatcher caps unbounded collections on every enqueue: 2000
files, 500 callback queries, 2000 draft snapshots, 500 faults (age
eviction, oldest id first). Update queue: 1000 pending + 200
delivered history per bot. Realistic test suites never trip these
caps; long-lived emulator processes don't grow without bound.

## Non-goals (permanent)

Payments, Games, Telegram Business API, Passport, TON wallets, real
BotFather account management. Forever out of scope — they'd bloat
the plugin without serving any realistic test-time use case.

## Non-goals (deferred until a concrete flow asks)

Custom reply keyboards + `force_reply`, deep-link `?start=payload`,
`forwardMessage` / `copyMessage` / `sendMediaGroup`, `pinChatMessage`,
non-bot `chat_member` / `chat_join_request` Updates, `inline_query`
mode, polls, stories, web apps.

## Scale

~9,500 LOC of plugin + test code + demo + docs + types. Plugin
tests: 131 passing across 8 files. Demo parity: 9 passing. Full
monorepo build and test: all green.

## Dependencies

`hono` (routing, inherited from `@emulators/core`), `zod@^4` (runtime
validators, ~11 kB min+gz). No other runtime deps. No dependency on
any external Telegram types package.

## Ports

The CLI assigns the Telegram service to port `4007` by default (next
after AWS at `4006`).

Co-authored-by: Sergei Patrikeev <6849689+serejke@users.noreply.github.com>
Real Telegram requires HTTPS webhooks, and the emulator keeps that check for
non-loopback hosts so tests catch production validation. But in-process
hermetic test suites (e.g. grammy-emulate's webhook-mode mount) need to run
a receiver on a random free port without terminating TLS. This relaxation
allows plain HTTP only when the URL hostname is localhost, 127.0.0.1, or ::1.
Everything else still requires HTTPS.
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