feat: Telegram Bot API emulator#75
Open
serejke wants to merge 2 commits intovercel-labs:mainfrom
Open
Conversation
Contributor
|
@serejke is attempting to deploy a commit to the Vercel Labs 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.
|
4 tasks
7878996 to
8eec55e
Compare
`@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>
8eec55e to
27e839b
Compare
This was referenced Apr 21, 2026
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.
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
@emulators/telegram— a stateful, wire-compatible Telegram Bot API emulator so grammY / telegraf /@chat-adapter/telegramclients can run againsthttp://localhost:4007unmodified. 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.src/types/{wire,request,store,internal,validators}/. Route handlers receive pre-validated typed input via zod schemas;Dispatcher.enqueueis generic overUpdateTypewithPayloadFor<T>;TelegramUpdate.payloadis a discriminatedWireUpdateunion. No dependency on grammY or telegraf types — self-contained.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:
getMegetUpdates(withoffset/limit/timeout/allowed_updates, 409 on concurrent polls with takeover semantics),setWebhook(HTTPS-only +secret_token+allowed_updates),deleteWebhook,getWebhookInfosendMessage,sendPhoto,sendDocument,sendVideo,sendAudio,sendVoice,sendAnimation,sendSticker,editMessageText,editMessageReplyMarkup,deleteMessage,sendChatActiongetFile+GET /file/bot<token>/<file_path>;file_idpreserved on re-sendsetMessageReaction(dispatches bothmessage_reactionper-user andmessage_reaction_countanonymous aggregate)getChat(returnsChatFullInfo),getChatMember,getChatAdministrators,getChatMemberCountcreateForumTopic,editForumTopic,closeForumTopic,reopenForumTopic,deleteForumTopicsetMyCommands,getMyCommandsanswerCallbackQuerysendMessageDraftfor animated chunked repliesFormatting:
parse_mode = MarkdownV2/HTML/ legacyMarkdownon text + caption surfaces, includingblockquote/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 viais_forum+message_thread_id),channel(withchannel_post/edited_channel_post+sender_chat-only messages).Non-obvious behaviour covered (the stuff a naive stub gets wrong):
/commandis dropped; only@bot_usernamementions and/command@bot_usernamereach privacy-on bots.getUpdates→ 409 "terminated by other getUpdates request" on the prior long-poll, new poll proceeds.reply_to_message_idpointing at a missing message → 400 "message to be replied not found".sendStickerwith caption: silently strips (real Telegram behaviour).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 withretry_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:
src/types/splits wire shapes (Bot API emission), request shapes (inbound validated bodies), store rows (persistence), and internal types (dispatcher/service). The serializer insrc/serializers.tsis the only place that maps store → wire. Validators insrc/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-botchat_member/chat_join_requestUpdates, inline mode, polls, stories, web apps.Scale + deps
hono(inherited from@emulators/core),zod@^4(~11 kB min+gz). Nothing else.4007(next after AWS).Verification
Verified by author on the squashed tree before opening:
pnpm --filter @emulators/telegram exec tsc --noEmit— typecheck cleanpnpm --filter @emulators/telegram test— 131/131 passpnpm --filter telegram-grammy-demo test— 9/9 grammY parity tests pass (~60s)pnpm -r test— full monorepo test suite green across all emulatorsReasonable spot-checks for a reviewer:
pnpm -r build— production build succeedsnpx emulate --service telegramthen openhttp://localhost:4007/and click through a seeded chat in the inspectorhttp://localhost:4007(e.g. grammYnew Bot(token, { client: { apiRoot: "http://localhost:4007" } })) and confirmgetMe/sendMessage/getUpdatesagainst a seeded botSee
packages/@emulators/telegram/README.mdfor the full Test API, seed configuration, privacy-rules reference, and the complete "what is implemented" + "not implemented yet" breakdown.