From 27e839b8620b192f714b6ddc79b6913b6a313ed2 Mon Sep 17 00:00:00 2001 From: Sergei Patrikeev <6849689+serejke@users.noreply.github.com> Date: Tue, 21 Apr 2026 15:42:03 +0100 Subject: [PATCH 1/2] feat(telegram): Telegram Bot API emulator MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@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/` translate JSON / multipart request bodies through zod validators into typed handler input, then into store operations. File download endpoint at `/file/bot/`. 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` 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/`; `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(botId: number, type: T, payload: PayloadFor): TelegramUpdate`. `TelegramUpdate.payload` is the discriminated `WireUpdate` union — no `Record` 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> --- CHANGELOG.md | 12 + README.md | 248 +++- apps/web/app/api/docs-chat/route.ts | 2 +- apps/web/app/architecture/page.mdx | 1 + apps/web/app/configuration/page.mdx | 26 + apps/web/app/page.mdx | 3 +- apps/web/app/programmatic-api/page.mdx | 1 + apps/web/app/telegram/page.mdx | 135 ++ apps/web/components/docs-mobile-nav.tsx | 1 + apps/web/components/docs-nav.tsx | 1 + examples/telegram-grammy/README.md | 56 + examples/telegram-grammy/package.json | 26 + .../src/__tests__/parity.test.ts | 266 ++++ examples/telegram-grammy/src/bot.ts | 28 + examples/telegram-grammy/src/handlers.ts | 110 ++ examples/telegram-grammy/tsconfig.json | 15 + examples/telegram-grammy/vitest.config.ts | 8 + packages/@emulators/telegram/README.md | 205 +++ packages/@emulators/telegram/package.json | 51 + .../src/__tests__/bot-api-extended.test.ts | 285 +++++ .../telegram/src/__tests__/bot-api.test.ts | 411 ++++++ .../telegram/src/__tests__/helpers.ts | 46 + .../src/__tests__/integration.test.ts | 991 +++++++++++++++ .../telegram/src/__tests__/parse-mode.test.ts | 183 +++ .../src/__tests__/test-client.test.ts | 128 ++ .../telegram/src/__tests__/validators.test.ts | 157 +++ .../telegram/src/__tests__/webhook.test.ts | 109 ++ .../@emulators/telegram/src/dispatcher.ts | 299 +++++ packages/@emulators/telegram/src/entities.ts | 4 + .../@emulators/telegram/src/entity-parser.ts | 75 ++ packages/@emulators/telegram/src/helpers.ts | 34 + packages/@emulators/telegram/src/html.ts | 217 ++++ packages/@emulators/telegram/src/http.ts | 83 ++ packages/@emulators/telegram/src/ids.ts | 100 ++ packages/@emulators/telegram/src/index.ts | 154 +++ packages/@emulators/telegram/src/markdown.ts | 372 ++++++ packages/@emulators/telegram/src/paths.ts | 46 + .../telegram/src/routes/bot-api-chats.ts | 158 +++ .../telegram/src/routes/bot-api-delivery.ts | 104 ++ .../telegram/src/routes/bot-api-forum.ts | 110 ++ .../@emulators/telegram/src/routes/bot-api.ts | 1104 ++++++++++++++++ .../src/routes/control-diagnostics.ts | 66 + .../@emulators/telegram/src/routes/control.ts | 1131 +++++++++++++++++ .../telegram/src/routes/inspector.ts | 301 +++++ .../@emulators/telegram/src/serializers.ts | 158 +++ .../@emulators/telegram/src/services/media.ts | 227 ++++ .../telegram/src/services/sweeper.ts | 31 + packages/@emulators/telegram/src/store.ts | 48 + packages/@emulators/telegram/src/test.ts | 496 ++++++++ .../@emulators/telegram/src/types/index.ts | 2 + .../telegram/src/types/request/index.ts | 48 + .../telegram/src/types/store/bot.ts | 15 + .../src/types/store/callback-query.ts | 14 + .../telegram/src/types/store/chat.ts | 50 + .../telegram/src/types/store/draft.ts | 11 + .../telegram/src/types/store/fault.ts | 10 + .../telegram/src/types/store/file.ts | 20 + .../telegram/src/types/store/forum-topic.ts | 11 + .../telegram/src/types/store/index.ts | 11 + .../telegram/src/types/store/message.ts | 37 + .../telegram/src/types/store/reaction.ts | 10 + .../telegram/src/types/store/update.ts | 33 + .../telegram/src/types/store/user.ts | 10 + .../telegram/src/types/validators/body.ts | 52 + .../src/types/validators/callback-query.ts | 11 + .../telegram/src/types/validators/chats.ts | 26 + .../telegram/src/types/validators/commands.ts | 12 + .../telegram/src/types/validators/control.ts | 197 +++ .../telegram/src/types/validators/delivery.ts | 21 + .../telegram/src/types/validators/draft.ts | 12 + .../telegram/src/types/validators/forum.ts | 27 + .../telegram/src/types/validators/index.ts | 14 + .../src/types/validators/message-entity.ts | 39 + .../src/types/validators/primitives.ts | 29 + .../telegram/src/types/validators/reaction.ts | 15 + .../src/types/validators/reply-markup.ts | 42 + .../src/types/validators/send-media.ts | 81 ++ .../src/types/validators/send-message.ts | 43 + .../telegram/src/types/wire/callback-query.ts | 14 + .../src/types/wire/chat-member-updated.ts | 16 + .../telegram/src/types/wire/chat-member.ts | 83 ++ .../telegram/src/types/wire/chat.ts | 35 + .../telegram/src/types/wire/index.ts | 12 + .../telegram/src/types/wire/media.ts | 74 ++ .../telegram/src/types/wire/message-entity.ts | 31 + .../telegram/src/types/wire/message.ts | 41 + .../src/types/wire/reaction-update.ts | 29 + .../telegram/src/types/wire/reaction.ts | 5 + .../telegram/src/types/wire/reply-markup.ts | 52 + .../telegram/src/types/wire/update.ts | 54 + .../telegram/src/types/wire/user.ts | 20 + packages/@emulators/telegram/tsconfig.json | 8 + packages/@emulators/telegram/tsup.config.ts | 19 + packages/@emulators/telegram/vitest.config.ts | 7 + packages/emulate/package.json | 1 + packages/emulate/src/registry.ts | 28 + pnpm-lock.yaml | 253 +++- skills/telegram/SKILL.md | 128 ++ 98 files changed, 10516 insertions(+), 120 deletions(-) create mode 100644 apps/web/app/telegram/page.mdx create mode 100644 examples/telegram-grammy/README.md create mode 100644 examples/telegram-grammy/package.json create mode 100644 examples/telegram-grammy/src/__tests__/parity.test.ts create mode 100644 examples/telegram-grammy/src/bot.ts create mode 100644 examples/telegram-grammy/src/handlers.ts create mode 100644 examples/telegram-grammy/tsconfig.json create mode 100644 examples/telegram-grammy/vitest.config.ts create mode 100644 packages/@emulators/telegram/README.md create mode 100644 packages/@emulators/telegram/package.json create mode 100644 packages/@emulators/telegram/src/__tests__/bot-api-extended.test.ts create mode 100644 packages/@emulators/telegram/src/__tests__/bot-api.test.ts create mode 100644 packages/@emulators/telegram/src/__tests__/helpers.ts create mode 100644 packages/@emulators/telegram/src/__tests__/integration.test.ts create mode 100644 packages/@emulators/telegram/src/__tests__/parse-mode.test.ts create mode 100644 packages/@emulators/telegram/src/__tests__/test-client.test.ts create mode 100644 packages/@emulators/telegram/src/__tests__/validators.test.ts create mode 100644 packages/@emulators/telegram/src/__tests__/webhook.test.ts create mode 100644 packages/@emulators/telegram/src/dispatcher.ts create mode 100644 packages/@emulators/telegram/src/entities.ts create mode 100644 packages/@emulators/telegram/src/entity-parser.ts create mode 100644 packages/@emulators/telegram/src/helpers.ts create mode 100644 packages/@emulators/telegram/src/html.ts create mode 100644 packages/@emulators/telegram/src/http.ts create mode 100644 packages/@emulators/telegram/src/ids.ts create mode 100644 packages/@emulators/telegram/src/index.ts create mode 100644 packages/@emulators/telegram/src/markdown.ts create mode 100644 packages/@emulators/telegram/src/paths.ts create mode 100644 packages/@emulators/telegram/src/routes/bot-api-chats.ts create mode 100644 packages/@emulators/telegram/src/routes/bot-api-delivery.ts create mode 100644 packages/@emulators/telegram/src/routes/bot-api-forum.ts create mode 100644 packages/@emulators/telegram/src/routes/bot-api.ts create mode 100644 packages/@emulators/telegram/src/routes/control-diagnostics.ts create mode 100644 packages/@emulators/telegram/src/routes/control.ts create mode 100644 packages/@emulators/telegram/src/routes/inspector.ts create mode 100644 packages/@emulators/telegram/src/serializers.ts create mode 100644 packages/@emulators/telegram/src/services/media.ts create mode 100644 packages/@emulators/telegram/src/services/sweeper.ts create mode 100644 packages/@emulators/telegram/src/store.ts create mode 100644 packages/@emulators/telegram/src/test.ts create mode 100644 packages/@emulators/telegram/src/types/index.ts create mode 100644 packages/@emulators/telegram/src/types/request/index.ts create mode 100644 packages/@emulators/telegram/src/types/store/bot.ts create mode 100644 packages/@emulators/telegram/src/types/store/callback-query.ts create mode 100644 packages/@emulators/telegram/src/types/store/chat.ts create mode 100644 packages/@emulators/telegram/src/types/store/draft.ts create mode 100644 packages/@emulators/telegram/src/types/store/fault.ts create mode 100644 packages/@emulators/telegram/src/types/store/file.ts create mode 100644 packages/@emulators/telegram/src/types/store/forum-topic.ts create mode 100644 packages/@emulators/telegram/src/types/store/index.ts create mode 100644 packages/@emulators/telegram/src/types/store/message.ts create mode 100644 packages/@emulators/telegram/src/types/store/reaction.ts create mode 100644 packages/@emulators/telegram/src/types/store/update.ts create mode 100644 packages/@emulators/telegram/src/types/store/user.ts create mode 100644 packages/@emulators/telegram/src/types/validators/body.ts create mode 100644 packages/@emulators/telegram/src/types/validators/callback-query.ts create mode 100644 packages/@emulators/telegram/src/types/validators/chats.ts create mode 100644 packages/@emulators/telegram/src/types/validators/commands.ts create mode 100644 packages/@emulators/telegram/src/types/validators/control.ts create mode 100644 packages/@emulators/telegram/src/types/validators/delivery.ts create mode 100644 packages/@emulators/telegram/src/types/validators/draft.ts create mode 100644 packages/@emulators/telegram/src/types/validators/forum.ts create mode 100644 packages/@emulators/telegram/src/types/validators/index.ts create mode 100644 packages/@emulators/telegram/src/types/validators/message-entity.ts create mode 100644 packages/@emulators/telegram/src/types/validators/primitives.ts create mode 100644 packages/@emulators/telegram/src/types/validators/reaction.ts create mode 100644 packages/@emulators/telegram/src/types/validators/reply-markup.ts create mode 100644 packages/@emulators/telegram/src/types/validators/send-media.ts create mode 100644 packages/@emulators/telegram/src/types/validators/send-message.ts create mode 100644 packages/@emulators/telegram/src/types/wire/callback-query.ts create mode 100644 packages/@emulators/telegram/src/types/wire/chat-member-updated.ts create mode 100644 packages/@emulators/telegram/src/types/wire/chat-member.ts create mode 100644 packages/@emulators/telegram/src/types/wire/chat.ts create mode 100644 packages/@emulators/telegram/src/types/wire/index.ts create mode 100644 packages/@emulators/telegram/src/types/wire/media.ts create mode 100644 packages/@emulators/telegram/src/types/wire/message-entity.ts create mode 100644 packages/@emulators/telegram/src/types/wire/message.ts create mode 100644 packages/@emulators/telegram/src/types/wire/reaction-update.ts create mode 100644 packages/@emulators/telegram/src/types/wire/reaction.ts create mode 100644 packages/@emulators/telegram/src/types/wire/reply-markup.ts create mode 100644 packages/@emulators/telegram/src/types/wire/update.ts create mode 100644 packages/@emulators/telegram/src/types/wire/user.ts create mode 100644 packages/@emulators/telegram/tsconfig.json create mode 100644 packages/@emulators/telegram/tsup.config.ts create mode 100644 packages/@emulators/telegram/vitest.config.ts create mode 100644 skills/telegram/SKILL.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 27852542..32632122 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,19 @@ # Changelog +## Unreleased + +### New Features + +- **Telegram Bot API emulator** (`@emulators/telegram`) — stateful, wire-compatible Bot API surface so grammY / telegraf / `@chat-adapter/telegram` clients can run against `http://localhost:4007` unmodified. Includes a typed test client (`@emulators/telegram/test`), per-bot webhook delivery with HTTPS validation and 5xx retry (initial + 3 retries, 1s/2s/4s backoff), long-poll `getUpdates` with 409 on concurrent polls, `MarkdownV2` / `HTML` / legacy `Markdown` parse modes with blockquote support, forum-topic methods, `ChatFullInfo` shape on `getChat`, per-chat creator / administrator modeling, `message_reaction` + `message_reaction_count` dispatch, inspector UI, and control-plane fault injection + +### Typing + +- **Telegram emulator — hand-authored type system** (`@emulators/telegram`) — replaced every `Record`, `as unknown as X`, and per-field `typeof body.X` ladder with a self-contained type system under `src/types/` backed by zod 4 validators. Store rows (`src/types/store/*`) are separated from Bot API wire shapes (`src/types/wire/*`) and request bodies (`src/types/request/*`, derived from `z.infer`). `Dispatcher.enqueue` is generic on `UpdateType` with `PayloadFor`; `TelegramUpdate.payload` is a discriminated `WireUpdate` union; `buildMediaField` returns a discriminated `WireMediaField`; `getChatMember` / `getChatAdministrators` return a discriminated `WireChatMember` union on `status`. Route handlers parse input through `parseJsonBody(c, schema)`; errors are normalised to Telegram's `Bad Request: X is required` wording. No runtime behaviour change. + ## 0.4.1 + ### Bug Fixes - Include README in all `@emulators/*` npm packages @@ -11,6 +22,7 @@ ## 0.4.0 + ### New Features - **Next.js adapter** — embed emulators directly in your Next.js app via `@emulators/adapter-next`, solving the Vercel preview deployment problem where OAuth callback URLs change with every deployment (#43) diff --git a/README.md b/README.md index 6e78d327..4e55751d 100644 --- a/README.md +++ b/README.md @@ -17,6 +17,7 @@ All services start with sensible defaults. No config file needed: - **Apple** on `http://localhost:4004` - **Microsoft** on `http://localhost:4005` - **AWS** on `http://localhost:4006` +- **Telegram** on `http://localhost:4011` ## CLI @@ -45,11 +46,11 @@ emulate list ### Options -| Flag | Default | Description | -|------|---------|-------------| -| `-p, --port` | `4000` | Base port (auto-increments per service) | -| `-s, --service` | all | Comma-separated services to enable | -| `--seed` | auto-detect | Path to seed config (YAML or JSON) | +| Flag | Default | Description | +| --------------- | ----------- | --------------------------------------- | +| `-p, --port` | `4000` | Base port (auto-increments per service) | +| `-s, --service` | all | Comma-separated services to enable | +| `--seed` | auto-detect | Path to seed config (YAML or JSON) | The port can also be set via `EMULATE_PORT` or `PORT` environment variables. @@ -62,54 +63,57 @@ npm install emulate Each call to `createEmulator` starts a single service: ```typescript -import { createEmulator } from 'emulate' +import { createEmulator } from "emulate"; -const github = await createEmulator({ service: 'github', port: 4001 }) -const vercel = await createEmulator({ service: 'vercel', port: 4002 }) +const github = await createEmulator({ service: "github", port: 4001 }); +const vercel = await createEmulator({ service: "vercel", port: 4002 }); -github.url // 'http://localhost:4001' -vercel.url // 'http://localhost:4002' +github.url; // 'http://localhost:4001' +vercel.url; // 'http://localhost:4002' -await github.close() -await vercel.close() +await github.close(); +await vercel.close(); ``` ### Vitest / Jest setup ```typescript // vitest.setup.ts -import { createEmulator, type Emulator } from 'emulate' +import { createEmulator, type Emulator } from "emulate"; -let github: Emulator -let vercel: Emulator +let github: Emulator; +let vercel: Emulator; beforeAll(async () => { - ;[github, vercel] = await Promise.all([ - createEmulator({ service: 'github', port: 4001 }), - createEmulator({ service: 'vercel', port: 4002 }), - ]) - process.env.GITHUB_EMULATOR_URL = github.url - process.env.VERCEL_EMULATOR_URL = vercel.url -}) - -afterEach(() => { github.reset(); vercel.reset() }) -afterAll(() => Promise.all([github.close(), vercel.close()])) + [github, vercel] = await Promise.all([ + createEmulator({ service: "github", port: 4001 }), + createEmulator({ service: "vercel", port: 4002 }), + ]); + process.env.GITHUB_EMULATOR_URL = github.url; + process.env.VERCEL_EMULATOR_URL = vercel.url; +}); + +afterEach(() => { + github.reset(); + vercel.reset(); +}); +afterAll(() => Promise.all([github.close(), vercel.close()])); ``` ### Options -| Option | Default | Description | -|--------|---------|-------------| -| `service` | *(required)* | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, or `'aws'` | -| `port` | `4000` | Port for the HTTP server | -| `seed` | none | Inline seed data (same shape as YAML config) | +| Option | Default | Description | +| --------- | ------------ | ------------------------------------------------------------------------------------------------- | +| `service` | _(required)_ | Service name: `'vercel'`, `'github'`, `'google'`, `'slack'`, `'apple'`, `'microsoft'`, or `'aws'` | +| `port` | `4000` | Port for the HTTP server | +| `seed` | none | Inline seed data (same shape as YAML config) | ### Instance methods -| Method | Description | -|--------|-------------| -| `url` | Base URL of the running server | -| `reset()` | Wipe the store and replay seed data | +| Method | Description | +| --------- | -------------------------------------------- | +| `url` | Base URL of the running server | +| `reset()` | Wipe the store and replay seed data | | `close()` | Shut down the HTTP server, returns a Promise | ## Configuration @@ -254,6 +258,25 @@ aws: roles: - role_name: lambda-execution-role description: Role for Lambda function execution + +telegram: + bots: + - username: trip_bot + first_name: Trip Bot + token: "100001:TRIP_BOT_TOKEN" + 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] ``` ## OAuth & Integrations @@ -315,6 +338,7 @@ github: JWT authentication: sign a JWT with `{ iss: "" }` using the app's private key (RS256). The emulator verifies the signature and resolves the app. **App webhook delivery**: When events occur on repos where a GitHub App is installed, the emulator mirrors real GitHub behavior: + - All webhook payloads (including repo and org hooks) include an `installation` field with `{ id, node_id }`. - If the app has a `webhook_url`, the emulator delivers the event there with the `installation` field and (if configured) an `X-Hub-Signature-256` header signed with `webhook_secret`. @@ -359,6 +383,7 @@ microsoft: Every endpoint below is fully stateful with Vercel-style JSON responses and cursor-based pagination. ### User & Teams + - `GET /v2/user` - authenticated user - `PATCH /v2/user` - update user - `GET /v2/teams` - list teams (cursor paginated) @@ -369,6 +394,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs - `POST /v2/teams/:teamId/members` - add member ### Projects + - `POST /v11/projects` - create project (with optional env vars and git integration) - `GET /v10/projects` - list projects (search, cursor pagination) - `GET /v9/projects/:idOrName` - get project (includes env vars) @@ -378,6 +404,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs - `PATCH /v1/projects/:idOrName/protection-bypass` - manage bypass secrets ### Deployments + - `POST /v13/deployments` - create deployment (auto-transitions to READY) - `GET /v13/deployments/:idOrUrl` - get deployment (by ID or URL) - `GET /v6/deployments` - list deployments (filter by project, target, state) @@ -389,6 +416,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs - `POST /v2/files` - upload file (by SHA digest) ### Domains + - `POST /v10/projects/:idOrName/domains` - add domain (with verification challenge) - `GET /v9/projects/:idOrName/domains` - list domains - `GET /v9/projects/:idOrName/domains/:domain` - get domain @@ -397,6 +425,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs - `POST /v9/projects/:idOrName/domains/:domain/verify` - verify domain ### Environment Variables + - `GET /v10/projects/:idOrName/env` - list env vars (with decrypt option) - `POST /v10/projects/:idOrName/env` - create env vars (single, batch, upsert) - `GET /v10/projects/:idOrName/env/:id` - get env var @@ -408,6 +437,7 @@ Every endpoint below is fully stateful with Vercel-style JSON responses and curs Every endpoint below is fully stateful. Creates, updates, and deletes persist in memory and affect related entities. ### Users + - `GET /user` - authenticated user - `PATCH /user` - update profile - `GET /users/:username` - get user @@ -418,6 +448,7 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - `GET /users/:username/following` - list following ### Repositories + - `GET /repos/:owner/:repo` - get repo - `POST /user/repos` - create user repo - `POST /orgs/:org/repos` - create org repo @@ -434,6 +465,7 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - `GET /repos/:owner/:repo/tags` - list tags ### Issues + - `GET /repos/:owner/:repo/issues` - list (filter by state, labels, assignee, milestone, creator, since) - `POST /repos/:owner/:repo/issues` - create - `GET /repos/:owner/:repo/issues/:number` - get @@ -444,6 +476,7 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - `POST/DELETE /repos/:owner/:repo/issues/:number/assignees` - manage assignees ### Pull Requests + - `GET /repos/:owner/:repo/pulls` - list (filter by state, head, base) - `POST /repos/:owner/:repo/pulls` - create - `GET /repos/:owner/:repo/pulls/:number` - get @@ -455,12 +488,14 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - `PUT /repos/:owner/:repo/pulls/:number/update-branch` - update branch ### Comments + - Issue comments: full CRUD on `/repos/:owner/:repo/issues/:number/comments` - Review comments: full CRUD on `/repos/:owner/:repo/pulls/:number/comments` - Commit comments: full CRUD on `/repos/:owner/:repo/commits/:sha/comments` - Repo-wide listings for each type ### Reviews + - `GET /repos/:owner/:repo/pulls/:number/reviews` - list - `POST /repos/:owner/:repo/pulls/:number/reviews` - create (with inline comments) - `GET/PUT /repos/:owner/:repo/pulls/:number/reviews/:id` - get/update @@ -468,10 +503,12 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - `PUT /repos/:owner/:repo/pulls/:number/reviews/:id/dismissals` - dismiss ### Labels & Milestones + - Labels: full CRUD, add/remove from issues, replace all - Milestones: full CRUD, state transitions, issue counts ### Branches & Git Data + - Branches: list, get, protection CRUD (status checks, PR reviews, enforce admins) - Refs: get, match, create, update, delete - Commits: get, create @@ -480,21 +517,25 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - Tags: get, create ### Organizations & Teams + - Orgs: get, update, list - Org members: list, check, remove, get/set membership - Teams: full CRUD, members, repos ### Releases + - Releases: full CRUD, latest, by tag - Release assets: full CRUD, upload - Generate release notes ### Webhooks + - Repo webhooks: full CRUD, ping, test, deliveries - Org webhooks: full CRUD, ping - Real HTTP delivery to registered URLs on all state changes ### Search + - `GET /search/repositories` - full query syntax (user, org, language, topic, stars, forks, etc.) - `GET /search/issues` - issues + PRs (repo, is, author, label, milestone, state, etc.) - `GET /search/users` - users + orgs @@ -504,6 +545,7 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - `GET /search/labels` - label search ### Actions + - Workflows: list, get, enable/disable, dispatch - Workflow runs: list, get, cancel, rerun, delete, logs - Jobs: list, get, logs @@ -511,11 +553,13 @@ Every endpoint below is fully stateful. Creates, updates, and deletes persist in - Secrets: repo + org CRUD ### Checks + - Check runs: create, update, get, annotations, rerequest, list by ref/suite - Check suites: create, get, preferences, rerequest, list by ref - Automatic suite status rollup from check run results ### Misc + - `GET /rate_limit` - rate limit status - `GET /meta` - server metadata - `GET /octocat` - ASCII art @@ -556,6 +600,7 @@ OAuth 2.0, OpenID Connect, and mutable Google Workspace-style surfaces for local Fully stateful Slack Web API emulation with channels, messages, threads, reactions, OAuth v2, and incoming webhooks. ### Auth & Chat + - `POST /api/auth.test` - test authentication - `POST /api/chat.postMessage` - post message (supports threads via `thread_ts`) - `POST /api/chat.update` - update message @@ -563,6 +608,7 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio - `POST /api/chat.meMessage` - /me message ### Conversations + - `POST /api/conversations.list` - list channels (cursor pagination) - `POST /api/conversations.info` - get channel info - `POST /api/conversations.create` - create channel @@ -572,17 +618,20 @@ Fully stateful Slack Web API emulation with channels, messages, threads, reactio - `POST /api/conversations.members` - list members ### Users & Reactions + - `POST /api/users.list` - list users (cursor pagination) - `POST /api/users.info` - get user info - `POST /api/users.lookupByEmail` - lookup by email - `POST /api/reactions.add` / `reactions.remove` / `reactions.get` - manage reactions ### Team, Bots & Webhooks + - `POST /api/team.info` - workspace info - `POST /api/bots.info` - bot info - `POST /services/:teamId/:botId/:webhookId` - incoming webhook ### OAuth + - `GET /oauth/v2/authorize` - authorization (shows user picker) - `POST /api/oauth.v2.access` - token exchange @@ -630,21 +679,101 @@ S3 routes use root paths matching the real AWS S3 wire format, so the official A - `DELETE /:bucket/:key` - delete object ### SQS + All operations via `POST /sqs/` with `Action` parameter: + - `CreateQueue`, `ListQueues`, `GetQueueUrl`, `GetQueueAttributes` - `SendMessage`, `ReceiveMessage`, `DeleteMessage` - `PurgeQueue`, `DeleteQueue` ### IAM + All operations via `POST /iam/` with `Action` parameter: + - `CreateUser`, `GetUser`, `ListUsers`, `DeleteUser` - `CreateAccessKey`, `ListAccessKeys`, `DeleteAccessKey` - `CreateRole`, `GetRole`, `ListRoles`, `DeleteRole` ### STS + All operations via `POST /sts/` with `Action` parameter: + - `GetCallerIdentity`, `AssumeRole` +## Telegram Bot API + +End-to-end emulation of the Telegram Bot API so you can test bots without creating a real bot or clicking through Telegram clients. Fully stateful. Real grammY / telegraf / `@chat-adapter/telegram` SDKs connect unmodified. + +### Bot API routes (implemented) + +All methods are exposed under `/bot/`: + +- **Identity**: `getMe` +- **Delivery**: `getUpdates` (with `offset`, `limit`, `timeout`, 409 on concurrent polls), `setWebhook` (HTTPS-only, with `secret_token`), `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/` +- **Reactions**: `setMessageReaction` +- **Callbacks**: `answerCallbackQuery` +- **Chats**: `getChat` (returns `ChatFullInfo`), `getChatMember`, `getChatAdministrators`, `getChatMemberCount` +- **Forum**: `createForumTopic`, `editForumTopic`, `closeForumTopic`, `reopenForumTopic`, `deleteForumTopic` +- **Commands**: `setMyCommands`, `getMyCommands` + +Update types dispatched to bots: `message`, `edited_message`, `callback_query`, `my_chat_member`, `message_reaction`, `message_reaction_count`, `channel_post`, `edited_channel_post`. + +### Update delivery + +Both modes supported, per bot: + +- **Webhook** — after `setWebhook({url})`, the emulator POSTs Update JSON on every user action. Retries on 5xx up to 3 times with 1s / 2s / 4s backoff. Terminal on 4xx. Sends `X-Telegram-Bot-Api-Secret-Token` header if configured. +- **Long polling** — `getUpdates(offset?, limit?, timeout?)` drains queued updates. `offset` confirms prior updates; `timeout > 0` opens a long-poll. + +### Test control plane + +The programmatic test client (`@emulators/telegram/test`) and its HTTP twin under `/_emu/telegram/*` let tests simulate user activity: + +```typescript +import { createEmulator } from "emulate"; +import { createTelegramTestClient } from "@emulators/telegram/test"; + +const emu = await createEmulator({ service: "telegram", port: 4011 }); +const tg = createTelegramTestClient(emu.url); + +const bot = await tg.createBot({ username: "trip_test_bot", first_name: "Trip Test" }); +const user = await tg.createUser({ first_name: "Alice" }); +const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + +await tg.sendUserMessage({ chatId: dm.id, userId: user.id, text: "/connect ABC" }); +await tg.sendUserPhoto({ chatId: dm.id, userId: user.id, photoBytes: fs.readFileSync("photo.jpg") }); +await tg.clickInlineButton({ chatId: dm.id, userId: user.id, messageId: 42, callbackData: "confirm:yes" }); + +const botReplies = await tg.getSentMessages({ chatId: dm.id }); +``` + +HTTP equivalents: + +- `POST /_emu/telegram/bots` — create bot (`{ username, first_name?, token? }`) +- `POST /_emu/telegram/users` — create user +- `POST /_emu/telegram/chats/private` — create DM (`{ botId, userId }`) +- `POST /_emu/telegram/chats/group` — create group (`{ title, memberIds, botIds }`) +- `POST /_emu/telegram/chats/:chatId/messages` — simulate user message (`{ userId, text }`) +- `POST /_emu/telegram/chats/:chatId/photos` — simulate user photo (`{ userId, photoBase64, caption? }`) +- `POST /_emu/telegram/chats/:chatId/callbacks` — simulate button click (`{ userId, messageId, data }`) +- `POST /_emu/telegram/chats/:chatId/edits` — simulate user message edit +- `POST /_emu/telegram/chats/:chatId/add-bot` — add bot to chat (`{ botId, byUserId }`, dispatches `my_chat_member`) +- `POST /_emu/telegram/chats/:chatId/remove-bot` — remove bot from chat +- `GET /_emu/telegram/chats/:chatId/messages?scope=all|bot` — inspect messages +- `GET /_emu/telegram/chats/:chatId/drafts/:draftId` — inspect `sendMessageDraft` snapshots +- `POST /_emu/telegram/reset` — full wipe + seed replay + +### Privacy rules in groups + +Matches real Telegram: non-privileged bots in groups only see messages that mention them (`@bot_username`) or are addressed bot commands (`/command` or `/command@bot_username`). Set `can_read_all_group_messages: true` on a bot to disable Privacy Mode and see every message. + +### Non-goals + +Payments, games, Telegram Business API, Telegram Passport, TON wallets, BotFather account management. Out of scope forever. + ## Next.js Integration Embed emulators directly in your Next.js app so they run on the same origin. This solves the Vercel preview deployment problem where OAuth callback URLs change with every deployment. @@ -663,27 +792,27 @@ Create a catch-all route that serves emulator traffic: ```typescript // app/emulate/[...path]/route.ts -import { createEmulateHandler } from '@emulators/adapter-next' -import * as github from '@emulators/github' -import * as google from '@emulators/google' +import { createEmulateHandler } from "@emulators/adapter-next"; +import * as github from "@emulators/github"; +import * as google from "@emulators/google"; export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({ services: { github: { emulator: github, seed: { - users: [{ login: 'octocat', name: 'The Octocat' }], - repos: [{ owner: 'octocat', name: 'hello-world', auto_init: true }], + users: [{ login: "octocat", name: "The Octocat" }], + repos: [{ owner: "octocat", name: "hello-world", auto_init: true }], }, }, google: { emulator: google, seed: { - users: [{ email: 'test@example.com', name: 'Test User' }], + users: [{ email: "test@example.com", name: "Test User" }], }, }, }, -}) +}); ``` ### Auth.js / NextAuth configuration @@ -691,19 +820,17 @@ export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({ Point your provider at the emulator paths on the same origin: ```typescript -import GitHub from 'next-auth/providers/github' +import GitHub from "next-auth/providers/github"; -const baseUrl = process.env.VERCEL_URL - ? `https://${process.env.VERCEL_URL}` - : 'http://localhost:3000' +const baseUrl = process.env.VERCEL_URL ? `https://${process.env.VERCEL_URL}` : "http://localhost:3000"; GitHub({ - clientId: 'any-value', - clientSecret: 'any-value', + clientId: "any-value", + clientSecret: "any-value", authorization: { url: `${baseUrl}/emulate/github/login/oauth/authorize` }, token: { url: `${baseUrl}/emulate/github/login/oauth/access_token` }, userinfo: { url: `${baseUrl}/emulate/github/user` }, -}) +}); ``` No `oauth_apps` need to be seeded. When none are configured, the emulator skips `client_id`, `client_secret`, and `redirect_uri` validation. @@ -714,17 +841,17 @@ Emulator UI pages use bundled fonts. Wrap your Next.js config to include them in ```typescript // next.config.mjs -import { withEmulate } from '@emulators/adapter-next' +import { withEmulate } from "@emulators/adapter-next"; export default withEmulate({ // your normal Next.js config -}) +}); ``` If you mount the catch-all at a custom path, pass the matching prefix: ```typescript -export default withEmulate(nextConfig, { routePrefix: '/api/emulate' }) +export default withEmulate(nextConfig, { routePrefix: "/api/emulate" }); ``` ### Persistence @@ -732,18 +859,22 @@ export default withEmulate(nextConfig, { routePrefix: '/api/emulate' }) By default, emulator state is in-memory and resets on every cold start. To persist state across restarts, pass a `persistence` adapter: ```typescript -import { createEmulateHandler } from '@emulators/adapter-next' -import * as github from '@emulators/github' +import { createEmulateHandler } from "@emulators/adapter-next"; +import * as github from "@emulators/github"; const kvAdapter = { - async load() { return await kv.get('emulate-state') }, - async save(data: string) { await kv.set('emulate-state', data) }, -} + async load() { + return await kv.get("emulate-state"); + }, + async save(data: string) { + await kv.set("emulate-state", data); + }, +}; export const { GET, POST, PUT, PATCH, DELETE } = createEmulateHandler({ services: { github: { emulator: github } }, persistence: kvAdapter, -}) +}); ``` For local development, `@emulators/core` ships `filePersistence`: @@ -772,6 +903,7 @@ packages/ apple/ # Apple Sign In / OIDC microsoft/ # Microsoft Entra ID OAuth 2.0 / OIDC + Graph /me aws/ # AWS S3, SQS, IAM, STS + telegram/ # Telegram Bot API — messages, groups, photos, callbacks, webhook/polling apps/ web/ # Documentation site (Next.js) ``` @@ -795,3 +927,5 @@ Tokens are configured in the seed config and map to users. Pass them as `Authori **Microsoft**: OIDC authorization code flow with PKCE support. Also supports client credentials grants. Microsoft Graph `/v1.0/me` available. **AWS**: Bearer tokens or IAM access key credentials. Default key pair always seeded: `AKIAIOSFODNN7EXAMPLE` / `wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY`. + +**Telegram**: Auth is per-request via the bot token in the URL path (`/bot/`). Bots are declared in the seed config or created dynamically via the test control plane (`/_emu/telegram/bots`). See `packages/@emulators/telegram/README.md` for the test-client API, webhook + long-polling support, and privacy-rule behaviour in groups. diff --git a/apps/web/app/api/docs-chat/route.ts b/apps/web/app/api/docs-chat/route.ts index 9c08e6da..ed81284e 100644 --- a/apps/web/app/api/docs-chat/route.ts +++ b/apps/web/app/api/docs-chat/route.ts @@ -12,7 +12,7 @@ export const maxDuration = 60; const DEFAULT_MODEL = "anthropic/claude-haiku-4.5"; -const SYSTEM_PROMPT = `You are a helpful documentation assistant for emulate, a local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Okta, MongoDB Atlas, Resend, and Stripe APIs used in CI and no-network sandboxes. +const SYSTEM_PROMPT = `You are a helpful documentation assistant for emulate, a local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Okta, MongoDB Atlas, Resend, Stripe, and Telegram Bot APIs used in CI and no-network sandboxes. emulate provides fully stateful, production-fidelity API emulation, not mocks. The CLI is installed as the "emulate" npm package and run via "npx emulate" or just "emulate". It also supports a programmatic API via createEmulator and a Next.js adapter (@emulators/adapter-next) for embedding emulators in your app. diff --git a/apps/web/app/architecture/page.mdx b/apps/web/app/architecture/page.mdx index de6102b3..53006f0e 100644 --- a/apps/web/app/architecture/page.mdx +++ b/apps/web/app/architecture/page.mdx @@ -17,6 +17,7 @@ packages/ mongoatlas/ # MongoDB Atlas Admin API + Data API resend/ # Resend email API stripe/ # Stripe billing and payments API + telegram/ # Telegram Bot API apps/ web/ # Documentation site (Next.js) ``` diff --git a/apps/web/app/configuration/page.mdx b/apps/web/app/configuration/page.mdx index 250be804..8c3109a9 100644 --- a/apps/web/app/configuration/page.mdx +++ b/apps/web/app/configuration/page.mdx @@ -344,3 +344,29 @@ stripe: recurring: interval: month ``` + +## Telegram Seed Config + +```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] +``` + +`chats[].type` supports `private` and `group`. Supergroups, channels, and forum topics are created at runtime via the control plane (`createSupergroup`, `createChannel`, `createForumTopic`). diff --git a/apps/web/app/page.mdx b/apps/web/app/page.mdx index 374829e0..fa17c144 100644 --- a/apps/web/app/page.mdx +++ b/apps/web/app/page.mdx @@ -1,6 +1,6 @@ # Getting Started -Local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Okta, MongoDB Atlas, Resend, and Stripe APIs. Built for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation. Not mocks. +Local drop-in replacement for Vercel, GitHub, Google, Slack, Apple, Microsoft, AWS, Okta, MongoDB Atlas, Resend, Stripe, and Telegram Bot APIs. Built for CI and no-network sandboxes. Fully stateful, production-fidelity API emulation. Not mocks. ## Quick Start @@ -21,6 +21,7 @@ All services start with sensible defaults. No config file needed: - **MongoDB Atlas** on `http://localhost:4008` - **Resend** on `http://localhost:4009` - **Stripe** on `http://localhost:4010` +- **Telegram** on `http://localhost:4011` ## CLI diff --git a/apps/web/app/programmatic-api/page.mdx b/apps/web/app/programmatic-api/page.mdx index a957f4e7..feb8a4d5 100644 --- a/apps/web/app/programmatic-api/page.mdx +++ b/apps/web/app/programmatic-api/page.mdx @@ -128,6 +128,7 @@ npm install @emulators/github @emulators/google @emulators/stripe @emulators/mongoatlasMongoDB Atlas Admin API + Data API @emulators/resendResend email API @emulators/stripeStripe billing and payments API + @emulators/telegramTelegram Bot API @emulators/coreShared store, middleware, and utilities @emulators/adapter-nextNext.js App Router integration diff --git a/apps/web/app/telegram/page.mdx b/apps/web/app/telegram/page.mdx new file mode 100644 index 00000000..4e4beea2 --- /dev/null +++ b/apps/web/app/telegram/page.mdx @@ -0,0 +1,135 @@ +# Telegram + +Telegram Bot API emulation. Wire-compatible with `api.telegram.org` — grammY, telegraf, and `@chat-adapter/telegram` clients connect unmodified by pointing `apiRoot` at `http://localhost:4011`. Fully stateful: messages, chats, callbacks, reactions, media, webhooks, and long-poll Updates all live in a typed in-memory store. + +All Bot API methods are exposed under `/bot/` and return the canonical Bot API envelope `{ ok, result }` or `{ ok: false, error_code, description }`. + +## Identity + +- `POST /bot/getMe` — bot identity + +## Update delivery + +- `POST /bot/getUpdates` — long-poll with `offset` / `limit` / `timeout` / `allowed_updates`. Returns `409 Conflict` if a webhook is active or another long-poll is in flight (takeover semantics) +- `POST /bot/setWebhook` — HTTPS URL + `secret_token` + `allowed_updates`. 5xx retry: initial + 3 attempts, 1s / 2s / 4s backoff, terminal on 4xx. Emits `X-Telegram-Bot-Api-Secret-Token` header when configured +- `POST /bot/deleteWebhook` +- `POST /bot/getWebhookInfo` + +Update types dispatched: `message`, `edited_message`, `callback_query`, `my_chat_member`, `message_reaction`, `message_reaction_count`, `channel_post`, `edited_channel_post`. + +## Messaging + +- `POST /bot/sendMessage` +- `POST /bot/sendPhoto` — `file_id` or multipart upload; returns three `PhotoSize` tiers; `file_id` preserved on re-send +- `POST /bot/sendDocument` +- `POST /bot/sendVideo` +- `POST /bot/sendAudio` +- `POST /bot/sendVoice` +- `POST /bot/sendAnimation` +- `POST /bot/sendSticker` +- `POST /bot/editMessageText` +- `POST /bot/editMessageReplyMarkup` +- `POST /bot/deleteMessage` +- `POST /bot/sendChatAction` + +## Formatting + +`parse_mode = MarkdownV2` / `HTML` / legacy `Markdown` on text + caption surfaces, including `blockquote` and `expandable_blockquote`. UTF-16 entity offsets match the real Bot API. Unescaped reserved chars produce the exact 400 wording: `can't parse entities: character 'X' is reserved and must be escaped with the preceding '\'`. + +Auto-detected entities in free text: `bot_command`, `mention`, `url`, `email`, `hashtag`, `cashtag`. + +## Files + +- `POST /bot/getFile` — returns `file_path` +- `GET /file/bot/` — HTTP download of the stored bytes + +## Reactions + +- `POST /bot/setMessageReaction` — dispatches both `message_reaction` (per-user) and `message_reaction_count` (anonymous aggregate) Updates + +## Callbacks + +- `POST /bot/answerCallbackQuery` — persists `text` / `show_alert` / `url` / `cache_time` + +## Chats + +- `POST /bot/getChat` — returns `ChatFullInfo` with `accent_color_id`, `max_reaction_count`, `permissions`, `pinned_message`, `bio`, `description`, `invite_link`, `slow_mode_delay`, etc. +- `POST /bot/getChatMember` — returns a discriminated `ChatMember` on `status` (creator / administrator / member / left) +- `POST /bot/getChatAdministrators` +- `POST /bot/getChatMemberCount` + +## Forum topics + +Requires a supergroup with `is_forum: true`. + +- `POST /bot/createForumTopic` +- `POST /bot/editForumTopic` +- `POST /bot/closeForumTopic` +- `POST /bot/reopenForumTopic` +- `POST /bot/deleteForumTopic` + +## Commands + +- `POST /bot/setMyCommands` +- `POST /bot/getMyCommands` + +## Privacy Mode + +Matches real Telegram behaviour in groups: + +- Bots with privacy mode on (the default) only receive messages that **mention** them (`@bot_username`) or are **addressed bot commands** (`/command@bot_username`). +- Bare `/command` with no `@` is **dropped**. +- Plain chatter between humans is **not** delivered. +- To receive every message, set `can_read_all_group_messages: true` when creating the bot. + +## Test control plane + +`/_emu/telegram/*` routes that real Telegram never exposes. Useful for test setup and assertions. + +- `POST /_emu/telegram/bots` — create bot +- `POST /_emu/telegram/users` — create user +- `POST /_emu/telegram/chats/private` — create DM +- `POST /_emu/telegram/chats/group` — create group +- `POST /_emu/telegram/chats/supergroup` — create supergroup (`isForum: true` enables forum methods) +- `POST /_emu/telegram/chats/channel` — create channel +- `POST /_emu/telegram/chats/:chatId/messages` — simulate user message +- `POST /_emu/telegram/chats/:chatId/photos` — simulate user photo +- `POST /_emu/telegram/chats/:chatId/media` — simulate user video/audio/voice/animation/sticker/document +- `POST /_emu/telegram/chats/:chatId/callbacks` — simulate inline-button click +- `POST /_emu/telegram/chats/:chatId/edits` — simulate user message edit +- `POST /_emu/telegram/chats/:chatId/reactions` — simulate reaction (dispatches both reaction + reaction_count) +- `POST /_emu/telegram/chats/:chatId/add-bot` / `remove-bot` — dispatch `my_chat_member` Update +- `POST /_emu/telegram/chats/:chatId/promote` — toggle administrator status +- `POST /_emu/telegram/chats/:chatId/topics` — create forum topic +- `POST /_emu/telegram/chats/:chatId/channel-posts` / `channel-post-edits` — post or edit as the channel itself +- `GET /_emu/telegram/chats/:chatId/messages?scope=bot|all` — inspect messages +- `GET /_emu/telegram/chats/:chatId/drafts/:draftId` — inspect streamed-draft snapshots +- `POST /_emu/telegram/faults` — inject controlled `401` / `403` / `404` / `429` (with `retry_after`) / generic `400` on the next N calls +- `DELETE /_emu/telegram/faults` — clear pending faults +- `GET /_emu/telegram/callbacks/:id` — inspect the bot's callback_query answer + +Mirrored one-to-one in a typed TS client at `@emulators/telegram/test`: + +```ts +import { createTelegramTestClient } from "@emulators/telegram/test"; + +const tg = createTelegramTestClient("http://localhost:4011"); +const bot = await tg.createBot({ username: "trip_test_bot" }); +const user = await tg.createUser({ first_name: "Alice" }); +const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); +await tg.sendUserMessage({ chatId: dm.id, userId: user.id, text: "/connect ABC" }); + +const botReplies = await tg.getSentMessages({ chatId: dm.id }); +``` + +## Streaming drafts (emulator-only extension) + +- `POST /bot/sendMessageDraft` — private chats only. Each call appends a snapshot under `(chat_id, draft_id, bot_id)`. Not a real Bot API method; lets tests exercise animated streamed replies. + +## Inspector + +Open `http://localhost:4011/` in a browser for a read-only view of bots, chats, message timelines (with entity highlighting, media/reaction badges, edit/delete markers), streaming drafts, and the per-bot Update queue. + +## Non-goals + +Payments, Games, Business API, Passport, TON wallets, BotFather. Permanent — they'd bloat the plugin without serving any realistic test-time use case. diff --git a/apps/web/components/docs-mobile-nav.tsx b/apps/web/components/docs-mobile-nav.tsx index c767548f..87bdfadf 100644 --- a/apps/web/components/docs-mobile-nav.tsx +++ b/apps/web/components/docs-mobile-nav.tsx @@ -33,6 +33,7 @@ const sections: NavSection[] = [ { href: "/mongoatlas", label: "MongoDB Atlas" }, { href: "/resend", label: "Resend" }, { href: "/stripe", label: "Stripe" }, + { href: "/telegram", label: "Telegram" }, ], }, { diff --git a/apps/web/components/docs-nav.tsx b/apps/web/components/docs-nav.tsx index a4be1da2..f5d093c9 100644 --- a/apps/web/components/docs-nav.tsx +++ b/apps/web/components/docs-nav.tsx @@ -31,6 +31,7 @@ const sections: NavSection[] = [ { href: "/mongoatlas", label: "MongoDB Atlas" }, { href: "/resend", label: "Resend" }, { href: "/stripe", label: "Stripe" }, + { href: "/telegram", label: "Telegram" }, ], }, { diff --git a/examples/telegram-grammy/README.md b/examples/telegram-grammy/README.md new file mode 100644 index 00000000..978313c0 --- /dev/null +++ b/examples/telegram-grammy/README.md @@ -0,0 +1,56 @@ +# telegram-grammy-demo + +A small [grammY](https://grammy.dev) bot that runs unchanged against the Telegram emulator **and** against real Telegram. Serves as the parity proof for `@emulators/telegram`. + +## Handlers + +| Command / event | Exercises | +| -------------------------- | ------------------------------------------------------------------------ | +| `/start` | Text send, command routing | +| `/echo ` | Command with arguments | +| `/menu` | `sendMessage` with `reply_markup.inline_keyboard` | +| click on inline button | `callback_query` update, `answerCallbackQuery`, `editMessageReplyMarkup` | +| photo message | Photo receive, `file_id` round-trip via `replyWithPhoto(file_id)` | +| plain text (not a command) | Fallback text echo | + +The handler code in `src/handlers.ts` is backend-agnostic. Only `src/bot.ts` reads `TELEGRAM_API_ROOT` and wires it into grammY's `client.apiRoot`. + +## Run against the emulator + +```bash +# Terminal 1 +npx emulate --service telegram --port 4011 + +# Terminal 2 +pnpm --filter telegram-grammy-demo start:emu +``` + +The bot connects to the default seeded bot (`@emulate_bot`) and long-polls. Drive user activity: + +```bash +curl -X POST http://localhost:4011/_emu/telegram/chats/1001/messages \ + -H 'content-type: application/json' \ + -d '{"userId":1001,"text":"/start"}' + +curl -s 'http://localhost:4011/_emu/telegram/chats/1001/messages?scope=bot' +``` + +Or open `http://localhost:4011/` in a browser — the inspector shows chats, messages, and the Update queue live. + +## Run against real Telegram + +```bash +BOT_TOKEN= pnpm --filter telegram-grammy-demo start +``` + +`TELEGRAM_API_ROOT` defaults to `https://api.telegram.org` when unset. Open your bot in a Telegram client, send `/start`, `/echo hi`, `/menu`, a photo — they should behave identically to the emulator run. This is the one-shot parity check. + +## Run the parity test + +```bash +pnpm --filter telegram-grammy-demo test +``` + +The test boots the emulator in-process on an OS-picked port, starts the bot against it, simulates user activity through the test control plane, and asserts the bot's replies against the store. Covers all six handler paths. Runs in ~10 seconds. + +Treat the emulator and real Telegram as interchangeable backends: if this test passes and the real-Telegram smoke run looks the same, the emulator is good enough for bot development. diff --git a/examples/telegram-grammy/package.json b/examples/telegram-grammy/package.json new file mode 100644 index 00000000..c147cb15 --- /dev/null +++ b/examples/telegram-grammy/package.json @@ -0,0 +1,26 @@ +{ + "name": "telegram-grammy-demo", + "version": "0.4.1", + "private": true, + "type": "module", + "description": "Small grammY bot that runs unchanged against the Telegram emulator and against real Telegram. Proves emulator parity.", + "scripts": { + "start": "tsx src/bot.ts", + "start:emu": "TELEGRAM_API_ROOT=http://localhost:4011 BOT_TOKEN=100001:EMULATE_DEFAULT_TOKEN tsx src/bot.ts", + "test": "vitest run", + "type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "grammy": "^1.29.0" + }, + "devDependencies": { + "@emulators/core": "workspace:*", + "@emulators/telegram": "workspace:*", + "@hono/node-server": "^1", + "hono": "^4", + "tsx": "^4", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} diff --git a/examples/telegram-grammy/src/__tests__/parity.test.ts b/examples/telegram-grammy/src/__tests__/parity.test.ts new file mode 100644 index 00000000..5bfe3a63 --- /dev/null +++ b/examples/telegram-grammy/src/__tests__/parity.test.ts @@ -0,0 +1,266 @@ +/** + * Parity test: this grammY bot runs unchanged against the Telegram emulator, + * just like it would against real Telegram. The handlers file never learns + * about either backend — grammY gets its apiRoot from env, that's all. + * + * If this test passes end-to-end through the emulator, and the same bot + * responds identically when pointed at real Telegram, the emulator's Bot API + * surface is faithful enough for day-to-day bot development. + */ +import { describe, it, expect, beforeAll, afterAll } from "vitest"; +import { serve } from "@hono/node-server"; +import { Hono } from "hono"; +import { + Store, + WebhookDispatcher, + authMiddleware, + type AppEnv, +} from "@emulators/core"; +import { telegramPlugin } from "@emulators/telegram"; +import { createTelegramTestClient } from "@emulators/telegram/test"; +import { Bot, type Api } from "grammy"; +import { registerHandlers } from "../handlers.js"; +import type { AddressInfo } from "net"; + +describe("grammY demo bot runs against Telegram emulator (parity)", () => { + let server: ReturnType; + let baseUrl: string; + let bot: Bot; + let store: Store; + + // IDs we'll reuse across tests + let botId: number; + let botToken: string; + let userId: number; + let chatId: number; + + beforeAll(async () => { + // 1. Boot emulator in-process on an OS-picked port. + store = new Store(); + const webhooks = new WebhookDispatcher(); + const app = new Hono(); + app.use("*", authMiddleware(new Map())); + telegramPlugin.register(app, store, webhooks, "http://localhost", new Map()); + server = serve({ fetch: app.fetch, port: 0 }); + await new Promise((r) => setTimeout(r, 20)); + const addr = (server as unknown as { address(): AddressInfo }).address(); + baseUrl = `http://localhost:${addr.port}`; + + // 2. Provision world via test client. + const tg = createTelegramTestClient(baseUrl); + const b = await tg.createBot({ + username: "grammy_demo_bot", + first_name: "Demo Bot", + }); + const u = await tg.createUser({ first_name: "Alice", username: "alice" }); + const c = await tg.createPrivateChat({ botId: b.bot_id, userId: u.id }); + botId = b.bot_id; + botToken = b.token; + userId = u.id; + chatId = c.id; + + // 3. Start the grammY bot pointed at the emulator. + // Same handlers file that production uses — we only swap apiRoot. + bot = new Bot(botToken, { + client: { apiRoot: baseUrl }, + }); + registerHandlers(bot); + bot.catch((e) => console.error("bot error:", e)); + await bot.init(); + // Start long-polling loop in background + void bot.start({ onStart: () => {} }); + await new Promise((r) => setTimeout(r, 20)); + }); + + afterAll(async () => { + // bot.stop() calls getUpdates one last time to cancel the polling + // loop. With real-Telegram concurrent-poll semantics, the + // outstanding long-poll returns 409 and grammY surfaces that as + // an error during shutdown. Swallow it — the loop is already + // winding down. + await bot.stop().catch((err: unknown) => { + if (err instanceof Error && /409/.test(err.message)) return; + throw err; + }); + await new Promise((resolve, reject) => { + server.close((err) => (err ? reject(err) : resolve())); + }); + }); + + async function simulateUserText(text: string): Promise { + const tg = createTelegramTestClient(baseUrl); + await tg.sendUserMessage({ chatId, userId, text }); + // Give grammY's polling loop a tick to pull the update and reply. Some + // handlers (/stream, /revise, /oops) intentionally sleep between steps, + // so the budget has to accommodate that. + await waitFor(() => getBotReplies().length > replyCursor + 1, 8000); + } + + async function simulateUserPhoto(bytes: Buffer, caption?: string): Promise { + const tg = createTelegramTestClient(baseUrl); + const res = await tg.sendUserPhoto({ chatId, userId, photoBytes: bytes, caption }); + await waitFor(() => getBotReplies().length >= replyCursor + 2, 3000); + return res.file_id; + } + + async function simulateCallback(messageId: number, data: string): Promise { + const tg = createTelegramTestClient(baseUrl); + await tg.clickInlineButton({ chatId, userId, messageId, callbackData: data }); + await waitFor(() => getBotReplies().length > replyCursor, 2000); + } + + interface StoredMessage { + id: number; + created_at: string; + updated_at: string; + chat_id: number; + from_bot_id: number | null; + message_id: number; + text?: string; + reply_markup?: unknown; + photo?: unknown[]; + } + + function getBotReplies(): StoredMessage[] { + return store + .collection("telegram.messages") + .all() + .filter((m) => m.chat_id === chatId && m.from_bot_id !== null) + .sort((a, b) => a.message_id - b.message_id); + } + + let replyCursor = 0; + function snapshotCursor() { + replyCursor = getBotReplies().length; + } + + it("/start returns a greeting", async () => { + snapshotCursor(); + await simulateUserText("/start"); + const replies = getBotReplies().slice(replyCursor); + expect(replies).toHaveLength(1); + expect(replies[0].text).toMatch(/emulate demo bot/i); + }); + + it("/echo repeats its argument", async () => { + snapshotCursor(); + await simulateUserText("/echo hello world"); + const replies = getBotReplies().slice(replyCursor); + expect(replies).toHaveLength(1); + expect(replies[0].text).toBe("hello world"); + }); + + it("/menu sends a message with inline keyboard", async () => { + snapshotCursor(); + await simulateUserText("/menu"); + const replies = getBotReplies().slice(replyCursor); + expect(replies).toHaveLength(1); + const kb = replies[0].reply_markup as { inline_keyboard: Array> }; + expect(kb.inline_keyboard[0].map((b) => b.callback_data)).toEqual(["opt:a", "opt:b"]); + }); + + it("callback query from inline button triggers answerCallbackQuery + editMessageReplyMarkup + reply", async () => { + snapshotCursor(); + await simulateUserText("/menu"); + const menuReplies = getBotReplies(); + const menuMessage = menuReplies[menuReplies.length - 1]; + + snapshotCursor(); + await simulateCallback(menuMessage.message_id, "opt:a"); + + // Wait for the edit + reply to propagate (edit is an API call; reply inserts a row). + await waitFor(() => getBotReplies().length > replyCursor, 3000); + + const after = getBotReplies().slice(replyCursor); + expect(after.some((m) => m.text === "You picked A.")).toBe(true); + }); + + it("photo message: bot acknowledges and echoes via file_id", async () => { + snapshotCursor(); + const PNG_1X1 = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQIW2P4//8/AAX+Av4zhb9VAAAAAElFTkSuQmCC", + "base64", + ); + await simulateUserPhoto(PNG_1X1, "check this"); + + const replies = getBotReplies().slice(replyCursor); + expect(replies.length).toBeGreaterThanOrEqual(2); + + const ack = replies.find((r) => r.text?.startsWith("Got a photo")); + expect(ack).toBeDefined(); + expect(ack!.text).toContain("check this"); + + const echo = replies.find((r) => Array.isArray(r.photo)); + expect(echo).toBeDefined(); + }); + + it("plain text falls through to the default handler", async () => { + snapshotCursor(); + await simulateUserText("not a command"); + const replies = getBotReplies().slice(replyCursor); + expect(replies).toHaveLength(1); + expect(replies[0].text).toBe("You said: not a command"); + }); + + // Phase 2 flows — sendMessageDraft, editMessageText, deleteMessage. + + it("/stream pushes multiple draft snapshots then commits a final message", async () => { + snapshotCursor(); + const draftsBefore = store.collection("telegram.draft_snapshots").all().length; + await simulateUserText("/stream"); + await waitFor(() => getBotReplies().length > replyCursor, 8000); + + const replies = getBotReplies().slice(replyCursor); + expect(replies.length).toBeGreaterThanOrEqual(1); + expect(replies[replies.length - 1].text).toContain("Tangier"); + + const draftsAfter = store.collection("telegram.draft_snapshots").all().length; + expect(draftsAfter - draftsBefore).toBe(5); + }); + + it("/revise edits the bot's own message in place (edited_date set)", async () => { + snapshotCursor(); + await simulateUserText("/revise"); + await waitFor(() => { + const r = getBotReplies().slice(replyCursor); + return r.length >= 1 && (r[0] as { edited_date?: number }).edited_date !== undefined; + }, 5000); + const reply = getBotReplies().slice(replyCursor)[0] as { text?: string; edited_date?: number }; + expect(reply.text).toBe("Final reply: done!"); + expect(reply.edited_date).toBeGreaterThan(0); + }); + + it("/oops deletes the previous message and sends a follow-up", async () => { + snapshotCursor(); + await simulateUserText("/oops"); + await waitFor(() => getBotReplies().filter((r) => r.message_id > 0).length >= replyCursor + 2, 5000); + + const allInRange = store + .collection<{ + id: number; + created_at: string; + updated_at: string; + chat_id: number; + from_bot_id: number | null; + message_id: number; + text?: string; + deleted?: boolean; + }>("telegram.messages") + .all() + .filter((m) => m.chat_id === chatId && m.from_bot_id !== null) + .sort((a, b) => a.message_id - b.message_id) + .slice(replyCursor); + + expect(allInRange.length).toBeGreaterThanOrEqual(2); + expect(allInRange[0].deleted).toBe(true); + expect(allInRange[0].text).toBe("This message will self-destruct."); + expect(allInRange[allInRange.length - 1].text).toBe("Deleted the previous message."); + }); +}); + +async function waitFor(predicate: () => boolean, timeoutMs: number): Promise { + const start = Date.now(); + while (!predicate() && Date.now() - start < timeoutMs) { + await new Promise((r) => setTimeout(r, 25)); + } +} diff --git a/examples/telegram-grammy/src/bot.ts b/examples/telegram-grammy/src/bot.ts new file mode 100644 index 00000000..8d9be6c5 --- /dev/null +++ b/examples/telegram-grammy/src/bot.ts @@ -0,0 +1,28 @@ +import { Bot } from "grammy"; +import { registerHandlers } from "./handlers.js"; + +const token = process.env.BOT_TOKEN; +if (!token) { + console.error("BOT_TOKEN is required. Set BOT_TOKEN= (or run `pnpm start:emu` for the default emulator token)."); + process.exit(1); +} + +const apiRoot = process.env.TELEGRAM_API_ROOT ?? "https://api.telegram.org"; + +const bot = new Bot(token, { client: { apiRoot } }); +registerHandlers(bot); + +bot.catch((err) => { + console.error("handler error:", err); +}); + +async function main() { + const me = await bot.api.getMe(); + console.log(`Starting bot @${me.username} (id=${me.id}) against ${apiRoot}`); + await bot.start({ + drop_pending_updates: false, + onStart: () => console.log("Polling for updates..."), + }); +} + +void main(); diff --git a/examples/telegram-grammy/src/handlers.ts b/examples/telegram-grammy/src/handlers.ts new file mode 100644 index 00000000..9cf8ef54 --- /dev/null +++ b/examples/telegram-grammy/src/handlers.ts @@ -0,0 +1,110 @@ +import { Bot, InlineKeyboard, InputFile } from "grammy"; + +/** + * Registers all demo handlers on a grammY Bot instance. + * Handlers are defined once and run unchanged against: + * - the Telegram emulator (TELEGRAM_API_ROOT=http://localhost:4011) + * - real Telegram (apiRoot unset) + */ +export function registerHandlers(bot: Bot): void { + bot.command("start", async (ctx) => { + await ctx.reply( + `Hi ${ctx.from?.first_name ?? "there"}! I'm the emulate demo bot.\n` + + `Try /echo , /menu, or send me a photo.`, + ); + }); + + bot.command("echo", async (ctx) => { + const arg = ctx.match?.toString().trim() ?? ""; + if (!arg) { + await ctx.reply("Usage: /echo "); + return; + } + await ctx.reply(arg); + }); + + // Streaming demo — simulates an LLM reply arriving in chunks. + // Uses sendMessageDraft, an emulator-only extension that models + // animated streaming (each call appends a snapshot under + // (chat_id, draft_id, bot_id)), then commits the final text as a + // real message. + bot.command("stream", async (ctx) => { + if (ctx.chat?.type !== "private") { + await ctx.reply("/stream only works in private chats (Bot API limit)."); + return; + } + const draftId = Math.floor(Date.now() / 1000); + const chunks = ["Thinking", "Thinking.", "Thinking..", "Thinking...", "Here is your plan: Tangier → Chefchaouen → Fez."]; + for (const text of chunks) { + await ctx.api.raw.sendMessageDraft({ + chat_id: ctx.chat.id, + draft_id: draftId, + text, + }); + await sleep(700); + } + await ctx.reply(chunks[chunks.length - 1]); + }); + + // editMessageText demo — bot revises its own reply in place. + bot.command("revise", async (ctx) => { + const first = await ctx.reply("Draft reply v1: working on it..."); + await sleep(1500); + await ctx.api.editMessageText(first.chat.id, first.message_id, "Final reply: done!"); + }); + + // deleteMessage demo — bot sends, then deletes its own message. + bot.command("oops", async (ctx) => { + const sent = await ctx.reply("This message will self-destruct."); + await sleep(1500); + await ctx.api.deleteMessage(sent.chat.id, sent.message_id); + await ctx.reply("Deleted the previous message."); + }); + + bot.command("menu", async (ctx) => { + const kb = new InlineKeyboard() + .text("Option A", "opt:a") + .text("Option B", "opt:b") + .row() + .text("Cancel", "opt:cancel"); + await ctx.reply("Pick an option:", { reply_markup: kb }); + }); + + bot.callbackQuery(/^opt:/, async (ctx) => { + const choice = ctx.callbackQuery.data.slice("opt:".length); + const label = + choice === "a" ? "You picked A." : choice === "b" ? "You picked B." : "Cancelled."; + await ctx.answerCallbackQuery({ text: label }); + if (ctx.callbackQuery.message) { + await ctx.api.editMessageReplyMarkup( + ctx.callbackQuery.message.chat.id, + ctx.callbackQuery.message.message_id, + { reply_markup: { inline_keyboard: [] } }, + ); + await ctx.reply(label); + } + }); + + bot.on("message:photo", async (ctx) => { + const photos = ctx.message.photo; + const largest = photos[photos.length - 1]; + const caption = ctx.message.caption; + await ctx.reply( + `Got a photo (${largest.width}x${largest.height}, file_id=${largest.file_id.slice(0, 16)}...)${caption ? ` with caption: ${caption}` : ""}.`, + ); + // Echo the photo back by file_id — exercises the file_id round-trip + await ctx.replyWithPhoto(largest.file_id, { caption: "echo" }); + }); + + bot.on("message:text", async (ctx) => { + // Fallback for plain text that isn't a command + if (ctx.message.text.startsWith("/")) return; + await ctx.reply(`You said: ${ctx.message.text}`); + }); +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +export { InlineKeyboard, InputFile }; diff --git a/examples/telegram-grammy/tsconfig.json b/examples/telegram-grammy/tsconfig.json new file mode 100644 index 00000000..66d69e6c --- /dev/null +++ b/examples/telegram-grammy/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "isolatedModules": true, + "noEmit": true, + "lib": ["ES2022"] + }, + "include": ["src/**/*"] +} diff --git a/examples/telegram-grammy/vitest.config.ts b/examples/telegram-grammy/vitest.config.ts new file mode 100644 index 00000000..f0adc16d --- /dev/null +++ b/examples/telegram-grammy/vitest.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + testTimeout: 30000, + }, +}); diff --git a/packages/@emulators/telegram/README.md b/packages/@emulators/telegram/README.md new file mode 100644 index 00000000..61b2b09b --- /dev/null +++ b/packages/@emulators/telegram/README.md @@ -0,0 +1,205 @@ +# @emulators/telegram + +Telegram Bot API emulator for `emulate`. Stateful, production-fidelity emulation of the Bot API so you can end-to-end test Telegram bots without creating a real bot, without clicking through Telegram clients, and without network. + +## Install + +```bash +npm install --save-dev @emulators/telegram +``` + +The emulator runs through the main `emulate` CLI; the package is only needed directly if you want the typed test client in your own tests. + +## Quick start + +```bash +npx emulate --service telegram +``` + +Boots on `http://localhost:4011` with a default seeded bot (`@emulate_bot`), a test user, and a private chat. + +## Test client + +For Vitest / Jest / Playwright: + +```typescript +import { createEmulator } from "emulate"; +import { createTelegramTestClient } from "@emulators/telegram/test"; + +const emu = await createEmulator({ service: "telegram", port: 4011 }); +const tg = createTelegramTestClient(emu.url); + +const bot = await tg.createBot({ username: "trip_test_bot", first_name: "Trip Test" }); +const user = await tg.createUser({ first_name: "Alice" }); +const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + +// Simulate a user sending a message; bot code running elsewhere picks it up. +await tg.sendUserMessage({ chatId: dm.id, userId: user.id, text: "/connect ABC123" }); + +// Inspect what the bot replied. +const replies = await tg.getSentMessages({ chatId: dm.id }); +``` + +Point your bot code at `emu.url` instead of `https://api.telegram.org`: + +```typescript +// grammY +new Bot(bot.token, { client: { apiRoot: emu.url } }); + +// telegraf +new Telegraf(bot.token, { telegram: { apiRoot: emu.url } }); +``` + +## What is implemented + +Full chat-SDK parity surface: + +| Area | Bot API methods | +| ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Identity | `getMe` | +| Delivery | `getUpdates` (with `allowed_updates` filter), `setWebhook` (HTTPS-only, with `secret_token` + `allowed_updates`), `deleteWebhook`, `getWebhookInfo` | +| Messaging | `sendMessage`, `sendPhoto`, `sendDocument`, `sendVideo`, `sendAudio`, `sendVoice`, `sendAnimation`, `sendSticker`, `editMessageText`, `editMessageReplyMarkup`, `deleteMessage`, `sendChatAction` | +| Formatting | `parse_mode = MarkdownV2` / `HTML` / `Markdown` (legacy v1) on text + caption surfaces, including `blockquote` / `expandable_blockquote` | +| Streaming | `sendMessageDraft` — emulator-only extension for testing animated streamed replies (no real Bot API method; appends snapshots under `(chat_id, draft_id, bot_id)`) | +| Files | `getFile`, `GET /file/bot/`. `file_id` preserved on re-send. | +| Reactions | `setMessageReaction` + dispatch of both `message_reaction` (per-user) and `message_reaction_count` (anonymous aggregate) Updates | +| Callbacks | `answerCallbackQuery` (persists `text` / `show_alert` / `url` / `cache_time`) | +| Chats | `getChat` (returns `ChatFullInfo` with `permissions` / `accent_color_id` / `pinned_message`), `getChatMember`, `getChatAdministrators`, `getChatMemberCount` | +| Forum | `createForumTopic`, `editForumTopic`, `closeForumTopic`, `reopenForumTopic`, `deleteForumTopic` (requires a supergroup with `is_forum: true`) | +| Commands | `setMyCommands`, `getMyCommands` | + +Chat types: `private`, `group`, `supergroup` (with forum topics via `message_thread_id`), `channel` (with `channel_post` / `edited_channel_post` + `sender_chat`-only messages). + +Update types dispatched: `message`, `edited_message`, `callback_query`, `my_chat_member`, `message_reaction`, `message_reaction_count`, `channel_post`, `edited_channel_post`. + +Validation (matches real Telegram — rejects, does not trim): + +- Text: > 4096 chars → `400 Bad Request: message is too long` +- Caption: > 1024 chars → `400 Bad Request: message caption is too long` +- MarkdownV2 unescaped reserved char → `400 can't parse entities: character 'X' is reserved and must be escaped with the preceding '\'` +- `message_thread_id` in non-supergroup → `400 Bad Request: message thread not found` +- `setWebhook` with non-HTTPS URL → `400 Bad Request: bad webhook: HTTPS url must be provided for webhook` +- `sendMessage` with `reply_to_message_id` pointing at a missing message → `400 Bad Request: message to be replied not found` +- Concurrent `getUpdates` for the same bot → `409 Conflict: terminated by other getUpdates request` (real-Telegram wording) + +Auto-detected entities in free text: `bot_command`, `mention`, `url`, `email`, `hashtag`, `cashtag`. + +Fault injection for adapter error-path testing: `POST /_emu/telegram/faults` with `{bot_id, method, error_code, description?, retry_after?, count?}` produces controlled `401` / `403` / `404` / `429` / generic `400` responses on the next N calls. + +Supported flows: + +- **DM text messages** with parsed entities (`bot_command`, `mention`) +- **Group chats** with Telegram's privacy rules (non-privileged bots only see messages that `@bot_username`-mention them or are addressed via `/command@bot_username`; bare `/command` is dropped in privacy mode) +- **Photos** with three `PhotoSize` tiers, stable `file_id` round-trip, `getFile` → HTTP file download, `sendPhoto` re-send by `file_id` +- **Documents** — `sendDocument` with multipart upload or `file_id` re-send +- **Callback queries** + **inline keyboards** (`reply_markup.inline_keyboard`) +- **Bot-initiated edits** via `editMessageText` / `editMessageReplyMarkup` (sets `edit_date`; bot edits dispatch `edited_message` to other bots in the chat) +- **Message deletions** via `deleteMessage` (soft-delete, hidden from `getAllMessages` and future `getUpdates`) +- **Streaming drafts** via the emulator-only `sendMessageDraft` — private chats only; each call appends a snapshot under `(chat_id, draft_id, bot_id)` so tests can inspect chunk-by-chunk output +- **Chat membership changes** via `addBotToChat` / `removeBotFromChat` test helpers, dispatching `my_chat_member` Updates +- **Webhook delivery** with retry on 5xx (initial + up to 3 retries with 1s/2s/4s backoff, terminal on 4xx), `X-Telegram-Bot-Api-Secret-Token` header +- **Long polling** with `offset` confirmation semantics and 409 on concurrent polls + +## Test API + +Programmatic client returned by `createTelegramTestClient(baseUrl, options?)`: + +| Method | Description | +| -------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------- | +| `createBot({ username, ... })` | Create a new bot. Returns `{ bot_id, token, username, ... }`. | +| `createUser({ first_name, ... })` | Create a new user. | +| `createPrivateChat({ botId, userId })` | Create (or fetch) a DM between bot and user. | +| `createGroupChat({ title, memberIds, botIds, creatorUserId?, adminUserIds?, adminBotIds? })` | Create a group chat; optional creator + admin flags feed `getChatAdministrators`. | +| `createSupergroup({ title, memberIds, botIds, isForum? })` | Create a supergroup (set `isForum: true` to enable forum-topic methods). | +| `createChannel({ title, username?, memberBotIds, memberUserIds? })` | Create a channel. | +| `createForumTopic({ chatId, name })` | Create a forum topic in a supergroup. | +| `promoteChatMember({ chatId, userId? / botId?, demote? })` | Promote (or demote) a member to administrator. | +| `sendUserMessage({ chatId, userId, text, replyToMessageId? })` | Simulate a user sending a text message. | +| `sendUserPhoto({ chatId, userId, photoBytes, mimeType?, caption? })` | Simulate a user sending a photo. | +| `sendUserMedia({ chatId, userId, kind, bytes, ... })` | Simulate a user sending video / audio / voice / animation / sticker / document. | +| `clickInlineButton({ chatId, userId, messageId, callbackData })` | Simulate a user clicking an inline keyboard button. | +| `editUserMessage({ chatId, messageId, userId, text })` | Simulate a user editing their message. | +| `reactToMessage({ chatId, messageId, userId, reaction })` | Simulate a user reacting; dispatches `message_reaction` + `message_reaction_count`. | +| `postAsChannel({ chatId, text?, caption?, replyToMessageId?, messageThreadId? })` | Post a `channel_post` as the channel itself. | +| `editChannelPost({ chatId, messageId, text?, caption? })` | Edit an existing channel post; dispatches `edited_channel_post`. | +| `addBotToChat({ chatId, botId, byUserId })` | Add a bot to a group chat; dispatches a `my_chat_member` Update. | +| `removeBotFromChat({ chatId, botId, byUserId })` | Remove a bot from a chat; dispatches a `my_chat_member` Update. | +| `injectFault({ botId, method, errorCode, description?, retryAfter?, count? })` | Queue a controlled 4xx / 429 on the next N calls to `method` (or `*`). | +| `clearFaults()` | Clear every pending fault. | +| `getCallbackAnswer({ callbackQueryId })` | Inspect what the bot answered on a callback query. | +| `getDraftHistory({ chatId, draftId })` | Ordered list of `sendMessageDraft` snapshots for a streamed reply. | +| `getSentMessages({ chatId })` | Messages sent by any bot in the chat (for assertions). | +| `getAllMessages({ chatId })` | All messages (user + bot) in the chat. | +| `reset()` | Full store wipe + seed replay (same as `emulator.reset()`). | + +`options.fetchImpl` lets you swap the HTTP client — useful when driving a Hono app in-process without booting a real server. + +All programmatic methods have matching HTTP routes under `/_emu/telegram/*` for cross-language drivers. See `src/paths.ts` for the full URL map. + +## 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] +``` + +The YAML `chats[].type` currently supports `private` and `group`. Supergroups, channels, and forum topics are created at runtime via the control plane (`createSupergroup` / `createChannel` / `createForumTopic`). + +## Telegram privacy rules (groups) + +Matches real Telegram: + +- Bots with privacy mode on (the default) only receive messages that **mention** them (`@bot_username`) or are **addressed bot commands** (`/command@bot_username`). +- Bare `/command` with no `@bot_username` is **dropped** in privacy mode. +- Plain chatter between humans is **not** delivered. +- To receive every message, set `can_read_all_group_messages: true` when creating the bot (equivalent to disabling Privacy Mode in real Telegram). + +## Ports + +The CLI assigns the Telegram service to port `4011` by default (next after Stripe at `4010`). + +## Non-goals + +This emulator deliberately does **not** implement: + +- Payments (`invoice`, `successful_payment`, `pre_checkout_query`) +- Games (`sendGame`, `setGameScore`) +- Telegram Business API (`business_connection`, connected accounts) +- Telegram Passport (encrypted identity documents) +- TON wallet integrations +- Real BotFather account management + +These are out of scope for the plugin's test-focused use case. + +## Not implemented yet + +Lands when a concrete flow demands it: + +- **Keyboards** — custom `reply_markup.keyboard`, `force_reply`, `selective` flag (inline keyboards are handled). +- **Deep links** — `t.me/?start=` handoff simulation. +- **Message operations** — `forwardMessage`, `copyMessage`, `sendMediaGroup` (albums), `pinChatMessage`, non-bot `chat_member` / `chat_join_request` Updates. +- **Inline mode** — `inline_query` + `answerInlineQuery`. +- **Business API, payments, polls, stories, web apps.** + +## Links + +- [Telegram Bot API reference](https://core.telegram.org/bots/api) +- [`emulate` monorepo](https://github.com/vercel-labs/emulate) +- [grammY](https://grammy.dev) · [Telegraf](https://telegraf.js.org) — SDKs this emulator is wire-compatible with diff --git a/packages/@emulators/telegram/package.json b/packages/@emulators/telegram/package.json new file mode 100644 index 00000000..f8cfede5 --- /dev/null +++ b/packages/@emulators/telegram/package.json @@ -0,0 +1,51 @@ +{ + "name": "@emulators/telegram", + "version": "0.4.1", + "license": "Apache-2.0", + "type": "module", + "main": "./dist/index.js", + "types": "./dist/index.d.ts", + "exports": { + ".": { + "import": "./dist/index.js", + "types": "./dist/index.d.ts" + }, + "./test": { + "import": "./dist/test.js", + "types": "./dist/test.d.ts" + } + }, + "homepage": "https://emulate.dev", + "repository": { + "type": "git", + "url": "https://github.com/vercel-labs/emulate.git", + "directory": "packages/@emulators/telegram" + }, + "bugs": { + "url": "https://github.com/vercel-labs/emulate/issues" + }, + "publishConfig": { + "access": "public" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsup --clean", + "dev": "tsup --watch", + "test": "vitest run", + "clean": "rm -rf dist .turbo", + "type-check": "tsc --noEmit", + "lint": "eslint src" + }, + "dependencies": { + "@emulators/core": "workspace:*", + "hono": "^4", + "zod": "^4.3.6" + }, + "devDependencies": { + "tsup": "^8", + "typescript": "^5.7", + "vitest": "^4.1.0" + } +} diff --git a/packages/@emulators/telegram/src/__tests__/bot-api-extended.test.ts b/packages/@emulators/telegram/src/__tests__/bot-api-extended.test.ts new file mode 100644 index 00000000..b860f67f --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/bot-api-extended.test.ts @@ -0,0 +1,285 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestApp, postJson, json, type TestApp } from "./helpers.js"; +import { getTelegramStore } from "../store.js"; +import { + addBotToChat, + createBot, + createGroupChat, + createPrivateChat, + createUser, + getDraftHistory, + removeBotFromChat, + simulateUserMessage, +} from "../routes/control.js"; + +describe("sendMessageDraft", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("appends draft snapshots under stable draft_id", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const chunks = ["Sure", "Sure, let me ", "Sure, let me check", "Sure, let me check. Done."]; + for (const text of chunks) { + const res = await postJson(tx.app, `/bot${bot.token}/sendMessageDraft`, { + chat_id: dm.chat_id, + draft_id: 42, + text, + }); + expect(res.status).toBe(200); + const body = await json<{ ok: boolean; result: boolean }>(res); + expect(body.ok).toBe(true); + expect(body.result).toBe(true); + } + + const history = getDraftHistory(tx.store, { chatId: dm.chat_id, draftId: 42 }); + expect(history).toHaveLength(4); + expect(history.map((s) => s.text)).toEqual(chunks); + expect(history.map((s) => s.seq)).toEqual([1, 2, 3, 4]); + }); + + it("does not insert a Message row (drafts are off-history)", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + await postJson(tx.app, `/bot${bot.token}/sendMessageDraft`, { + chat_id: dm.chat_id, + draft_id: 1, + text: "stream chunk", + }); + + const messagesInChat = getTelegramStore(tx.store).messages.findBy("chat_id", dm.chat_id); + expect(messagesInChat).toHaveLength(0); + }); + + it("rejects draft in a group chat (private chats only, per Bot API 9.5)", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { title: "g", memberIds: [user.user_id], botIds: [bot.bot_id] }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessageDraft`, { + chat_id: group.chat_id, + draft_id: 1, + text: "nope", + }); + expect(res.status).toBe(400); + }); + + it("rejects draft_id = 0", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const res = await postJson(tx.app, `/bot${bot.token}/sendMessageDraft`, { + chat_id: dm.chat_id, + draft_id: 0, + text: "x", + }); + expect(res.status).toBe(400); + }); +}); + +describe("editMessageText", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("edits a bot-sent message and sets edit_date", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const sendRes = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "v1", + }); + const sent = await json<{ result: { message_id: number } }>(sendRes); + + const editRes = await postJson(tx.app, `/bot${bot.token}/editMessageText`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + text: "v2", + }); + const edited = await json<{ ok: boolean; result: { text: string; edit_date: number } }>(editRes); + expect(edited.ok).toBe(true); + expect(edited.result.text).toBe("v2"); + expect(edited.result.edit_date).toBeGreaterThan(0); + }); + + it("rejects editing a message sent by another bot", async () => { + const a = createBot(tx.store, { username: "a" }); + const b = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: a.bot_id, userId: user.user_id }); + // a is only bot — add b to the chat so we can exercise rejection + const ts = getTelegramStore(tx.store); + ts.chats.update(ts.chats.findOneBy("chat_id", dm.chat_id)!.id, { + member_bot_ids: [a.bot_id, b.bot_id], + }); + + const sendRes = await postJson(tx.app, `/bot${a.token}/sendMessage`, { chat_id: dm.chat_id, text: "mine" }); + const sent = await json<{ result: { message_id: number } }>(sendRes); + + const editRes = await postJson(tx.app, `/bot${b.token}/editMessageText`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + text: "hijack", + }); + expect(editRes.status).toBe(403); + }); +}); + +describe("deleteMessage", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("soft-deletes the message; getAllMessages hides it", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const sendRes = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "to be deleted" }); + const sent = await json<{ result: { message_id: number } }>(sendRes); + + const delRes = await postJson(tx.app, `/bot${bot.token}/deleteMessage`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + }); + const delBody = await json<{ ok: boolean; result: boolean }>(delRes); + expect(delBody.ok).toBe(true); + expect(delBody.result).toBe(true); + + const all = await json<{ messages: unknown[] }>( + await tx.app.request(`http://localhost:4011/_emu/telegram/chats/${dm.chat_id}/messages?scope=all`), + ); + expect(all.messages).toHaveLength(0); + + const storeRow = getTelegramStore(tx.store) + .messages.findBy("chat_id", dm.chat_id) + .find((m) => m.message_id === sent.result.message_id); + expect(storeRow?.deleted).toBe(true); + }); +}); + +describe("sendDocument", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("accepts file_id and echoes it back in message.document", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + // Upload a doc directly via the files collection to mimic a prior upload + const ts = getTelegramStore(tx.store); + ts.files.insert({ + file_id: "seed_doc_1", + file_unique_id: "uq_seed_doc_1", + owner_bot_id: bot.bot_id, + mime_type: "application/pdf", + file_size: 1024, + width: 0, + height: 0, + file_path: `documents/${bot.bot_id}/seed_doc_1`, + bytes_base64: Buffer.from("pdfdata").toString("base64"), + kind: "document", + file_name: "trip.pdf", + }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendDocument`, { + chat_id: dm.chat_id, + document: "seed_doc_1", + caption: "your itinerary", + }); + const body = await json<{ ok: boolean; result: { document: { file_id: string; file_name?: string; mime_type?: string } } }>(res); + expect(body.ok).toBe(true); + expect(body.result.document.file_name).toBe("trip.pdf"); + expect(body.result.document.mime_type).toBe("application/pdf"); + // file_id preserved on re-send (matches real Telegram behaviour). + expect(body.result.document.file_id).toBe("seed_doc_1"); + + // Download the file_id via HTTP — reuses the original file path. + const dl = await tx.app.request( + `http://localhost:4011/file/bot${bot.token}/documents/${bot.bot_id}/${body.result.document.file_id}`, + ); + expect(dl.status).toBe(200); + expect(Buffer.from(await dl.arrayBuffer()).toString()).toBe("pdfdata"); + }); +}); + +describe("my_chat_member Update", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("dispatches my_chat_member when bot is added to a group", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { title: "g", memberIds: [user.user_id], botIds: [] }); + + addBotToChat(tx.store, { chatId: group.chat_id, botId: bot.bot_id, byUserId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + const body = await json<{ + result: Array<{ my_chat_member?: { new_chat_member: { status: string } } }>; + }>(res); + expect(body.result).toHaveLength(1); + expect(body.result[0].my_chat_member?.new_chat_member.status).toBe("member"); + }); + + it("dispatches my_chat_member when bot is removed from a group", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { + title: "g", + memberIds: [user.user_id], + botIds: [bot.bot_id], + }); + + // Drain the prior state — no updates yet + removeBotFromChat(tx.store, { chatId: group.chat_id, botId: bot.bot_id, byUserId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + const body = await json<{ + result: Array<{ my_chat_member?: { new_chat_member: { status: string }; old_chat_member: { status: string } } }>; + }>(res); + expect(body.result).toHaveLength(1); + expect(body.result[0].my_chat_member?.old_chat_member.status).toBe("member"); + expect(body.result[0].my_chat_member?.new_chat_member.status).toBe("left"); + }); + + it("removed bot no longer sees new group messages", async () => { + const bot = createBot(tx.store, { username: "greedy", can_read_all_group_messages: true }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { + title: "g", + memberIds: [user.user_id], + botIds: [bot.bot_id], + }); + + removeBotFromChat(tx.store, { chatId: group.chat_id, botId: bot.bot_id, byUserId: user.user_id }); + // Drain the my_chat_member update + await postJson(tx.app, `/bot${bot.token}/getUpdates`, { offset: 2 }); + + // Now a user sends a regular message in the group — bot should not get it + simulateUserMessage(tx.store, { + chatId: group.chat_id, + userId: user.user_id, + text: "hello group", + }); + // But simulateUserMessage only dispatches to bots in member_bot_ids — which no longer includes us + const after = await postJson(tx.app, `/bot${bot.token}/getUpdates`, { offset: 2 }); + const body = await json<{ result: unknown[] }>(after); + expect(body.result).toHaveLength(0); + }); +}); diff --git a/packages/@emulators/telegram/src/__tests__/bot-api.test.ts b/packages/@emulators/telegram/src/__tests__/bot-api.test.ts new file mode 100644 index 00000000..5e4b7364 --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/bot-api.test.ts @@ -0,0 +1,411 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestApp, postJson, json, type TestApp } from "./helpers.js"; +import { getTelegramStore } from "../store.js"; +import { + createBot, + createGroupChat, + createPrivateChat, + createUser, + simulateCallback, + simulateUserMessage, +} from "../routes/control.js"; +import type { WireMessage, WireUpdate } from "../types/wire/index.js"; + +describe("Telegram Bot API - getMe", () => { + let tx: TestApp; + + beforeEach(() => { + tx = createTestApp(); + }); + + it("returns bot identity for a valid token", async () => { + const bot = getTelegramStore(tx.store).bots.all()[0]!; + const res = await postJson(tx.app, `/bot${bot.token}/getMe`, {}); + expect(res.status).toBe(200); + const body = await json<{ + ok: boolean; + result: { id: number; is_bot: boolean; username: string }; + }>(res); + expect(body.ok).toBe(true); + expect(body.result.id).toBe(bot.bot_id); + expect(body.result.is_bot).toBe(true); + expect(body.result.username).toBe("emulate_bot"); + }); + + it("rejects invalid token with 401", async () => { + const res = await postJson(tx.app, "/bot999:BAD/getMe", {}); + expect(res.status).toBe(401); + const body = await json<{ ok: boolean; error_code: number }>(res); + expect(body.ok).toBe(false); + expect(body.error_code).toBe(401); + }); +}); + +describe("Telegram Bot API - sendMessage / DM round-trip", () => { + let tx: TestApp; + + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("bot can send a message into a private chat it participates in", async () => { + const bot = createBot(tx.store, { username: "trip_test_bot", first_name: "Trip Test" }); + const user = createUser(tx.store, { first_name: "Alice" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "hello there", + }); + + const body = await json<{ ok: boolean; result: WireMessage }>(res); + expect(body.ok).toBe(true); + const msg = body.result; + expect(msg.text).toBe("hello there"); + expect(msg.message_id).toBe(1); + expect(msg.chat.id).toBe(dm.chat_id); + expect(msg.from?.id).toBe(bot.bot_id); + }); + + it("rejects sendMessage to a chat the bot is not a member of", async () => { + const bot = createBot(tx.store, { username: "bot1" }); + // Chat not containing this bot + const user = createUser(tx.store, { first_name: "Alice" }); + const otherBot = createBot(tx.store, { username: "other_bot" }); + const dm = createPrivateChat(tx.store, { botId: otherBot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "sneaky", + }); + expect(res.status).toBe(403); + }); +}); + +describe("Telegram Bot API - getUpdates (long polling)", () => { + let tx: TestApp; + + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("returns queued user message as a message Update", async () => { + const bot = createBot(tx.store, { username: "trip_test_bot" }); + const user = createUser(tx.store, { first_name: "Alice" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "hello from alice" }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + const body = await json<{ ok: boolean; result: WireUpdate[] }>(res); + expect(body.ok).toBe(true); + expect(body.result.length).toBe(1); + const upd = body.result[0]; + expect(upd.update_id).toBe(1); + if (!("message" in upd)) throw new Error("expected message update"); + expect(upd.message.text).toBe("hello from alice"); + expect(upd.message.from?.id).toBe(user.user_id); + }); + + it("offset confirms prior updates so they don't come back", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "one" }); + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "two" }); + + const first = await json<{ result: Array<{ update_id: number }> }>( + await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}), + ); + expect(first.result.length).toBe(2); + + const confirmed = await json<{ result: Array<{ update_id: number }> }>( + await postJson(tx.app, `/bot${bot.token}/getUpdates`, { offset: first.result.at(-1)!.update_id + 1 }), + ); + expect(confirmed.result.length).toBe(0); + }); + + it("rejects getUpdates while webhook is active (409)", async () => { + const bot = createBot(tx.store, { username: "b" }); + // Activate webhook + await postJson(tx.app, `/bot${bot.token}/setWebhook`, { url: "https://example.com/webhook" }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + expect(res.status).toBe(409); + }); + + it("setWebhook rejects non-HTTPS URLs with 400", async () => { + const bot = createBot(tx.store, { username: "b" }); + const res = await postJson(tx.app, `/bot${bot.token}/setWebhook`, { + url: "http://example.com/webhook", + }); + expect(res.status).toBe(400); + const body = await json<{ description: string }>(res); + expect(body.description).toMatch(/HTTPS/); + }); + + it("sendMessage rejects reply_to_message_id pointing at a non-existent message", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "reply to ghost", + reply_to_message_id: 9999, + }); + expect(res.status).toBe(400); + const body = await json<{ description: string }>(res); + expect(body.description).toMatch(/replied not found/); + }); + + it("accepts legacy parse_mode=Markdown", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "*bold* and _italic_", + parse_mode: "Markdown", + }); + expect(res.status).toBe(200); + const body = await json<{ result: { text: string; entities: Array<{ type: string }> } }>(res); + expect(body.result.text).toBe("bold and italic"); + expect(body.result.entities.map((e) => e.type).sort()).toEqual(["bold", "italic"]); + }); + + it("setWebhook / deleteWebhook / setMyCommands return bare true", async () => { + const bot = createBot(tx.store, { username: "b" }); + const set = await json<{ ok: boolean; result: unknown }>( + await postJson(tx.app, `/bot${bot.token}/setWebhook`, { url: "https://example.com/webhook" }), + ); + expect(set.result).toBe(true); + + const del = await json<{ ok: boolean; result: unknown }>( + await postJson(tx.app, `/bot${bot.token}/deleteWebhook`, {}), + ); + expect(del.result).toBe(true); + + const cmd = await json<{ ok: boolean; result: unknown }>( + await postJson(tx.app, `/bot${bot.token}/setMyCommands`, { + commands: [{ command: "start", description: "Start" }], + }), + ); + expect(cmd.result).toBe(true); + }); + + it("a new long-poll terminates a prior long-poll with 409 (takeover)", async () => { + const bot = createBot(tx.store, { username: "b" }); + const first = postJson(tx.app, `/bot${bot.token}/getUpdates`, { timeout: 2 }); + // Yield so the first request registers its long-poll waiter. + await new Promise((r) => setImmediate(r)); + const second = postJson(tx.app, `/bot${bot.token}/getUpdates`, { timeout: 1 }); + // Yield again so the takeover can resolve the first. + await new Promise((r) => setImmediate(r)); + const oldRes = await first; + expect(oldRes.status).toBe(409); + const newRes = await second; + // The new poll also times out without updates but returns 200. + expect(newRes.status).toBe(200); + }, 4000); +}); + +describe("Telegram Bot API - commands and entities", () => { + let tx: TestApp; + + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("parses /command as bot_command entity", async () => { + const bot = createBot(tx.store, { username: "trip_bot" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "/connect ABC" }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + const body = await json<{ result: Array<{ message: { entities: Array<{ type: string; offset: number; length: number }> } }> }>(res); + const entities = body.result[0].message.entities; + expect(entities).toHaveLength(1); + expect(entities[0].type).toBe("bot_command"); + expect(entities[0].offset).toBe(0); + expect(entities[0].length).toBe("/connect".length); + }); + + it("parses /command@botname addressed to specific bot", async () => { + const botA = createBot(tx.store, { username: "bot_a" }); + const botB = createBot(tx.store, { username: "bot_b" }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [user.user_id], + botIds: [botA.bot_id, botB.bot_id], + }); + + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "/help@bot_a" }); + + // Only bot_a receives it, bot_b does not + const aRes = await json<{ result: unknown[] }>(await postJson(tx.app, `/bot${botA.token}/getUpdates`, {})); + const bRes = await json<{ result: unknown[] }>(await postJson(tx.app, `/bot${botB.token}/getUpdates`, {})); + expect(aRes.result).toHaveLength(1); + expect(bRes.result).toHaveLength(0); + }); +}); + +describe("Telegram Bot API - mentions in groups", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("bot only sees group messages that mention it", async () => { + const bot = createBot(tx.store, { username: "trip_bot" }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [user.user_id], + botIds: [bot.bot_id], + }); + + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "chatting to the group" }); + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "hi @trip_bot, what's the plan?" }); + + const updates = await json<{ result: Array<{ message: { text: string } }> }>( + await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}), + ); + expect(updates.result).toHaveLength(1); + expect(updates.result[0].message.text).toContain("@trip_bot"); + }); + + it("privacy-mode bot does NOT receive bare /command without @mention", async () => { + const bot = createBot(tx.store, { username: "quiet_bot" }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [user.user_id], + botIds: [bot.bot_id], + }); + + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "/help" }); + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "/help@quiet_bot" }); + + const updates = await json<{ result: Array<{ message: { text: string } }> }>( + await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}), + ); + expect(updates.result).toHaveLength(1); + expect(updates.result[0].message.text).toBe("/help@quiet_bot"); + }); + + it("can_read_all_group_messages bot sees every message", async () => { + const bot = createBot(tx.store, { username: "greedy_bot", can_read_all_group_messages: true }); + const user = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { title: "G", memberIds: [user.user_id], botIds: [bot.bot_id] }); + + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "one" }); + simulateUserMessage(tx.store, { chatId: group.chat_id, userId: user.user_id, text: "two" }); + + const updates = await json<{ result: unknown[] }>( + await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}), + ); + expect(updates.result).toHaveLength(2); + }); +}); + +describe("Telegram Bot API - photos and file_id round-trip", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("bot can sendPhoto by file_id and getFile, then download bytes", async () => { + const bot = createBot(tx.store, { username: "bot" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + // User sends a photo via control plane + const photoBase64 = Buffer.from(PNG_1X1).toString("base64"); + const upl = await postJson(tx.app, `/_emu/telegram/chats/${dm.chat_id}/photos`, { + userId: user.user_id, + photoBase64, + caption: "nice", + }); + const { file_id } = await json<{ file_id: string }>(upl); + expect(file_id).toMatch(/^tg_emu_/); + + // Bot resolves file via getFile + const fileRes = await json<{ result: { file_path: string } }>( + await postJson(tx.app, `/bot${bot.token}/getFile`, { file_id }), + ); + expect(fileRes.result.file_path).toContain("photos/"); + + // Bot downloads bytes + const dl = await tx.app.request(`http://localhost:4011/file/bot${bot.token}/${fileRes.result.file_path}`); + expect(dl.status).toBe(200); + const buf = Buffer.from(await dl.arrayBuffer()); + expect(buf.equals(Buffer.from(PNG_1X1))).toBe(true); + + // Bot re-sends by file_id + const send = await postJson(tx.app, `/bot${bot.token}/sendPhoto`, { + chat_id: dm.chat_id, + photo: file_id, + caption: "echo", + }); + const sent = await json<{ ok: boolean; result: { photo: unknown[] } }>(send); + expect(sent.ok).toBe(true); + expect(Array.isArray(sent.result.photo)).toBe(true); + }); +}); + +describe("Telegram Bot API - callback queries + inline keyboards", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("user click on inline button yields callback_query Update, bot can answerCallbackQuery", async () => { + const bot = createBot(tx.store, { username: "bot" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + // Bot sends a message with inline keyboard + const sendRes = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "Confirm?", + reply_markup: { inline_keyboard: [[{ text: "Yes", callback_data: "confirm:yes" }]] }, + }); + const sent = await json<{ result: { message_id: number } }>(sendRes); + const messageId = sent.result.message_id; + + // Simulate click via programmatic helper + const { update_id } = simulateCallback(tx.store, { + chatId: dm.chat_id, + userId: user.user_id, + messageId, + callbackData: "confirm:yes", + }); + expect(update_id).toBeGreaterThan(0); + + // Bot polls and sees the callback + const updates = await json<{ + result: Array<{ callback_query?: { id: string; data: string } }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + expect(updates.result[0].callback_query?.data).toBe("confirm:yes"); + const cqId = updates.result[0].callback_query!.id; + + // Bot answers + const ack = await postJson(tx.app, `/bot${bot.token}/answerCallbackQuery`, { + callback_query_id: cqId, + text: "Confirmed", + }); + expect(ack.status).toBe(200); + }); +}); + +// 1x1 red PNG (89 bytes) +const PNG_1X1 = Uint8Array.from([ + 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, + 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, 0x90, 0x77, 0x53, 0xde, 0x00, 0x00, 0x00, 0x0c, 0x49, + 0x44, 0x41, 0x54, 0x08, 0x99, 0x63, 0xf8, 0xcf, 0xc0, 0x00, 0x00, 0x00, 0x03, 0x00, 0x01, 0x5b, 0x07, 0xe8, 0xd7, + 0x00, 0x00, 0x00, 0x00, 0x49, 0x45, 0x4e, 0x44, 0xae, 0x42, 0x60, 0x82, +]); diff --git a/packages/@emulators/telegram/src/__tests__/helpers.ts b/packages/@emulators/telegram/src/__tests__/helpers.ts new file mode 100644 index 00000000..adaa573f --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/helpers.ts @@ -0,0 +1,46 @@ +import { Hono } from "hono"; +import { Store, WebhookDispatcher, type TokenMap, type AppEnv, authMiddleware } from "@emulators/core"; +import { telegramPlugin } from "../index.js"; +import { getDispatcher } from "../dispatcher.js"; + +export interface TestApp { + app: Hono; + store: Store; + webhooks: WebhookDispatcher; +} + +export function createTestApp(options?: { seed?: boolean; baseUrl?: string }): TestApp { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + const tokenMap: TokenMap = new Map(); + const app = new Hono(); + app.use("*", authMiddleware(tokenMap)); + + const baseUrl = options?.baseUrl ?? "http://localhost:4011"; + telegramPlugin.register(app, store, webhooks, baseUrl, tokenMap); + if (options?.seed !== false) { + telegramPlugin.seed!(store, baseUrl); + } + + // Keep retries fast for tests + getDispatcher(store).setRetryPolicy({ maxRetries: 2, backoffMs: [5, 10] }); + getDispatcher(store).setBackoffEnabled(false); + + return { app, store, webhooks }; +} + +export async function postJson(app: Hono, path: string, body: unknown): Promise { + return app.request(`http://localhost:4011${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +export async function getJson(app: Hono, path: string): Promise { + return app.request(`http://localhost:4011${path}`); +} + +export async function json(res: Response): Promise { + return (await res.json()) as T; +} diff --git a/packages/@emulators/telegram/src/__tests__/integration.test.ts b/packages/@emulators/telegram/src/__tests__/integration.test.ts new file mode 100644 index 00000000..72dc8411 --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/integration.test.ts @@ -0,0 +1,991 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestApp, postJson, json, type TestApp } from "./helpers.js"; +import { getTelegramStore } from "../store.js"; +import { + clearFaults, + createBot, + createChannel, + createForumTopic, + createGroupChat, + createPrivateChat, + createSupergroup, + createUser, + getCallbackAnswer, + injectFault, + simulateCallback, + simulateChannelPost, + simulateReaction, + simulateUserMedia, + simulateUserMessage, +} from "../routes/control.js"; + +describe("Integration — channel posts", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("channel_post reaches bots added to the channel, with sender_chat and no from", async () => { + const bot = createBot(tx.store, { username: "newsbot" }); + const channel = createChannel(tx.store, { title: "News", memberBotIds: [bot.bot_id] }); + + simulateChannelPost(tx.store, { chatId: channel.chat_id, text: "headline" }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + const body = await json<{ + result: Array<{ + channel_post?: { + sender_chat: { id: number; type: string; title: string }; + chat: { id: number; type: string }; + text: string; + from?: unknown; + }; + }>; + }>(res); + expect(body.result).toHaveLength(1); + const post = body.result[0].channel_post!; + expect(post.sender_chat).toMatchObject({ id: channel.chat_id, type: "channel", title: "News" }); + expect(post.chat).toMatchObject({ id: channel.chat_id, type: "channel" }); + expect(post.text).toBe("headline"); + expect(post.from).toBeUndefined(); + }); + + it("edited_channel_post dispatches with new text", async () => { + const bot = createBot(tx.store, { username: "newsbot" }); + const channel = createChannel(tx.store, { title: "News", memberBotIds: [bot.bot_id] }); + const { message_id } = simulateChannelPost(tx.store, { chatId: channel.chat_id, text: "v1" }); + simulateChannelPost(tx.store, { chatId: channel.chat_id, edited: true, existingMessageId: message_id, text: "v2" }); + + const updates = await json<{ + result: Array<{ edited_channel_post?: { text: string } }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + const edited = updates.result.find((u) => u.edited_channel_post); + expect(edited?.edited_channel_post?.text).toBe("v2"); + }); +}); + +describe("Integration — forum topics", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("routes message_thread_id through user → bot → reply round trip", async () => { + const bot = createBot(tx.store, { username: "topicbot" }); + const user = createUser(tx.store, { first_name: "A" }); + const sg = createSupergroup(tx.store, { title: "SG", memberIds: [user.user_id], botIds: [bot.bot_id] }); + const { message_thread_id } = createForumTopic(tx.store, { chatId: sg.chat_id, name: "discussion" }); + + simulateUserMessage(tx.store, { + chatId: sg.chat_id, + userId: user.user_id, + text: "@topicbot hi", + messageThreadId: message_thread_id, + }); + + const updates = await json<{ + result: Array<{ message: { message_thread_id?: number } }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + expect(updates.result[0].message.message_thread_id).toBe(message_thread_id); + + const sent = await json<{ result: { message_thread_id?: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: sg.chat_id, + message_thread_id, + text: "hi back", + }), + ); + expect(sent.result.message_thread_id).toBe(message_thread_id); + }); + + it("bot can createForumTopic / editForumTopic / closeForumTopic / deleteForumTopic via Bot API", async () => { + const bot = createBot(tx.store, { username: "fbot" }); + const user = createUser(tx.store, { first_name: "A" }); + const sg = createSupergroup(tx.store, { + title: "SG", + memberIds: [user.user_id], + botIds: [bot.bot_id], + isForum: true, + }); + + const created = await json<{ result: { message_thread_id: number; name: string; icon_color: number } }>( + await postJson(tx.app, `/bot${bot.token}/createForumTopic`, { + chat_id: sg.chat_id, + name: "General", + }), + ); + expect(created.result.name).toBe("General"); + expect(typeof created.result.message_thread_id).toBe("number"); + + const edited = await json<{ result: boolean }>( + await postJson(tx.app, `/bot${bot.token}/editForumTopic`, { + chat_id: sg.chat_id, + message_thread_id: created.result.message_thread_id, + name: "Renamed", + }), + ); + expect(edited.result).toBe(true); + + const closed = await json<{ result: boolean }>( + await postJson(tx.app, `/bot${bot.token}/closeForumTopic`, { + chat_id: sg.chat_id, + message_thread_id: created.result.message_thread_id, + }), + ); + expect(closed.result).toBe(true); + + const reopened = await json<{ result: boolean }>( + await postJson(tx.app, `/bot${bot.token}/reopenForumTopic`, { + chat_id: sg.chat_id, + message_thread_id: created.result.message_thread_id, + }), + ); + expect(reopened.result).toBe(true); + + const deleted = await json<{ result: boolean }>( + await postJson(tx.app, `/bot${bot.token}/deleteForumTopic`, { + chat_id: sg.chat_id, + message_thread_id: created.result.message_thread_id, + }), + ); + expect(deleted.result).toBe(true); + }); + + it("rejects message_thread_id in non-supergroup chats", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + message_thread_id: 42, + text: "nope", + }); + expect(res.status).toBe(400); + const body = await json<{ description: string }>(res); + expect(body.description).toContain("message thread not found"); + }); +}); + +describe("Integration — error shapes", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("429 with retry_after surfaced in the body", async () => { + const bot = createBot(tx.store, { username: "b" }); + injectFault(tx.store, { + botId: bot.bot_id, + method: "sendMessage", + error_code: 429, + retry_after: 7, + }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "x", + }); + expect(res.status).toBe(429); + const body = await json<{ + ok: boolean; + error_code: number; + description: string; + parameters: { retry_after: number }; + }>(res); + expect(body.ok).toBe(false); + expect(body.error_code).toBe(429); + expect(body.description).toContain("Too Many Requests"); + expect(body.parameters.retry_after).toBe(7); + }); + + it("fault is consumed once, then the call succeeds", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + injectFault(tx.store, { botId: bot.bot_id, method: "sendMessage", error_code: 429, retry_after: 1 }); + + const r1 = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "a" }); + expect(r1.status).toBe(429); + const r2 = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "b" }); + expect(r2.status).toBe(200); + }); + + it("403 on bot not in chat stays structured", async () => { + const bot = createBot(tx.store, { username: "b" }); + const other = createBot(tx.store, { username: "other" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: other.bot_id, userId: user.user_id }); + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "x", + }); + expect(res.status).toBe(403); + const body = await json<{ ok: boolean; error_code: number }>(res); + expect(body.error_code).toBe(403); + }); + + it("404 on unknown method", async () => { + const bot = createBot(tx.store, { username: "b" }); + const res = await postJson(tx.app, `/bot${bot.token}/thisMethodDoesNotExist`, {}); + expect(res.status).toBe(404); + const body = await json<{ error_code: number }>(res); + expect(body.error_code).toBe(404); + }); + + it("401 on unknown token", async () => { + const res = await postJson(tx.app, "/bot999:FAKE/sendMessage", { chat_id: 1, text: "x" }); + expect(res.status).toBe(401); + }); + + it("clearFaults drops all faults", async () => { + const bot = createBot(tx.store, { username: "b" }); + injectFault(tx.store, { botId: bot.bot_id, method: "*", error_code: 403 }); + clearFaults(tx.store); + expect(getTelegramStore(tx.store).faults.all()).toHaveLength(0); + }); +}); + +describe("Integration — length caps", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("sendMessage rejects text over 4096 chars", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "a".repeat(4097), + }); + expect(res.status).toBe(400); + const body = await json<{ description: string }>(res); + expect(body.description).toContain("message is too long"); + }); + + it("sendMessage accepts exactly 4096 chars", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "a".repeat(4096), + }); + expect(res.status).toBe(200); + }); + + it("editMessageText rejects text over 4096 chars", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "v1" }), + ); + const res = await postJson(tx.app, `/bot${bot.token}/editMessageText`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + text: "a".repeat(4097), + }); + expect(res.status).toBe(400); + }); + + it("sendPhoto rejects caption over 1024 chars", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQIW2P4//8/AAX+Av4zhb9VAAAAAElFTkSuQmCC", + "base64", + ); + // Upload first so we have a file_id to echo + const up = await json<{ file_id: string }>( + await postJson(tx.app, `/_emu/telegram/chats/${dm.chat_id}/photos`, { + userId: user.user_id, + photoBase64: PNG.toString("base64"), + }), + ); + const res = await postJson(tx.app, `/bot${bot.token}/sendPhoto`, { + chat_id: dm.chat_id, + photo: up.file_id, + caption: "b".repeat(1025), + }); + expect(res.status).toBe(400); + const body = await json<{ description: string }>(res); + expect(body.description).toContain("caption is too long"); + }); + + it("MarkdownV2 text length is measured after stripping markup", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + // 2*2050 letters wrapped in asterisks → raw is ~4104, stripped is ~4100 > 4096 + const text = "*" + "a".repeat(4100) + "*"; + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text, + parse_mode: "MarkdownV2", + }); + expect(res.status).toBe(400); + expect((await json<{ description: string }>(res)).description).toContain("message is too long"); + }); +}); + +describe("Integration — rich media", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + const cases: Array<{ method: string; field: "video" | "audio" | "voice" | "animation" | "sticker" }> = [ + { method: "sendVideo", field: "video" }, + { method: "sendAudio", field: "audio" }, + { method: "sendVoice", field: "voice" }, + { method: "sendAnimation", field: "animation" }, + { method: "sendSticker", field: "sticker" }, + ]; + + for (const { method, field } of cases) { + it(`${method} stores the media and round-trips file_id on re-send`, async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + // Seed a file so the re-send path is exercised (skip the multipart path + // in this shape-level test). + const ts = getTelegramStore(tx.store); + ts.files.insert({ + file_id: `seed_${field}`, + file_unique_id: `uq_${field}`, + owner_bot_id: bot.bot_id, + mime_type: "application/octet-stream", + file_size: 1, + width: 120, + height: 90, + file_path: `${field}s/${bot.bot_id}/seed_${field}`, + bytes_base64: "", + kind: field, + }); + + const body: { [key: string]: unknown } = { chat_id: dm.chat_id, [field]: `seed_${field}` }; + if (field !== "sticker") body.caption = "c"; + const res = await postJson(tx.app, `/bot${bot.token}/${method}`, body); + expect(res.status).toBe(200); + const result = (await json<{ result: { [key: string]: { file_id: string } } }>(res)).result; + expect(result[field].file_id).toBe(`seed_${field}`); + }); + } + + it("sticker silently strips caption (matches real Telegram)", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const ts = getTelegramStore(tx.store); + ts.files.insert({ + file_id: "stk", + file_unique_id: "uq_stk", + owner_bot_id: bot.bot_id, + mime_type: "image/webp", + file_size: 1, + width: 50, + height: 50, + file_path: `stickers/${bot.bot_id}/stk`, + bytes_base64: "", + kind: "sticker", + }); + const res = await postJson(tx.app, `/bot${bot.token}/sendSticker`, { + chat_id: dm.chat_id, + sticker: "stk", + caption: "nope", + }); + expect(res.status).toBe(200); + const body = await json<{ result: { caption?: string } }>(res); + expect(body.result.caption).toBeUndefined(); + }); + + it("simulateUserMedia produces a user message with the media field and file_id", async () => { + const bot = createBot(tx.store, { username: "greedy", can_read_all_group_messages: true }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const result = simulateUserMedia(tx.store, { + chatId: dm.chat_id, + userId: user.user_id, + kind: "voice", + bytes: Buffer.from("oggdata"), + duration: 3, + }); + expect(result.file_id).toMatch(/^tg_emu_/); + + const updates = await json<{ + result: Array<{ message: { voice?: { duration: number } } }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + expect(updates.result[0].message.voice?.duration).toBe(3); + }); +}); + +describe("Integration — file_id preservation on photo echo", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("sendPhoto by file_id returns the same file_id in the largest tier", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const PNG = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQIW2P4//8/AAX+Av4zhb9VAAAAAElFTkSuQmCC", + "base64", + ); + const up = await json<{ file_id: string }>( + await postJson(tx.app, `/_emu/telegram/chats/${dm.chat_id}/photos`, { + userId: user.user_id, + photoBase64: PNG.toString("base64"), + }), + ); + const echoed = await json<{ result: { photo: Array<{ file_id: string }> } }>( + await postJson(tx.app, `/bot${bot.token}/sendPhoto`, { + chat_id: dm.chat_id, + photo: up.file_id, + }), + ); + const largest = echoed.result.photo.at(-1)!; + expect(largest.file_id).toBe(up.file_id); + }); +}); + +describe("Integration — entity auto-detection", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("detects url, email, hashtag, cashtag in user text", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + simulateUserMessage(tx.store, { + chatId: dm.chat_id, + userId: user.user_id, + text: "see https://example.com or email me@x.io #urgent $AAPL", + }); + + const updates = await json<{ + result: Array<{ message: { entities: Array<{ type: string }> } }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + const types = updates.result[0].message.entities.map((e) => e.type); + expect(types).toContain("url"); + expect(types).toContain("email"); + expect(types).toContain("hashtag"); + expect(types).toContain("cashtag"); + }); + + it("strips trailing punctuation from URLs", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + simulateUserMessage(tx.store, { + chatId: dm.chat_id, + userId: user.user_id, + text: "see https://example.com.", + }); + const updates = await json<{ + result: Array<{ + message: { text: string; entities: Array<{ type: string; offset: number; length: number }> }; + }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + const url = updates.result[0].message.entities.find((e) => e.type === "url"); + const text = updates.result[0].message.text; + const extracted = text.slice(url!.offset, url!.offset + url!.length); + expect(extracted).toBe("https://example.com"); + }); +}); + +describe("Integration — allowed_updates filter", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("getUpdates honours allowed_updates and skips filtered types", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "hi" }); + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "ok" }), + ); + simulateReaction(tx.store, { + chatId: dm.chat_id, + messageId: sent.result.message_id, + userId: user.user_id, + reaction: [{ type: "emoji", emoji: "👍" }], + }); + + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, { + allowed_updates: ["message_reaction"], + }); + const body = await json<{ + result: Array<{ message_reaction?: unknown; message?: unknown }>; + }>(res); + expect(body.result).toHaveLength(1); + expect(body.result[0].message_reaction).toBeDefined(); + expect(body.result[0].message).toBeUndefined(); + }); +}); + +describe("Integration — callback answer reader", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("GET /_emu/telegram/callbacks/:id returns the stored answer", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "pick", + reply_markup: { inline_keyboard: [[{ text: "Yes", callback_data: "y" }]] }, + }); + const ts = getTelegramStore(tx.store); + const msg = ts.messages.all()[0]; + simulateCallback(tx.store, { + chatId: dm.chat_id, + userId: user.user_id, + messageId: msg.message_id, + callbackData: "y", + }); + const updates = await json<{ result: Array<{ callback_query?: { id: string } }> }>( + await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}), + ); + const id = updates.result[0].callback_query!.id; + await postJson(tx.app, `/bot${bot.token}/answerCallbackQuery`, { + callback_query_id: id, + text: "Yes!", + show_alert: true, + }); + + const answer = getCallbackAnswer(tx.store, id); + expect(answer?.answered).toBe(true); + expect(answer?.answer_text).toBe("Yes!"); + expect(answer?.answer_show_alert).toBe(true); + + const httpAnswer = await tx.app.request(`http://localhost:4011/_emu/telegram/callbacks/${id}`); + expect(httpAnswer.status).toBe(200); + const httpBody = (await httpAnswer.json()) as { + ok: true; + callback_query_id: string; + answered: boolean; + answer_text: string; + answer_show_alert?: boolean; + }; + expect(httpBody.answer_text).toBe("Yes!"); + }); +}); + +describe("Integration — parse_mode", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("MarkdownV2 round-trips formatted message into entities", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "*hello* [x](https://a.io)", + parse_mode: "MarkdownV2", + }); + const body = await json<{ ok: boolean; result: { text: string; entities: unknown[] } }>(res); + expect(body.ok).toBe(true); + expect(body.result.text).toBe("hello x"); + expect(body.result.entities).toEqual([ + { type: "bold", offset: 0, length: 5 }, + { type: "text_link", offset: 6, length: 1, url: "https://a.io" }, + ]); + }); + + it("MarkdownV2 returns 400 with Telegram-shaped error on unescaped reserved char", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "hello.", + parse_mode: "MarkdownV2", + }); + expect(res.status).toBe(400); + const body = await json<{ ok: boolean; description: string }>(res); + expect(body.ok).toBe(false); + expect(body.description).toContain("can't parse entities"); + expect(body.description).toContain("."); + }); + + it("HTML parse_mode works for sendMessage", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: 'hello x', + parse_mode: "HTML", + }); + const body = await json<{ result: { text: string; entities: unknown[] } }>(res); + expect(body.result.text).toBe("hello x"); + expect(body.result.entities).toEqual([ + { type: "bold", offset: 0, length: 5 }, + { type: "text_link", offset: 6, length: 1, url: "https://a.io" }, + ]); + }); + + it("parse_mode applies to sendPhoto.caption and sendDocument.caption", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + // Seed a doc + getTelegramStore(tx.store).files.insert({ + file_id: "doc_1", + file_unique_id: "uq_doc_1", + owner_bot_id: bot.bot_id, + mime_type: "application/pdf", + file_size: 1, + width: 0, + height: 0, + file_path: `documents/${bot.bot_id}/doc_1`, + bytes_base64: "", + kind: "document", + }); + + const res = await postJson(tx.app, `/bot${bot.token}/sendDocument`, { + chat_id: dm.chat_id, + document: "doc_1", + caption: "*heading*", + parse_mode: "MarkdownV2", + }); + const body = await json<{ result: { caption: string; caption_entities: unknown[] } }>(res); + expect(body.result.caption).toBe("heading"); + expect(body.result.caption_entities).toEqual([{ type: "bold", offset: 0, length: 7 }]); + }); + + it("editMessageText applies parse_mode", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "v1" }), + ); + const edited = await json<{ result: { text: string; entities: unknown[] } }>( + await postJson(tx.app, `/bot${bot.token}/editMessageText`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + text: "~v2~", + parse_mode: "MarkdownV2", + }), + ); + expect(edited.result.text).toBe("v2"); + expect(edited.result.entities).toEqual([{ type: "strikethrough", offset: 0, length: 2 }]); + }); +}); + +describe("Integration — small methods", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("sendChatAction returns true for any action", async () => { + const bot = createBot(tx.store, { username: "b" }); + const res = await postJson(tx.app, `/bot${bot.token}/sendChatAction`, { + chat_id: 1, + action: "typing", + }); + const body = await json<{ ok: boolean; result: boolean }>(res); + expect(body.ok).toBe(true); + expect(body.result).toBe(true); + }); + + it("getChatMember reports creator / administrator / member", async () => { + const bot = createBot(tx.store, { username: "b" }); + const alice = createUser(tx.store, { first_name: "A" }); + const bob = createUser(tx.store, { first_name: "B" }); + const carol = createUser(tx.store, { first_name: "C" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [alice.user_id, bob.user_id, carol.user_id], + botIds: [bot.bot_id], + creatorUserId: alice.user_id, + adminUserIds: [bob.user_id], + }); + + const call = async (uid: number) => + json<{ result: { status: string; can_manage_chat?: boolean } }>( + await postJson(tx.app, `/bot${bot.token}/getChatMember`, { chat_id: group.chat_id, user_id: uid }), + ); + + expect((await call(alice.user_id)).result.status).toBe("creator"); + expect((await call(bob.user_id)).result.status).toBe("administrator"); + expect((await call(carol.user_id)).result.status).toBe("member"); + expect((await call(alice.user_id)).result.can_manage_chat).toBe(true); + }); + + it("getChatAdministrators returns creator + admins", async () => { + const bot = createBot(tx.store, { username: "b" }); + const alice = createUser(tx.store, { first_name: "A" }); + const bob = createUser(tx.store, { first_name: "B" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [alice.user_id, bob.user_id], + botIds: [bot.bot_id], + creatorUserId: alice.user_id, + adminUserIds: [bob.user_id], + }); + const body = await json<{ result: Array<{ status: string; user: { id: number } }> }>( + await postJson(tx.app, `/bot${bot.token}/getChatAdministrators`, { chat_id: group.chat_id }), + ); + expect(body.result).toHaveLength(2); + expect(body.result[0].status).toBe("creator"); + expect(body.result[0].user.id).toBe(alice.user_id); + expect(body.result[1].status).toBe("administrator"); + expect(body.result[1].user.id).toBe(bob.user_id); + }); + + it("getChat returns ChatFullInfo shape with permissions + accent_color_id", async () => { + const bot = createBot(tx.store, { username: "b" }); + const u1 = createUser(tx.store, { first_name: "A" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [u1.user_id], + botIds: [bot.bot_id], + }); + const body = await json<{ + ok: boolean; + result: { + id: number; + type: string; + title: string; + accent_color_id: number; + max_reaction_count: number; + permissions: { can_send_messages: boolean }; + }; + }>(await postJson(tx.app, `/bot${bot.token}/getChat`, { chat_id: group.chat_id })); + expect(body.result.type).toBe("group"); + expect(body.result.title).toBe("G"); + expect(body.result.accent_color_id).toBe(0); + expect(body.result.max_reaction_count).toBe(11); + expect(body.result.permissions.can_send_messages).toBe(true); + }); + + it("getChatMemberCount returns member + bot count", async () => { + const bot = createBot(tx.store, { username: "b" }); + const u1 = createUser(tx.store, { first_name: "A" }); + const u2 = createUser(tx.store, { first_name: "B" }); + const group = createGroupChat(tx.store, { + title: "g", + memberIds: [u1.user_id, u2.user_id], + botIds: [bot.bot_id], + }); + const body = await json<{ ok: boolean; result: number }>( + await postJson(tx.app, `/bot${bot.token}/getChatMemberCount`, { chat_id: group.chat_id }), + ); + expect(body.result).toBe(3); + }); +}); + +describe("Integration — reply_to_message full object", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("bot reply with reply_to_message_id populates a reply_to_message object", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const first = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "parent" }), + ); + + const reply = await json<{ + result: { reply_to_message_id: number; reply_to_message: { text: string } }; + }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "child", + reply_to_message_id: first.result.message_id, + }), + ); + expect(reply.result.reply_to_message_id).toBe(first.result.message_id); + expect(reply.result.reply_to_message.text).toBe("parent"); + }); +}); + +describe("Integration — reactions", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("setMessageReaction stores the bot's reaction and is observable via store", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "ok" }), + ); + + const res = await postJson(tx.app, `/bot${bot.token}/setMessageReaction`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + reaction: [{ type: "emoji", emoji: "👍" }], + }); + expect(res.status).toBe(200); + const stored = getTelegramStore(tx.store).reactions.all(); + expect(stored).toHaveLength(1); + expect(stored[0].reaction[0]).toEqual({ type: "emoji", emoji: "👍" }); + expect(stored[0].sender_bot_id).toBe(bot.bot_id); + }); + + it("setMessageReaction with empty array clears the reaction", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "ok" }), + ); + + await postJson(tx.app, `/bot${bot.token}/setMessageReaction`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + reaction: [{ type: "emoji", emoji: "👍" }], + }); + await postJson(tx.app, `/bot${bot.token}/setMessageReaction`, { + chat_id: dm.chat_id, + message_id: sent.result.message_id, + reaction: [], + }); + expect(getTelegramStore(tx.store).reactions.all()).toHaveLength(0); + }); + + it("bot editMessageText dispatches edited_message to other bots in the chat", async () => { + const botA = createBot(tx.store, { username: "ba", can_read_all_group_messages: true }); + const botB = createBot(tx.store, { username: "bb", can_read_all_group_messages: true }); + const user = createUser(tx.store, { first_name: "U" }); + const group = createGroupChat(tx.store, { + title: "G", + memberIds: [user.user_id], + botIds: [botA.bot_id, botB.bot_id], + }); + + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${botA.token}/sendMessage`, { chat_id: group.chat_id, text: "hi" }), + ); + // Drain any existing updates for botB first. + await postJson(tx.app, `/bot${botB.token}/getUpdates`, {}); + + await postJson(tx.app, `/bot${botA.token}/editMessageText`, { + chat_id: group.chat_id, + message_id: sent.result.message_id, + text: "hi (edited)", + }); + + const updates = await json<{ result: Array<{ edited_message?: { text: string } }> }>( + await postJson(tx.app, `/bot${botB.token}/getUpdates`, {}), + ); + expect(updates.result.some((u) => u.edited_message?.text === "hi (edited)")).toBe(true); + }); + + it("simulateReaction dispatches message_reaction Update to bots in chat", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + const sent = await json<{ result: { message_id: number } }>( + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { chat_id: dm.chat_id, text: "ok" }), + ); + + simulateReaction(tx.store, { + chatId: dm.chat_id, + messageId: sent.result.message_id, + userId: user.user_id, + reaction: [{ type: "emoji", emoji: "❤️" }], + }); + + const updates = await json<{ + result: Array<{ + message_reaction?: { new_reaction: Array<{ emoji: string }> }; + message_reaction_count?: { reactions: Array<{ type: unknown; total_count: number }> }; + }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + // One message_reaction (per-user) + one message_reaction_count (anonymous + // aggregate) — matches real Telegram's dispatch shape. + expect(updates.result).toHaveLength(2); + const reaction = updates.result.find((u) => u.message_reaction); + const count = updates.result.find((u) => u.message_reaction_count); + expect(reaction?.message_reaction?.new_reaction[0].emoji).toBe("❤️"); + expect(count?.message_reaction_count?.reactions).toHaveLength(1); + expect(count?.message_reaction_count?.reactions[0].total_count).toBe(1); + }); +}); + +describe("Integration — answerCallbackQuery stores the answer", () => { + let tx: TestApp; + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("answerCallbackQuery persists text + show_alert on the callback row", async () => { + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + // Bot sends a keyboard + await postJson(tx.app, `/bot${bot.token}/sendMessage`, { + chat_id: dm.chat_id, + text: "pick", + reply_markup: { inline_keyboard: [[{ text: "A", callback_data: "a" }]] }, + }); + + // User taps + const ts = getTelegramStore(tx.store); + const sent = ts.messages.all()[0]; + simulateCallback(tx.store, { + chatId: dm.chat_id, + userId: user.user_id, + messageId: sent.message_id, + callbackData: "a", + }); + + // Bot answers with text + alert + const updates = await json<{ + result: Array<{ callback_query?: { id: string } }>; + }>(await postJson(tx.app, `/bot${bot.token}/getUpdates`, {})); + const id = updates.result[0].callback_query!.id; + await postJson(tx.app, `/bot${bot.token}/answerCallbackQuery`, { + callback_query_id: id, + text: "Confirmed", + show_alert: true, + }); + + const row = ts.callbackQueries.findOneBy("callback_query_id", id); + expect(row?.answered).toBe(true); + expect(row?.answer_text).toBe("Confirmed"); + expect(row?.answer_show_alert).toBe(true); + }); +}); diff --git a/packages/@emulators/telegram/src/__tests__/parse-mode.test.ts b/packages/@emulators/telegram/src/__tests__/parse-mode.test.ts new file mode 100644 index 00000000..a00249c9 --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/parse-mode.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect } from "vitest"; +import { parseMarkdownV2, MarkdownParseError } from "../markdown.js"; +import { parseHtml, HtmlParseError } from "../html.js"; + +describe("MarkdownV2 parser", () => { + it("extracts bold with a single-asterisk pair", () => { + const r = parseMarkdownV2("*hello*"); + expect(r.text).toBe("hello"); + expect(r.entities).toEqual([{ type: "bold", offset: 0, length: 5 }]); + }); + + it("extracts italic and bold together", () => { + const r = parseMarkdownV2("*bold* and _italic_"); + expect(r.text).toBe("bold and italic"); + expect(r.entities).toContainEqual({ type: "bold", offset: 0, length: 4 }); + expect(r.entities).toContainEqual({ type: "italic", offset: 9, length: 6 }); + }); + + it("handles underline via double underscore", () => { + const r = parseMarkdownV2("__under__"); + expect(r.text).toBe("under"); + expect(r.entities).toEqual([{ type: "underline", offset: 0, length: 5 }]); + }); + + it("handles strikethrough", () => { + const r = parseMarkdownV2("~gone~"); + expect(r.text).toBe("gone"); + expect(r.entities).toEqual([{ type: "strikethrough", offset: 0, length: 4 }]); + }); + + it("handles spoiler (double pipe)", () => { + const r = parseMarkdownV2("||hush||"); + expect(r.text).toBe("hush"); + expect(r.entities).toEqual([{ type: "spoiler", offset: 0, length: 4 }]); + }); + + it("handles inline code", () => { + const r = parseMarkdownV2("run `npm test` now"); + expect(r.text).toBe("run npm test now"); + expect(r.entities).toContainEqual({ type: "code", offset: 4, length: 8 }); + }); + + it("handles pre block with language", () => { + const r = parseMarkdownV2("```typescript\nconst x = 1;\n```"); + expect(r.text).toBe("const x = 1;\n"); + expect(r.entities[0].type).toBe("pre"); + expect(r.entities[0].language).toBe("typescript"); + }); + + it("handles inline text_link", () => { + const r = parseMarkdownV2("[hello](https://example.com)"); + expect(r.text).toBe("hello"); + expect(r.entities).toEqual([ + { type: "text_link", offset: 0, length: 5, url: "https://example.com" }, + ]); + }); + + it("handles text_mention via tg://user?id=N", () => { + const r = parseMarkdownV2("[me](tg://user?id=42)"); + expect(r.text).toBe("me"); + expect(r.entities[0]).toMatchObject({ type: "text_mention", offset: 0, length: 2 }); + expect(r.entities[0].user?.id).toBe(42); + }); + + it("rejects unescaped reserved character (period)", () => { + expect(() => parseMarkdownV2("hello.")).toThrowError(MarkdownParseError); + }); + + it("accepts escaped period", () => { + const r = parseMarkdownV2("hello\\."); + expect(r.text).toBe("hello."); + expect(r.entities).toHaveLength(0); + }); + + it("rejects unclosed bold", () => { + expect(() => parseMarkdownV2("*hello")).toThrowError(MarkdownParseError); + }); + + it("rejects unclosed inline code", () => { + expect(() => parseMarkdownV2("run `npm test")).toThrowError(MarkdownParseError); + }); + + it("rejects malformed link", () => { + expect(() => parseMarkdownV2("[hello](no-close")).toThrowError(MarkdownParseError); + }); + + it("rejects stray reserved char after escape", () => { + // `\` at end is terminal, not a dangling escape. + expect(() => parseMarkdownV2("foo\\")).toThrowError(MarkdownParseError); + }); + + it("rejects every reserved char unescaped (spot-check)", () => { + for (const ch of [".", "-", "!", "#", "+", "=", "{", "}"]) { + expect(() => parseMarkdownV2(`hello${ch}world`)).toThrowError(MarkdownParseError); + } + }); +}); + +describe("HTML parser", () => { + it("extracts bold", () => { + const r = parseHtml("hi"); + expect(r.text).toBe("hi"); + expect(r.entities).toEqual([{ type: "bold", offset: 0, length: 2 }]); + }); + + it("accepts as bold alias", () => { + const r = parseHtml("hi"); + expect(r.entities[0].type).toBe("bold"); + }); + + it("extracts link with href", () => { + const r = parseHtml('hello'); + expect(r.text).toBe("hello"); + expect(r.entities).toEqual([ + { type: "text_link", offset: 0, length: 5, url: "https://x.io" }, + ]); + }); + + it("extracts text_mention from tg://user", () => { + const r = parseHtml('me'); + expect(r.entities[0].type).toBe("text_mention"); + expect(r.entities[0].user?.id).toBe(42); + }); + + it("decodes HTML entities (& < > ")", () => { + const r = parseHtml("A & B <3"); + expect(r.text).toBe("A & B <3"); + }); + + it("handles tg-spoiler", () => { + const r = parseHtml("hush"); + expect(r.entities[0].type).toBe("spoiler"); + }); + + it("handles span.tg-spoiler", () => { + const r = parseHtml('hush'); + expect(r.entities[0].type).toBe("spoiler"); + }); + + it("rejects unclosed tag", () => { + expect(() => parseHtml("hi")).toThrowError(HtmlParseError); + }); + + it("rejects mismatched closing tag", () => { + expect(() => parseHtml("hi")).toThrowError(HtmlParseError); + }); + + it("rejects unsupported tag", () => { + expect(() => parseHtml("nope")).toThrowError(HtmlParseError); + }); + + it("parses
", () => { + const r = parseHtml("
quoted
"); + expect(r.text).toBe("quoted"); + expect(r.entities).toEqual([{ type: "blockquote", offset: 0, length: 6 }]); + }); + + it("parses
", () => { + const r = parseHtml("
long
"); + expect(r.text).toBe("long"); + expect(r.entities).toEqual([{ type: "expandable_blockquote", offset: 0, length: 4 }]); + }); +}); + +describe("MarkdownV2 blockquote", () => { + it("parses single-line > blockquote", () => { + const r = parseMarkdownV2(">quoted"); + expect(r.text).toBe("quoted"); + expect(r.entities).toEqual([{ type: "blockquote", offset: 0, length: 6 }]); + }); + + it("parses multi-line > blockquote joined with newline", () => { + const r = parseMarkdownV2(">line one\n>line two"); + expect(r.text).toBe("line one\nline two"); + expect(r.entities).toEqual([{ type: "blockquote", offset: 0, length: 17 }]); + }); + + it("parses expandable blockquote **>...||", () => { + const r = parseMarkdownV2("**>line one\n>line two||"); + expect(r.text).toBe("line one\nline two"); + expect(r.entities).toEqual([{ type: "expandable_blockquote", offset: 0, length: 17 }]); + }); +}); diff --git a/packages/@emulators/telegram/src/__tests__/test-client.test.ts b/packages/@emulators/telegram/src/__tests__/test-client.test.ts new file mode 100644 index 00000000..079139c9 --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/test-client.test.ts @@ -0,0 +1,128 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { Hono } from "hono"; +import { Store, WebhookDispatcher, authMiddleware, type AppEnv } from "@emulators/core"; +import { telegramPlugin } from "../index.js"; +import { createTelegramTestClient } from "../test.js"; +import type { WireMessage, WireUpdate } from "../types/wire/index.js"; + +describe("Telegram test client + in-process HTTP round-trip", () => { + let app: Hono; + let baseUrl: string; + + beforeEach(() => { + const store = new Store(); + const webhooks = new WebhookDispatcher(); + app = new Hono(); + app.use("*", authMiddleware(new Map())); + telegramPlugin.register(app, store, webhooks, "http://localhost", new Map()); + baseUrl = "http://localhost:0"; + }); + + // Drive the client via Hono's in-process request() API so tests don't + // need to boot a real HTTP server. Same observable behaviour, no + // @hono/node-server dependency. + const makeClient = () => + createTelegramTestClient(baseUrl, { + fetchImpl: async (input, init) => app.request(input, init), + }); + + it("end-to-end: create bot, user, DM, send and receive text", async () => { + const tg = makeClient(); + + const bot = await tg.createBot({ username: "trip_test_bot", first_name: "Trip Test" }); + const user = await tg.createUser({ first_name: "Alice" }); + const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + + const sendRes = await app.request(`${baseUrl}/bot${bot.token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: dm.id, text: "hello from bot" }), + }); + const sendBody = (await sendRes.json()) as { ok: boolean; result: WireMessage }; + expect(sendBody.ok).toBe(true); + expect(sendBody.result.text).toBe("hello from bot"); + + await tg.sendUserMessage({ chatId: dm.id, userId: user.id, text: "/connect ABC123" }); + + const updatesRes = await app.request(`${baseUrl}/bot${bot.token}/getUpdates`); + const updates = (await updatesRes.json()) as { ok: boolean; result: WireUpdate[] }; + expect(updates.result).toHaveLength(1); + const first = updates.result[0]; + if (!("message" in first)) throw new Error("expected message update"); + expect(first.message.text).toBe("/connect ABC123"); + expect(first.message.entities?.[0].type).toBe("bot_command"); + + const all = await tg.getAllMessages({ chatId: dm.id }); + expect(all).toHaveLength(2); + }); + + it("inspector returns HTML with Telegram label", async () => { + const res = await app.request(`${baseUrl}/`); + expect(res.status).toBe(200); + expect(res.headers.get("content-type")).toContain("text/html"); + const html = await res.text(); + expect(html).toContain("Telegram"); + }); + + it("injectFault / clearFaults / getCallbackAnswer route through fetchImpl (not global fetch)", async () => { + const tg = makeClient(); + const bot = await tg.createBot({ username: "b" }); + const user = await tg.createUser({ first_name: "A" }); + const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + + // injectFault uses postJson → fetchImpl. Inject + clear and confirm + // no real-network I/O happens (baseUrl is the dummy localhost:0). + await tg.injectFault({ botId: bot.bot_id, method: "sendMessage", errorCode: 429, retryAfter: 3 }); + await tg.clearFaults(); + + // Queue a callback via control plane so there's something to look up. + const sent = await app.request(`${baseUrl}/bot${bot.token}/sendMessage`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: dm.id, text: "with button", reply_markup: { + inline_keyboard: [[{ text: "ok", callback_data: "ok" }]], + } }), + }); + const sentBody = (await sent.json()) as { result: WireMessage }; + const click = await tg.clickInlineButton({ + chatId: dm.id, + userId: user.id, + messageId: sentBody.result.message_id, + callbackData: "ok", + }); + // answerCallbackQuery so getCallbackAnswer has something to return. + await app.request(`${baseUrl}/bot${bot.token}/answerCallbackQuery`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ callback_query_id: click.callback_query_id, text: "thanks" }), + }); + + const answer = await tg.getCallbackAnswer({ callbackQueryId: click.callback_query_id }); + expect(answer?.answered).toBe(true); + expect(answer?.answer_text).toBe("thanks"); + }); + + it("simulated photo upload and sendPhoto echo via test client", async () => { + const tg = makeClient(); + const bot = await tg.createBot({ username: "b" }); + const user = await tg.createUser({ first_name: "A" }); + const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + + const png = Buffer.from( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVQIW2P4//8/AAX+Av4zhb9VAAAAAElFTkSuQmCC", + "base64", + ); + + const up = await tg.sendUserPhoto({ chatId: dm.id, userId: user.id, photoBytes: png, caption: "test" }); + expect(up.file_id).toMatch(/^tg_emu_/); + + const send = await app.request(`${baseUrl}/bot${bot.token}/sendPhoto`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ chat_id: dm.id, photo: up.file_id, caption: "echo" }), + }); + const body = (await send.json()) as { ok: boolean; result: WireMessage }; + expect(body.ok).toBe(true); + expect(Array.isArray(body.result.photo)).toBe(true); + }); +}); diff --git a/packages/@emulators/telegram/src/__tests__/validators.test.ts b/packages/@emulators/telegram/src/__tests__/validators.test.ts new file mode 100644 index 00000000..428fd5c3 --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/validators.test.ts @@ -0,0 +1,157 @@ +// Smoke tests for the zod validators. These are not exhaustive — +// detailed error-message assertions live in the integration tests — but +// they cover: (a) each schema accepts a realistic body, (b) the error +// normalisation (firstZodError) produces Bot API-style strings. +import { describe, expect, it } from "vitest"; +import { z } from "zod"; +import { + zChatId, + zSendMessageBody, + zSendPhotoBody, + zSendVideoBody, + zAnswerCallbackQueryBody, + zGetChatBody, + zGetChatMemberBody, + zCreateForumTopicBody, + zGetUpdatesBody, + zSetWebhookBody, + zSetMessageReactionBody, + zSetMyCommandsBody, + zSendMessageDraftBody, + zCreateBotInput, + zInjectFaultInput, + firstZodError, +} from "../types/validators/index.js"; + +describe("zChatId", () => { + it("accepts a number", () => { + expect(zChatId.parse(123)).toBe(123); + }); + it("coerces a digit-string to number", () => { + expect(zChatId.parse("-100123")).toBe(-100123); + }); + it("rejects a non-numeric string", () => { + expect(zChatId.safeParse("@channel").success).toBe(false); + }); +}); + +describe("zSendMessageBody", () => { + it("accepts a minimal body", () => { + const r = zSendMessageBody.parse({ chat_id: 1, text: "hi" }); + expect(r.chat_id).toBe(1); + expect(r.text).toBe("hi"); + }); + it("accepts parse_mode + entities", () => { + const r = zSendMessageBody.parse({ + chat_id: "1", + text: "hi", + parse_mode: "HTML", + entities: [{ type: "bold", offset: 0, length: 2 }], + }); + expect(r.parse_mode).toBe("HTML"); + expect(r.entities?.[0].type).toBe("bold"); + }); + it("accepts all four reply_markup variants", () => { + expect( + zSendMessageBody.parse({ + chat_id: 1, + text: "x", + reply_markup: { inline_keyboard: [[{ text: "ok", callback_data: "cb" }]] }, + }).reply_markup, + ).toBeDefined(); + expect( + zSendMessageBody.parse({ + chat_id: 1, + text: "x", + reply_markup: { keyboard: [[{ text: "a" }]] }, + }).reply_markup, + ).toBeDefined(); + expect( + zSendMessageBody.parse({ + chat_id: 1, + text: "x", + reply_markup: { force_reply: true }, + }).reply_markup, + ).toBeDefined(); + expect( + zSendMessageBody.parse({ + chat_id: 1, + text: "x", + reply_markup: { remove_keyboard: true }, + }).reply_markup, + ).toBeDefined(); + }); +}); + +describe("zSendPhotoBody / zSendVideoBody", () => { + it("accepts string file_id", () => { + expect(zSendPhotoBody.parse({ chat_id: 1, photo: "AgAD..." }).photo).toBe("AgAD..."); + expect(zSendVideoBody.parse({ chat_id: 1, video: "BAAD..." }).video).toBe("BAAD..."); + }); + it("accepts multipart file", () => { + const mp = { __file: true, name: "a.jpg", type: "image/jpeg", bytes: Buffer.from("x") }; + const r = zSendPhotoBody.parse({ chat_id: 1, photo: mp }); + expect(typeof r.photo).not.toBe("string"); + }); +}); + +describe("simple schemas", () => { + it("zAnswerCallbackQueryBody", () => { + expect(zAnswerCallbackQueryBody.parse({ callback_query_id: "abc" })).toEqual({ + callback_query_id: "abc", + }); + }); + it("zGetChatBody / zGetChatMemberBody", () => { + expect(zGetChatBody.parse({ chat_id: 1 }).chat_id).toBe(1); + expect(zGetChatMemberBody.parse({ chat_id: 1, user_id: 2 }).user_id).toBe(2); + }); + it("zCreateForumTopicBody requires name", () => { + expect(zCreateForumTopicBody.parse({ chat_id: 1, name: "General" }).name).toBe("General"); + expect(zCreateForumTopicBody.safeParse({ chat_id: 1, name: "" }).success).toBe(false); + }); + it("zGetUpdatesBody allows empty", () => { + expect(zGetUpdatesBody.parse({})).toEqual({}); + }); + it("zSetWebhookBody allows empty", () => { + expect(zSetWebhookBody.parse({})).toEqual({}); + }); + it("zSetMessageReactionBody accepts emoji + custom_emoji", () => { + const r = zSetMessageReactionBody.parse({ + chat_id: 1, + message_id: 2, + reaction: [{ type: "emoji", emoji: "👍" }, { type: "custom_emoji", custom_emoji_id: "x" }], + }); + expect(r.reaction?.length).toBe(2); + }); + it("zSetMyCommandsBody", () => { + expect( + zSetMyCommandsBody.parse({ commands: [{ command: "start", description: "Start" }] }) + .commands[0].command, + ).toBe("start"); + }); + it("zSendMessageDraftBody", () => { + expect( + zSendMessageDraftBody.parse({ chat_id: 1, draft_id: 10, text: "draft" }).draft_id, + ).toBe(10); + }); + it("zCreateBotInput / zInjectFaultInput", () => { + expect(zCreateBotInput.parse({ username: "foo_bot" }).username).toBe("foo_bot"); + expect( + zInjectFaultInput.parse({ bot_id: 1, method: "*", error_code: 429 }).error_code, + ).toBe(429); + }); +}); + +describe("firstZodError", () => { + const schema = z.object({ chat_id: z.number() }); + it("reports missing required field", () => { + const r = schema.safeParse({}); + if (r.success) throw new Error("expected failure"); + expect(firstZodError(r.error)).toBe("Bad Request: chat_id is required"); + }); + it("reports invalid type", () => { + const r = schema.safeParse({ chat_id: "nope" }); + if (r.success) throw new Error("expected failure"); + expect(firstZodError(r.error)).toBe("Bad Request: chat_id has invalid type"); + }); +}); diff --git a/packages/@emulators/telegram/src/__tests__/webhook.test.ts b/packages/@emulators/telegram/src/__tests__/webhook.test.ts new file mode 100644 index 00000000..24601616 --- /dev/null +++ b/packages/@emulators/telegram/src/__tests__/webhook.test.ts @@ -0,0 +1,109 @@ +import { describe, it, expect, beforeEach } from "vitest"; +import { createTestApp, postJson, json, type TestApp } from "./helpers.js"; +import { getTelegramStore } from "../store.js"; +import { getDispatcher } from "../dispatcher.js"; +import { createBot, createPrivateChat, createUser, simulateUserMessage } from "../routes/control.js"; +import type { WireUpdate } from "../types/wire/index.js"; + +describe("Telegram webhook delivery", () => { + let tx: TestApp; + + beforeEach(() => { + tx = createTestApp({ seed: false }); + }); + + it("POSTs Update JSON to the configured webhook URL on user activity", async () => { + const received: Array<{ url: string; headers: Record; body: unknown }> = []; + getDispatcher(tx.store).setFetchImpl(async (url, init) => { + const headers: Record = {}; + const hdrs = (init?.headers ?? {}) as Record; + for (const [k, v] of Object.entries(hdrs)) headers[k.toLowerCase()] = v; + received.push({ + url: typeof url === "string" ? url : url.toString(), + headers, + body: JSON.parse(String(init?.body ?? "null")), + }); + return new Response(null, { status: 200 }); + }); + + const bot = createBot(tx.store, { username: "trip_bot" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + + await postJson(tx.app, `/bot${bot.token}/setWebhook`, { + url: "https://example.com/webhook", + secret_token: "sekret", + }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "hello" }); + + // Dispatcher is async; yield the event loop a few times. + await new Promise((r) => setTimeout(r, 20)); + + expect(received).toHaveLength(1); + expect(received[0].url).toBe("https://example.com/webhook"); + expect(received[0].headers["x-telegram-bot-api-secret-token"]).toBe("sekret"); + const body = received[0].body as WireUpdate; + expect(body.update_id).toBe(1); + if (!("message" in body)) throw new Error("expected message update"); + expect(body.message.text).toBe("hello"); + }); + + it("retries on 5xx up to maxRetries, succeeds on eventual 200", async () => { + let attempts = 0; + getDispatcher(tx.store).setRetryPolicy({ maxRetries: 2, backoffMs: [1, 1] }); + getDispatcher(tx.store).setBackoffEnabled(false); + getDispatcher(tx.store).setFetchImpl(async () => { + attempts += 1; + if (attempts < 3) return new Response(null, { status: 502 }); + return new Response(null, { status: 200 }); + }); + + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + await postJson(tx.app, `/bot${bot.token}/setWebhook`, { url: "https://example.com/webhook" }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "retry-me" }); + await new Promise((r) => setTimeout(r, 20)); + + expect(attempts).toBe(3); + const updates = getTelegramStore(tx.store).updates.all(); + expect(updates).toHaveLength(1); + expect(updates[0].delivered).toBe(true); + expect(updates[0].delivery_attempts).toBe(3); + }); + + it("stops retrying on 4xx terminal error", async () => { + let attempts = 0; + getDispatcher(tx.store).setFetchImpl(async () => { + attempts += 1; + return new Response(null, { status: 403 }); + }); + + const bot = createBot(tx.store, { username: "b" }); + const user = createUser(tx.store, { first_name: "A" }); + const dm = createPrivateChat(tx.store, { botId: bot.bot_id, userId: user.user_id }); + await postJson(tx.app, `/bot${bot.token}/setWebhook`, { url: "https://example.com/webhook" }); + + simulateUserMessage(tx.store, { chatId: dm.chat_id, userId: user.user_id, text: "nope" }); + await new Promise((r) => setTimeout(r, 20)); + + expect(attempts).toBe(1); + const updates = getTelegramStore(tx.store).updates.all(); + expect(updates[0].delivered).toBe(false); + expect(updates[0].delivery_error).toContain("403"); + }); + + it("deleteWebhook reverts bot to long-polling mode", async () => { + const bot = createBot(tx.store, { username: "b" }); + await postJson(tx.app, `/bot${bot.token}/setWebhook`, { url: "https://example.com/webhook" }); + await postJson(tx.app, `/bot${bot.token}/deleteWebhook`, {}); + + // Now getUpdates should work (not 409) + const res = await postJson(tx.app, `/bot${bot.token}/getUpdates`, {}); + expect(res.status).toBe(200); + const body = await json<{ ok: boolean }>(res); + expect(body.ok).toBe(true); + }); +}); diff --git a/packages/@emulators/telegram/src/dispatcher.ts b/packages/@emulators/telegram/src/dispatcher.ts new file mode 100644 index 00000000..2fbd7775 --- /dev/null +++ b/packages/@emulators/telegram/src/dispatcher.ts @@ -0,0 +1,299 @@ +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "./store.js"; +import { nextUpdateId } from "./ids.js"; +import { sweep } from "./services/sweeper.js"; +import type { TelegramBot, TelegramUpdate, UpdateType } from "./entities.js"; +import { wrapPayload, type PayloadFor } from "./types/wire/update.js"; + +export interface RetryPolicy { + maxRetries: number; + backoffMs: number[]; +} + +const DEFAULT_RETRY: RetryPolicy = { + maxRetries: 3, + backoffMs: [1000, 2000, 4000], +}; + +const MAX_QUEUE_PER_BOT = 1000; +const MAX_DELIVERED_HISTORY = 200; + +interface LongPollWaiter { + botId: number; + offset: number; + resolve: (result: GetUpdatesResult) => void; +} + +export interface GetUpdatesResult { + cancelled: boolean; + updates: TelegramUpdate[]; +} + +export class TelegramDispatcher { + private waiters: LongPollWaiter[] = []; + private retryPolicy: RetryPolicy = DEFAULT_RETRY; + private fetchImpl: typeof fetch = fetch; + private backoffEnabled = true; + + constructor(private store: Store) {} + + setRetryPolicy(policy: Partial): void { + this.retryPolicy = { ...this.retryPolicy, ...policy }; + } + + setFetchImpl(impl: typeof fetch): void { + this.fetchImpl = impl; + } + + setBackoffEnabled(enabled: boolean): void { + this.backoffEnabled = enabled; + } + + enqueue( + botId: number, + type: T, + payload: PayloadFor, + ): TelegramUpdate { + const ts = getTelegramStore(this.store); + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) { + throw new Error(`enqueue: bot ${botId} not found`); + } + + const update_id = nextUpdateId(this.store, botId); + const mode: TelegramUpdate["delivery_mode"] = bot.webhook_url ? "webhook" : "pending"; + + const inserted = ts.updates.insert({ + update_id, + for_bot_id: botId, + type, + payload: wrapPayload(update_id, type, payload), + delivered: false, + delivered_at: null, + delivery_mode: mode, + delivery_attempts: 0, + delivery_error: null, + }); + + this.pruneQueue(botId); + sweep(this.store); + + if (bot.webhook_url) { + if (!isAllowed(bot.webhook_allowed_updates, type)) { + // Matches real Telegram's webhook allowlist: filtered updates are + // never delivered. Mark the row so the inspector shows why. + ts.updates.update(inserted.id, { + delivered: true, + delivered_at: new Date().toISOString(), + delivery_mode: "webhook", + delivery_error: "filtered by allowed_updates", + }); + } else { + void this.deliverWebhook(inserted, bot); + } + } else { + this.notifyWaiters(botId); + } + + return inserted; + } + + private pruneQueue(botId: number): void { + const ts = getTelegramStore(this.store); + const forBot = ts.updates + .findBy("for_bot_id", botId) + .sort((a, b) => a.update_id - b.update_id); + const delivered = forBot.filter((u) => u.delivered); + while (delivered.length > MAX_DELIVERED_HISTORY) { + const drop = delivered.shift()!; + ts.updates.delete(drop.id); + } + const pending = forBot.filter((u) => !u.delivered); + while (pending.length > MAX_QUEUE_PER_BOT) { + const drop = pending.shift()!; + ts.updates.delete(drop.id); + } + } + + private async deliverWebhook(update: TelegramUpdate, bot: TelegramBot): Promise { + const ts = getTelegramStore(this.store); + if (!bot.webhook_url) return; + + const body = JSON.stringify(update.payload); + const headers: Record = { + "Content-Type": "application/json", + "User-Agent": "emulate-telegram-bot-api/1", + }; + if (bot.webhook_secret) { + headers["X-Telegram-Bot-Api-Secret-Token"] = bot.webhook_secret; + } + + let attempt = 0; + // Per spec: retry on 5xx up to maxRetries with backoff. + // 4xx is terminal (matches real Telegram behaviour). + while (attempt <= this.retryPolicy.maxRetries) { + try { + const res = await this.fetchImpl(bot.webhook_url, { + method: "POST", + headers, + body, + signal: AbortSignal.timeout(10000), + }); + + ts.updates.update(update.id, { delivery_attempts: attempt + 1 }); + + if (res.ok) { + ts.updates.update(update.id, { + delivered: true, + delivered_at: new Date().toISOString(), + delivery_mode: "webhook", + }); + return; + } + if (res.status >= 400 && res.status < 500) { + ts.updates.update(update.id, { + delivered: false, + delivery_error: `webhook responded ${res.status}`, + }); + return; + } + // 5xx -> retry + ts.updates.update(update.id, { + delivery_error: `webhook responded ${res.status}`, + }); + } catch (err) { + ts.updates.update(update.id, { + delivery_attempts: attempt + 1, + delivery_error: err instanceof Error ? err.message : String(err), + }); + } + + attempt += 1; + if (attempt > this.retryPolicy.maxRetries) break; + if (this.backoffEnabled) { + const delay = this.retryPolicy.backoffMs[attempt - 1] ?? this.retryPolicy.backoffMs[this.retryPolicy.backoffMs.length - 1]; + await sleep(delay); + } + } + } + + getUpdates( + botId: number, + offset?: number, + limit = 100, + timeoutSec = 0, + ): Promise { + const ready = this.drain(botId, offset, limit); + // Short-poll: drain and return immediately. Does not cancel any + // in-flight long-poll — grammY/telegraf's own `bot.stop()` issues + // a short getUpdates to ack its last offset, and bubbling a 409 + // up to the prior poll would break their shutdown path. + if (ready.length > 0 || timeoutSec === 0) { + return Promise.resolve({ cancelled: false, updates: ready }); + } + + // Long-poll takeover: real Telegram responds with 409 + // "terminated by other getUpdates request" to any existing poll + // when a new one arrives. This lets a second bot instance take + // over cleanly. + for (const prior of this.waiters.filter((w) => w.botId === botId)) { + const idx = this.waiters.indexOf(prior); + if (idx >= 0) this.waiters.splice(idx, 1); + prior.resolve({ cancelled: true, updates: [] }); + } + return new Promise((resolve) => { + const waiter: LongPollWaiter = { + botId, + offset: offset ?? 0, + resolve: (result) => { + resolve({ cancelled: result.cancelled, updates: result.updates.slice(0, limit) }); + }, + }; + this.waiters.push(waiter); + const timer = setTimeout(() => { + const idx = this.waiters.indexOf(waiter); + if (idx >= 0) { + this.waiters.splice(idx, 1); + resolve({ cancelled: false, updates: this.drain(botId, offset, limit) }); + } + }, timeoutSec * 1000); + // Unref so the process can exit if only a long-poll is pending. + if (typeof timer.unref === "function") timer.unref(); + }); + } + + private drain(botId: number, offset: number | undefined, limit: number): TelegramUpdate[] { + const ts = getTelegramStore(this.store); + const all = ts.updates + .findBy("for_bot_id", botId) + .sort((a, b) => a.update_id - b.update_id); + + // offset semantics: if set, confirm-and-drop updates with update_id < offset. + if (offset !== undefined && offset > 0) { + for (const u of all) { + if (u.update_id < offset && !u.delivered) { + ts.updates.update(u.id, { + delivered: true, + delivered_at: new Date().toISOString(), + delivery_mode: "polling", + }); + } + } + } + + const ready = ts.updates + .findBy("for_bot_id", botId) + .filter((u) => !u.delivered && (offset === undefined || u.update_id >= (offset ?? 0))) + .sort((a, b) => a.update_id - b.update_id) + .slice(0, limit); + + for (const u of ready) { + ts.updates.update(u.id, { + delivery_mode: "polling", + }); + } + + return ready; + } + + private notifyWaiters(botId: number): void { + const matching = this.waiters.filter((w) => w.botId === botId); + for (const waiter of matching) { + const updates = this.drain(botId, waiter.offset, 100); + if (updates.length > 0) { + const idx = this.waiters.indexOf(waiter); + if (idx >= 0) this.waiters.splice(idx, 1); + waiter.resolve({ cancelled: false, updates }); + } + } + } + + clear(): void { + for (const w of this.waiters) w.resolve({ cancelled: false, updates: [] }); + this.waiters.length = 0; + } +} + +function sleep(ms: number): Promise { + return new Promise((resolve) => { + const t = setTimeout(resolve, ms); + if (typeof t.unref === "function") t.unref(); + }); +} + +function isAllowed(allowed: string[] | null, type: string): boolean { + if (!allowed || allowed.length === 0) return true; + return allowed.includes(type); +} + +let singleton: WeakMap | null = null; + +export function getDispatcher(store: Store): TelegramDispatcher { + if (!singleton) singleton = new WeakMap(); + let d = singleton.get(store); + if (!d) { + d = new TelegramDispatcher(store); + singleton.set(store, d); + } + return d; +} diff --git a/packages/@emulators/telegram/src/entities.ts b/packages/@emulators/telegram/src/entities.ts new file mode 100644 index 00000000..11300051 --- /dev/null +++ b/packages/@emulators/telegram/src/entities.ts @@ -0,0 +1,4 @@ +// Barrel re-export preserved for backward compatibility. All entity types +// now live under src/types/{store,wire}/. New code should import directly +// from ./types/store/*.ts or ./types/wire/*.ts. +export * from "./types/index.js"; diff --git a/packages/@emulators/telegram/src/entity-parser.ts b/packages/@emulators/telegram/src/entity-parser.ts new file mode 100644 index 00000000..19db428b --- /dev/null +++ b/packages/@emulators/telegram/src/entity-parser.ts @@ -0,0 +1,75 @@ +import type { MessageEntity, TelegramBot } from "./entities.js"; + +/** Auto-detect bot_command / mention / url / email / hashtag / cashtag + * entities in a raw text. Matches Telegram's behaviour for unparsed + * messages (no parse_mode). */ +export function parseEntities(text: string, bot?: TelegramBot | null): MessageEntity[] { + const entities: MessageEntity[] = []; + if (!text) return entities; + void bot; + + const overlapsExisting = (start: number, end: number) => + entities.some((e) => start < e.offset + e.length && end > e.offset); + + // Bot commands: /command or /command@botname at the start or after whitespace + const cmdRe = /(?:^|\s)(\/[A-Za-z0-9_]+(?:@[A-Za-z0-9_]+)?)/g; + for (const m of text.matchAll(cmdRe)) { + const full = m[1]; + const start = (m.index ?? 0) + m[0].length - full.length; + entities.push({ type: "bot_command", offset: start, length: full.length }); + } + + // Mentions: @username anywhere. + const mentionRe = /(?:^|[^A-Za-z0-9_\/])(@[A-Za-z0-9_]+)/g; + for (const m of text.matchAll(mentionRe)) { + const full = m[1]; + const start = (m.index ?? 0) + m[0].length - full.length; + if (!overlapsExisting(start, start + full.length)) { + entities.push({ type: "mention", offset: start, length: full.length }); + } + } + + // URLs: http:// or https://... (strip trailing .,!?)]}) + const urlRe = /\bhttps?:\/\/[^\s<]+/g; + for (const m of text.matchAll(urlRe)) { + let full = m[0]; + full = full.replace(/[.,!?)\]}]+$/, ""); + const start = m.index ?? 0; + if (!overlapsExisting(start, start + full.length)) { + entities.push({ type: "url", offset: start, length: full.length }); + } + } + + // Emails + const emailRe = /\b[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}\b/g; + for (const m of text.matchAll(emailRe)) { + const full = m[0]; + const start = m.index ?? 0; + if (!overlapsExisting(start, start + full.length)) { + entities.push({ type: "email", offset: start, length: full.length }); + } + } + + // Hashtags: #tag after whitespace or start + const hashRe = /(?:^|\s)(#[A-Za-z0-9_]+)/g; + for (const m of text.matchAll(hashRe)) { + const full = m[1]; + const start = (m.index ?? 0) + m[0].length - full.length; + if (!overlapsExisting(start, start + full.length)) { + entities.push({ type: "hashtag", offset: start, length: full.length }); + } + } + + // Cashtags: $AAPL style + const cashRe = /(?:^|\s)(\$[A-Z]{1,8})\b/g; + for (const m of text.matchAll(cashRe)) { + const full = m[1]; + const start = (m.index ?? 0) + m[0].length - full.length; + if (!overlapsExisting(start, start + full.length)) { + entities.push({ type: "cashtag", offset: start, length: full.length }); + } + } + + entities.sort((a, b) => a.offset - b.offset); + return entities; +} diff --git a/packages/@emulators/telegram/src/helpers.ts b/packages/@emulators/telegram/src/helpers.ts new file mode 100644 index 00000000..6eee1566 --- /dev/null +++ b/packages/@emulators/telegram/src/helpers.ts @@ -0,0 +1,34 @@ +// Barrel re-export for backwards compatibility. The canonical homes are: +// - ids.ts — counters, tokens, file/update/callback IDs +// - http.ts — ok / okRaw / tgError / parseTelegramBody +// - serializers.ts — serializeUser/Bot/Chat/Message/ChatFullInfo + resolveBotFromToken +// - entity-parser.ts — parseEntities (bot_command/mention/url/email/hashtag/cashtag) +// - services/media.ts — buildPhotoSizes / readImageDimensions / buildMediaField + +export { + generateBotToken, + generateCallbackQueryId, + nextBotId, + nextChannelChatId, + nextFileId, + nextGroupChatId, + nextSupergroupChatId, + nextUpdateId, + nextUserId, + parseBotIdFromToken, +} from "./ids.js"; + +export { ok, okRaw, parseTelegramBody, tgError } from "./http.js"; + +export { + resolveBotFromToken, + serializeBotAsUser, + serializeChat, + serializeChatFullInfo, + serializeMessage, + serializeUser, +} from "./serializers.js"; + +export { parseEntities } from "./entity-parser.js"; + +export { buildPhotoSizes, readImageDimensions } from "./services/media.js"; diff --git a/packages/@emulators/telegram/src/html.ts b/packages/@emulators/telegram/src/html.ts new file mode 100644 index 00000000..75a07fac --- /dev/null +++ b/packages/@emulators/telegram/src/html.ts @@ -0,0 +1,217 @@ +import type { MessageEntity } from "./entities.js"; + +/** + * HTML parse_mode support — stripped-down parser that handles the tag set + * Telegram's Bot API recognises. Minimal HTML entity decoding for the four + * common names (`& < > "`). + * + * Rejects unclosed or mismatched tags with a 400-style "can't parse + * entities" error. + */ +export interface ParsedHtml { + text: string; + entities: MessageEntity[]; +} + +export class HtmlParseError extends Error { + constructor(message: string) { + super(message); + this.name = "HtmlParseError"; + } +} + +type TagName = "b" | "i" | "u" | "s" | "code" | "pre" | "a" | "tg-spoiler" | "span"; +type OpenTag = { + name: TagName; + entityType: MessageEntity["type"]; + startOffset: number; + attrs?: Record; +}; + +const TAG_TO_ENTITY: Record = { + b: "bold", + strong: "bold", + i: "italic", + em: "italic", + u: "underline", + ins: "underline", + s: "strikethrough", + strike: "strikethrough", + del: "strikethrough", + code: "code", + pre: "pre", + a: "text_link", + "tg-spoiler": "spoiler", + blockquote: "blockquote", +}; + +export function parseHtml(input: string): ParsedHtml { + const out: string[] = []; + const entities: MessageEntity[] = []; + const stack: OpenTag[] = []; + let i = 0; + + while (i < input.length) { + const ch = input[i]; + + if (ch === "<") { + const close = input.indexOf(">", i); + if (close === -1) { + throw new HtmlParseError("Bad Request: can't parse entities: unclosed tag"); + } + const tagBody = input.slice(i + 1, close); + if (tagBody.startsWith("/")) { + // Closing tag + const name = tagBody.slice(1).trim().toLowerCase(); + const open = stack.pop(); + if (!open || open.name !== name) { + throw new HtmlParseError( + `Bad Request: can't parse entities: mismatched closing tag `, + ); + } + const endOffset = utf16Length(out.join("")); + let entType = open.entityType; + if (entType === "blockquote" && open.attrs && "expandable" in open.attrs) { + entType = "expandable_blockquote"; + } + const ent: MessageEntity = { + type: entType, + offset: open.startOffset, + length: endOffset - open.startOffset, + }; + if (open.entityType === "text_link") { + const href = open.attrs?.href ?? ""; + if (href.startsWith("tg://user?id=")) { + const uid = Number(href.slice("tg://user?id=".length)); + if (Number.isFinite(uid)) { + entities.push({ + type: "text_mention", + offset: ent.offset, + length: ent.length, + user: { id: uid, is_bot: false, first_name: "" }, + }); + i = close + 1; + continue; + } + } + ent.url = href; + } + entities.push(ent); + i = close + 1; + continue; + } + + // Opening tag + const { name, attrs, selfClose } = parseTagBody(tagBody); + const normalized = name.toLowerCase(); + // Special: + let entityName = normalized; + if (normalized === "span" && (attrs.class ?? "").includes("tg-spoiler")) { + entityName = "tg-spoiler"; + } + const entType = TAG_TO_ENTITY[entityName]; + if (!entType) { + throw new HtmlParseError( + `Bad Request: can't parse entities: unsupported tag <${normalized}>`, + ); + } + if (selfClose) { + // Self-closing with no content doesn't produce an entity. + i = close + 1; + continue; + } + const startOffset = utf16Length(out.join("")); + // Track the actual tag name for close matching, not the normalized + // entity name — closes with . + stack.push({ + name: normalized as TagName, + entityType: entType, + startOffset, + attrs, + }); + i = close + 1; + continue; + } + + if (ch === "&") { + const semi = input.indexOf(";", i); + if (semi === -1) { + throw new HtmlParseError("Bad Request: can't parse entities: stray '&' without terminator"); + } + const ref = input.slice(i + 1, semi); + const decoded = decodeHtmlEntity(ref); + if (decoded === null) { + throw new HtmlParseError( + `Bad Request: can't parse entities: unknown HTML entity '&${ref};'`, + ); + } + out.push(decoded); + i = semi + 1; + continue; + } + + out.push(ch); + i += 1; + } + + if (stack.length > 0) { + throw new HtmlParseError( + `Bad Request: can't parse entities: unclosed tag <${stack[stack.length - 1].name}>`, + ); + } + + entities.sort((a, b) => (a.offset !== b.offset ? a.offset - b.offset : b.length - a.length)); + return { text: out.join(""), entities }; +} + +function parseTagBody(body: string): { + name: string; + attrs: Record; + selfClose: boolean; +} { + let s = body.trim(); + let selfClose = false; + if (s.endsWith("/")) { + selfClose = true; + s = s.slice(0, -1).trim(); + } + const nameMatch = s.match(/^([A-Za-z][A-Za-z0-9-]*)/); + const name = nameMatch ? nameMatch[1] : s; + const rest = nameMatch ? s.slice(name.length).trim() : ""; + const attrs: Record = {}; + // Attribute parser: key="value" / key='value' / key=value / bare key. + const attrRe = /([A-Za-z_:][A-Za-z0-9_:.-]*)(?:\s*=\s*(?:"([^"]*)"|'([^']*)'|(\S+)))?/g; + let m: RegExpExecArray | null; + while ((m = attrRe.exec(rest)) !== null) { + if (!m[0]) break; + attrs[m[1].toLowerCase()] = m[2] ?? m[3] ?? m[4] ?? ""; + } + return { name, attrs, selfClose }; +} + +function decodeHtmlEntity(ref: string): string | null { + switch (ref) { + case "amp": + return "&"; + case "lt": + return "<"; + case "gt": + return ">"; + case "quot": + return '"'; + case "apos": + return "'"; + default: + if (ref.startsWith("#")) { + const isHex = ref[1] === "x" || ref[1] === "X"; + const code = parseInt(ref.slice(isHex ? 2 : 1), isHex ? 16 : 10); + if (!Number.isFinite(code)) return null; + return String.fromCodePoint(code); + } + return null; + } +} + +function utf16Length(s: string): number { + return s.length; +} diff --git a/packages/@emulators/telegram/src/http.ts b/packages/@emulators/telegram/src/http.ts new file mode 100644 index 00000000..4198265c --- /dev/null +++ b/packages/@emulators/telegram/src/http.ts @@ -0,0 +1,83 @@ +import type { Context } from "hono"; +import type { ContentfulStatusCode } from "hono/utils/http-status"; + +// `ok(c, result)` envelopes the method's response body. The payload +// may be any Bot API wire object (WireMessage, WireUser, WireChat, +// WireChatFullInfo, WireChatMember) or plain scalar (e.g. a bare +// `true` for setWebhook). Kept `unknown` at this layer — the emitting +// serializer is what stamps the concrete type. +export function ok(c: Context, result: unknown) { + return c.json({ ok: true, result }); +} + +export function okRaw(c: Context, result: unknown) { + return c.json({ ok: true, result }); +} + +export function tgError( + c: Context, + description: string, + error_code: number = 400, + status: ContentfulStatusCode = 400, +) { + return c.json({ ok: false, error_code, description }, status); +} + +// Transport-layer parser. Returns raw JSON — narrowing is the job of +// zod validators downstream (see src/types/validators/body.ts). The +// shape is effectively { [key: string]: unknown } at runtime for +// successful parses, but declaring it as `unknown` forces every +// consumer to go through a schema before touching fields. +export async function parseTelegramBody(c: Context): Promise { + const contentType = c.req.header("Content-Type") ?? ""; + + if (contentType.includes("application/json")) { + try { + const raw = await c.req.text(); + return raw ? JSON.parse(raw) : {}; + } catch { + return {}; + } + } + + if (contentType.includes("application/x-www-form-urlencoded")) { + const raw = await c.req.text(); + const params = new URLSearchParams(raw); + const result: { [key: string]: unknown } = {}; + for (const [key, value] of params) { + result[key] = tryParseJsonScalar(value); + } + return result; + } + + if (contentType.includes("multipart/form-data")) { + const formData = await c.req.formData(); + const result: { [key: string]: unknown } = {}; + for (const [key, value] of formData.entries()) { + if (typeof value !== "string" && typeof (value as File).arrayBuffer === "function") { + const file = value as File; + const bytes = Buffer.from(await file.arrayBuffer()); + result[key] = { __file: true, name: file.name, type: file.type, bytes }; + } else { + result[key] = tryParseJsonScalar(String(value)); + } + } + return result; + } + + return {}; +} + +function tryParseJsonScalar(value: string): unknown { + const trimmed = value.trim(); + if (!trimmed) return value; + const first = trimmed[0]; + if (first === "{" || first === "[" || first === '"' || trimmed === "true" || trimmed === "false" || /^-?\d/.test(trimmed)) { + try { + return JSON.parse(trimmed); + } catch { + return value; + } + } + return value; +} diff --git a/packages/@emulators/telegram/src/ids.ts b/packages/@emulators/telegram/src/ids.ts new file mode 100644 index 00000000..96c88d5c --- /dev/null +++ b/packages/@emulators/telegram/src/ids.ts @@ -0,0 +1,100 @@ +import { randomBytes, createHash } from "crypto"; +import type { Store } from "@emulators/core"; + +interface TelegramCounters { + botId: number; + userId: number; + fileSequence: number; + updateSequence: Map; + groupChatId: number; + supergroupChatId: number; + channelChatId: number; +} + +const COUNTERS_KEY = "telegram.counters"; + +function getCounters(store: Store): TelegramCounters { + let counters = store.getData(COUNTERS_KEY); + if (!counters) { + counters = { + botId: 100000, + userId: 1000, + fileSequence: 1, + updateSequence: new Map(), + groupChatId: -1000000000, + supergroupChatId: -1001000000000, + channelChatId: -1002000000000, + }; + store.setData(COUNTERS_KEY, counters); + } + return counters; +} + +export function nextBotId(store: Store): number { + const c = getCounters(store); + c.botId += 1; + return c.botId; +} + +export function nextUserId(store: Store): number { + const c = getCounters(store); + c.userId += 1; + return c.userId; +} + +export function nextGroupChatId(store: Store): number { + const c = getCounters(store); + c.groupChatId -= 1; + return c.groupChatId; +} + +export function nextSupergroupChatId(store: Store): number { + const c = getCounters(store); + c.supergroupChatId -= 1; + return c.supergroupChatId; +} + +export function nextChannelChatId(store: Store): number { + const c = getCounters(store); + c.channelChatId -= 1; + return c.channelChatId; +} + +export function generateBotToken(botId: number): string { + const secret = randomBytes(24).toString("base64url").slice(0, 35); + return `${botId}:${secret}`; +} + +export function parseBotIdFromToken(token: string): number | null { + const m = token.match(/^(\d+):/); + if (!m) return null; + const n = Number(m[1]); + return Number.isFinite(n) ? n : null; +} + +export function nextFileId( + store: Store, + botId: number, + chatId: number, + tier: string, +): { file_id: string; file_unique_id: string } { + const c = getCounters(store); + c.fileSequence += 1; + const seq = c.fileSequence; + const raw = `${botId}:${chatId}:${seq}:${tier}`; + const file_id = `tg_emu_${Buffer.from(raw).toString("base64url")}`; + const file_unique_id = `uq_${createHash("sha1").update(raw).digest("hex").slice(0, 16)}`; + return { file_id, file_unique_id }; +} + +export function nextUpdateId(store: Store, botId: number): number { + const c = getCounters(store); + const current = c.updateSequence.get(botId) ?? 0; + const next = current + 1; + c.updateSequence.set(botId, next); + return next; +} + +export function generateCallbackQueryId(): string { + return randomBytes(8).toString("hex"); +} diff --git a/packages/@emulators/telegram/src/index.ts b/packages/@emulators/telegram/src/index.ts new file mode 100644 index 00000000..da49b8f2 --- /dev/null +++ b/packages/@emulators/telegram/src/index.ts @@ -0,0 +1,154 @@ +import type { Hono } from "hono"; +import type { ServicePlugin, Store, WebhookDispatcher, TokenMap, AppEnv, RouteContext } from "@emulators/core"; +import { getTelegramStore } from "./store.js"; +import { botApiRoutes } from "./routes/bot-api.js"; +import { controlRoutes, createBot, createUser, createPrivateChat, createGroupChat } from "./routes/control.js"; +import { inspectorRoutes } from "./routes/inspector.js"; + +export { getTelegramStore, type TelegramStore } from "./store.js"; +export * from "./entities.js"; +export type { + CreateBotInput, + CreateUserInput, + CreatePrivateChatInput, + CreateGroupChatInput, + SimulateUserMessageInput, + SimulateUserPhotoInput, + SimulateCallbackInput, +} from "./routes/control.js"; +export { + simulateUserMessage, + simulateUserPhoto, + simulateUserMedia, + simulateCallback, + simulateEditedUserMessage, + simulateReaction, + simulateChannelPost, + createSupergroup, + createChannel, + createForumTopic, + addBotToChat, + removeBotFromChat, + injectFault, + clearFaults, + getCallbackAnswer, + getDraftHistory, + getSentMessages, + getAllMessages, +} from "./routes/control.js"; +export { getDispatcher, TelegramDispatcher } from "./dispatcher.js"; + +export interface TelegramBotSeed { + username: string; + name?: string; + first_name?: string; + token?: string; + can_join_groups?: boolean; + can_read_all_group_messages?: boolean; + commands?: Array<{ command: string; description: string }>; +} + +export interface TelegramUserSeed { + first_name: string; + last_name?: string; + username?: string; + language_code?: string; +} + +export interface TelegramChatSeed { + type: "private" | "group"; + title?: string; + between?: [string, string]; // [bot_username, user_username] for private + members?: string[]; // user usernames for groups + bots?: string[]; // bot usernames for groups +} + +export interface TelegramSeedConfig { + bots?: TelegramBotSeed[]; + users?: TelegramUserSeed[]; + chats?: TelegramChatSeed[]; +} + +export function seedDefaults(store: Store): void { + const ts = getTelegramStore(store); + // Only seed defaults if nothing is present. + if (ts.bots.all().length > 0) return; + + const bot = createBot(store, { + username: "emulate_bot", + name: "Emulate Bot", + token: "100001:EMULATE_DEFAULT_TOKEN", + commands: [{ command: "start", description: "Start the bot" }], + }); + const user = createUser(store, { first_name: "Tester", username: "tester" }); + createPrivateChat(store, { botId: bot.bot_id, userId: user.user_id }); +} + +export function seedFromConfig(store: Store, _baseUrl: string, config: TelegramSeedConfig): void { + const ts = getTelegramStore(store); + + if (config.bots) { + for (const b of config.bots) { + const existing = ts.bots.findOneBy("username", b.username); + if (existing) continue; + createBot(store, { + username: b.username, + name: b.name ?? b.first_name, + first_name: b.first_name, + token: b.token, + can_join_groups: b.can_join_groups, + can_read_all_group_messages: b.can_read_all_group_messages, + commands: b.commands, + }); + } + } + + if (config.users) { + for (const u of config.users) { + if (u.username && ts.users.findOneBy("username", u.username)) continue; + createUser(store, u); + } + } + + if (config.chats) { + for (const ch of config.chats) { + if (ch.type === "private" && ch.between) { + const [botUsername, userUsername] = ch.between; + const bot = ts.bots.findOneBy("username", botUsername); + const user = ts.users.findOneBy("username", userUsername); + if (!bot || !user) continue; + createPrivateChat(store, { botId: bot.bot_id, userId: user.user_id }); + } else if (ch.type === "group") { + const botIds = (ch.bots ?? []) + .map((u) => ts.bots.findOneBy("username", u)?.bot_id) + .filter((v): v is number => v !== undefined); + const memberIds = (ch.members ?? []) + .map((u) => ts.users.findOneBy("username", u)?.user_id) + .filter((v): v is number => v !== undefined); + createGroupChat(store, { title: ch.title ?? "Group", memberIds, botIds }); + } + } + } +} + +export const telegramPlugin: ServicePlugin = { + name: "telegram", + register( + app: Hono, + store: Store, + webhooks: WebhookDispatcher, + baseUrl: string, + tokenMap?: TokenMap, + ): void { + const ctx: RouteContext = { app, store, webhooks, baseUrl, tokenMap }; + // Specific routes first so they win over the catchall in bot-api. + controlRoutes(ctx); + inspectorRoutes(ctx); + botApiRoutes(ctx); + }, + seed(store: Store): void { + seedDefaults(store); + }, +}; + +export default telegramPlugin; diff --git a/packages/@emulators/telegram/src/markdown.ts b/packages/@emulators/telegram/src/markdown.ts new file mode 100644 index 00000000..96831406 --- /dev/null +++ b/packages/@emulators/telegram/src/markdown.ts @@ -0,0 +1,372 @@ +import type { MessageEntity } from "./entities.js"; + +/** + * MarkdownV2 parser. Extracts entities from marked-up text and returns the + * stripped plain text alongside the entity array. + * + * Matches real Telegram's behaviour on the happy path and on the common + * failure modes: + * + * - Reserved characters that must be escaped with `\` anywhere outside a + * valid marker: `_ * [ ] ( ) ~ ` > # + - = | { } . !` + * Unescaped appearance outside a matching entity marker triggers a 400 + * error: "can't parse entities: character 'X' is reserved and must be + * escaped with the preceding '\'". + * - Unbalanced markers (open `*` without a matching close) also trigger a + * "can't parse entities" error. + * - Supported markers: `*bold*`, `_italic_`, `__underline__`, + * `~strikethrough~`, `||spoiler||`, `` `code` ``, ` ```pre``` `, + * `[text](url)` including `tg://user?id=N` for text_mentions. + * - Any character can be escaped with `\`; the backslash is removed and + * the next character is emitted literally. + * + * Entity offsets and lengths are counted in UTF-16 code units to match the + * Bot API (JavaScript's native `.length`). + * + * This parser is not a byte-for-byte clone of Telegram's parser — edge + * cases around nested emphasis and tricky mixed-escape inputs may diverge. + * The target is faithfulness for the inputs bots and SDKs actually + * generate. + */ +export interface ParsedMarkup { + text: string; + entities: MessageEntity[]; +} + +export class MarkdownParseError extends Error { + constructor(message: string) { + super(message); + this.name = "MarkdownParseError"; + } +} + +const RESERVED = new Set("_*[]()~`>#+-=|{}.!".split("")); + +type OpenEntity = { + type: MessageEntity["type"]; + markerLen: number; + startOffset: number; // offset in output text where entity begins + extra?: Partial; +}; + +export function parseMarkdownV2(input: string): ParsedMarkup { + const out: string[] = []; + const entities: MessageEntity[] = []; + const stack: OpenEntity[] = []; + let i = 0; + + const pushEntity = (e: Omit & { startOffset: number }) => { + const endOffset = utf16Length(out.join("")); + entities.push({ + type: e.type, + offset: e.startOffset, + length: endOffset - e.startOffset, + ...(e.url ? { url: e.url } : {}), + ...(e.user ? { user: e.user } : {}), + ...(e.language ? { language: e.language } : {}), + }); + }; + + while (i < input.length) { + const ch = input[i]; + + // --- Blockquote (`>` at line start, consecutive `>`-prefixed lines). + // Expandable blockquote: leading `**>` on first line, trailing `||` + // on last line. + const atLineStart = i === 0 || input[i - 1] === "\n"; + if (atLineStart) { + const expandable = input.startsWith("**>", i); + const plain = !expandable && ch === ">"; + if (expandable || plain) { + const quote = parseBlockquote(input, i, expandable); + if (quote) { + const startOffset = utf16Length(out.join("")); + const inner = parseMarkdownV2(quote.body); + out.push(inner.text); + for (const e of inner.entities) { + entities.push({ ...e, offset: e.offset + startOffset }); + } + entities.push({ + type: expandable ? "expandable_blockquote" : "blockquote", + offset: startOffset, + length: utf16Length(inner.text), + }); + i = quote.nextIdx; + continue; + } + } + } + + if (ch === "\\") { + // Escape: emit the next char literally, consume both. + if (i + 1 >= input.length) { + throw new MarkdownParseError( + "Bad Request: can't parse entities: stray '\\' at end of input", + ); + } + out.push(input[i + 1]); + i += 2; + continue; + } + + // --- Code blocks ``` --- + if (input.startsWith("```", i)) { + // Find a closing ``` that is not escaped. + const closeStart = findUnescaped(input, "```", i + 3); + if (closeStart === -1) { + throw new MarkdownParseError( + "Bad Request: can't parse entities: unclosed code block", + ); + } + let body = input.slice(i + 3, closeStart); + // Optional language line: first line before newline. + let language: string | undefined; + const nl = body.indexOf("\n"); + if (nl >= 0) { + const maybeLang = body.slice(0, nl).trim(); + if (maybeLang && /^[A-Za-z][A-Za-z0-9_+-]*$/.test(maybeLang)) { + language = maybeLang; + body = body.slice(nl + 1); + } + } + // Unescape \` and \\ inside pre blocks (real Telegram requires them + // to be escaped; the emitted text is the unescaped form). + body = body.replace(/\\([`\\])/g, "$1"); + const startOffset = utf16Length(out.join("")); + out.push(body); + entities.push({ + type: "pre", + offset: startOffset, + length: utf16Length(body), + ...(language ? { language } : {}), + }); + i = closeStart + 3; + continue; + } + + // --- Inline code ` --- + if (ch === "`") { + const closeIdx = findUnescaped(input, "`", i + 1); + if (closeIdx === -1) { + throw new MarkdownParseError( + "Bad Request: can't parse entities: unclosed inline code", + ); + } + const raw = input.slice(i + 1, closeIdx); + const body = raw.replace(/\\([`\\])/g, "$1"); + const startOffset = utf16Length(out.join("")); + out.push(body); + entities.push({ type: "code", offset: startOffset, length: utf16Length(body) }); + i = closeIdx + 1; + continue; + } + + // --- Inline link [text](url) --- + if (ch === "[") { + const parsed = parseLink(input, i); + if (!parsed) { + throw new MarkdownParseError( + "Bad Request: can't parse entities: malformed or unclosed inline link", + ); + } + const { text: linkText, url, nextIdx } = parsed; + const inner = parseMarkdownV2(linkText); // recursive for styled link text + const startOffset = utf16Length(out.join("")); + out.push(inner.text); + // Shift inner entities by startOffset and keep them + for (const e of inner.entities) { + entities.push({ ...e, offset: e.offset + startOffset }); + } + if (url.startsWith("tg://user?id=")) { + const uid = Number(url.slice("tg://user?id=".length)); + if (Number.isFinite(uid)) { + entities.push({ + type: "text_mention", + offset: startOffset, + length: utf16Length(inner.text), + user: { id: uid, is_bot: false, first_name: "" }, + }); + } + } else { + entities.push({ + type: "text_link", + offset: startOffset, + length: utf16Length(inner.text), + url, + }); + } + i = nextIdx; + continue; + } + + // --- Two-char markers (check before single) --- + if (input.startsWith("__", i) && input[i + 2] !== "_") { + toggleEntity(stack, "underline", 2, out, i, input, entities); + i += 2; + continue; + } + if (input.startsWith("||", i)) { + toggleEntity(stack, "spoiler", 2, out, i, input, entities); + i += 2; + continue; + } + + // --- Single-char markers --- + if (ch === "*") { + toggleEntity(stack, "bold", 1, out, i, input, entities); + i += 1; + continue; + } + if (ch === "_") { + toggleEntity(stack, "italic", 1, out, i, input, entities); + i += 1; + continue; + } + if (ch === "~") { + toggleEntity(stack, "strikethrough", 1, out, i, input, entities); + i += 1; + continue; + } + + // --- Reserved character check --- + if (RESERVED.has(ch)) { + throw new MarkdownParseError( + `Bad Request: can't parse entities: character '${ch}' is reserved and must be escaped with the preceding '\\'`, + ); + } + + out.push(ch); + i += 1; + } + + if (stack.length > 0) { + const open = stack[stack.length - 1]; + throw new MarkdownParseError( + `Bad Request: can't parse entities: unclosed entity of type ${open.type}`, + ); + } + + // Sort entities by (offset, length desc) so the order is deterministic — + // real Telegram groups them by offset ascending. + entities.sort((a, b) => (a.offset !== b.offset ? a.offset - b.offset : b.length - a.length)); + + return { text: out.join(""), entities }; +} + +function toggleEntity( + stack: OpenEntity[], + type: MessageEntity["type"], + markerLen: number, + out: string[], + inputIdx: number, + input: string, + entities: MessageEntity[], +): void { + void inputIdx; + void input; + const currentOffset = utf16Length(out.join("")); + const topIdx = stack.findIndex((e) => e.type === type); + if (topIdx === -1) { + // Open a new entity of this type. + stack.push({ type, markerLen, startOffset: currentOffset }); + return; + } + // Close: pop the top-most entity of this type (simple model; real + // Telegram enforces strict nesting). + const open = stack.splice(topIdx, 1)[0]; + entities.push({ + type: open.type, + offset: open.startOffset, + length: currentOffset - open.startOffset, + }); +} + +function parseLink( + input: string, + start: number, +): { text: string; url: string; nextIdx: number } | null { + // Find matching `]` without traversing into nested `[` or escapes. + let i = start + 1; + let text = ""; + while (i < input.length) { + const c = input[i]; + if (c === "\\" && i + 1 < input.length) { + text += input[i + 1]; + i += 2; + continue; + } + if (c === "]") break; + if (c === "\n") return null; + text += c; + i += 1; + } + if (i >= input.length || input[i] !== "]") return null; + if (input[i + 1] !== "(") return null; + // Parse URL inside (...) — backslash-escapes allowed. + let j = i + 2; + let url = ""; + while (j < input.length) { + const c = input[j]; + if (c === "\\" && j + 1 < input.length) { + url += input[j + 1]; + j += 2; + continue; + } + if (c === ")") break; + if (c === "\n") return null; + url += c; + j += 1; + } + if (j >= input.length || input[j] !== ")") return null; + if (url.length === 0) return null; + return { text, url, nextIdx: j + 1 }; +} + +function parseBlockquote( + input: string, + start: number, + expandable: boolean, +): { body: string; nextIdx: number } | null { + // Strip the leading `**` for expandable; each line must start with `>`. + let i = expandable ? start + 2 : start; + if (input[i] !== ">") return null; + const lines: string[] = []; + while (i < input.length) { + if (input[i] !== ">") break; + i += 1; // consume the leading `>` + let line = ""; + while (i < input.length && input[i] !== "\n") { + line += input[i]; + i += 1; + } + lines.push(line); + if (i < input.length && input[i] === "\n") { + i += 1; // consume the newline; the next iter checks if the new line starts with `>` + } + } + if (lines.length === 0) return null; + let nextIdx = i; + if (expandable) { + // The closing `||` must sit at the end of the last line content. + const last = lines[lines.length - 1]; + if (!last.endsWith("||")) return null; + lines[lines.length - 1] = last.slice(0, -2); + } + const body = lines.join("\n"); + return { body, nextIdx }; +} + +function findUnescaped(input: string, needle: string, startIdx: number): number { + for (let i = startIdx; i <= input.length - needle.length; i++) { + if (input[i] === "\\") { + i += 1; + continue; + } + if (input.startsWith(needle, i)) return i; + } + return -1; +} + +function utf16Length(s: string): number { + return s.length; +} diff --git a/packages/@emulators/telegram/src/paths.ts b/packages/@emulators/telegram/src/paths.ts new file mode 100644 index 00000000..34565baf --- /dev/null +++ b/packages/@emulators/telegram/src/paths.ts @@ -0,0 +1,46 @@ +// Shared control-plane URL builders. Every place that constructs a +// `/_emu/telegram/*` string — both sides of the control plane (server +// routes in control.ts, typed client in test.ts) — goes through here. + +export const TELEGRAM_CONTROL_PREFIX = "/_emu/telegram"; + +export const telegramPaths = { + reset: () => `${TELEGRAM_CONTROL_PREFIX}/reset`, + bots: () => `${TELEGRAM_CONTROL_PREFIX}/bots`, + users: () => `${TELEGRAM_CONTROL_PREFIX}/users`, + faults: () => `${TELEGRAM_CONTROL_PREFIX}/faults`, + callbackById: (id: string | number) => + `${TELEGRAM_CONTROL_PREFIX}/callbacks/${id}`, + + privateChat: () => `${TELEGRAM_CONTROL_PREFIX}/chats/private`, + groupChat: () => `${TELEGRAM_CONTROL_PREFIX}/chats/group`, + supergroup: () => `${TELEGRAM_CONTROL_PREFIX}/chats/supergroup`, + channel: () => `${TELEGRAM_CONTROL_PREFIX}/chats/channel`, + + chatMessages: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/messages`, + chatPhotos: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/photos`, + chatMedia: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/media`, + chatCallbacks: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/callbacks`, + chatEdits: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/edits`, + chatAddBot: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/add-bot`, + chatRemoveBot: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/remove-bot`, + chatPromote: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/promote`, + chatReactions: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/reactions`, + chatTopics: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/topics`, + channelPosts: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/channel-posts`, + channelPostEdits: (chatId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/channel-post-edits`, + chatDraft: (chatId: number | string, draftId: number | string) => + `${TELEGRAM_CONTROL_PREFIX}/chats/${chatId}/drafts/${draftId}`, +} as const; diff --git a/packages/@emulators/telegram/src/routes/bot-api-chats.ts b/packages/@emulators/telegram/src/routes/bot-api-chats.ts new file mode 100644 index 00000000..8434a363 --- /dev/null +++ b/packages/@emulators/telegram/src/routes/bot-api-chats.ts @@ -0,0 +1,158 @@ +// Chat-inspection Bot API methods: getChat, getChatMember, +// getChatAdministrators, getChatMemberCount, sendChatAction. +import type { Context } from "hono"; +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import { ok, okRaw, tgError } from "../http.js"; +import { serializeBotAsUser, serializeChatFullInfo, serializeUser } from "../serializers.js"; +import { parseWithSchema } from "../types/validators/body.js"; +import { + zGetChatAdministratorsBody, + zGetChatBody, + zGetChatMemberBody, + zGetChatMemberCountBody, +} from "../types/validators/chats.js"; +import type { TelegramChat } from "../entities.js"; +import type { + WireBotAsUser, + WireChatMember, + WireChatMemberAdministrator, + WireChatMemberOwner, + WireUser, +} from "../types/wire/index.js"; + +type MemberStatus = "creator" | "administrator" | "member" | "left"; + +function chatMemberStatus(chat: TelegramChat, subjectId: number, isBot: boolean): MemberStatus { + if (isBot) { + if (!chat.member_bot_ids.includes(subjectId)) return "left"; + if ((chat.admin_bot_ids ?? []).includes(subjectId)) return "administrator"; + // Bots in their own private chat effectively behave as admins. + if (chat.type === "private") return "administrator"; + return "member"; + } + if (!chat.member_user_ids.includes(subjectId)) return "left"; + if (chat.creator_user_id === subjectId) return "creator"; + if ((chat.admin_user_ids ?? []).includes(subjectId)) return "administrator"; + return "member"; +} + +function buildChatMember( + status: MemberStatus, + user: WireUser | WireBotAsUser, + chat: TelegramChat, +): WireChatMember { + if (status === "left") return { status: "left", user }; + if (status === "member") return { status: "member", user }; + + const isForum = chat.type === "supergroup" && chat.is_forum === true; + const base = { + user, + is_anonymous: false, + can_manage_chat: true, + can_delete_messages: true, + can_manage_video_chats: true, + can_restrict_members: status === "creator", + can_promote_members: status === "creator", + can_change_info: true, + can_invite_users: true, + }; + const channelRights = chat.type === "channel" + ? { can_post_messages: true, can_edit_messages: true } + : { can_pin_messages: true }; + const forumRights = isForum ? { can_manage_topics: true } : {}; + + if (status === "creator") { + const owner: WireChatMemberOwner = { + ...base, + ...channelRights, + ...forumRights, + status: "creator", + can_be_edited: false, + }; + return owner; + } + const admin: WireChatMemberAdministrator = { + ...base, + ...channelRights, + ...forumRights, + status: "administrator", + can_be_edited: true, + }; + return admin; +} + +export function getChat(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zGetChatBody, raw); + if (!r.ok) return r.response; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", r.data.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + return ok(c, serializeChatFullInfo(chat, { store })); +} + +export function getChatMember(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zGetChatMemberBody, raw); + if (!r.ok) return r.response; + const { chat_id: chatId, user_id: userId } = r.data; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", chatId); + if (!chat) return tgError(c, "Bad Request: chat not found"); + + const user = ts.users.findOneBy("user_id", userId); + const bot = ts.bots.findOneBy("bot_id", userId); + const isBotSubject = !!bot && !user; + const knownMember = + (user && chat.member_user_ids.includes(userId)) || (bot && chat.member_bot_ids.includes(userId)); + + if (!user && !bot) return tgError(c, "Bad Request: user not found"); + if (!knownMember) return tgError(c, "Bad Request: user not found in chat"); + + const status = chatMemberStatus(chat, userId, isBotSubject); + const subject: WireUser | WireBotAsUser = user ? serializeUser(user) : serializeBotAsUser(bot!); + const member = buildChatMember(status, subject, chat); + return ok(c, member); +} + +export function getChatAdministrators(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zGetChatAdministratorsBody, raw); + if (!r.ok) return r.response; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", r.data.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + + const admins: WireChatMember[] = []; + + if (chat.creator_user_id !== undefined) { + const u = ts.users.findOneBy("user_id", chat.creator_user_id); + if (u) admins.push(buildChatMember("creator", serializeUser(u), chat)); + } + for (const uid of chat.admin_user_ids ?? []) { + if (uid === chat.creator_user_id) continue; + const u = ts.users.findOneBy("user_id", uid); + if (u) admins.push(buildChatMember("administrator", serializeUser(u), chat)); + } + for (const bid of chat.admin_bot_ids ?? []) { + const b = ts.bots.findOneBy("bot_id", bid); + if (b) admins.push(buildChatMember("administrator", serializeBotAsUser(b), chat)); + } + return okRaw(c, admins); +} + +export function getChatMemberCount(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zGetChatMemberCountBody, raw); + if (!r.ok) return r.response; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", r.data.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + const count = chat.member_user_ids.length + chat.member_bot_ids.length; + return c.json({ ok: true, result: count }); +} + +export function sendChatAction(c: Context) { + return c.json({ ok: true, result: true }); +} diff --git a/packages/@emulators/telegram/src/routes/bot-api-delivery.ts b/packages/@emulators/telegram/src/routes/bot-api-delivery.ts new file mode 100644 index 00000000..a743f9c3 --- /dev/null +++ b/packages/@emulators/telegram/src/routes/bot-api-delivery.ts @@ -0,0 +1,104 @@ +// Update-delivery Bot API methods: getMe, getUpdates, setWebhook, +// deleteWebhook, getWebhookInfo. +import type { Context } from "hono"; +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import { ok, okRaw, tgError } from "../http.js"; +import { getDispatcher } from "../dispatcher.js"; +import { parseWithSchema } from "../types/validators/body.js"; +import { zGetUpdatesBody, zSetWebhookBody } from "../types/validators/delivery.js"; +import type { TelegramBot } from "../entities.js"; + +export function getMe(c: Context, bot: TelegramBot) { + return ok(c, { + id: bot.bot_id, + is_bot: true, + first_name: bot.first_name, + username: bot.username, + can_join_groups: bot.can_join_groups, + can_read_all_group_messages: bot.can_read_all_group_messages, + supports_inline_queries: bot.supports_inline_queries, + }); +} + +export async function getUpdates( + c: Context, + bot: TelegramBot, + raw: unknown, + dispatcher: ReturnType, +) { + const r = parseWithSchema(c, zGetUpdatesBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const limit = body.limit !== undefined ? Math.min(body.limit, 100) : 100; + const timeout = body.timeout !== undefined ? Math.min(body.timeout, 50) : 0; + const allowedUpdates = body.allowed_updates ?? bot.webhook_allowed_updates ?? null; + + if (bot.webhook_url) { + return tgError(c, "Conflict: can't use getUpdates method while webhook is active", 409, 409); + } + + const { cancelled, updates } = await dispatcher.getUpdates(bot.bot_id, body.offset, limit, timeout); + if (cancelled) { + return tgError( + c, + "Conflict: terminated by other getUpdates request; make sure that only one bot instance is running", + 409, + 409, + ); + } + const filtered = + allowedUpdates && allowedUpdates.length > 0 + ? updates.filter((u) => allowedUpdates.includes(u.type)) + : updates; + return c.json({ + ok: true, + result: filtered.map((u) => u.payload), + }); +} + +export function setWebhook( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSetWebhookBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const url = body.url ?? ""; + if (!url) { + // Empty URL removes the webhook, matching real behaviour. + return deleteWebhook(c, bot, store); + } + // Real Telegram only allows HTTPS webhooks (and rejects localhost, though + // the emulator is lenient there for hermetic testing). + if (!/^https:\/\//i.test(url)) { + return tgError(c, "Bad Request: bad webhook: HTTPS url must be provided for webhook", 400, 400); + } + + const ts = getTelegramStore(store); + ts.bots.update(bot.id, { + webhook_url: url, + webhook_secret: body.secret_token ?? null, + webhook_allowed_updates: body.allowed_updates ?? null, + }); + return okRaw(c, true); +} + +export function deleteWebhook(c: Context, bot: TelegramBot, store: Store) { + const ts = getTelegramStore(store); + ts.bots.update(bot.id, { webhook_url: null, webhook_secret: null, webhook_allowed_updates: null }); + return okRaw(c, true); +} + +export function getWebhookInfo(c: Context, bot: TelegramBot) { + return ok(c, { + url: bot.webhook_url ?? "", + has_custom_certificate: false, + pending_update_count: 0, + allowed_updates: bot.webhook_allowed_updates ?? undefined, + }); +} diff --git a/packages/@emulators/telegram/src/routes/bot-api-forum.ts b/packages/@emulators/telegram/src/routes/bot-api-forum.ts new file mode 100644 index 00000000..81005f9b --- /dev/null +++ b/packages/@emulators/telegram/src/routes/bot-api-forum.ts @@ -0,0 +1,110 @@ +// Forum-topic Bot API methods: createForumTopic / editForumTopic / +// closeForumTopic / reopenForumTopic / deleteForumTopic. All four +// require a supergroup with is_forum=true. +import type { Context } from "hono"; +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import { ok, okRaw, tgError } from "../http.js"; +import { parseWithSchema } from "../types/validators/body.js"; +import { + zCloseForumTopicBody, + zCreateForumTopicBody, + zDeleteForumTopicBody, + zEditForumTopicBody, +} from "../types/validators/forum.js"; +import type { TelegramForumTopic } from "../entities.js"; + +function requireForumChat(c: Context, chatId: number, store: Store) { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", chatId); + if (!chat) return { error: tgError(c, "Bad Request: chat not found") }; + if (chat.type !== "supergroup" || !chat.is_forum) { + return { error: tgError(c, "Bad Request: CHAT_NOT_FORUM") }; + } + return { chat }; +} + +function findForumTopic(store: Store, chatId: number, threadId: number): TelegramForumTopic | undefined { + const ts = getTelegramStore(store); + return ts.forumTopics + .findBy("chat_id", chatId) + .find((t) => t.message_thread_id === threadId && !t.is_deleted); +} + +export function createForumTopicMethod(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zCreateForumTopicBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const { chat, error } = requireForumChat(c, body.chat_id, store); + if (error) return error; + + const ts = getTelegramStore(store); + const existing = ts.forumTopics.findBy("chat_id", chat.chat_id); + const maxId = existing.reduce((m, t) => Math.max(m, t.message_thread_id), 1); + const message_thread_id = maxId + 1; + const iconColor = body.icon_color ?? 0x6fb9f0; + ts.forumTopics.insert({ + chat_id: chat.chat_id, + message_thread_id, + name: body.name, + icon_color: iconColor, + icon_custom_emoji_id: body.icon_custom_emoji_id, + }); + const out: { + message_thread_id: number; + name: string; + icon_color: number; + icon_custom_emoji_id?: string; + } = { message_thread_id, name: body.name, icon_color: iconColor }; + if (body.icon_custom_emoji_id) out.icon_custom_emoji_id = body.icon_custom_emoji_id; + return ok(c, out); +} + +export function editForumTopicMethod(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zEditForumTopicBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const { chat, error } = requireForumChat(c, body.chat_id, store); + if (error) return error; + const topic = findForumTopic(store, chat.chat_id, body.message_thread_id); + if (!topic) return tgError(c, "Bad Request: topic not found"); + + const ts = getTelegramStore(store); + const updates: Partial = {}; + if (body.name !== undefined) updates.name = body.name; + if (body.icon_custom_emoji_id !== undefined) updates.icon_custom_emoji_id = body.icon_custom_emoji_id; + ts.forumTopics.update(topic.id, updates); + return okRaw(c, true); +} + +export function closeForumTopicMethod(c: Context, raw: unknown, store: Store, close: boolean) { + const r = parseWithSchema(c, zCloseForumTopicBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const { chat, error } = requireForumChat(c, body.chat_id, store); + if (error) return error; + const topic = findForumTopic(store, chat.chat_id, body.message_thread_id); + if (!topic) return tgError(c, "Bad Request: topic not found"); + + const ts = getTelegramStore(store); + ts.forumTopics.update(topic.id, { is_closed: close }); + return okRaw(c, true); +} + +export function deleteForumTopicMethod(c: Context, raw: unknown, store: Store) { + const r = parseWithSchema(c, zDeleteForumTopicBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const { chat, error } = requireForumChat(c, body.chat_id, store); + if (error) return error; + const topic = findForumTopic(store, chat.chat_id, body.message_thread_id); + if (!topic) return tgError(c, "Bad Request: topic not found"); + + const ts = getTelegramStore(store); + ts.forumTopics.update(topic.id, { is_deleted: true }); + return okRaw(c, true); +} diff --git a/packages/@emulators/telegram/src/routes/bot-api.ts b/packages/@emulators/telegram/src/routes/bot-api.ts new file mode 100644 index 00000000..a35139aa --- /dev/null +++ b/packages/@emulators/telegram/src/routes/bot-api.ts @@ -0,0 +1,1104 @@ +import type { Context } from "hono"; +import type { RouteContext, Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import { nextFileId } from "../ids.js"; +import { ok, okRaw, parseTelegramBody, tgError } from "../http.js"; +import { + resolveBotFromToken, + serializeMessage, +} from "../serializers.js"; +import { parseEntities } from "../entity-parser.js"; +import { getDispatcher } from "../dispatcher.js"; +import { MarkdownParseError, parseMarkdownV2 } from "../markdown.js"; +import { HtmlParseError, parseHtml } from "../html.js"; +import { allocateMessageId, buildMediaField, buildPhotoSizes } from "../services/media.js"; +import { + closeForumTopicMethod, + createForumTopicMethod, + deleteForumTopicMethod, + editForumTopicMethod, +} from "./bot-api-forum.js"; +import { + getChat, + getChatAdministrators, + getChatMember, + getChatMemberCount, + sendChatAction, +} from "./bot-api-chats.js"; +import { + deleteWebhook, + getMe, + getUpdates, + getWebhookInfo, + setWebhook, +} from "./bot-api-delivery.js"; +import type { z } from "zod"; +import { parseWithSchema, type ParseResult } from "../types/validators/body.js"; +import { + zAnswerCallbackQueryBody, + zDeleteMessageBody, + zEditMessageReplyMarkupBody, + zEditMessageTextBody, + zGetFileBody, + zSendMessageBody, + zSendMessageDraftBody, + zSendPhotoBody, + zSendDocumentBody, + zSetMessageReactionBody, + zSetMyCommandsBody, + BODY_FOR_MEDIA, + type MediaKind, + type SendAnimationBody, + type SendAudioBody, + type SendStickerBody, + type SendVideoBody, + type SendVoiceBody, + type MultipartFileRef, +} from "../types/validators/index.js"; +import type { + InlineKeyboardMarkup, + MessageEntity, + PhotoSize, + ReplyMarkup, + TelegramBot, + TelegramChat, + TelegramDocument, + TelegramFault, + TelegramMessage, +} from "../entities.js"; +import { isInlineKeyboardMarkup } from "../types/wire/reply-markup.js"; + +function isMultipartFile(v: unknown): v is MultipartFileRef { + return typeof v === "object" && v !== null && (v as { __file?: boolean }).__file === true; +} + +export function botApiRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + const ts = () => getTelegramStore(store); + const dispatcher = () => getDispatcher(store); + + // All Bot API methods live under /bot/. + // We accept both GET (query/form) and POST (json/form/multipart). + // Hono's inline :param doesn't play well with tokens containing ":", so we + // use a wildcard and parse the path ourselves. + const methodHandler = async (c: Context) => { + const path = new URL(c.req.url).pathname; + const m = path.match(/^\/bot([^/]+)\/([A-Za-z]+)$/); + if (!m) return tgError(c, "Not Found", 404, 404); + const token = m[1]; + const method = m[2]; + + const bot = resolveBotFromToken(store, token); + if (!bot) return tgError(c, "Unauthorized", 401, 401); + + // Check injected faults before dispatching the real handler. + const fault = consumeFault(store, bot.bot_id, method); + if (fault) { + const status = faultHttpStatus(fault.error_code); + const parameters = fault.retry_after !== null ? { retry_after: fault.retry_after } : undefined; + return c.json( + { ok: false, error_code: fault.error_code, description: fault.description, ...(parameters ? { parameters } : {}) }, + status, + ); + } + + const body: unknown = + c.req.method === "GET" + ? queryToBody(c) + : await parseTelegramBody(c); + + switch (method) { + case "getMe": + return getMe(c, bot); + case "getUpdates": + return await getUpdates(c, bot, body, dispatcher()); + case "setWebhook": + return setWebhook(c, bot, body, store); + case "deleteWebhook": + return deleteWebhook(c, bot, store); + case "getWebhookInfo": + return getWebhookInfo(c, bot); + case "sendMessage": + return sendMessage(c, bot, body, store); + case "sendPhoto": + return sendPhoto(c, bot, body, store); + case "getFile": + return getFile(c, bot, body, store); + case "answerCallbackQuery": + return answerCallbackQuery(c, bot, body, store); + case "getChat": + return getChat(c, body, store); + case "getChatMember": + return getChatMember(c, body, store); + case "getChatAdministrators": + return getChatAdministrators(c, body, store); + case "setMyCommands": + return setMyCommands(c, bot, body, store); + case "getMyCommands": + return getMyCommands(c, bot); + case "editMessageReplyMarkup": + return editMessageReplyMarkup(c, bot, body, store); + case "editMessageText": + return editMessageText(c, bot, body, store); + case "deleteMessage": + return deleteMessage(c, bot, body, store); + case "sendMessageDraft": + return sendMessageDraft(c, bot, body, store); + case "sendDocument": + return sendDocument(c, bot, body, store); + case "sendChatAction": + return sendChatAction(c); + case "getChatMemberCount": + return getChatMemberCount(c, body, store); + case "setMessageReaction": + return setMessageReaction(c, bot, body, store); + case "sendVideo": + return sendMediaMessage(c, bot, body, store, "video"); + case "sendAudio": + return sendMediaMessage(c, bot, body, store, "audio"); + case "sendVoice": + return sendMediaMessage(c, bot, body, store, "voice"); + case "sendAnimation": + return sendMediaMessage(c, bot, body, store, "animation"); + case "sendSticker": + return sendMediaMessage(c, bot, body, store, "sticker"); + case "createForumTopic": + return createForumTopicMethod(c, body, store); + case "editForumTopic": + return editForumTopicMethod(c, body, store); + case "closeForumTopic": + return closeForumTopicMethod(c, body, store, true); + case "reopenForumTopic": + return closeForumTopicMethod(c, body, store, false); + case "deleteForumTopic": + return deleteForumTopicMethod(c, body, store); + default: + return tgError(c, `Method ${method} is not implemented in the emulator`, 404, 404); + } + }; + + // Hono's parametric and wildcard routes do not cooperate with path segments + // containing ":" (used by Telegram bot tokens). We register a catchall that + // dispatches based on path shape — specific routes registered by other + // modules still win because Hono prioritises specific matches over "*". + app.all("*", async (c) => { + const path = new URL(c.req.url).pathname; + + const botMatch = path.match(/^\/bot([^/]+)\/([A-Za-z]+)$/); + if (botMatch) return methodHandler(c); + + const fileMatch = path.match(/^\/file\/bot([^/]+)\/(.+)$/); + if (fileMatch) { + const token = fileMatch[1]; + const bot = resolveBotFromToken(store, token); + if (!bot) return c.text("Unauthorized", 401); + const filePath = decodeURIComponent(fileMatch[2]); + const file = ts() + .files.all() + .find((f) => f.file_path === filePath); + if (!file) return c.text("File not found", 404); + const bytes = Buffer.from(file.bytes_base64, "base64"); + return c.body(bytes, 200, { + "Content-Type": file.mime_type, + "Content-Length": String(bytes.length), + }); + } + + return c.notFound(); + }); +} + +// GET-style requests embed body fields in the query string; preserve the +// transport at this layer — zod validators handle type-coercing the values. +function queryToBody(c: Context): unknown { + const url = new URL(c.req.url); + const result: { [key: string]: unknown } = {}; + for (const [key, value] of url.searchParams) { + try { + result[key] = JSON.parse(value); + } catch { + result[key] = value; + } + } + return result; +} + +function sendMessage( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSendMessageBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + if (!body.text) return tgError(c, "Bad Request: text is required"); + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found", 400); + if (!chat.member_bot_ids.includes(bot.bot_id)) { + return tgError(c, "Forbidden: bot is not a member of the chat", 403, 403); + } + + const parsed = applyParseMode(body.text, body.parse_mode, body.entities); + if (!parsed.ok) return tgError(c, parsed.description, 400); + + // Real Telegram rejects messages whose visible text exceeds 4096 chars. + // Length is counted in UTF-16 code units on the parsed (stripped) text. + if (parsed.text.length > TEXT_LIMIT) { + return tgError(c, "Bad Request: message is too long", 400, 400); + } + + const threadErr = validateMessageThreadId(body.message_thread_id, chat); + if (threadErr) return tgError(c, threadErr, 400); + + if (body.reply_to_message_id !== undefined) { + const target = ts.messages + .findBy("chat_id", body.chat_id) + .find((m) => m.message_id === body.reply_to_message_id && !m.deleted); + if (!target) { + return tgError(c, "Bad Request: message to be replied not found", 400, 400); + } + } + + const messageId = allocateMessageId(store, chat); + + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: null, + from_bot_id: bot.bot_id, + sender_chat_id: null, + message_thread_id: body.message_thread_id, + date: Math.floor(Date.now() / 1000), + text: parsed.text, + entities: parsed.entities, + reply_to_message_id: body.reply_to_message_id, + reply_markup: body.reply_markup, + }); + + return ok(c, serializeMessage(msg, { store })); +} + +const TEXT_LIMIT = 4096; +const CAPTION_LIMIT = 1024; + +function validateMessageThreadId( + raw: number | undefined, + chat: TelegramChat, +): string | null { + if (raw === undefined) return null; + if (chat.type !== "supergroup") return "Bad Request: message thread not found"; + return null; +} + +/** + * Interprets `parse_mode` against the raw text. Returns the stripped text + * and the entities derived from markup. Caller-supplied `entities` are + * preserved and appended to the parsed set (real Telegram behaviour: when + * both are present, entities take precedence and parse_mode is ignored, + * but we merge so tests can exercise either path without losing data). + */ +function applyParseMode( + rawText: string, + parseMode: "MarkdownV2" | "HTML" | "Markdown" | undefined, + callerEntities: MessageEntity[] | undefined, +): { ok: true; text: string; entities: MessageEntity[] | undefined } | { ok: false; description: string } { + if (!parseMode) { + return { ok: true, text: rawText, entities: callerEntities }; + } + try { + if (parseMode === "MarkdownV2") { + const { text, entities } = parseMarkdownV2(rawText); + const merged = mergeEntities(entities, callerEntities); + return { ok: true, text, entities: merged.length > 0 ? merged : undefined }; + } + if (parseMode === "HTML") { + const { text, entities } = parseHtml(rawText); + const merged = mergeEntities(entities, callerEntities); + return { ok: true, text, entities: merged.length > 0 ? merged : undefined }; + } + // Legacy Markdown v1: simpler grammar than V2 (no spoiler/underline/ + // strikethrough, `_` and `*` only, no escape syntax). Real Telegram + // still accepts it; many older SDKs default to it. + const { text, entities } = parseLegacyMarkdown(rawText); + const merged = mergeEntities(entities, callerEntities); + return { ok: true, text, entities: merged.length > 0 ? merged : undefined }; + } catch (err) { + if (err instanceof MarkdownParseError || err instanceof HtmlParseError) { + return { ok: false, description: err.message }; + } + throw err; + } +} + +function mergeEntities( + parsed: MessageEntity[], + caller: MessageEntity[] | undefined, +): MessageEntity[] { + if (!caller || caller.length === 0) return parsed; + // When caller supplies entities explicitly, real Telegram discards + // parse_mode output — drop the parsed side. + const all = [...caller]; + all.sort((a, b) => (a.offset !== b.offset ? a.offset - b.offset : b.length - a.length)); + return all; +} + +// Minimal legacy Markdown (v1) parser: *bold*, _italic_, `code`, +// ```pre```, [text](url). No escape syntax; unmatched markers are +// emitted as literal text. +function parseLegacyMarkdown(input: string): { text: string; entities: MessageEntity[] } { + const out: string[] = []; + const entities: MessageEntity[] = []; + let i = 0; + const emit = ( + type: MessageEntity["type"], + markerLen: number, + closer: string, + extras?: Partial, + ): boolean => { + const close = input.indexOf(closer, i + markerLen); + if (close === -1) return false; + const body = input.slice(i + markerLen, close); + const start = out.join("").length; + out.push(body); + entities.push({ type, offset: start, length: body.length, ...extras }); + i = close + closer.length; + return true; + }; + while (i < input.length) { + const ch = input[i]; + if (input.startsWith("```", i) && emit("pre", 3, "```")) continue; + if (ch === "`" && emit("code", 1, "`")) continue; + if (ch === "*" && emit("bold", 1, "*")) continue; + if (ch === "_" && emit("italic", 1, "_")) continue; + if (ch === "[") { + const close = input.indexOf("]", i + 1); + if (close !== -1 && input[close + 1] === "(") { + const urlClose = input.indexOf(")", close + 2); + if (urlClose !== -1) { + const text = input.slice(i + 1, close); + const url = input.slice(close + 2, urlClose); + const start = out.join("").length; + out.push(text); + if (url.startsWith("tg://user?id=")) { + const uid = Number(url.slice("tg://user?id=".length)); + if (Number.isFinite(uid)) { + entities.push({ + type: "text_mention", + offset: start, + length: text.length, + user: { id: uid, is_bot: false, first_name: "" }, + }); + } + } else { + entities.push({ type: "text_link", offset: start, length: text.length, url }); + } + i = urlClose + 1; + continue; + } + } + } + out.push(ch); + i += 1; + } + entities.sort((a, b) => (a.offset !== b.offset ? a.offset - b.offset : b.length - a.length)); + return { text: out.join(""), entities }; +} + +async function sendPhoto( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSendPhotoBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + if (!chat.member_bot_ids.includes(bot.bot_id)) { + return tgError(c, "Forbidden: bot is not a member of the chat", 403, 403); + } + + const captionResult = applyCaption(body.caption, body.parse_mode, body.caption_entities); + if (!captionResult.ok) return tgError(c, captionResult.description, 400); + const { caption, captionEntities } = captionResult; + + const threadErr = validateMessageThreadId(body.message_thread_id, chat); + if (threadErr) return tgError(c, threadErr, 400); + + let photoSizes: PhotoSize[]; + + if (typeof body.photo === "string") { + // Re-send by file_id — real Telegram preserves the file_id exactly. + const file = ts.files.findOneBy("file_id", body.photo); + if (!file) return tgError(c, "Bad Request: file not found"); + if (file.photo_sizes_json) { + photoSizes = JSON.parse(file.photo_sizes_json) as PhotoSize[]; + } else { + // Legacy row without saved tiers — fall back to synthesising but + // keep the same file_id for the primary tier. + photoSizes = [ + { + file_id: file.file_id, + file_unique_id: file.file_unique_id, + width: file.width, + height: file.height, + file_size: file.file_size, + }, + ]; + } + } else { + const upload = body.photo; + const bytes = upload.bytes; + const { sizes } = buildPhotoSizes(store, bytes, bot.bot_id, body.chat_id); + const photoSizesJson = JSON.stringify(sizes); + for (const size of sizes) { + ts.files.insert({ + file_id: size.file_id, + file_unique_id: size.file_unique_id, + owner_bot_id: bot.bot_id, + mime_type: upload.type || "image/jpeg", + file_size: bytes.length, + width: size.width, + height: size.height, + file_path: `photos/${bot.bot_id}/${size.file_id}`, + bytes_base64: bytes.toString("base64"), + kind: "photo" as const, + photo_sizes_json: photoSizesJson, + }); + } + photoSizes = sizes; + } + + const messageId = allocateMessageId(store, chat); + + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: null, + from_bot_id: bot.bot_id, + sender_chat_id: null, + message_thread_id: body.message_thread_id, + date: Math.floor(Date.now() / 1000), + photo: photoSizes, + caption, + caption_entities: captionEntities, + reply_markup: body.reply_markup, + }); + + return ok(c, serializeMessage(msg, { store })); +} + +function applyCaption( + rawCaption: string | undefined, + parseMode: "MarkdownV2" | "HTML" | "Markdown" | undefined, + callerEntities: MessageEntity[] | undefined, +): + | { ok: true; caption: string | undefined; captionEntities: MessageEntity[] | undefined } + | { ok: false; description: string } { + if (rawCaption === undefined) { + return { ok: true, caption: undefined, captionEntities: callerEntities }; + } + const parsed = applyParseMode(rawCaption, parseMode, callerEntities); + if (!parsed.ok) return parsed; + if (parsed.text.length > CAPTION_LIMIT) { + return { ok: false, description: "Bad Request: message caption is too long" }; + } + return { ok: true, caption: parsed.text, captionEntities: parsed.entities }; +} + +function setMessageReaction( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSetMessageReactionBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + const msg = ts.messages + .findBy("chat_id", body.chat_id) + .find((m) => m.message_id === body.message_id); + if (!msg) return tgError(c, "Bad Request: message not found"); + + // Upsert the reaction row for this bot on this message. + const existing = ts.reactions.all().find( + (rr) => rr.chat_id === body.chat_id && rr.message_id === body.message_id && rr.sender_bot_id === bot.bot_id, + ); + const typed = (body.reaction ?? []).map((rr) => + rr.type === "emoji" + ? { type: "emoji" as const, emoji: rr.emoji ?? "" } + : { type: "custom_emoji" as const, custom_emoji_id: rr.custom_emoji_id ?? "" }, + ); + + if (existing) { + if (typed.length === 0) { + ts.reactions.delete(existing.id); + } else { + ts.reactions.update(existing.id, { reaction: typed }); + } + } else if (typed.length > 0) { + ts.reactions.insert({ + chat_id: body.chat_id, + message_id: body.message_id, + sender_user_id: null, + sender_bot_id: bot.bot_id, + reaction: typed, + }); + } + + return c.json({ ok: true, result: true }); +} + +function getFile( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zGetFileBody, raw); + if (!r.ok) return r.response; + + const ts = getTelegramStore(store); + const file = ts.files.findOneBy("file_id", r.data.file_id); + if (!file) return tgError(c, "Bad Request: file not found"); + + void bot; + return ok(c, { + file_id: file.file_id, + file_unique_id: file.file_unique_id, + file_size: file.file_size, + file_path: file.file_path, + }); +} + +function answerCallbackQuery( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zAnswerCallbackQueryBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const cq = ts.callbackQueries.findOneBy("callback_query_id", body.callback_query_id); + if (!cq) return tgError(c, "Bad Request: callback_query not found"); + + ts.callbackQueries.update(cq.id, { + answered: true, + answer_text: body.text, + answer_show_alert: body.show_alert, + answer_url: body.url, + answer_cache_time: body.cache_time, + }); + void bot; + return c.json({ ok: true, result: true }); +} + +function setMyCommands( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSetMyCommandsBody, raw); + if (!r.ok) return r.response; + + const ts = getTelegramStore(store); + ts.bots.update(bot.id, { commands: r.data.commands }); + return okRaw(c, true); +} + +function getMyCommands(c: Context, bot: TelegramBot) { + return c.json({ ok: true, result: bot.commands }); +} + +function editMessageReplyMarkup( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zEditMessageReplyMarkupBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const msg = ts.messages + .findBy("chat_id", body.chat_id) + .find((m) => m.message_id === body.message_id); + if (!msg) return tgError(c, "Bad Request: message not found"); + + // editMessageReplyMarkup only accepts inline keyboards — the other + // reply-markup kinds can't be attached to an existing message. + let newMarkup: InlineKeyboardMarkup | undefined; + if (body.reply_markup !== undefined) { + if (!isInlineKeyboardMarkup(body.reply_markup)) { + return tgError(c, "Bad Request: reply_markup must be an inline keyboard"); + } + newMarkup = body.reply_markup; + } + ts.messages.update(msg.id, { reply_markup: newMarkup, edited_date: Math.floor(Date.now() / 1000) }); + + void bot; + const updated = ts.messages.get(msg.id)!; + return ok(c, serializeMessage(updated, { store })); +} + +export function parseInlineKeyboard(markup: unknown): InlineKeyboardMarkup | null { + if (!markup || typeof markup !== "object") return null; + const m = markup as ReplyMarkup; + if (isInlineKeyboardMarkup(m)) return m; + return null; +} + +export { parseEntities }; + +// expose for control plane +export function simulateInsertMessage( + store: Store, + input: { + chatId: number; + fromUserId: number; + text: string; + entities?: MessageEntity[]; + replyTo?: number; + messageThreadId?: number; + }, +): TelegramMessage { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + if (!chat.member_user_ids.includes(input.fromUserId)) { + throw new Error(`user ${input.fromUserId} is not a member of chat ${input.chatId}`); + } + const messageId = allocateMessageId(store, chat); + return ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: input.fromUserId, + from_bot_id: null, + sender_chat_id: null, + message_thread_id: input.messageThreadId, + date: Math.floor(Date.now() / 1000), + text: input.text, + entities: input.entities, + reply_to_message_id: input.replyTo, + }); +} + +// ---- Full-parity additions: faults, rich media, length limits ---- + +function consumeFault( + store: Store, + botId: number, + method: string, +): TelegramFault | null { + const ts = getTelegramStore(store); + const matches = ts.faults.all().filter( + (f) => f.bot_id === botId && (f.method === "*" || f.method === method) && f.remaining > 0, + ); + if (matches.length === 0) return null; + // Prefer specific-method faults over wildcards. + matches.sort((a, b) => (a.method === "*" ? 1 : 0) - (b.method === "*" ? 1 : 0)); + const fault = matches[0]; + const remaining = fault.remaining - 1; + if (remaining <= 0) { + ts.faults.delete(fault.id); + } else { + ts.faults.update(fault.id, { remaining }); + } + return fault; +} + +function faultHttpStatus(errorCode: number): 400 | 401 | 403 | 404 | 429 { + if (errorCode === 401 || errorCode === 403 || errorCode === 404 || errorCode === 429) { + return errorCode; + } + return 400; +} + +// Narrow the per-kind zod body to (a) its media input field and +// (b) the extra per-kind fields the handler consumes. Keeps the +// dispatcher typed without a cast at the output site. +type AnyMediaBody = + | SendVideoBody + | SendAudioBody + | SendVoiceBody + | SendAnimationBody + | SendStickerBody; + +// Lift the media-input field out of the per-kind body. Each branch has +// exactly one such field whose key matches the method's kind. Using +// `in` guards keeps the access type-checked without a cast. +function pickMediaInput(body: AnyMediaBody, kind: MediaKind): string | MultipartFileRef { + switch (kind) { + case "video": + return "video" in body ? body.video : unreachable(kind); + case "audio": + return "audio" in body ? body.audio : unreachable(kind); + case "voice": + return "voice" in body ? body.voice : unreachable(kind); + case "animation": + return "animation" in body ? body.animation : unreachable(kind); + case "sticker": + return "sticker" in body ? body.sticker : unreachable(kind); + } +} + +function unreachable(kind: MediaKind): never { + // The zod schema guarantees the per-kind field is present. Reached + // only if BODY_FOR_MEDIA routing drifts from pickMediaInput — the + // assertion surfaces the bug loudly rather than silently. + throw new Error(`sendMediaMessage: ${kind} body missing its media field`); +} + +async function sendMediaMessage( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, + kind: MediaKind, +) { + const schema = BODY_FOR_MEDIA[kind] as z.ZodType; + const r: ParseResult = parseWithSchema(c, schema, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + if (!chat.member_bot_ids.includes(bot.bot_id)) { + return tgError(c, "Forbidden: bot is not a member of the chat", 403, 403); + } + + // Caption + parse_mode (sticker has no caption). + let caption: string | undefined; + let captionEntities: MessageEntity[] | undefined; + if (kind === "sticker") { + // Real Telegram silently ignores captions on stickers. + caption = undefined; + captionEntities = undefined; + } else { + const capResult = applyCaption(body.caption, body.parse_mode, body.caption_entities); + if (!capResult.ok) return tgError(c, capResult.description, 400); + caption = capResult.caption; + captionEntities = capResult.captionEntities; + } + + const threadErr = validateMessageThreadId(body.message_thread_id, chat); + if (threadErr) return tgError(c, threadErr, 400); + + const input = pickMediaInput(body, kind); + + const duration = body.duration ?? 0; + const width = body.width ?? 0; + const height = body.height ?? 0; + + let file_id: string; + let file_unique_id: string; + let file_name: string | undefined; + let mime_type: string | undefined; + let file_size = 0; + + if (typeof input === "string") { + // Re-send by file_id. + const file = ts.files.findOneBy("file_id", input); + if (!file) return tgError(c, "Bad Request: file not found"); + file_id = file.file_id; + file_unique_id = file.file_unique_id; + file_name = file.file_name; + mime_type = file.mime_type; + file_size = file.file_size; + } else if (isMultipartFile(input)) { + const bytes = input.bytes; + const ids = nextFileId(store, bot.bot_id, body.chat_id, kind[0]); + file_id = ids.file_id; + file_unique_id = ids.file_unique_id; + file_name = input.name; + mime_type = input.type || defaultMimeForKind(kind); + file_size = bytes.length; + ts.files.insert({ + file_id, + file_unique_id, + owner_bot_id: bot.bot_id, + mime_type, + file_size, + width, + height, + file_path: `${kind}s/${bot.bot_id}/${file_id}`, + bytes_base64: bytes.toString("base64"), + kind, + file_name, + duration, + }); + } else { + return tgError(c, `Bad Request: ${kind} must be a file_id string or multipart upload`); + } + + const mediaField = buildMediaField({ + kind, + file_id, + file_unique_id, + file_size, + width, + height, + duration, + mime_type, + file_name, + performer: "performer" in body ? body.performer : undefined, + title: "title" in body ? body.title : undefined, + emoji: "emoji" in body ? body.emoji : undefined, + is_animated: body.is_animated === true, + is_video: body.is_video === true, + }); + + const messageId = allocateMessageId(store, chat); + + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: null, + from_bot_id: bot.bot_id, + sender_chat_id: null, + message_thread_id: body.message_thread_id, + date: Math.floor(Date.now() / 1000), + [kind]: mediaField, + caption, + caption_entities: captionEntities, + reply_markup: body.reply_markup, + }); + + return ok(c, serializeMessage(msg, { store })); +} + +function defaultMimeForKind(kind: MediaKind): string { + switch (kind) { + case "video": + return "video/mp4"; + case "audio": + return "audio/mpeg"; + case "voice": + return "audio/ogg"; + case "animation": + return "video/mp4"; + case "sticker": + return "image/webp"; + } +} + +// ---- editMessageText / deleteMessage / sendMessageDraft / sendDocument ---- + +function editMessageText( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zEditMessageTextBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + if (!body.text) return tgError(c, "Bad Request: text is required"); + + const ts = getTelegramStore(store); + const msg = ts.messages + .findBy("chat_id", body.chat_id) + .find((m) => m.message_id === body.message_id); + if (!msg) return tgError(c, "Bad Request: message not found"); + if (msg.from_bot_id !== bot.bot_id) { + return tgError(c, "Bad Request: message can't be edited by this bot", 403, 403); + } + const parsed = applyParseMode(body.text, body.parse_mode, body.entities); + if (!parsed.ok) return tgError(c, parsed.description, 400); + if (parsed.text.length > TEXT_LIMIT) { + return tgError(c, "Bad Request: message is too long", 400, 400); + } + ts.messages.update(msg.id, { + text: parsed.text, + entities: parsed.entities, + edited_date: Math.floor(Date.now() / 1000), + }); + const updated = ts.messages.get(msg.id)!; + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (chat) dispatchEditedMessage(store, chat, bot.bot_id, updated); + return ok(c, serializeMessage(updated, { store })); +} + +function dispatchEditedMessage( + store: Store, + chat: TelegramChat, + editorBotId: number, + updated: TelegramMessage, +): void { + const ts = getTelegramStore(store); + const dispatcher = getDispatcher(store); + const payload = serializeMessage(updated, { store }); + for (const otherBotId of chat.member_bot_ids) { + if (otherBotId === editorBotId) continue; + const bot = ts.bots.findOneBy("bot_id", otherBotId); + if (!bot) continue; + const type = chat.type === "channel" ? "edited_channel_post" : "edited_message"; + dispatcher.enqueue(otherBotId, type, payload); + } +} + +function deleteMessage( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zDeleteMessageBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const msg = ts.messages + .findBy("chat_id", body.chat_id) + .find((m) => m.message_id === body.message_id); + if (!msg) return tgError(c, "Bad Request: message not found"); + // Telegram allows bots to delete their own messages unconditionally, + // and messages from other senders only if the bot has sufficient rights + // (which in our emulator we simplify to: always allowed for member bots). + if (msg.from_bot_id !== null && msg.from_bot_id !== bot.bot_id) { + return tgError(c, "Bad Request: message can't be deleted by this bot", 403, 403); + } + ts.messages.update(msg.id, { deleted: true }); + return c.json({ ok: true, result: true }); +} + +function sendMessageDraft( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSendMessageDraftBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + if (body.draft_id === 0 || !body.text) { + return tgError(c, "Bad Request: chat_id, draft_id (non-zero), text required"); + } + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + // Real Telegram restricts sendMessageDraft to private chats. + if (chat.type !== "private") { + return tgError(c, "Bad Request: message drafts are supported only in private chats"); + } + if (!chat.member_bot_ids.includes(bot.bot_id)) { + return tgError(c, "Forbidden: bot is not a member of the chat", 403, 403); + } + + const existing = ts.draftSnapshots + .findBy("chat_id", body.chat_id) + .filter((s) => s.draft_id === body.draft_id && s.bot_id === bot.bot_id); + const seq = existing.length > 0 ? Math.max(...existing.map((s) => s.seq)) + 1 : 1; + + ts.draftSnapshots.insert({ + chat_id: body.chat_id, + draft_id: body.draft_id, + bot_id: bot.bot_id, + seq, + text: body.text, + entities: body.entities, + }); + + return c.json({ ok: true, result: true }); +} + +async function sendDocument( + c: Context, + bot: TelegramBot, + raw: unknown, + store: Store, +) { + const r = parseWithSchema(c, zSendDocumentBody, raw); + if (!r.ok) return r.response; + const body = r.data; + + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", body.chat_id); + if (!chat) return tgError(c, "Bad Request: chat not found"); + if (!chat.member_bot_ids.includes(bot.bot_id)) { + return tgError(c, "Forbidden: bot is not a member of the chat", 403, 403); + } + + const capResult = applyCaption(body.caption, body.parse_mode, body.caption_entities); + if (!capResult.ok) return tgError(c, capResult.description, 400); + const { caption, captionEntities } = capResult; + + const threadErr = validateMessageThreadId(body.message_thread_id, chat); + if (threadErr) return tgError(c, threadErr, 400); + + let document: TelegramDocument; + + if (typeof body.document === "string") { + // Re-send by file_id — Telegram preserves the same ids on echo. + const file = ts.files.findOneBy("file_id", body.document); + if (!file) return tgError(c, "Bad Request: file not found"); + document = { + file_id: file.file_id, + file_unique_id: file.file_unique_id, + file_name: file.file_name, + mime_type: file.mime_type, + file_size: file.file_size, + }; + } else { + const upload = body.document; + const bytes = upload.bytes; + const { file_id, file_unique_id } = nextFileId(store, bot.bot_id, body.chat_id, "d"); + ts.files.insert({ + file_id, + file_unique_id, + owner_bot_id: bot.bot_id, + mime_type: upload.type || "application/octet-stream", + file_size: bytes.length, + width: 0, + height: 0, + file_path: `documents/${bot.bot_id}/${file_id}`, + bytes_base64: bytes.toString("base64"), + kind: "document" as const, + file_name: upload.name, + }); + document = { + file_id, + file_unique_id, + file_name: upload.name, + mime_type: upload.type || "application/octet-stream", + file_size: bytes.length, + }; + } + + const messageId = allocateMessageId(store, chat); + + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: null, + from_bot_id: bot.bot_id, + sender_chat_id: null, + message_thread_id: body.message_thread_id, + date: Math.floor(Date.now() / 1000), + document, + caption, + caption_entities: captionEntities, + reply_markup: body.reply_markup, + }); + + return ok(c, serializeMessage(msg, { store })); +} diff --git a/packages/@emulators/telegram/src/routes/control-diagnostics.ts b/packages/@emulators/telegram/src/routes/control-diagnostics.ts new file mode 100644 index 00000000..16b4eec8 --- /dev/null +++ b/packages/@emulators/telegram/src/routes/control-diagnostics.ts @@ -0,0 +1,66 @@ +// Diagnostic control-plane helpers: fault injection + callback-answer +// inspection. Split out of control.ts so the "what to mock / what got +// answered" concern stays visible. +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; + +export interface InjectFaultInput { + botId: number; + method: string; + error_code: number; + description?: string; + retry_after?: number; + count?: number; +} + +export function injectFault(store: Store, input: InjectFaultInput): { fault_id: number } { + const ts = getTelegramStore(store); + const row = ts.faults.insert({ + bot_id: input.botId, + method: input.method, + error_code: input.error_code, + description: + input.description ?? + (input.error_code === 429 + ? `Too Many Requests: retry after ${input.retry_after ?? 1}` + : input.error_code === 401 + ? "Unauthorized" + : input.error_code === 403 + ? "Forbidden" + : input.error_code === 404 + ? "Not Found" + : `Bad Request: injected fault ${input.error_code}`), + retry_after: input.retry_after ?? null, + remaining: Math.max(1, input.count ?? 1), + }); + return { fault_id: row.id }; +} + +export function clearFaults(store: Store): void { + const ts = getTelegramStore(store); + for (const f of ts.faults.all()) ts.faults.delete(f.id); +} + +export function getCallbackAnswer( + store: Store, + id: string, +): { + callback_query_id: string; + answered: boolean; + answer_text?: string; + answer_show_alert?: boolean; + answer_url?: string; + answer_cache_time?: number; +} | null { + const ts = getTelegramStore(store); + const row = ts.callbackQueries.findOneBy("callback_query_id", id); + if (!row) return null; + return { + callback_query_id: row.callback_query_id, + answered: row.answered, + answer_text: row.answer_text, + answer_show_alert: row.answer_show_alert, + answer_url: row.answer_url, + answer_cache_time: row.answer_cache_time, + }; +} diff --git a/packages/@emulators/telegram/src/routes/control.ts b/packages/@emulators/telegram/src/routes/control.ts new file mode 100644 index 00000000..a0cbe5a4 --- /dev/null +++ b/packages/@emulators/telegram/src/routes/control.ts @@ -0,0 +1,1131 @@ +import type { Context } from "hono"; +import type { RouteContext, Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import { + generateBotToken, + generateCallbackQueryId, + nextBotId, + nextChannelChatId, + nextFileId, + nextGroupChatId, + nextSupergroupChatId, + nextUserId, +} from "../ids.js"; +import { parseJsonBody } from "../types/validators/body.js"; +import { + zChatMembershipInput, + zCreateBotInput, + zCreateChannelInput, + zCreateForumTopicControlBody, + zCreateGroupChatInput, + zCreatePrivateChatInput, + zCreateSupergroupInput, + zCreateUserInput, + zEditChannelPostInput, + zInjectFaultInput, + zPromoteChatMemberInput, + zSimulateCallbackInput, + zSimulateChannelPostInput, + zSimulateEditedUserMessageInput, + zSimulateReactionInput, + zSimulateUserMediaInput, + zSimulateUserMessageInput, + zSimulateUserPhotoInput, +} from "../types/validators/control.js"; +import { + serializeBotAsUser, + serializeChat, + serializeMessage, + serializeUser, +} from "../serializers.js"; +import { parseEntities } from "../entity-parser.js"; +import { + allocateMessageId, + buildMediaField as buildMediaFieldShared, + buildPhotoSizes, + defaultMimeForMediaKind as defaultMimeForMediaKindShared, +} from "../services/media.js"; +import { getDispatcher } from "../dispatcher.js"; +import { telegramPaths } from "../paths.js"; +import type { + InlineKeyboardMarkup, + MessageEntity, + TelegramBot, + TelegramChat, + TelegramMessage, + TelegramUser, +} from "../entities.js"; +import type { + WireCallbackQuery, + WireChatMemberUpdated, + WireMessage, + WireMessageReactionCountUpdated, + WireMessageReactionUpdated, + WireReactionCount, +} from "../types/wire/index.js"; +import type { ReactionType } from "../types/wire/reaction.js"; + +// Chat ID conventions (match real Telegram): +// private: positive numbers equal to user_id +// group: negative numbers (allocator: nextGroupChatId) +// supergroup: -100xxx range (allocator: nextSupergroupChatId) +// channel: -100xxx range, distinct allocator (nextChannelChatId) +function allocatePrivateChatId(userId: number): number { + return userId; +} + +export interface CreateBotInput { + username: string; + name?: string; + first_name?: string; + can_join_groups?: boolean; + can_read_all_group_messages?: boolean; + commands?: Array<{ command: string; description: string }>; + token?: string; +} + +export interface CreateUserInput { + first_name: string; + last_name?: string; + username?: string; + language_code?: string; +} + +export interface CreatePrivateChatInput { + botId: number; + userId: number; +} + +export interface CreateGroupChatInput { + title: string; + type?: "group" | "supergroup"; + memberIds: number[]; + botIds: number[]; + creatorUserId?: number; + adminUserIds?: number[]; + adminBotIds?: number[]; + isForum?: boolean; +} + +export function createBot(store: Store, input: CreateBotInput): TelegramBot { + const ts = getTelegramStore(store); + const existingByUsername = ts.bots.findOneBy("username", input.username); + if (existingByUsername) return existingByUsername; + + const bot_id = nextBotId(store); + const token = input.token ?? generateBotToken(bot_id); + return ts.bots.insert({ + bot_id, + token, + username: input.username, + first_name: input.first_name ?? input.name ?? input.username, + can_join_groups: input.can_join_groups ?? true, + can_read_all_group_messages: input.can_read_all_group_messages ?? false, + supports_inline_queries: false, + webhook_url: null, + webhook_secret: null, + webhook_allowed_updates: null, + commands: input.commands ?? [], + }); +} + +export function createUser(store: Store, input: CreateUserInput): TelegramUser { + const ts = getTelegramStore(store); + if (input.username) { + const existing = ts.users.findOneBy("username", input.username); + if (existing) return existing; + } + const user_id = nextUserId(store); + return ts.users.insert({ + user_id, + is_bot: false, + first_name: input.first_name, + last_name: input.last_name, + username: input.username, + language_code: input.language_code, + }); +} + +export function createPrivateChat(store: Store, input: CreatePrivateChatInput): TelegramChat { + const ts = getTelegramStore(store); + const chatId = allocatePrivateChatId(input.userId); + const existing = ts.chats.findOneBy("chat_id", chatId); + if (existing) { + const updates: Partial = {}; + if (!existing.member_bot_ids.includes(input.botId)) { + updates.member_bot_ids = [...existing.member_bot_ids, input.botId]; + } + if (!existing.member_user_ids.includes(input.userId)) { + updates.member_user_ids = [...existing.member_user_ids, input.userId]; + } + if (Object.keys(updates).length > 0) { + return ts.chats.update(existing.id, updates)!; + } + return existing; + } + const user = ts.users.findOneBy("user_id", input.userId); + return ts.chats.insert({ + chat_id: chatId, + type: "private", + first_name: user?.first_name, + last_name: user?.last_name, + username: user?.username, + member_user_ids: [input.userId], + member_bot_ids: [input.botId], + next_message_id: 1, + }); +} + +export function createGroupChat(store: Store, input: CreateGroupChatInput): TelegramChat { + const ts = getTelegramStore(store); + const chatId = input.type === "supergroup" ? nextSupergroupChatId(store) : nextGroupChatId(store); + // Creator defaults to the first member if not specified; real Telegram + // always has a creator, so getChatAdministrators never returns empty. + const creatorUserId = input.creatorUserId ?? input.memberIds[0]; + return ts.chats.insert({ + chat_id: chatId, + type: input.type ?? "group", + title: input.title, + member_user_ids: [...input.memberIds], + member_bot_ids: [...input.botIds], + creator_user_id: creatorUserId, + admin_user_ids: input.adminUserIds ? [...input.adminUserIds] : undefined, + admin_bot_ids: input.adminBotIds ? [...input.adminBotIds] : undefined, + next_message_id: 1, + is_forum: input.type === "supergroup" ? input.isForum : undefined, + }); +} + +export interface PromoteChatMemberInput { + chatId: number; + userId?: number; + botId?: number; + demote?: boolean; +} + +export function promoteChatMember(store: Store, input: PromoteChatMemberInput): TelegramChat { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + const updates: Partial = {}; + if (input.userId !== undefined) { + const admins = new Set(chat.admin_user_ids ?? []); + if (input.demote) admins.delete(input.userId); + else admins.add(input.userId); + updates.admin_user_ids = Array.from(admins); + } + if (input.botId !== undefined) { + const admins = new Set(chat.admin_bot_ids ?? []); + if (input.demote) admins.delete(input.botId); + else admins.add(input.botId); + updates.admin_bot_ids = Array.from(admins); + } + return ts.chats.update(chat.id, updates) ?? chat; +} + +export interface CreateSupergroupInput { + title: string; + memberIds: number[]; + botIds: number[]; + creatorUserId?: number; + adminUserIds?: number[]; + adminBotIds?: number[]; + isForum?: boolean; +} + +export function createSupergroup(store: Store, input: CreateSupergroupInput): TelegramChat { + return createGroupChat(store, { ...input, type: "supergroup" }); +} + +export interface CreateChannelInput { + title: string; + username?: string; + memberBotIds: number[]; + memberUserIds?: number[]; +} + +export function createChannel(store: Store, input: CreateChannelInput): TelegramChat { + const ts = getTelegramStore(store); + const chatId = nextChannelChatId(store); + return ts.chats.insert({ + chat_id: chatId, + type: "channel", + title: input.title, + username: input.username, + member_user_ids: input.memberUserIds ?? [], + member_bot_ids: [...input.memberBotIds], + next_message_id: 1, + }); +} + +export interface CreateForumTopicInput { + chatId: number; + name: string; +} + +export function createForumTopic( + store: Store, + input: CreateForumTopicInput, +): { message_thread_id: number; name: string } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + if (chat.type !== "supergroup") { + throw new Error(`forum topics require a supergroup; chat ${input.chatId} is ${chat.type}`); + } + if (!chat.is_forum) { + ts.chats.update(chat.id, { is_forum: true }); + } + const existing = ts.forumTopics.findBy("chat_id", input.chatId); + const maxId = existing.reduce((m, t) => Math.max(m, t.message_thread_id), 1); + const message_thread_id = maxId + 1; + ts.forumTopics.insert({ + chat_id: input.chatId, + message_thread_id, + name: input.name, + }); + return { message_thread_id, name: input.name }; +} + +export interface SimulateChannelPostInput { + chatId: number; + text?: string; + entities?: MessageEntity[]; + caption?: string; + photoBytes?: Buffer; + replyToMessageId?: number; + messageThreadId?: number; + edited?: boolean; + existingMessageId?: number; +} + +export function simulateChannelPost( + store: Store, + input: SimulateChannelPostInput, +): { message_id: number; update_id: number } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + if (chat.type !== "channel") { + throw new Error(`simulateChannelPost requires a channel; chat ${input.chatId} is ${chat.type}`); + } + + let msg: TelegramMessage; + if (input.edited) { + if (input.existingMessageId === undefined) throw new Error("existingMessageId required for edited post"); + const existing = ts.messages + .findBy("chat_id", input.chatId) + .find((m) => m.message_id === input.existingMessageId); + if (!existing) throw new Error(`message ${input.existingMessageId} not found`); + ts.messages.update(existing.id, { + text: input.text, + entities: input.entities, + caption: input.caption, + edited_date: Math.floor(Date.now() / 1000), + }); + msg = ts.messages.get(existing.id)!; + } else { + const messageId = allocateMessageId(store, chat); + msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: null, + from_bot_id: null, + sender_chat_id: chat.chat_id, + message_thread_id: input.messageThreadId, + date: Math.floor(Date.now() / 1000), + text: input.text, + entities: input.entities, + caption: input.caption, + reply_to_message_id: input.replyToMessageId, + }); + } + + const dispatcher = getDispatcher(store); + let firstUpdateId = 0; + for (const botId of chat.member_bot_ids) { + const payload = serializeMessage(msg, { store }); + const type = input.edited ? ("edited_channel_post" as const) : ("channel_post" as const); + const upd = dispatcher.enqueue(botId, type, payload); + if (firstUpdateId === 0) firstUpdateId = upd.update_id; + } + + return { message_id: msg.message_id, update_id: firstUpdateId }; +} + +import { + clearFaults, + getCallbackAnswer, + injectFault, + type InjectFaultInput, +} from "./control-diagnostics.js"; +export { clearFaults, getCallbackAnswer, injectFault }; +export type { InjectFaultInput }; + +export interface SimulateUserMediaInput { + chatId: number; + userId: number; + kind: "photo" | "video" | "audio" | "voice" | "animation" | "sticker" | "document"; + bytes: Buffer; + mimeType?: string; + caption?: string; + duration?: number; + width?: number; + height?: number; + fileName?: string; + messageThreadId?: number; +} + +export function simulateUserMedia( + store: Store, + input: SimulateUserMediaInput, +): { message_id: number; update_id: number; file_id: string } { + if (input.kind === "photo") { + return simulateUserPhoto(store, { + chatId: input.chatId, + userId: input.userId, + photoBytes: input.bytes, + mimeType: input.mimeType, + caption: input.caption, + }); + } + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + if (!chat.member_user_ids.includes(input.userId)) { + throw new Error(`user ${input.userId} is not a member of chat ${input.chatId}`); + } + const firstBotId = chat.member_bot_ids[0]; + const ownerBot = firstBotId ?? 0; + const { file_id, file_unique_id } = nextFileIdForKind(store, ownerBot, input.chatId, input.kind); + ts.files.insert({ + file_id, + file_unique_id, + owner_bot_id: firstBotId ?? null, + mime_type: input.mimeType ?? defaultMimeForMediaKindShared(input.kind), + file_size: input.bytes.length, + width: input.width ?? 0, + height: input.height ?? 0, + file_path: `${input.kind}s/${ownerBot}/${file_id}`, + bytes_base64: input.bytes.toString("base64"), + kind: input.kind, + file_name: input.fileName, + duration: input.duration, + }); + + const messageId = allocateMessageId(store, chat); + + const mediaField = buildMediaFieldShared({ + kind: input.kind, + file_id, + file_unique_id, + file_size: input.bytes.length, + width: input.width, + height: input.height, + duration: input.duration, + mime_type: input.mimeType, + file_name: input.fileName, + }); + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: input.userId, + from_bot_id: null, + sender_chat_id: null, + message_thread_id: input.messageThreadId, + date: Math.floor(Date.now() / 1000), + [input.kind]: mediaField, + caption: input.caption, + } as Parameters[0]); + + const dispatcher = getDispatcher(store); + let firstUpdateId = 0; + for (const botId of chat.member_bot_ids) { + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) continue; + if (chat.type !== "private" && !bot.can_read_all_group_messages) continue; + const upd = dispatcher.enqueue(botId, "message", serializeMessage(msg, { store })); + if (firstUpdateId === 0) firstUpdateId = upd.update_id; + } + + return { message_id: msg.message_id, update_id: firstUpdateId, file_id }; +} + +function nextFileIdForKind( + store: Store, + botId: number, + chatId: number, + kind: string, +): { file_id: string; file_unique_id: string } { + return nextFileId(store, botId, chatId, kind[0]); +} + +export interface SimulateUserMessageInput { + chatId: number; + userId: number; + text: string; + replyToMessageId?: number; + messageThreadId?: number; +} + +export function simulateUserMessage(store: Store, input: SimulateUserMessageInput): { + message_id: number; + update_id: number; +} { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + if (!chat.member_user_ids.includes(input.userId)) { + throw new Error(`user ${input.userId} is not a member of chat ${input.chatId}`); + } + + const entities = parseEntities(input.text); + + const messageId = allocateMessageId(store, chat); + + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: input.userId, + from_bot_id: null, + sender_chat_id: null, + message_thread_id: input.messageThreadId, + date: Math.floor(Date.now() / 1000), + text: input.text, + entities, + reply_to_message_id: input.replyToMessageId, + }); + + // Dispatch an Update to every bot in the chat, subject to Telegram's + // privacy rules for groups: bots only see messages that mention them or + // start with a command addressed to them, unless can_read_all_group_messages. + const dispatcher = getDispatcher(store); + let firstUpdateId = 0; + for (const botId of chat.member_bot_ids) { + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) continue; + if (chat.type !== "private" && !shouldBotSeeGroupMessage(input.text, entities, bot)) { + continue; + } + const upd = dispatcher.enqueue(botId, "message", serializeMessage(msg, { store })); + if (firstUpdateId === 0) firstUpdateId = upd.update_id; + } + + return { message_id: msg.message_id, update_id: firstUpdateId }; +} + +function shouldBotSeeGroupMessage(text: string, entities: MessageEntity[], bot: TelegramBot): boolean { + // Privacy mode (the default, can_read_all_group_messages = false): + // - @bot_username mention anywhere → delivered + // - /command@bot_username → delivered to that specific bot only + // - bare /command (no @) → NOT delivered (real Telegram drops it unless + // the bot has privacy off) + // - everything else → not delivered + // Privacy off (can_read_all_group_messages = true): delivered unconditionally. + if (bot.can_read_all_group_messages) return true; + for (const e of entities) { + const chunk = text.slice(e.offset, e.offset + e.length); + if (e.type === "mention" && chunk.toLowerCase() === `@${bot.username.toLowerCase()}`) return true; + if (e.type === "bot_command" && chunk.toLowerCase().endsWith(`@${bot.username.toLowerCase()}`)) return true; + } + return false; +} + +export interface SimulateUserPhotoInput { + chatId: number; + userId: number; + photoBytes: Buffer; + mimeType?: string; + caption?: string; +} + +export function simulateUserPhoto( + store: Store, + input: SimulateUserPhotoInput, +): { message_id: number; update_id: number; file_id: string } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + if (!chat.member_user_ids.includes(input.userId)) { + throw new Error(`user ${input.userId} is not a member of chat ${input.chatId}`); + } + + // Attribute uploaded photos to an "owner bot" scope for file_id — use the + // first bot in the chat so getFile works for any bot that sees the message. + const firstBotId = chat.member_bot_ids[0]; + if (firstBotId === undefined) { + throw new Error(`chat ${input.chatId} has no bots to receive the photo`); + } + + const { sizes } = buildPhotoSizes(store, input.photoBytes, firstBotId, input.chatId); + const photoSizesJson = JSON.stringify(sizes); + for (const size of sizes) { + ts.files.insert({ + file_id: size.file_id, + file_unique_id: size.file_unique_id, + owner_bot_id: firstBotId, + mime_type: input.mimeType ?? "image/jpeg", + file_size: input.photoBytes.length, + width: size.width, + height: size.height, + file_path: `photos/${firstBotId}/${size.file_id}`, + bytes_base64: input.photoBytes.toString("base64"), + kind: "photo" as const, + photo_sizes_json: photoSizesJson, + }); + } + + const messageId = allocateMessageId(store, chat); + + const msg = ts.messages.insert({ + message_id: messageId, + chat_id: chat.chat_id, + from_user_id: input.userId, + from_bot_id: null, + sender_chat_id: null, + date: Math.floor(Date.now() / 1000), + photo: sizes, + caption: input.caption, + }); + + const dispatcher = getDispatcher(store); + let firstUpdateId = 0; + for (const botId of chat.member_bot_ids) { + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) continue; + if (chat.type !== "private" && !bot.can_read_all_group_messages) { + // Per Telegram privacy rules, non-privileged bots do not see plain media in groups. + continue; + } + const upd = dispatcher.enqueue(botId, "message", serializeMessage(msg, { store })); + if (firstUpdateId === 0) firstUpdateId = upd.update_id; + } + + return { message_id: msg.message_id, update_id: firstUpdateId, file_id: sizes[sizes.length - 1].file_id }; +} + +export interface SimulateCallbackInput { + chatId: number; + userId: number; + messageId: number; + callbackData: string; +} + +export function simulateCallback( + store: Store, + input: SimulateCallbackInput, +): { callback_query_id: string; update_id: number } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + const msg = ts.messages.findBy("chat_id", input.chatId).find((m) => m.message_id === input.messageId); + if (!msg) throw new Error(`message ${input.messageId} in chat ${input.chatId} not found`); + const botId = msg.from_bot_id; + if (botId === null) throw new Error(`message ${input.messageId} was not sent by a bot`); + + const id = generateCallbackQueryId(); + ts.callbackQueries.insert({ + callback_query_id: id, + from_user_id: input.userId, + message_id: input.messageId, + chat_id: input.chatId, + data: input.callbackData, + answered: false, + }); + + const user = ts.users.findOneBy("user_id", input.userId); + if (!user) throw new Error(`user ${input.userId} not found`); + + const dispatcher = getDispatcher(store); + const payload: WireCallbackQuery = { + id, + from: serializeUser(user), + chat_instance: String(input.chatId), + message: serializeMessage(msg, { store }), + data: input.callbackData, + }; + const upd = dispatcher.enqueue(botId, "callback_query", payload); + return { callback_query_id: id, update_id: upd.update_id }; +} + +export function addBotToChat( + store: Store, + input: { chatId: number; botId: number; byUserId: number }, +): { update_id: number } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + const bot = ts.bots.findOneBy("bot_id", input.botId); + if (!bot) throw new Error(`bot ${input.botId} not found`); + const by = ts.users.findOneBy("user_id", input.byUserId); + if (!by) throw new Error(`user ${input.byUserId} not found`); + + if (chat.member_bot_ids.includes(input.botId)) { + return { update_id: 0 }; + } + ts.chats.update(chat.id, { member_bot_ids: [...chat.member_bot_ids, input.botId] }); + + const dispatcher = getDispatcher(store); + const botUser = serializeBotAsUser(bot); + const payload: WireChatMemberUpdated = { + chat: serializeChat(chat), + from: serializeUser(by), + date: Math.floor(Date.now() / 1000), + old_chat_member: { status: "left", user: botUser }, + new_chat_member: { status: "member", user: botUser }, + }; + const upd = dispatcher.enqueue(input.botId, "my_chat_member", payload); + return { update_id: upd.update_id }; +} + +export function removeBotFromChat( + store: Store, + input: { chatId: number; botId: number; byUserId: number }, +): { update_id: number } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + const bot = ts.bots.findOneBy("bot_id", input.botId); + if (!bot) throw new Error(`bot ${input.botId} not found`); + const by = ts.users.findOneBy("user_id", input.byUserId); + if (!by) throw new Error(`user ${input.byUserId} not found`); + + if (!chat.member_bot_ids.includes(input.botId)) { + return { update_id: 0 }; + } + ts.chats.update(chat.id, { + member_bot_ids: chat.member_bot_ids.filter((id) => id !== input.botId), + }); + + const dispatcher = getDispatcher(store); + const botUser = serializeBotAsUser(bot); + const payload: WireChatMemberUpdated = { + chat: serializeChat(chat), + from: serializeUser(by), + date: Math.floor(Date.now() / 1000), + old_chat_member: { status: "member", user: botUser }, + new_chat_member: { status: "left", user: botUser }, + }; + const upd = dispatcher.enqueue(input.botId, "my_chat_member", payload); + return { update_id: upd.update_id }; +} + +export interface SimulateReactionInput { + chatId: number; + messageId: number; + userId: number; + reaction: Array<{ type: "emoji"; emoji: string } | { type: "custom_emoji"; custom_emoji_id: string }>; +} + +export function simulateReaction( + store: Store, + input: SimulateReactionInput, +): { update_id: number } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + const msg = ts.messages.findBy("chat_id", input.chatId).find((m) => m.message_id === input.messageId); + if (!msg) throw new Error(`message ${input.messageId} not found`); + const user = ts.users.findOneBy("user_id", input.userId); + if (!user) throw new Error(`user ${input.userId} not found`); + + // Upsert this user's reaction row + const existing = ts.reactions + .all() + .find((r) => r.chat_id === input.chatId && r.message_id === input.messageId && r.sender_user_id === input.userId); + const old_reaction = existing?.reaction ?? []; + if (input.reaction.length === 0) { + if (existing) ts.reactions.delete(existing.id); + } else if (existing) { + ts.reactions.update(existing.id, { reaction: input.reaction }); + } else { + ts.reactions.insert({ + chat_id: input.chatId, + message_id: input.messageId, + sender_user_id: input.userId, + sender_bot_id: null, + reaction: input.reaction, + }); + } + + // Aggregate current reactions across all users for the count variant. + const aggregate = new Map(); + for (const r of ts.reactions.findBy("chat_id", input.chatId)) { + if (r.message_id !== input.messageId) continue; + for (const rx of r.reaction) { + const key = rx.type === "emoji" ? `e:${rx.emoji}` : `c:${rx.custom_emoji_id}`; + const cur = aggregate.get(key); + if (cur) cur.total += 1; + else aggregate.set(key, { total: 1, reaction: rx }); + } + } + const reactions_count: WireReactionCount[] = Array.from(aggregate.values()).map((a) => ({ + type: a.reaction, + total_count: a.total, + })); + + // Dispatch message_reaction (per-user) and message_reaction_count + // (anonymous aggregate) to all bots in the chat. + const dispatcher = getDispatcher(store); + let firstUpdateId = 0; + const chatPayload = serializeChat(chat); + const date = Math.floor(Date.now() / 1000); + for (const botId of chat.member_bot_ids) { + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) continue; + const perUser: WireMessageReactionUpdated = { + chat: chatPayload, + message_id: input.messageId, + user: serializeUser(user), + date, + old_reaction, + new_reaction: input.reaction, + }; + const upd = dispatcher.enqueue(botId, "message_reaction", perUser); + if (firstUpdateId === 0) firstUpdateId = upd.update_id; + const countPayload: WireMessageReactionCountUpdated = { + chat: chatPayload, + message_id: input.messageId, + date, + reactions: reactions_count, + }; + dispatcher.enqueue(botId, "message_reaction_count", countPayload); + } + + return { update_id: firstUpdateId }; +} + +export function getDraftHistory( + store: Store, + input: { chatId: number; draftId: number; botId?: number }, +): Array<{ seq: number; text: string; entities?: unknown[]; bot_id: number }> { + const ts = getTelegramStore(store); + return ts.draftSnapshots + .findBy("chat_id", input.chatId) + .filter((s) => s.draft_id === input.draftId && (input.botId === undefined || s.bot_id === input.botId)) + .sort((a, b) => a.seq - b.seq) + .map((s) => ({ seq: s.seq, text: s.text, entities: s.entities, bot_id: s.bot_id })); +} + +export function simulateEditedUserMessage( + store: Store, + input: { chatId: number; messageId: number; userId: number; text: string; messageThreadId?: number }, +): { update_id: number } { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", input.chatId); + if (!chat) throw new Error(`chat ${input.chatId} not found`); + const msg = ts.messages.findBy("chat_id", input.chatId).find((m) => m.message_id === input.messageId); + if (!msg) throw new Error(`message ${input.messageId} not found`); + if (msg.from_user_id !== input.userId) throw new Error("cannot edit message from another user"); + const entities = parseEntities(input.text); + ts.messages.update(msg.id, { text: input.text, entities, edited_date: Math.floor(Date.now() / 1000) }); + + const dispatcher = getDispatcher(store); + const updated = ts.messages.get(msg.id)!; + let firstUpdateId = 0; + for (const botId of chat.member_bot_ids) { + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) continue; + if (chat.type !== "private" && !shouldBotSeeGroupMessage(input.text, entities, bot)) continue; + const upd = dispatcher.enqueue(botId, "edited_message", serializeMessage(updated, { store })); + if (firstUpdateId === 0) firstUpdateId = upd.update_id; + } + return { update_id: firstUpdateId }; +} + +export function getSentMessages(store: Store, chatId: number): WireMessage[] { + const ts = getTelegramStore(store); + return ts.messages + .findBy("chat_id", chatId) + .filter((m) => m.from_bot_id !== null && !m.deleted) + .sort((a, b) => a.message_id - b.message_id) + .map((m) => serializeMessage(m, { store })); +} + +export function getAllMessages(store: Store, chatId: number): WireMessage[] { + const ts = getTelegramStore(store); + return ts.messages + .findBy("chat_id", chatId) + .filter((m) => !m.deleted) + .sort((a, b) => a.message_id - b.message_id) + .map((m) => serializeMessage(m, { store })); +} + +export function controlRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + + app.post(telegramPaths.reset(), (c: Context) => { + store.reset(); + return c.json({ ok: true }); + }); + + app.post(telegramPaths.bots(), async (c) => { + const r = await parseJsonBody(c, zCreateBotInput); + if (!r.ok) return r.response; + const bot = createBot(store, r.data); + return c.json({ ok: true, bot: toBotDto(bot) }); + }); + + app.get(telegramPaths.bots(), (c) => { + const ts = getTelegramStore(store); + return c.json({ ok: true, bots: ts.bots.all().map(toBotDto) }); + }); + + app.post(telegramPaths.users(), async (c) => { + const r = await parseJsonBody(c, zCreateUserInput); + if (!r.ok) return r.response; + const user = createUser(store, r.data); + return c.json({ ok: true, user: serializeUser(user) }); + }); + + app.post(telegramPaths.privateChat(), async (c) => { + const r = await parseJsonBody(c, zCreatePrivateChatInput); + if (!r.ok) return r.response; + const chat = createPrivateChat(store, r.data); + return c.json({ ok: true, chat: serializeChat(chat) }); + }); + + app.post(telegramPaths.groupChat(), async (c) => { + const r = await parseJsonBody(c, zCreateGroupChatInput); + if (!r.ok) return r.response; + const chat = createGroupChat(store, r.data); + return c.json({ ok: true, chat: serializeChat(chat) }); + }); + + app.post(telegramPaths.chatPromote(":chatId"), async (c) => { + const r = await parseJsonBody(c, zPromoteChatMemberInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const chat = promoteChatMember(store, { + chatId, + userId: r.data.userId, + botId: r.data.botId, + demote: r.data.demote === true, + }); + return c.json({ ok: true, chat: serializeChat(chat) }); + }); + + app.post(telegramPaths.chatMessages(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateUserMessageInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = simulateUserMessage(store, { + chatId, + userId: r.data.userId, + text: r.data.text, + replyToMessageId: r.data.replyToMessageId, + messageThreadId: r.data.messageThreadId, + }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.chatPhotos(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateUserPhotoInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const photoBytes = r.data.photoBase64 !== undefined + ? Buffer.from(r.data.photoBase64, "base64") + : r.data.photo!.bytes; + const result = simulateUserPhoto(store, { + chatId, + userId: r.data.userId, + photoBytes, + mimeType: r.data.mimeType, + caption: r.data.caption, + }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.chatCallbacks(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateCallbackInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = simulateCallback(store, { + chatId, + userId: r.data.userId, + messageId: r.data.messageId, + callbackData: r.data.data ?? r.data.callbackData ?? "", + }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.chatEdits(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateEditedUserMessageInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = simulateEditedUserMessage(store, { + chatId, + messageId: r.data.messageId, + userId: r.data.userId, + text: r.data.text, + messageThreadId: r.data.messageThreadId, + }); + return c.json({ ok: true, ...result }); + }); + + app.get(telegramPaths.chatMessages(":chatId"), (c) => { + const chatId = Number(c.req.param("chatId")); + const scope = c.req.query("scope") ?? "all"; + const items = scope === "bot" ? getSentMessages(store, chatId) : getAllMessages(store, chatId); + return c.json({ ok: true, messages: items }); + }); + + app.post(telegramPaths.chatAddBot(":chatId"), async (c) => { + const r = await parseJsonBody(c, zChatMembershipInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = addBotToChat(store, { chatId, botId: r.data.botId, byUserId: r.data.byUserId }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.chatRemoveBot(":chatId"), async (c) => { + const r = await parseJsonBody(c, zChatMembershipInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = removeBotFromChat(store, { chatId, botId: r.data.botId, byUserId: r.data.byUserId }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.chatReactions(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateReactionInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = simulateReaction(store, { + chatId, + messageId: r.data.messageId, + userId: r.data.userId, + reaction: r.data.reaction, + }); + return c.json({ ok: true, ...result }); + }); + + app.get(telegramPaths.chatDraft(":chatId", ":draftId"), (c) => { + const chatId = Number(c.req.param("chatId")); + const draftId = Number(c.req.param("draftId")); + const snapshots = getDraftHistory(store, { chatId, draftId }); + return c.json({ ok: true, snapshots }); + }); + + app.post(telegramPaths.channel(), async (c) => { + const r = await parseJsonBody(c, zCreateChannelInput); + if (!r.ok) return r.response; + const chat = createChannel(store, r.data); + return c.json({ ok: true, chat: serializeChat(chat) }); + }); + + app.post(telegramPaths.supergroup(), async (c) => { + const r = await parseJsonBody(c, zCreateSupergroupInput); + if (!r.ok) return r.response; + const chat = createSupergroup(store, r.data); + return c.json({ ok: true, chat: serializeChat(chat) }); + }); + + app.post(telegramPaths.chatTopics(":chatId"), async (c) => { + const r = await parseJsonBody(c, zCreateForumTopicControlBody); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = createForumTopic(store, { chatId, name: r.data.name }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.channelPosts(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateChannelPostInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const photoBytes = + r.data.photo_bytes_base64 !== undefined + ? Buffer.from(r.data.photo_bytes_base64, "base64") + : undefined; + const result = simulateChannelPost(store, { + chatId, + text: r.data.text, + entities: r.data.entities, + caption: r.data.caption, + photoBytes, + replyToMessageId: r.data.reply_to_message_id, + messageThreadId: r.data.message_thread_id, + }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.channelPostEdits(":chatId"), async (c) => { + const r = await parseJsonBody(c, zEditChannelPostInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const result = simulateChannelPost(store, { + chatId, + text: r.data.text, + caption: r.data.caption, + edited: true, + existingMessageId: r.data.messageId, + }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.chatMedia(":chatId"), async (c) => { + const r = await parseJsonBody(c, zSimulateUserMediaInput); + if (!r.ok) return r.response; + const chatId = Number(c.req.param("chatId")); + const bytes = r.data.bytesBase64 !== undefined + ? Buffer.from(r.data.bytesBase64, "base64") + : r.data.file!.bytes; + const result = simulateUserMedia(store, { + chatId, + userId: r.data.userId, + kind: r.data.kind, + bytes, + mimeType: r.data.mimeType, + caption: r.data.caption, + duration: r.data.duration, + width: r.data.width, + height: r.data.height, + fileName: r.data.fileName, + messageThreadId: r.data.messageThreadId, + }); + return c.json({ ok: true, ...result }); + }); + + app.post(telegramPaths.faults(), async (c) => { + const r = await parseJsonBody(c, zInjectFaultInput); + if (!r.ok) return r.response; + const result = injectFault(store, { + botId: (r.data.bot_id ?? r.data.botId)!, + method: r.data.method ?? "*", + error_code: (r.data.error_code ?? r.data.errorCode)!, + description: r.data.description, + retry_after: r.data.retry_after ?? r.data.retryAfter, + count: r.data.count, + }); + return c.json({ ok: true, ...result }); + }); + + app.delete(telegramPaths.faults(), (c) => { + clearFaults(store); + return c.json({ ok: true }); + }); + + app.get(telegramPaths.callbackById(":id"), (c) => { + const id = c.req.param("id") ?? ""; + const answer = getCallbackAnswer(store, id); + if (!answer) return c.json({ ok: false, error: "callback_query not found" }, 404); + return c.json({ ok: true, ...answer }); + }); +} + +function toBotDto(bot: TelegramBot) { + return { + bot_id: bot.bot_id, + token: bot.token, + username: bot.username, + first_name: bot.first_name, + webhook_url: bot.webhook_url, + commands: bot.commands, + }; +} + +// Re-export for tests +export function registerInlineKeyboardMarkup( + markup: InlineKeyboardMarkup, +): InlineKeyboardMarkup { + return markup; +} diff --git a/packages/@emulators/telegram/src/routes/inspector.ts b/packages/@emulators/telegram/src/routes/inspector.ts new file mode 100644 index 00000000..2cb0361e --- /dev/null +++ b/packages/@emulators/telegram/src/routes/inspector.ts @@ -0,0 +1,301 @@ +import type { Context } from "hono"; +import type { RouteContext, Store } from "@emulators/core"; +import { escapeHtml, renderSettingsPage } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import type { + MessageEntity, + TelegramBot, + TelegramChat, + TelegramMessage, + TelegramUpdate, +} from "../entities.js"; +import { isInlineKeyboardMarkup } from "../types/wire/reply-markup.js"; + +const SERVICE_LABEL = "Telegram"; + +function timeAgo(iso: string): string { + const seconds = Math.floor((Date.now() - new Date(iso).getTime()) / 1000); + if (seconds < 60) return "just now"; + if (seconds < 3600) return `${Math.floor(seconds / 60)}m ago`; + if (seconds < 86400) return `${Math.floor(seconds / 3600)}h ago`; + return `${Math.floor(seconds / 86400)}d ago`; +} + +function renderEntities(text: string, entities?: MessageEntity[]): string { + if (!entities || entities.length === 0) return escapeHtml(text); + const sorted = [...entities].sort((a, b) => a.offset - b.offset); + let html = ""; + let pos = 0; + for (const e of sorted) { + if (e.offset > pos) html += escapeHtml(text.slice(pos, e.offset)); + const slice = text.slice(e.offset, e.offset + e.length); + const cls = `ent-${e.type}`; + html += `${escapeHtml(slice)}`; + pos = e.offset + e.length; + } + if (pos < text.length) html += escapeHtml(text.slice(pos)); + return html; +} + +function renderPhoto(msg: TelegramMessage, botToken: string): string { + if (!msg.photo || msg.photo.length === 0) return ""; + const last = msg.photo[msg.photo.length - 1]; + const href = `/file/bot${botToken}/photos/${encodeURIComponent(String(msg.chat_id))}/${encodeURIComponent(last.file_id)}`; + return `
photo ${last.width}x${last.height} ${escapeHtml(last.file_id)}
`; +} + +function renderReplyMarkup(msg: TelegramMessage): string { + const rm = msg.reply_markup; + if (!rm) return ""; + if (!isInlineKeyboardMarkup(rm)) return ""; + const buttons = rm.inline_keyboard + .flat() + .map( + (b) => + `${escapeHtml(b.text)}${b.callback_data ? ` → ${escapeHtml(b.callback_data)}` : ""}`, + ) + .join(" "); + return `
${buttons}
`; +} + +function renderDocument(msg: TelegramMessage): string { + if (!msg.document) return ""; + const d = msg.document; + return `
document ${escapeHtml(d.file_name ?? d.file_id)}${d.mime_type ? ` · ${escapeHtml(d.mime_type)}` : ""}${d.file_size ? ` · ${d.file_size}B` : ""}
`; +} + +function renderMessage(msg: TelegramMessage, lookups: Lookups): string { + const from = + msg.from_bot_id !== null + ? lookups.bots.get(msg.from_bot_id) ?? `bot:${msg.from_bot_id}` + : msg.from_user_id !== null + ? lookups.users.get(msg.from_user_id) ?? `user:${msg.from_user_id}` + : "?"; + + const isBot = msg.from_bot_id !== null; + const botBadge = isBot ? ` bot` : ""; + const editedBadge = msg.edited_date ? ` edited` : ""; + const deletedBadge = msg.deleted ? ` deleted` : ""; + const letter = (from[0] ?? "?").toUpperCase(); + + const bot = isBot ? lookups.botsByIdForToken.get(msg.from_bot_id ?? 0) : undefined; + const token = bot?.token ?? lookups.anyToken; + + return `
+ ${escapeHtml(letter)} + ${escapeHtml(from)}${botBadge} + ${timeAgo(msg.created_at)}${editedBadge}${deletedBadge} +
+
${renderEntities(msg.text ?? "", msg.entities)}${msg.caption ? ` ${escapeHtml(msg.caption)}` : ""}
+${renderPhoto(msg, token)} +${renderDocument(msg)} +${renderReplyMarkup(msg)}`; +} + +interface Lookups { + users: Map; + bots: Map; + botsByIdForToken: Map; + anyToken: string; +} + +function buildLookups(store: Store): Lookups { + const ts = getTelegramStore(store); + const users = new Map(); + for (const u of ts.users.all()) { + users.set(u.user_id, u.username ? `@${u.username}` : u.first_name); + } + const bots = new Map(); + const botsByIdForToken = new Map(); + for (const b of ts.bots.all()) { + bots.set(b.bot_id, `@${b.username}`); + botsByIdForToken.set(b.bot_id, b); + } + const anyToken = ts.bots.all()[0]?.token ?? ""; + return { users, bots, botsByIdForToken, anyToken }; +} + +function renderChatLink(ch: TelegramChat, activeId: number, view: string): string { + const active = ch.chat_id === activeId && view === "chats" ? ' class="active"' : ""; + const icon = ch.type === "private" ? "💬" : ch.type === "group" ? "#" : "ch"; + const label = + ch.type === "private" + ? ch.first_name || ch.username || `user:${ch.chat_id}` + : ch.title ?? `chat:${ch.chat_id}`; + return `${escapeHtml(icon)} ${escapeHtml(label)}`; +} + +function renderBotLink(bot: TelegramBot, activeId: number, view: string): string { + const active = bot.bot_id === activeId && view === "bots" ? ' class="active"' : ""; + return `🤖 @${escapeHtml(bot.username)}`; +} + +function renderSidebar(store: Store, view: string, activeId: number): string { + const ts = getTelegramStore(store); + const tabs = ``; + + if (view === "bots") { + const bots = ts.bots.all(); + if (bots.length === 0) return `${tabs}

No bots

`; + return tabs + bots.map((b) => renderBotLink(b, activeId, view)).join("\n"); + } + + const chats = ts.chats.all(); + if (chats.length === 0) return `${tabs}

No chats

`; + return tabs + chats.map((ch) => renderChatLink(ch, activeId, view)).join("\n"); +} + +function renderChatView(store: Store, chatId: number): string { + const ts = getTelegramStore(store); + const chat = ts.chats.findOneBy("chat_id", chatId); + if (!chat) return `

Chat ${chatId} not found

`; + + const messages = ts.messages + .findBy("chat_id", chatId) + .sort((a, b) => a.message_id - b.message_id) + .slice(-50); + + const draftSnapshots = ts.draftSnapshots + .findBy("chat_id", chatId) + .sort((a, b) => (a.draft_id !== b.draft_id ? a.draft_id - b.draft_id : a.seq - b.seq)); + + const lookups = buildLookups(store); + + const header = `
+
${chat.type === "private" ? "💬" : "#"}
+
+
${escapeHtml(chat.title ?? chat.first_name ?? String(chat.chat_id))}
+
${chat.type} · chat_id ${chat.chat_id} · ${chat.member_user_ids.length} users, ${chat.member_bot_ids.length} bots
+
+
`; + + const body = + messages.length === 0 + ? '

No messages yet.

' + : messages.map((m) => renderMessage(m, lookups)).join("\n
\n"); + + const draftsByKey = new Map(); + for (const s of draftSnapshots) { + const key = `${s.draft_id}:${s.bot_id}`; + const list = draftsByKey.get(key) ?? []; + list.push(s); + draftsByKey.set(key, list); + } + + const draftsHtml = + draftSnapshots.length === 0 + ? "" + : `
Streaming drafts ${draftSnapshots.length} snapshots across ${draftsByKey.size} drafts
+${[...draftsByKey.entries()] + .map(([key, list]) => { + const [draftId, botId] = key.split(":"); + const botLabel = lookups.bots.get(Number(botId)) ?? `bot:${botId}`; + const rows = list + .map( + (s) => + `${s.seq}${timeAgo(s.created_at)}${escapeHtml(s.text.slice(0, 160))}${s.text.length > 160 ? "..." : ""}`, + ) + .join(""); + return `
+
draft_id ${escapeHtml(draftId)} · ${escapeHtml(botLabel)}
+ + + ${rows} +
seqattext
+
`; + }) + .join("\n")}`; + + return `
${header}
Messages
${body}${draftsHtml}
`; +} + +function renderBotView(store: Store, botId: number): string { + const ts = getTelegramStore(store); + const bot = ts.bots.findOneBy("bot_id", botId); + if (!bot) return `

Bot ${botId} not found

`; + + const updates = ts.updates + .findBy("for_bot_id", bot.bot_id) + .sort((a, b) => b.update_id - a.update_id) + .slice(0, 30); + + const pending = updates.filter((u) => !u.delivered).length; + + const header = `
+
🤖
+
+
@${escapeHtml(bot.username)}
+
bot_id ${bot.bot_id} · token ${escapeHtml(bot.token)}
+
+
`; + + const webhookRow = bot.webhook_url + ? `Webhook${escapeHtml(bot.webhook_url)}${bot.webhook_secret ? ' with secret' : ""}` + : `Webhooknot set · uses long polling`; + + const config = `
Configuration
+ + + + + ${webhookRow} + +
First name${escapeHtml(bot.first_name)}
Can join groups${bot.can_join_groups}
Read all group messages${bot.can_read_all_group_messages}
Commands${bot.commands.length === 0 ? 'none' : bot.commands.map((c) => `/${escapeHtml(c.command)} — ${escapeHtml(c.description)}`).join("
")}
`; + + const queue = `
Update Queue ${pending} pending / ${updates.length} shown
+${renderUpdateTable(updates)}`; + + return `
${header}${config}${queue}
`; +} + +function renderUpdateTable(updates: TelegramUpdate[]): string { + if (updates.length === 0) return '

No updates yet.

'; + const rows = updates + .map((u) => { + const status = u.delivered + ? `delivered` + : `pending`; + const mode = `${u.delivery_mode}`; + return ` + ${u.update_id} + ${u.type} + ${mode} + ${status} + ${u.delivery_attempts} + ${u.delivery_error ? escapeHtml(u.delivery_error) : ""} + `; + }) + .join(""); + return ` + + ${rows} +
IDTypeModeStatusAttemptsError
`; +} + +export function inspectorRoutes(ctx: RouteContext): void { + const { app, store } = ctx; + + app.get("/", (c: Context) => { + const view = c.req.query("view") === "bots" ? "bots" : "chats"; + const activeId = Number(c.req.query(view === "bots" ? "bot" : "chat") ?? 0); + + const sidebar = renderSidebar(store, view, activeId); + + let body: string; + if (view === "bots") { + const ts = getTelegramStore(store); + const defaultId = activeId || ts.bots.all()[0]?.bot_id || 0; + body = defaultId + ? renderBotView(store, defaultId) + : '

No bots yet. Create one via POST /_emu/telegram/bots.

'; + } else { + const ts = getTelegramStore(store); + const defaultId = activeId || ts.chats.all()[0]?.chat_id || 0; + body = defaultId + ? renderChatView(store, defaultId) + : '

No chats yet. Simulate activity via POST /_emu/telegram/chats/private.

'; + } + + return c.html(renderSettingsPage("Telegram Inspector", sidebar, body, SERVICE_LABEL)); + }); +} diff --git a/packages/@emulators/telegram/src/serializers.ts b/packages/@emulators/telegram/src/serializers.ts new file mode 100644 index 00000000..ebc2059d --- /dev/null +++ b/packages/@emulators/telegram/src/serializers.ts @@ -0,0 +1,158 @@ +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "./store.js"; +import type { + TelegramBot, + TelegramChat, + TelegramMessage, + TelegramUser, +} from "./entities.js"; +import type { + WireBotAsUser, + WireChat, + WireChatFullInfo, + WireMessage, + WireUser, +} from "./types/wire/index.js"; +import type { ChatPermissions } from "./types/store/chat.js"; + +export function resolveBotFromToken(store: Store, token: string): TelegramBot | null { + const ts = getTelegramStore(store); + const direct = ts.bots.findOneBy("token", token); + return direct ?? null; +} + +export function serializeUser(u: TelegramUser): WireUser { + const out: WireUser = { + id: u.user_id, + is_bot: u.is_bot, + first_name: u.first_name, + }; + if (u.last_name) out.last_name = u.last_name; + if (u.username) out.username = u.username; + if (u.language_code) out.language_code = u.language_code; + return out; +} + +export function serializeBotAsUser(b: TelegramBot): WireBotAsUser { + return { + id: b.bot_id, + is_bot: true, + first_name: b.first_name, + username: b.username, + }; +} + +export function serializeChat(ch: TelegramChat): WireChat { + const out: WireChat = { + id: ch.chat_id, + type: ch.type, + }; + if (ch.title) out.title = ch.title; + if (ch.username) out.username = ch.username; + if (ch.first_name) out.first_name = ch.first_name; + if (ch.last_name) out.last_name = ch.last_name; + if (ch.is_forum) out.is_forum = true; + return out; +} + +const DEFAULT_GROUP_PERMISSIONS: ChatPermissions = { + can_send_messages: true, + can_send_audios: true, + can_send_documents: true, + can_send_photos: true, + can_send_videos: true, + can_send_video_notes: true, + can_send_voice_notes: true, + can_send_polls: true, + can_send_other_messages: true, + can_add_web_page_previews: true, + can_change_info: false, + can_invite_users: true, + can_pin_messages: false, + can_manage_topics: false, +}; + +export function serializeChatFullInfo( + ch: TelegramChat, + ctx: { store: Store }, +): WireChatFullInfo { + const out: WireChatFullInfo = { + ...serializeChat(ch), + accent_color_id: ch.accent_color_id ?? 0, + max_reaction_count: ch.max_reaction_count ?? 11, + }; + if (ch.bio) out.bio = ch.bio; + if (ch.description) out.description = ch.description; + if (ch.invite_link) out.invite_link = ch.invite_link; + if (ch.slow_mode_delay !== undefined) out.slow_mode_delay = ch.slow_mode_delay; + if (ch.message_auto_delete_time !== undefined) out.message_auto_delete_time = ch.message_auto_delete_time; + if (ch.has_protected_content) out.has_protected_content = true; + if (ch.linked_chat_id !== undefined) out.linked_chat_id = ch.linked_chat_id; + if (ch.available_reactions) out.available_reactions = ch.available_reactions; + if (ch.type === "group" || ch.type === "supergroup") { + out.permissions = ch.permissions ?? DEFAULT_GROUP_PERMISSIONS; + } + if (ch.pinned_message_id !== undefined) { + const ts = getTelegramStore(ctx.store); + const pinned = ts.messages + .findBy("chat_id", ch.chat_id) + .find((m) => m.message_id === ch.pinned_message_id && !m.deleted); + if (pinned) out.pinned_message = serializeMessage(pinned, { store: ctx.store }); + } + return out; +} + +export function serializeMessage( + msg: TelegramMessage, + ctx: { store: Store; depth?: number }, +): WireMessage { + const ts = getTelegramStore(ctx.store); + const chat = ts.chats.findOneBy("chat_id", msg.chat_id); + const fromUser = msg.from_user_id !== null ? ts.users.findOneBy("user_id", msg.from_user_id) : undefined; + const fromBot = msg.from_bot_id !== null ? ts.bots.findOneBy("bot_id", msg.from_bot_id) : undefined; + const senderChat = + msg.sender_chat_id !== null && msg.sender_chat_id !== undefined + ? ts.chats.findOneBy("chat_id", msg.sender_chat_id) + : undefined; + + const from: WireUser | WireBotAsUser | undefined = fromUser + ? serializeUser(fromUser) + : fromBot + ? serializeBotAsUser(fromBot) + : undefined; + + const out: WireMessage = { + message_id: msg.message_id, + date: msg.date, + chat: chat ? serializeChat(chat) : { id: msg.chat_id, type: "private" }, + }; + if (from) out.from = from; + if (senderChat) out.sender_chat = serializeChat(senderChat); + if (msg.message_thread_id !== undefined) out.message_thread_id = msg.message_thread_id; + if (msg.text !== undefined) out.text = msg.text; + if (msg.entities && msg.entities.length > 0) out.entities = msg.entities; + if (msg.photo && msg.photo.length > 0) out.photo = msg.photo; + if (msg.document) out.document = msg.document; + if (msg.audio) out.audio = msg.audio; + if (msg.voice) out.voice = msg.voice; + if (msg.video) out.video = msg.video; + if (msg.animation) out.animation = msg.animation; + if (msg.sticker) out.sticker = msg.sticker; + if (msg.caption !== undefined) out.caption = msg.caption; + if (msg.caption_entities && msg.caption_entities.length > 0) out.caption_entities = msg.caption_entities; + if (msg.reply_to_message_id !== undefined) { + out.reply_to_message_id = msg.reply_to_message_id; + const depth = ctx.depth ?? 0; + if (depth < 1) { + const quoted = ts.messages + .findBy("chat_id", msg.chat_id) + .find((m) => m.message_id === msg.reply_to_message_id && !m.deleted); + if (quoted) { + out.reply_to_message = serializeMessage(quoted, { store: ctx.store, depth: depth + 1 }); + } + } + } + if (msg.reply_markup) out.reply_markup = msg.reply_markup; + if (msg.edited_date !== undefined) out.edit_date = msg.edited_date; + return out; +} diff --git a/packages/@emulators/telegram/src/services/media.ts b/packages/@emulators/telegram/src/services/media.ts new file mode 100644 index 00000000..8d56b18e --- /dev/null +++ b/packages/@emulators/telegram/src/services/media.ts @@ -0,0 +1,227 @@ +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; +import { nextFileId } from "../ids.js"; +import type { + PhotoSize, + TelegramAnimation, + TelegramAudio, + TelegramChat, + TelegramDocument, + TelegramSticker, + TelegramVideo, + TelegramVoice, +} from "../entities.js"; + +export type MediaKind = "video" | "animation" | "audio" | "voice" | "sticker" | "document"; + +export interface MediaFieldInput { + kind: MediaKind | string; + file_id: string; + file_unique_id: string; + file_size?: number; + width?: number; + height?: number; + duration?: number; + mime_type?: string; + file_name?: string; + performer?: string; + title?: string; + emoji?: string; + is_animated?: boolean; + is_video?: boolean; +} + +// Discriminated union across every kind buildMediaField can emit. +// Each branch is the canonical Bot API wire shape for that field — +// no `kind` tag (the key of TelegramMessage that holds it acts as +// the discriminator in the store row). +export type WireMediaField = + | TelegramVideo + | TelegramAnimation + | TelegramAudio + | TelegramVoice + | TelegramSticker + | TelegramDocument; + +/** Build the media field object (video/animation/audio/voice/sticker/document) + * that lives on a Message, normalised so both the Bot API surface and + * the control-plane simulate routes emit identical shapes. */ +export function buildMediaField(input: MediaFieldInput): WireMediaField { + const base = { + file_id: input.file_id, + file_unique_id: input.file_unique_id, + file_size: input.file_size, + }; + switch (input.kind) { + case "video": + case "animation": + return { + ...base, + width: input.width ?? 0, + height: input.height ?? 0, + duration: input.duration ?? 0, + mime_type: input.mime_type, + file_name: input.file_name, + }; + case "audio": + return { + ...base, + duration: input.duration ?? 0, + performer: input.performer, + title: input.title, + mime_type: input.mime_type, + file_name: input.file_name, + }; + case "voice": + return { + ...base, + duration: input.duration ?? 0, + mime_type: input.mime_type, + }; + case "sticker": + return { + ...base, + width: input.width ?? 0, + height: input.height ?? 0, + is_animated: input.is_animated ?? false, + is_video: input.is_video ?? false, + emoji: input.emoji, + }; + case "document": + default: + return { + ...base, + file_name: input.file_name, + mime_type: input.mime_type, + }; + } +} + +/** Allocate the next message_id for a chat and advance the per-chat counter. + * Consolidates the read-modify-write that was duplicated at 8+ call sites. */ +export function allocateMessageId(store: Store, chat: TelegramChat): number { + const ts = getTelegramStore(store); + const messageId = chat.next_message_id; + ts.chats.update(chat.id, { next_message_id: messageId + 1 }); + return messageId; +} + +export function buildPhotoSizes( + store: Store, + bytes: Buffer, + botId: number, + chatId: number, +): { sizes: PhotoSize[]; originalFileId: string; originalUniqueId: string } { + const { width, height } = readImageDimensions(bytes); + const tiers: Array<{ tier: string; w: number; h: number }> = [ + { tier: "s", w: Math.min(width, 160), h: Math.max(1, Math.round((Math.min(width, 160) / width) * height)) }, + { tier: "m", w: Math.min(width, 800), h: Math.max(1, Math.round((Math.min(width, 800) / width) * height)) }, + { tier: "x", w: width, h: height }, + ]; + const sizes: PhotoSize[] = []; + let originalFileId = ""; + let originalUniqueId = ""; + for (const t of tiers) { + const { file_id, file_unique_id } = nextFileId(store, botId, chatId, t.tier); + sizes.push({ + file_id, + file_unique_id, + width: t.w, + height: t.h, + file_size: bytes.length, + }); + if (t.tier === "x") { + originalFileId = file_id; + originalUniqueId = file_unique_id; + } + } + return { sizes, originalFileId, originalUniqueId }; +} + +export function readImageDimensions(bytes: Buffer): { width: number; height: number } { + // PNG: width at offset 16 (BE uint32), height at 20 + if ( + bytes.length >= 24 && + bytes[0] === 0x89 && + bytes[1] === 0x50 && + bytes[2] === 0x4e && + bytes[3] === 0x47 + ) { + return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) }; + } + // JPEG: walk markers + if (bytes.length >= 2 && bytes[0] === 0xff && bytes[1] === 0xd8) { + let i = 2; + while (i < bytes.length) { + if (bytes[i] !== 0xff) { + i += 1; + continue; + } + const marker = bytes[i + 1]; + if ( + (marker >= 0xc0 && marker <= 0xc3) || + (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || + (marker >= 0xcd && marker <= 0xcf) + ) { + const height = bytes.readUInt16BE(i + 5); + const width = bytes.readUInt16BE(i + 7); + return { width, height }; + } + const segLen = bytes.readUInt16BE(i + 2); + i += 2 + segLen; + } + } + // GIF + if ( + bytes.length >= 10 && + bytes[0] === 0x47 && + bytes[1] === 0x49 && + bytes[2] === 0x46 + ) { + return { width: bytes.readUInt16LE(6), height: bytes.readUInt16LE(8) }; + } + // WebP (VP8/VP8L/VP8X) + if ( + bytes.length >= 30 && + bytes.slice(0, 4).toString("ascii") === "RIFF" && + bytes.slice(8, 12).toString("ascii") === "WEBP" + ) { + const chunk = bytes.slice(12, 16).toString("ascii"); + if (chunk === "VP8X") { + const w = (bytes.readUIntLE(24, 3) + 1) & 0xffffff; + const h = (bytes.readUIntLE(27, 3) + 1) & 0xffffff; + return { width: w, height: h }; + } + if (chunk === "VP8 ") { + return { width: bytes.readUInt16LE(26) & 0x3fff, height: bytes.readUInt16LE(28) & 0x3fff }; + } + if (chunk === "VP8L") { + const b = bytes.slice(21, 25); + const w = ((b[0] | ((b[1] & 0x3f) << 8)) + 1) & 0xffff; + const h = ((((b[1] & 0xc0) >> 6) | (b[2] << 2) | ((b[3] & 0x0f) << 10)) + 1) & 0xffff; + return { width: w, height: h }; + } + } + // Fallback for unknown formats (tests often pass tiny bytes) + return { width: 100, height: 100 }; +} + +export function defaultMimeForMediaKind(kind: string): string { + switch (kind) { + case "video": + return "video/mp4"; + case "audio": + return "audio/mpeg"; + case "voice": + return "audio/ogg"; + case "animation": + return "video/mp4"; + case "sticker": + return "image/webp"; + case "document": + return "application/octet-stream"; + default: + return "application/octet-stream"; + } +} diff --git a/packages/@emulators/telegram/src/services/sweeper.ts b/packages/@emulators/telegram/src/services/sweeper.ts new file mode 100644 index 00000000..05b2a316 --- /dev/null +++ b/packages/@emulators/telegram/src/services/sweeper.ts @@ -0,0 +1,31 @@ +import type { Store } from "@emulators/core"; +import { getTelegramStore } from "../store.js"; + +// Retention caps. Tests create ~100 files / ~50 callbacks, so caps of +// a few thousand are generous enough to never trip during realistic +// test runs while preventing unbounded growth in long-lived processes. +const MAX_FILES = 2000; +const MAX_CALLBACK_QUERIES = 500; +const MAX_DRAFT_SNAPSHOTS = 2000; +const MAX_FAULTS = 500; + +function trimByAge(all: T[], max: number): T[] { + if (all.length <= max) return []; + // Oldest first — Collection autoId is monotonic, so id ordering == age. + return all.sort((a, b) => a.id - b.id).slice(0, all.length - max); +} + +/** Prune unbounded per-store collections. Safe to call from any write + * path; delegates to the collection's own delete() so indexes stay + * consistent. */ +export function sweep(store: Store): void { + const ts = getTelegramStore(store); + for (const victim of trimByAge(ts.files.all(), MAX_FILES)) ts.files.delete(victim.id); + for (const victim of trimByAge(ts.callbackQueries.all(), MAX_CALLBACK_QUERIES)) { + ts.callbackQueries.delete(victim.id); + } + for (const victim of trimByAge(ts.draftSnapshots.all(), MAX_DRAFT_SNAPSHOTS)) { + ts.draftSnapshots.delete(victim.id); + } + for (const victim of trimByAge(ts.faults.all(), MAX_FAULTS)) ts.faults.delete(victim.id); +} diff --git a/packages/@emulators/telegram/src/store.ts b/packages/@emulators/telegram/src/store.ts new file mode 100644 index 00000000..4a094e9f --- /dev/null +++ b/packages/@emulators/telegram/src/store.ts @@ -0,0 +1,48 @@ +import { Store, type Collection } from "@emulators/core"; +import type { + TelegramBot, + TelegramUser, + TelegramChat, + TelegramMessage, + TelegramFile, + TelegramCallbackQuery, + TelegramUpdate, + TelegramDraftSnapshot, + TelegramReaction, + TelegramFault, + TelegramForumTopic, +} from "./entities.js"; + +export interface TelegramStore { + bots: Collection; + users: Collection; + chats: Collection; + messages: Collection; + files: Collection; + callbackQueries: Collection; + updates: Collection; + draftSnapshots: Collection; + reactions: Collection; + faults: Collection; + forumTopics: Collection; +} + +export function getTelegramStore(store: Store): TelegramStore { + return { + bots: store.collection("telegram.bots", ["bot_id", "token", "username"]), + users: store.collection("telegram.users", ["user_id", "username"]), + chats: store.collection("telegram.chats", ["chat_id", "type"]), + messages: store.collection("telegram.messages", ["chat_id", "message_id", "from_bot_id"]), + files: store.collection("telegram.files", ["file_id", "file_unique_id"]), + callbackQueries: store.collection("telegram.callback_queries", ["callback_query_id"]), + updates: store.collection("telegram.updates", ["for_bot_id", "update_id", "delivered"]), + draftSnapshots: store.collection("telegram.draft_snapshots", [ + "chat_id", + "draft_id", + "bot_id", + ]), + reactions: store.collection("telegram.reactions", ["chat_id", "message_id"]), + faults: store.collection("telegram.faults", ["bot_id", "method"]), + forumTopics: store.collection("telegram.forum_topics", ["chat_id", "message_thread_id"]), + }; +} diff --git a/packages/@emulators/telegram/src/test.ts b/packages/@emulators/telegram/src/test.ts new file mode 100644 index 00000000..d1b46d6f --- /dev/null +++ b/packages/@emulators/telegram/src/test.ts @@ -0,0 +1,496 @@ +import { telegramPaths } from "./paths.js"; +import type { + WireMessageEntity, + WireReplyMarkup, +} from "./types/wire/index.js"; + +/** + * Programmatic test client for the Telegram emulator. + * + * Create one with a running emulator URL: + * + * const emu = await createEmulator({ service: "telegram", port: 4011 }); + * const tg = createTelegramTestClient(emu.url); + * const bot = await tg.createBot({ username: "trip_test_bot" }); + * const user = await tg.createUser({ first_name: "Alice" }); + * const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + * await tg.sendUserMessage({ chatId: dm.id, userId: user.id, text: "/connect ABC" }); + */ + +export interface TestBot { + bot_id: number; + token: string; + username: string; + first_name: string; + webhook_url: string | null; + commands: Array<{ command: string; description: string }>; +} + +export interface TestUser { + id: number; + is_bot: false; + first_name: string; + last_name?: string; + username?: string; + language_code?: string; +} + +export interface TestChat { + id: number; + type: "private" | "group" | "supergroup" | "channel"; + title?: string; + username?: string; + first_name?: string; + last_name?: string; +} + +export interface TestMessage { + message_id: number; + chat: TestChat; + from?: { id: number; is_bot: boolean; first_name: string; username?: string }; + date: number; + text?: string; + entities?: WireMessageEntity[]; + photo?: Array<{ file_id: string; file_unique_id: string; width: number; height: number; file_size?: number }>; + caption?: string; + reply_to_message_id?: number; + reply_markup?: WireReplyMarkup; + edit_date?: number; +} + +export interface TelegramTestClient { + baseUrl: string; + + createBot(input: { + username: string; + name?: string; + first_name?: string; + token?: string; + can_join_groups?: boolean; + can_read_all_group_messages?: boolean; + commands?: Array<{ command: string; description: string }>; + }): Promise; + + createUser(input: { + first_name: string; + last_name?: string; + username?: string; + language_code?: string; + }): Promise; + + createPrivateChat(input: { botId: number; userId: number }): Promise; + + createGroupChat(input: { + title: string; + type?: "group" | "supergroup"; + memberIds: number[]; + botIds: number[]; + }): Promise; + + sendUserMessage(input: { + chatId: number; + userId: number; + text: string; + replyToMessageId?: number; + }): Promise<{ message_id: number; update_id: number }>; + + sendUserPhoto(input: { + chatId: number; + userId: number; + photoBytes: Buffer | Uint8Array; + mimeType?: string; + caption?: string; + }): Promise<{ message_id: number; update_id: number; file_id: string }>; + + clickInlineButton(input: { + chatId: number; + userId: number; + messageId: number; + callbackData: string; + }): Promise<{ callback_query_id: string; update_id: number }>; + + editUserMessage(input: { + chatId: number; + messageId: number; + userId: number; + text: string; + }): Promise<{ update_id: number }>; + + addBotToChat(input: { chatId: number; botId: number; byUserId: number }): Promise<{ update_id: number }>; + removeBotFromChat(input: { chatId: number; botId: number; byUserId: number }): Promise<{ update_id: number }>; + + promoteChatMember(input: { + chatId: number; + userId?: number; + botId?: number; + demote?: boolean; + }): Promise; + + reactToMessage(input: { + chatId: number; + messageId: number; + userId: number; + reaction: Array<{ type: "emoji"; emoji: string } | { type: "custom_emoji"; custom_emoji_id: string }>; + }): Promise<{ update_id: number }>; + + createSupergroup(input: { title: string; memberIds: number[]; botIds: number[] }): Promise; + createChannel(input: { + title: string; + username?: string; + memberBotIds: number[]; + memberUserIds?: number[]; + }): Promise; + createForumTopic(input: { chatId: number; name: string }): Promise<{ message_thread_id: number; name: string }>; + + postAsChannel(input: { + chatId: number; + text?: string; + caption?: string; + replyToMessageId?: number; + messageThreadId?: number; + }): Promise<{ message_id: number; update_id: number }>; + + editChannelPost(input: { + chatId: number; + messageId: number; + text?: string; + caption?: string; + }): Promise<{ message_id: number; update_id: number }>; + + sendUserMedia(input: { + chatId: number; + userId: number; + kind: "video" | "audio" | "voice" | "animation" | "sticker" | "document"; + bytes: Buffer | Uint8Array; + mimeType?: string; + caption?: string; + duration?: number; + width?: number; + height?: number; + fileName?: string; + messageThreadId?: number; + }): Promise<{ message_id: number; update_id: number; file_id: string }>; + + injectFault(input: { + botId: number; + method: string; + errorCode: number; + description?: string; + retryAfter?: number; + count?: number; + }): Promise<{ fault_id: number }>; + + clearFaults(): Promise; + + getCallbackAnswer(input: { callbackQueryId: string }): Promise<{ + callback_query_id: string; + answered: boolean; + answer_text?: string; + answer_show_alert?: boolean; + answer_url?: string; + answer_cache_time?: number; + } | null>; + + getDraftHistory(input: { chatId: number; draftId: number }): Promise< + Array<{ seq: number; text: string; entities?: WireMessageEntity[]; bot_id: number }> + >; + + getSentMessages(input: { chatId: number }): Promise; + getAllMessages(input: { chatId: number }): Promise; + + reset(): Promise; +} + +// T is the expected non-envelope keys of the response body (e.g. +// { bot: TestBot } for the create-bot endpoint). The envelope itself +// adds `ok: true` on success. The client calls postJson<{ X: Y }>() +// per endpoint — no index signature, so typos surface at the call. +type JsonResponse = T & { ok: true }; + +type FetchImpl = (input: string, init?: RequestInit) => Promise; + +async function postJsonOuter( + fetchImpl: FetchImpl, + baseUrl: string, + path: string, + body: unknown, +): Promise> { + const res = await fetchImpl(`${baseUrl}${path}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); + if (!res.ok) { + const text = await res.text(); + throw new Error(`POST ${path} failed: ${res.status} ${text}`); + } + const parsed = (await res.json()) as JsonResponse; + if (!parsed.ok) throw new Error(`POST ${path} returned ok=false: ${JSON.stringify(parsed)}`); + return parsed; +} + +async function getJsonOuter(fetchImpl: FetchImpl, baseUrl: string, path: string): Promise> { + const res = await fetchImpl(`${baseUrl}${path}`); + if (!res.ok) { + const text = await res.text(); + throw new Error(`GET ${path} failed: ${res.status} ${text}`); + } + return (await res.json()) as JsonResponse; +} + +export interface CreateTelegramTestClientOptions { + /** Override the HTTP client used for control-plane calls. Useful in + * tests that want to drive a Hono app in-process via `app.request` + * without booting a real HTTP server. Defaults to global fetch. */ + fetchImpl?: FetchImpl; +} + +export function createTelegramTestClient( + baseUrl: string, + options?: CreateTelegramTestClientOptions, +): TelegramTestClient { + const stripTrailingSlash = baseUrl.replace(/\/+$/, ""); + const root = stripTrailingSlash; + const fetchImpl: FetchImpl = options?.fetchImpl ?? ((input, init) => fetch(input, init)); + // Local closures shadowing the top-level postJson/getJson names so the + // existing call sites below still look like postJson(root, ...) without + // needing to thread fetchImpl through each one. + const postJson = (r: string, path: string, body: unknown) => + postJsonOuter(fetchImpl, r, path, body); + const getJson = (r: string, path: string) => getJsonOuter(fetchImpl, r, path); + + return { + baseUrl: root, + + async createBot(input) { + const r = await postJson<{ bot: TestBot }>(root, telegramPaths.bots(), input); + return r.bot; + }, + + async createUser(input) { + const r = await postJson<{ user: TestUser }>(root, telegramPaths.users(), input); + return r.user; + }, + + async createPrivateChat(input) { + const r = await postJson<{ chat: TestChat }>(root, telegramPaths.privateChat(), input); + return r.chat; + }, + + async createGroupChat(input) { + const r = await postJson<{ chat: TestChat }>(root, telegramPaths.groupChat(), input); + return r.chat; + }, + + async sendUserMessage(input) { + const r = await postJson<{ message_id: number; update_id: number }>( + root, + telegramPaths.chatMessages(input.chatId), + { + userId: input.userId, + text: input.text, + replyToMessageId: input.replyToMessageId, + }, + ); + return { message_id: r.message_id, update_id: r.update_id }; + }, + + async sendUserPhoto(input) { + const buf = Buffer.isBuffer(input.photoBytes) ? input.photoBytes : Buffer.from(input.photoBytes); + const r = await postJson<{ message_id: number; update_id: number; file_id: string }>( + root, + telegramPaths.chatPhotos(input.chatId), + { + userId: input.userId, + photoBase64: buf.toString("base64"), + mimeType: input.mimeType, + caption: input.caption, + }, + ); + return { + message_id: r.message_id, + update_id: r.update_id, + file_id: r.file_id, + }; + }, + + async clickInlineButton(input) { + const r = await postJson<{ callback_query_id: string; update_id: number }>( + root, + telegramPaths.chatCallbacks(input.chatId), + { + userId: input.userId, + messageId: input.messageId, + data: input.callbackData, + }, + ); + return { callback_query_id: r.callback_query_id, update_id: r.update_id }; + }, + + async editUserMessage(input) { + const r = await postJson<{ update_id: number }>( + root, + telegramPaths.chatEdits(input.chatId), + { messageId: input.messageId, userId: input.userId, text: input.text }, + ); + return { update_id: r.update_id }; + }, + + async addBotToChat(input) { + const r = await postJson<{ update_id: number }>( + root, + telegramPaths.chatAddBot(input.chatId), + { botId: input.botId, byUserId: input.byUserId }, + ); + return { update_id: r.update_id }; + }, + + async removeBotFromChat(input) { + const r = await postJson<{ update_id: number }>( + root, + telegramPaths.chatRemoveBot(input.chatId), + { botId: input.botId, byUserId: input.byUserId }, + ); + return { update_id: r.update_id }; + }, + + async promoteChatMember(input) { + await postJson<{ chat: TestChat }>(root, telegramPaths.chatPromote(input.chatId), { + userId: input.userId, + botId: input.botId, + demote: input.demote, + }); + }, + + async reactToMessage(input) { + const r = await postJson<{ update_id: number }>( + root, + telegramPaths.chatReactions(input.chatId), + { messageId: input.messageId, userId: input.userId, reaction: input.reaction }, + ); + return { update_id: r.update_id }; + }, + + async createSupergroup(input) { + const r = await postJson<{ chat: TestChat }>(root, telegramPaths.supergroup(), input); + return r.chat; + }, + + async createChannel(input) { + const r = await postJson<{ chat: TestChat }>(root, telegramPaths.channel(), input); + return r.chat; + }, + + async createForumTopic(input) { + const r = await postJson<{ message_thread_id: number; name: string }>( + root, + telegramPaths.chatTopics(input.chatId), + { name: input.name }, + ); + return { message_thread_id: r.message_thread_id, name: r.name }; + }, + + async postAsChannel(input) { + const r = await postJson<{ message_id: number; update_id: number }>( + root, + telegramPaths.channelPosts(input.chatId), + { + text: input.text, + caption: input.caption, + reply_to_message_id: input.replyToMessageId, + message_thread_id: input.messageThreadId, + }, + ); + return { message_id: r.message_id, update_id: r.update_id }; + }, + + async editChannelPost(input) { + const r = await postJson<{ message_id: number; update_id: number }>( + root, + telegramPaths.channelPostEdits(input.chatId), + { messageId: input.messageId, text: input.text, caption: input.caption }, + ); + return { message_id: r.message_id, update_id: r.update_id }; + }, + + async sendUserMedia(input) { + const buf = Buffer.isBuffer(input.bytes) ? input.bytes : Buffer.from(input.bytes); + const r = await postJson<{ message_id: number; update_id: number; file_id: string }>( + root, + telegramPaths.chatMedia(input.chatId), + { + userId: input.userId, + kind: input.kind, + bytesBase64: buf.toString("base64"), + mimeType: input.mimeType, + caption: input.caption, + duration: input.duration, + width: input.width, + height: input.height, + fileName: input.fileName, + messageThreadId: input.messageThreadId, + }, + ); + return { message_id: r.message_id, update_id: r.update_id, file_id: r.file_id }; + }, + + async injectFault(input) { + const r = await postJson<{ fault_id: number }>(root, telegramPaths.faults(), { + bot_id: input.botId, + method: input.method, + error_code: input.errorCode, + description: input.description, + retry_after: input.retryAfter, + count: input.count, + }); + return { fault_id: r.fault_id }; + }, + + async clearFaults() { + const res = await fetchImpl(`${root}${telegramPaths.faults()}`, { method: "DELETE" }); + if (!res.ok) throw new Error(`DELETE ${telegramPaths.faults()} failed: ${res.status}`); + }, + + async getCallbackAnswer(input) { + const path = telegramPaths.callbackById(encodeURIComponent(input.callbackQueryId)); + const res = await fetchImpl(`${root}${path}`); + if (res.status === 404) return null; + if (!res.ok) throw new Error(`GET ${path} failed: ${res.status}`); + return (await res.json()) as { + callback_query_id: string; + answered: boolean; + answer_text?: string; + answer_show_alert?: boolean; + answer_url?: string; + answer_cache_time?: number; + }; + }, + + async getDraftHistory(input) { + const r = await getJson<{ + snapshots: Array<{ seq: number; text: string; entities?: WireMessageEntity[]; bot_id: number }>; + }>(root, telegramPaths.chatDraft(input.chatId, input.draftId)); + return r.snapshots; + }, + + async getSentMessages(input) { + const r = await getJson<{ messages: TestMessage[] }>( + root, + `${telegramPaths.chatMessages(input.chatId)}?scope=bot`, + ); + return r.messages; + }, + + async getAllMessages(input) { + const r = await getJson<{ messages: TestMessage[] }>( + root, + `${telegramPaths.chatMessages(input.chatId)}?scope=all`, + ); + return r.messages; + }, + + async reset() { + await postJson>(root, telegramPaths.reset(), {}); + }, + }; +} diff --git a/packages/@emulators/telegram/src/types/index.ts b/packages/@emulators/telegram/src/types/index.ts new file mode 100644 index 00000000..e6fe38bd --- /dev/null +++ b/packages/@emulators/telegram/src/types/index.ts @@ -0,0 +1,2 @@ +export * from "./wire/index.js"; +export * from "./store/index.js"; diff --git a/packages/@emulators/telegram/src/types/request/index.ts b/packages/@emulators/telegram/src/types/request/index.ts new file mode 100644 index 00000000..61e72862 --- /dev/null +++ b/packages/@emulators/telegram/src/types/request/index.ts @@ -0,0 +1,48 @@ +// Inbound Bot API request body types. Each is derived from its zod +// schema in ../validators — the schema is the single source of truth. +// Re-exported here so callers don't have to know about zod. + +export type { + SendMessageBody, + EditMessageTextBody, + DeleteMessageBody, + EditMessageReplyMarkupBody, +} from "../validators/send-message.js"; + +export type { + SendPhotoBody, + SendVideoBody, + SendAudioBody, + SendVoiceBody, + SendAnimationBody, + SendStickerBody, + SendDocumentBody, + MediaKind, +} from "../validators/send-media.js"; + +export type { AnswerCallbackQueryBody } from "../validators/callback-query.js"; + +export type { + GetChatBody, + GetChatMemberBody, + GetChatAdministratorsBody, + GetChatMemberCountBody, + SendChatActionBody, +} from "../validators/chats.js"; + +export type { + CreateForumTopicBody, + EditForumTopicBody, + CloseForumTopicBody, + DeleteForumTopicBody, +} from "../validators/forum.js"; + +export type { GetUpdatesBody, SetWebhookBody, GetFileBody } from "../validators/delivery.js"; + +export type { SetMessageReactionBody } from "../validators/reaction.js"; + +export type { SetMyCommandsBody } from "../validators/commands.js"; + +export type { SendMessageDraftBody } from "../validators/draft.js"; + +export type { ChatIdInput, MultipartFileRef } from "../validators/primitives.js"; diff --git a/packages/@emulators/telegram/src/types/store/bot.ts b/packages/@emulators/telegram/src/types/store/bot.ts new file mode 100644 index 00000000..c3997dfd --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/bot.ts @@ -0,0 +1,15 @@ +import type { Entity } from "@emulators/core"; + +export interface TelegramBot extends Entity { + bot_id: number; + token: string; + username: string; + first_name: string; + can_join_groups: boolean; + can_read_all_group_messages: boolean; + supports_inline_queries: boolean; + webhook_url: string | null; + webhook_secret: string | null; + webhook_allowed_updates: string[] | null; + commands: Array<{ command: string; description: string }>; +} diff --git a/packages/@emulators/telegram/src/types/store/callback-query.ts b/packages/@emulators/telegram/src/types/store/callback-query.ts new file mode 100644 index 00000000..d55fbeb8 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/callback-query.ts @@ -0,0 +1,14 @@ +import type { Entity } from "@emulators/core"; + +export interface TelegramCallbackQuery extends Entity { + callback_query_id: string; + from_user_id: number; + message_id: number; + chat_id: number; + data: string; + answered: boolean; + answer_text?: string; + answer_show_alert?: boolean; + answer_url?: string; + answer_cache_time?: number; +} diff --git a/packages/@emulators/telegram/src/types/store/chat.ts b/packages/@emulators/telegram/src/types/store/chat.ts new file mode 100644 index 00000000..2067263c --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/chat.ts @@ -0,0 +1,50 @@ +import type { Entity } from "@emulators/core"; +import type { ReactionType } from "../wire/reaction.js"; + +export type ChatType = "private" | "group" | "supergroup" | "channel"; + +export interface ChatPermissions { + can_send_messages?: boolean; + can_send_audios?: boolean; + can_send_documents?: boolean; + can_send_photos?: boolean; + can_send_videos?: boolean; + can_send_video_notes?: boolean; + can_send_voice_notes?: boolean; + can_send_polls?: boolean; + can_send_other_messages?: boolean; + can_add_web_page_previews?: boolean; + can_change_info?: boolean; + can_invite_users?: boolean; + can_pin_messages?: boolean; + can_manage_topics?: boolean; +} + +export interface TelegramChat extends Entity { + chat_id: number; + type: ChatType; + title?: string; + username?: string; + first_name?: string; + last_name?: string; + member_user_ids: number[]; + member_bot_ids: number[]; + creator_user_id?: number; + admin_user_ids?: number[]; + admin_bot_ids?: number[]; + next_message_id: number; + // ChatFullInfo extras — defaulted, mutable via control plane. + bio?: string; + description?: string; + invite_link?: string; + pinned_message_id?: number; + permissions?: ChatPermissions; + slow_mode_delay?: number; + message_auto_delete_time?: number; + has_protected_content?: boolean; + linked_chat_id?: number; + available_reactions?: ReactionType[]; + accent_color_id?: number; + max_reaction_count?: number; + is_forum?: boolean; +} diff --git a/packages/@emulators/telegram/src/types/store/draft.ts b/packages/@emulators/telegram/src/types/store/draft.ts new file mode 100644 index 00000000..17941c86 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/draft.ts @@ -0,0 +1,11 @@ +import type { Entity } from "@emulators/core"; +import type { MessageEntity } from "../wire/message-entity.js"; + +export interface TelegramDraftSnapshot extends Entity { + chat_id: number; + draft_id: number; + bot_id: number; + seq: number; + text: string; + entities?: MessageEntity[]; +} diff --git a/packages/@emulators/telegram/src/types/store/fault.ts b/packages/@emulators/telegram/src/types/store/fault.ts new file mode 100644 index 00000000..58dfcff2 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/fault.ts @@ -0,0 +1,10 @@ +import type { Entity } from "@emulators/core"; + +export interface TelegramFault extends Entity { + bot_id: number; + method: string; // "*" or specific method name + error_code: number; + description: string; + retry_after: number | null; + remaining: number; +} diff --git a/packages/@emulators/telegram/src/types/store/file.ts b/packages/@emulators/telegram/src/types/store/file.ts new file mode 100644 index 00000000..1bd3bb6a --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/file.ts @@ -0,0 +1,20 @@ +import type { Entity } from "@emulators/core"; + +export interface TelegramFile extends Entity { + file_id: string; + file_unique_id: string; + owner_bot_id: number | null; + mime_type: string; + file_size: number; + width: number; + height: number; + file_path: string; + bytes_base64: string; + kind: "photo" | "document" | "voice" | "video" | "audio" | "sticker" | "animation"; + file_name?: string; + duration?: number; + is_animated?: boolean; + is_video?: boolean; + // For photos, the full PhotoSize[] so echoes can reuse the same tiers. + photo_sizes_json?: string; +} diff --git a/packages/@emulators/telegram/src/types/store/forum-topic.ts b/packages/@emulators/telegram/src/types/store/forum-topic.ts new file mode 100644 index 00000000..645acef0 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/forum-topic.ts @@ -0,0 +1,11 @@ +import type { Entity } from "@emulators/core"; + +export interface TelegramForumTopic extends Entity { + chat_id: number; + message_thread_id: number; + name: string; + icon_color?: number; + icon_custom_emoji_id?: string; + is_closed?: boolean; + is_deleted?: boolean; +} diff --git a/packages/@emulators/telegram/src/types/store/index.ts b/packages/@emulators/telegram/src/types/store/index.ts new file mode 100644 index 00000000..118a7458 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/index.ts @@ -0,0 +1,11 @@ +export * from "./bot.js"; +export * from "./user.js"; +export * from "./chat.js"; +export * from "./message.js"; +export * from "./file.js"; +export * from "./fault.js"; +export * from "./forum-topic.js"; +export * from "./draft.js"; +export * from "./callback-query.js"; +export * from "./reaction.js"; +export * from "./update.js"; diff --git a/packages/@emulators/telegram/src/types/store/message.ts b/packages/@emulators/telegram/src/types/store/message.ts new file mode 100644 index 00000000..fcfbf963 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/message.ts @@ -0,0 +1,37 @@ +import type { Entity } from "@emulators/core"; +import type { MessageEntity } from "../wire/message-entity.js"; +import type { + PhotoSize, + TelegramAnimation, + TelegramAudio, + TelegramDocument, + TelegramSticker, + TelegramVideo, + TelegramVoice, +} from "../wire/media.js"; +import type { ReplyMarkup } from "../wire/reply-markup.js"; + +export interface TelegramMessage extends Entity { + message_id: number; + chat_id: number; + from_user_id: number | null; + from_bot_id: number | null; + sender_chat_id: number | null; + message_thread_id?: number; + date: number; + text?: string; + entities?: MessageEntity[]; + photo?: PhotoSize[]; + document?: TelegramDocument; + audio?: TelegramAudio; + voice?: TelegramVoice; + video?: TelegramVideo; + animation?: TelegramAnimation; + sticker?: TelegramSticker; + caption?: string; + caption_entities?: MessageEntity[]; + reply_to_message_id?: number; + reply_markup?: ReplyMarkup; + edited_date?: number; + deleted?: boolean; +} diff --git a/packages/@emulators/telegram/src/types/store/reaction.ts b/packages/@emulators/telegram/src/types/store/reaction.ts new file mode 100644 index 00000000..773c00dc --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/reaction.ts @@ -0,0 +1,10 @@ +import type { Entity } from "@emulators/core"; +import type { ReactionType } from "../wire/reaction.js"; + +export interface TelegramReaction extends Entity { + chat_id: number; + message_id: number; + sender_user_id: number | null; + sender_bot_id: number | null; + reaction: ReactionType[]; +} diff --git a/packages/@emulators/telegram/src/types/store/update.ts b/packages/@emulators/telegram/src/types/store/update.ts new file mode 100644 index 00000000..f29539ed --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/update.ts @@ -0,0 +1,33 @@ +import type { Entity } from "@emulators/core"; +import type { WireBotAsUser, WireUser } from "../wire/user.js"; +import type { WireUpdate } from "../wire/update.js"; + +export type UpdateType = + | "message" + | "edited_message" + | "callback_query" + | "channel_post" + | "edited_channel_post" + | "my_chat_member" + | "chat_member" + | "message_reaction" + | "message_reaction_count"; + +export type ChatMemberStatus = "creator" | "administrator" | "member" | "restricted" | "left" | "kicked"; + +export interface ChatMemberLike { + status: ChatMemberStatus; + user: WireUser | WireBotAsUser; +} + +export interface TelegramUpdate extends Entity { + update_id: number; + for_bot_id: number; + type: UpdateType; + payload: WireUpdate; + delivered: boolean; + delivered_at: string | null; + delivery_mode: "webhook" | "polling" | "pending"; + delivery_attempts: number; + delivery_error: string | null; +} diff --git a/packages/@emulators/telegram/src/types/store/user.ts b/packages/@emulators/telegram/src/types/store/user.ts new file mode 100644 index 00000000..87891cd1 --- /dev/null +++ b/packages/@emulators/telegram/src/types/store/user.ts @@ -0,0 +1,10 @@ +import type { Entity } from "@emulators/core"; + +export interface TelegramUser extends Entity { + user_id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; + language_code?: string; +} diff --git a/packages/@emulators/telegram/src/types/validators/body.ts b/packages/@emulators/telegram/src/types/validators/body.ts new file mode 100644 index 00000000..a7f38e30 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/body.ts @@ -0,0 +1,52 @@ +import type { Context } from "hono"; +import { z } from "zod"; +import { parseTelegramBody, tgError } from "../../http.js"; + +export type ParseResult = + | { ok: true; data: T } + | { ok: false; response: Response }; + +export async function parseJsonBody( + c: Context, + schema: z.ZodType, +): Promise> { + const raw = await parseTelegramBody(c); + return parseWithSchema(c, schema, raw); +} + +export function parseWithSchema( + c: Context, + schema: z.ZodType, + raw: unknown, +): ParseResult { + const parsed = schema.safeParse(raw); + if (!parsed.success) { + return { ok: false, response: tgError(c, firstZodError(parsed.error)) }; + } + return { ok: true, data: parsed.data }; +} + +// Translate a zod parsing failure into the closest Bot API-style error +// message. The emulator keeps the wording close to real Telegram so +// that tests asserting on exact "Bad Request: X" strings keep working. +export function firstZodError(err: z.ZodError): string { + const issue = err.issues[0]; + const path = issue.path.length ? issue.path.join(".") : "body"; + + if (issue.code === "invalid_type") { + // zod 4 folds the "received" marker into the issue message + // ("expected number, received undefined") — a missing required + // field is reported as `received undefined`, which we normalise + // to Telegram's canonical "field is required" wording. + if (/received undefined/.test(issue.message)) { + return `Bad Request: ${path} is required`; + } + return `Bad Request: ${path} has invalid type`; + } + + if (issue.code === "invalid_union") { + return `Bad Request: ${path} has invalid type`; + } + + return `Bad Request: ${path}: ${issue.message}`; +} diff --git a/packages/@emulators/telegram/src/types/validators/callback-query.ts b/packages/@emulators/telegram/src/types/validators/callback-query.ts new file mode 100644 index 00000000..f2196046 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/callback-query.ts @@ -0,0 +1,11 @@ +import { z } from "zod"; + +export const zAnswerCallbackQueryBody = z.object({ + callback_query_id: z.string().min(1), + text: z.string().optional(), + show_alert: z.boolean().optional(), + url: z.string().optional(), + cache_time: z.number().int().optional(), +}); + +export type AnswerCallbackQueryBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/chats.ts b/packages/@emulators/telegram/src/types/validators/chats.ts new file mode 100644 index 00000000..e236d2f6 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/chats.ts @@ -0,0 +1,26 @@ +import { z } from "zod"; +import { zChatId } from "./primitives.js"; + +export const zGetChatBody = z.object({ + chat_id: zChatId, +}); +export type GetChatBody = z.infer; + +export const zGetChatMemberBody = z.object({ + chat_id: zChatId, + user_id: z.number().int(), +}); +export type GetChatMemberBody = z.infer; + +export const zGetChatAdministratorsBody = zGetChatBody; +export type GetChatAdministratorsBody = z.infer; + +export const zGetChatMemberCountBody = zGetChatBody; +export type GetChatMemberCountBody = z.infer; + +export const zSendChatActionBody = z.object({ + chat_id: zChatId, + action: z.string().optional(), + message_thread_id: z.number().int().optional(), +}); +export type SendChatActionBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/commands.ts b/packages/@emulators/telegram/src/types/validators/commands.ts new file mode 100644 index 00000000..69f0d16e --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/commands.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; + +export const zBotCommand = z.object({ + command: z.string(), + description: z.string(), +}); + +export const zSetMyCommandsBody = z.object({ + commands: z.array(zBotCommand), +}); + +export type SetMyCommandsBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/control.ts b/packages/@emulators/telegram/src/types/validators/control.ts new file mode 100644 index 00000000..551bd11b --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/control.ts @@ -0,0 +1,197 @@ +import { z } from "zod"; +import { zMessageEntity } from "./message-entity.js"; +import { zMultipartFile } from "./primitives.js"; + +// Bot commands input shape (small, reused in create-bot + setMyCommands). +const zCommandPair = z.object({ + command: z.string(), + description: z.string(), +}); + +export const zCreateBotInput = z.object({ + username: z.string().min(1), + name: z.string().optional(), + first_name: z.string().optional(), + can_join_groups: z.boolean().optional(), + can_read_all_group_messages: z.boolean().optional(), + commands: z.array(zCommandPair).optional(), + token: z.string().optional(), +}); +export type CreateBotInput = z.infer; + +export const zCreateUserInput = z.object({ + first_name: z.string(), + last_name: z.string().optional(), + username: z.string().optional(), + language_code: z.string().optional(), +}); +export type CreateUserInput = z.infer; + +export const zCreatePrivateChatInput = z.object({ + botId: z.number().int(), + userId: z.number().int(), +}); +export type CreatePrivateChatInput = z.infer; + +export const zCreateGroupChatInput = z.object({ + title: z.string(), + type: z.enum(["group", "supergroup"]).optional(), + memberIds: z.array(z.number().int()), + botIds: z.array(z.number().int()), + creatorUserId: z.number().int().optional(), + adminUserIds: z.array(z.number().int()).optional(), + adminBotIds: z.array(z.number().int()).optional(), + isForum: z.boolean().optional(), +}); +export type CreateGroupChatInput = z.infer; + +// Supergroup is a group with type fixed to supergroup; the handler fills +// `type` in, so the input mirrors CreateGroupChatInput minus `type`. +export const zCreateSupergroupInput = zCreateGroupChatInput.omit({ type: true }); +export type CreateSupergroupInput = z.infer; + +export const zCreateChannelInput = z.object({ + title: z.string(), + username: z.string().optional(), + memberBotIds: z.array(z.number().int()), + memberUserIds: z.array(z.number().int()).optional(), +}); +export type CreateChannelInput = z.infer; + +export const zPromoteChatMemberInput = z.object({ + userId: z.number().int().optional(), + botId: z.number().int().optional(), + demote: z.boolean().optional(), +}); +export type PromoteChatMemberInput = z.infer; + +export const zSimulateUserMessageInput = z.object({ + userId: z.number().int(), + text: z.string(), + replyToMessageId: z.number().int().optional(), + messageThreadId: z.number().int().optional(), +}); +export type SimulateUserMessageInput = z.infer; + +// Photo upload body accepts either a base64 blob (wire-friendly) or a +// multipart reference (in-process test-client shortcut). The route +// handler extracts the bytes from whichever branch is populated. +export const zSimulateUserPhotoInput = z + .object({ + userId: z.number().int(), + mimeType: z.string().optional(), + caption: z.string().optional(), + photoBase64: z.string().optional(), + photo: zMultipartFile.optional(), + }) + .refine( + (v) => v.photoBase64 !== undefined || v.photo !== undefined, + { message: "photoBase64 or multipart photo required" }, + ); +export type SimulateUserPhotoInput = z.infer; + +export const zSimulateCallbackInput = z.object({ + userId: z.number().int(), + messageId: z.number().int(), + data: z.string().optional(), + callbackData: z.string().optional(), +}); +export type SimulateCallbackInput = z.infer; + +export const zSimulateEditedUserMessageInput = z.object({ + userId: z.number().int(), + messageId: z.number().int(), + text: z.string(), + messageThreadId: z.number().int().optional(), +}); +export type SimulateEditedUserMessageInput = z.infer; + +export const zChatMembershipInput = z.object({ + botId: z.number().int(), + byUserId: z.number().int(), +}); +export type ChatMembershipInput = z.infer; + +export const zReactionEntry = z.union([ + z.object({ type: z.literal("emoji"), emoji: z.string() }), + z.object({ type: z.literal("custom_emoji"), custom_emoji_id: z.string() }), +]); + +export const zSimulateReactionInput = z.object({ + userId: z.number().int(), + messageId: z.number().int(), + reaction: z.array(zReactionEntry), +}); +export type SimulateReactionInput = z.infer; + +export const zCreateForumTopicControlBody = z.object({ + name: z.string().min(1), +}); +export type CreateForumTopicControlInput = z.infer; + +export const zSimulateChannelPostInput = z.object({ + text: z.string().optional(), + caption: z.string().optional(), + entities: z.array(zMessageEntity).optional(), + reply_to_message_id: z.number().int().optional(), + message_thread_id: z.number().int().optional(), + photo_bytes_base64: z.string().optional(), +}); +export type SimulateChannelPostControlInput = z.infer; + +export const zEditChannelPostInput = z.object({ + messageId: z.number().int(), + text: z.string().optional(), + caption: z.string().optional(), +}); +export type EditChannelPostInput = z.infer; + +export const zMediaKind = z.enum([ + "photo", + "video", + "audio", + "voice", + "animation", + "sticker", + "document", +]); + +export const zSimulateUserMediaInput = z + .object({ + userId: z.number().int(), + kind: zMediaKind, + mimeType: z.string().optional(), + caption: z.string().optional(), + duration: z.number().int().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), + fileName: z.string().optional(), + messageThreadId: z.number().int().optional(), + bytesBase64: z.string().optional(), + file: zMultipartFile.optional(), + }) + .refine( + (v) => v.bytesBase64 !== undefined || v.file !== undefined, + { message: "bytesBase64 or file required" }, + ); +export type SimulateUserMediaInput = z.infer; + +export const zInjectFaultInput = z + .object({ + bot_id: z.number().int().optional(), + botId: z.number().int().optional(), + method: z.string().optional(), + error_code: z.number().int().optional(), + errorCode: z.number().int().optional(), + description: z.string().optional(), + retry_after: z.number().int().optional(), + retryAfter: z.number().int().optional(), + count: z.number().int().optional(), + }) + .refine((v) => v.bot_id !== undefined || v.botId !== undefined, { + message: "bot_id required", + }) + .refine((v) => v.error_code !== undefined || v.errorCode !== undefined, { + message: "error_code required", + }); +export type InjectFaultControlInput = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/delivery.ts b/packages/@emulators/telegram/src/types/validators/delivery.ts new file mode 100644 index 00000000..251c6148 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/delivery.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const zGetUpdatesBody = z.object({ + offset: z.number().int().optional(), + limit: z.number().int().optional(), + timeout: z.number().int().optional(), + allowed_updates: z.array(z.string()).optional(), +}); +export type GetUpdatesBody = z.infer; + +export const zSetWebhookBody = z.object({ + url: z.string().optional(), + secret_token: z.string().optional(), + allowed_updates: z.array(z.string()).optional(), +}); +export type SetWebhookBody = z.infer; + +export const zGetFileBody = z.object({ + file_id: z.string().min(1), +}); +export type GetFileBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/draft.ts b/packages/@emulators/telegram/src/types/validators/draft.ts new file mode 100644 index 00000000..8295586d --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/draft.ts @@ -0,0 +1,12 @@ +import { z } from "zod"; +import { zChatId } from "./primitives.js"; +import { zMessageEntity } from "./message-entity.js"; + +export const zSendMessageDraftBody = z.object({ + chat_id: zChatId, + draft_id: z.number().int(), + text: z.string(), + entities: z.array(zMessageEntity).optional(), +}); + +export type SendMessageDraftBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/forum.ts b/packages/@emulators/telegram/src/types/validators/forum.ts new file mode 100644 index 00000000..8c7ae64c --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/forum.ts @@ -0,0 +1,27 @@ +import { z } from "zod"; +import { zChatId } from "./primitives.js"; + +export const zCreateForumTopicBody = z.object({ + chat_id: zChatId, + name: z.string().min(1), + icon_color: z.number().int().optional(), + icon_custom_emoji_id: z.string().optional(), +}); +export type CreateForumTopicBody = z.infer; + +export const zEditForumTopicBody = z.object({ + chat_id: zChatId, + message_thread_id: z.number().int(), + name: z.string().optional(), + icon_custom_emoji_id: z.string().optional(), +}); +export type EditForumTopicBody = z.infer; + +export const zCloseForumTopicBody = z.object({ + chat_id: zChatId, + message_thread_id: z.number().int(), +}); +export type CloseForumTopicBody = z.infer; + +export const zDeleteForumTopicBody = zCloseForumTopicBody; +export type DeleteForumTopicBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/index.ts b/packages/@emulators/telegram/src/types/validators/index.ts new file mode 100644 index 00000000..ed90c47c --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/index.ts @@ -0,0 +1,14 @@ +export * from "./body.js"; +export * from "./primitives.js"; +export * from "./message-entity.js"; +export * from "./reply-markup.js"; +export * from "./send-message.js"; +export * from "./send-media.js"; +export * from "./callback-query.js"; +export * from "./chats.js"; +export * from "./forum.js"; +export * from "./delivery.js"; +export * from "./reaction.js"; +export * from "./commands.js"; +export * from "./draft.js"; +export * from "./control.js"; diff --git a/packages/@emulators/telegram/src/types/validators/message-entity.ts b/packages/@emulators/telegram/src/types/validators/message-entity.ts new file mode 100644 index 00000000..083014d3 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/message-entity.ts @@ -0,0 +1,39 @@ +import { z } from "zod"; + +export const zMessageEntityType = z.enum([ + "mention", + "hashtag", + "cashtag", + "bot_command", + "url", + "email", + "phone_number", + "bold", + "italic", + "underline", + "strikethrough", + "spoiler", + "code", + "pre", + "text_link", + "text_mention", + "custom_emoji", + "blockquote", + "expandable_blockquote", +]); + +export const zMessageEntityUser = z.object({ + id: z.number().int(), + is_bot: z.boolean(), + first_name: z.string(), + username: z.string().optional(), +}); + +export const zMessageEntity = z.object({ + type: zMessageEntityType, + offset: z.number().int().nonnegative(), + length: z.number().int().nonnegative(), + url: z.string().optional(), + user: zMessageEntityUser.optional(), + language: z.string().optional(), +}); diff --git a/packages/@emulators/telegram/src/types/validators/primitives.ts b/packages/@emulators/telegram/src/types/validators/primitives.ts new file mode 100644 index 00000000..9182e107 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/primitives.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +// Bot API `chat_id` accepts either a numeric id or a digit-string; some +// SDKs also pass @username but the emulator currently rejects those. +// Coerce digit-strings to number so downstream handlers see one type. +export const zChatId = z.union([ + z.number().int(), + z + .string() + .regex(/^-?\d+$/, "chat_id must be an integer") + .transform((s) => Number(s)), +]); + +export type ChatIdInput = z.infer; + +export const zMessageId = z.number().int().positive(); + +// Multipart upload scalar placed in a body field by parseTelegramBody's +// form-data branch. The __file brand disambiguates from string file_ids. +export const zMultipartFile = z.object({ + __file: z.literal(true), + name: z.string(), + type: z.string(), + // parseTelegramBody puts a Node Buffer here. Buffer extends Uint8Array + // at runtime; typing it as Uint8Array keeps validators portable. + bytes: z.custom((v) => v instanceof Uint8Array, "expected Buffer"), +}); + +export type MultipartFileRef = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/reaction.ts b/packages/@emulators/telegram/src/types/validators/reaction.ts new file mode 100644 index 00000000..7ce26ac5 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/reaction.ts @@ -0,0 +1,15 @@ +import { z } from "zod"; +import { zChatId } from "./primitives.js"; + +export const zReactionTypeInput = z.union([ + z.object({ type: z.literal("emoji"), emoji: z.string().optional() }), + z.object({ type: z.literal("custom_emoji"), custom_emoji_id: z.string().optional() }), +]); + +export const zSetMessageReactionBody = z.object({ + chat_id: zChatId, + message_id: z.number().int(), + reaction: z.array(zReactionTypeInput).optional(), +}); + +export type SetMessageReactionBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/validators/reply-markup.ts b/packages/@emulators/telegram/src/types/validators/reply-markup.ts new file mode 100644 index 00000000..b2539a81 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/reply-markup.ts @@ -0,0 +1,42 @@ +import { z } from "zod"; + +export const zInlineKeyboardButton = z.object({ + text: z.string(), + callback_data: z.string().optional(), + url: z.string().optional(), +}); + +export const zInlineKeyboardMarkup = z.object({ + inline_keyboard: z.array(z.array(zInlineKeyboardButton)), +}); + +export const zReplyKeyboardButton = z.object({ + text: z.string(), +}); + +export const zReplyKeyboardMarkup = z.object({ + keyboard: z.array(z.array(zReplyKeyboardButton)), + resize_keyboard: z.boolean().optional(), + one_time_keyboard: z.boolean().optional(), + selective: z.boolean().optional(), +}); + +export const zForceReply = z.object({ + force_reply: z.literal(true), + selective: z.boolean().optional(), +}); + +export const zReplyKeyboardRemove = z.object({ + remove_keyboard: z.literal(true), + selective: z.boolean().optional(), +}); + +// Telegram's reply_markup has no shared discriminator tag — branches +// are distinguished by required-key presence. Ordering matters: match +// the most specific shape first. +export const zReplyMarkup = z.union([ + zInlineKeyboardMarkup, + zReplyKeyboardMarkup, + zForceReply, + zReplyKeyboardRemove, +]); diff --git a/packages/@emulators/telegram/src/types/validators/send-media.ts b/packages/@emulators/telegram/src/types/validators/send-media.ts new file mode 100644 index 00000000..2cfcfac5 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/send-media.ts @@ -0,0 +1,81 @@ +import { z } from "zod"; +import { zChatId, zMultipartFile } from "./primitives.js"; +import { zMessageEntity } from "./message-entity.js"; +import { zReplyMarkup } from "./reply-markup.js"; +import { zParseMode } from "./send-message.js"; + +// Media input fields accept either a string file_id (for resends) or a +// multipart upload. Control-plane tests drive multipart via __file. +const zMediaFileInput = z.union([z.string(), zMultipartFile]); + +// Shared send-* body fields. Each concrete method extends this with +// its own media-input key (video/audio/voice/animation/sticker/photo). +const zSendMediaCommon = z.object({ + chat_id: zChatId, + caption: z.string().optional(), + caption_entities: z.array(zMessageEntity).optional(), + parse_mode: zParseMode.optional(), + message_thread_id: z.number().int().optional(), + reply_markup: zReplyMarkup.optional(), + duration: z.number().int().optional(), + width: z.number().int().optional(), + height: z.number().int().optional(), + is_animated: z.boolean().optional(), + is_video: z.boolean().optional(), +}); + +export const zSendPhotoBody = zSendMediaCommon.extend({ + photo: zMediaFileInput, +}); +export type SendPhotoBody = z.infer; + +export const zSendVideoBody = zSendMediaCommon.extend({ + video: zMediaFileInput, +}); +export type SendVideoBody = z.infer; + +export const zSendAudioBody = zSendMediaCommon.extend({ + audio: zMediaFileInput, + performer: z.string().optional(), + title: z.string().optional(), +}); +export type SendAudioBody = z.infer; + +export const zSendVoiceBody = zSendMediaCommon.extend({ + voice: zMediaFileInput, +}); +export type SendVoiceBody = z.infer; + +export const zSendAnimationBody = zSendMediaCommon.extend({ + animation: zMediaFileInput, +}); +export type SendAnimationBody = z.infer; + +export const zSendStickerBody = zSendMediaCommon.extend({ + sticker: zMediaFileInput, + emoji: z.string().optional(), +}); +export type SendStickerBody = z.infer; + +export const zSendDocumentBody = zSendMediaCommon.extend({ + document: zMediaFileInput, +}); +export type SendDocumentBody = z.infer; + +export type MediaKind = "video" | "audio" | "voice" | "animation" | "sticker"; + +// Map method kind to the zod schema for its request body. Used by the +// sendMediaMessage dispatcher in routes/bot-api.ts. +export const BODY_FOR_MEDIA: { + video: typeof zSendVideoBody; + audio: typeof zSendAudioBody; + voice: typeof zSendVoiceBody; + animation: typeof zSendAnimationBody; + sticker: typeof zSendStickerBody; +} = { + video: zSendVideoBody, + audio: zSendAudioBody, + voice: zSendVoiceBody, + animation: zSendAnimationBody, + sticker: zSendStickerBody, +}; diff --git a/packages/@emulators/telegram/src/types/validators/send-message.ts b/packages/@emulators/telegram/src/types/validators/send-message.ts new file mode 100644 index 00000000..684a0313 --- /dev/null +++ b/packages/@emulators/telegram/src/types/validators/send-message.ts @@ -0,0 +1,43 @@ +import { z } from "zod"; +import { zChatId } from "./primitives.js"; +import { zMessageEntity } from "./message-entity.js"; +import { zReplyMarkup } from "./reply-markup.js"; + +export const zParseMode = z.enum(["MarkdownV2", "HTML", "Markdown"]); + +export const zSendMessageBody = z.object({ + chat_id: zChatId, + text: z.string(), + parse_mode: zParseMode.optional(), + entities: z.array(zMessageEntity).optional(), + reply_to_message_id: z.number().int().optional(), + message_thread_id: z.number().int().optional(), + reply_markup: zReplyMarkup.optional(), +}); + +export type SendMessageBody = z.infer; + +export const zEditMessageTextBody = z.object({ + chat_id: zChatId, + message_id: z.number().int(), + text: z.string(), + parse_mode: zParseMode.optional(), + entities: z.array(zMessageEntity).optional(), +}); + +export type EditMessageTextBody = z.infer; + +export const zDeleteMessageBody = z.object({ + chat_id: zChatId, + message_id: z.number().int(), +}); + +export type DeleteMessageBody = z.infer; + +export const zEditMessageReplyMarkupBody = z.object({ + chat_id: zChatId, + message_id: z.number().int(), + reply_markup: zReplyMarkup.optional(), +}); + +export type EditMessageReplyMarkupBody = z.infer; diff --git a/packages/@emulators/telegram/src/types/wire/callback-query.ts b/packages/@emulators/telegram/src/types/wire/callback-query.ts new file mode 100644 index 00000000..3ca567b9 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/callback-query.ts @@ -0,0 +1,14 @@ +import type { WireMessage } from "./message.js"; +import type { WireBotAsUser, WireUser } from "./user.js"; + +// Bot API `CallbackQuery` wire shape. The emulator dispatches this as +// the body of a `callback_query` Update when a user clicks an inline +// button. The emulator skips the optional `message_instance` / +// `game_short_name` / `inline_message_id` fields it never emits. +export interface WireCallbackQuery { + id: string; + from: WireUser | WireBotAsUser; + chat_instance: string; + message?: WireMessage; + data?: string; +} diff --git a/packages/@emulators/telegram/src/types/wire/chat-member-updated.ts b/packages/@emulators/telegram/src/types/wire/chat-member-updated.ts new file mode 100644 index 00000000..c55f7839 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/chat-member-updated.ts @@ -0,0 +1,16 @@ +import type { WireChat } from "./chat.js"; +import type { WireChatMember } from "./chat-member.js"; +import type { WireBotAsUser, WireUser } from "./user.js"; + +// Bot API `ChatMemberUpdated` object — the body of `chat_member` and +// `my_chat_member` Updates. The emulator emits abbreviated owner / +// member rows but keeps the full WireChatMember discriminator so +// consumers can narrow on `status`. +export interface WireChatMemberUpdated { + chat: WireChat; + from: WireUser | WireBotAsUser; + date: number; + old_chat_member: WireChatMember; + new_chat_member: WireChatMember; + invite_link?: { invite_link: string; creator: WireUser; is_primary: boolean; is_revoked: boolean }; +} diff --git a/packages/@emulators/telegram/src/types/wire/chat-member.ts b/packages/@emulators/telegram/src/types/wire/chat-member.ts new file mode 100644 index 00000000..fe2fd8f5 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/chat-member.ts @@ -0,0 +1,83 @@ +import type { WireBotAsUser, WireUser } from "./user.js"; + +// Bot API `ChatMember` — discriminated union on `status`. The emulator +// models only the subset required for getChatMember and +// getChatAdministrators. Restricted/left/kicked carry the full wire +// shape so future work can emit them without widening. + +type AdminPermissions = { + can_be_edited: boolean; + is_anonymous: boolean; + can_manage_chat: boolean; + can_delete_messages: boolean; + can_manage_video_chats: boolean; + can_restrict_members: boolean; + can_promote_members: boolean; + can_change_info: boolean; + can_invite_users: boolean; + can_post_messages?: boolean; + can_edit_messages?: boolean; + can_pin_messages?: boolean; + can_manage_topics?: boolean; +}; + +// The emulator emits full admin-permission fields on the creator row +// too, matching real Telegram's ChatMemberOwner practice of carrying +// the same permission fields for introspection convenience. +export interface WireChatMemberOwner extends AdminPermissions { + status: "creator"; + user: WireUser | WireBotAsUser; + custom_title?: string; +} + +export interface WireChatMemberAdministrator extends AdminPermissions { + status: "administrator"; + user: WireUser | WireBotAsUser; + custom_title?: string; +} + +export interface WireChatMemberMember { + status: "member"; + user: WireUser | WireBotAsUser; + until_date?: number; +} + +export interface WireChatMemberRestricted { + status: "restricted"; + user: WireUser | WireBotAsUser; + is_member: boolean; + can_send_messages: boolean; + can_send_audios: boolean; + can_send_documents: boolean; + can_send_photos: boolean; + can_send_videos: boolean; + can_send_video_notes: boolean; + can_send_voice_notes: boolean; + can_send_polls: boolean; + can_send_other_messages: boolean; + can_add_web_page_previews: boolean; + can_change_info: boolean; + can_invite_users: boolean; + can_pin_messages: boolean; + can_manage_topics: boolean; + until_date: number; +} + +export interface WireChatMemberLeft { + status: "left"; + user: WireUser | WireBotAsUser; +} + +export interface WireChatMemberBanned { + status: "kicked"; + user: WireUser | WireBotAsUser; + until_date: number; +} + +export type WireChatMember = + | WireChatMemberOwner + | WireChatMemberAdministrator + | WireChatMemberMember + | WireChatMemberRestricted + | WireChatMemberLeft + | WireChatMemberBanned; diff --git a/packages/@emulators/telegram/src/types/wire/chat.ts b/packages/@emulators/telegram/src/types/wire/chat.ts new file mode 100644 index 00000000..bdc33165 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/chat.ts @@ -0,0 +1,35 @@ +import type { ChatPermissions, ChatType } from "../store/chat.js"; +import type { ReactionType } from "./reaction.js"; +import type { WireMessage } from "./message.js"; + +// Bot API `Chat` object — what `sendMessage.chat` and friends look like +// on the wire. Emitted by serializeChat. +export interface WireChat { + id: number; + type: ChatType; + title?: string; + username?: string; + first_name?: string; + last_name?: string; + is_forum?: boolean; +} + +// Bot API `ChatFullInfo` — the response body of getChat. Superset of +// WireChat with all the settings/metadata the bot is allowed to read. +export interface WireChatFullInfo extends WireChat { + accent_color_id: number; + max_reaction_count: number; + bio?: string; + description?: string; + invite_link?: string; + slow_mode_delay?: number; + message_auto_delete_time?: number; + has_protected_content?: boolean; + linked_chat_id?: number; + available_reactions?: ReactionType[]; + permissions?: ChatPermissions; + // `pinned_message` is self-recursive through WireMessage — serializer + // guards against cycles with a depth cap. + pinned_message?: WireMessage; +} + diff --git a/packages/@emulators/telegram/src/types/wire/index.ts b/packages/@emulators/telegram/src/types/wire/index.ts new file mode 100644 index 00000000..501daa4c --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/index.ts @@ -0,0 +1,12 @@ +export * from "./message-entity.js"; +export * from "./media.js"; +export * from "./reply-markup.js"; +export * from "./reaction.js"; +export * from "./user.js"; +export * from "./chat.js"; +export * from "./message.js"; +export * from "./chat-member.js"; +export * from "./callback-query.js"; +export * from "./chat-member-updated.js"; +export * from "./reaction-update.js"; +export * from "./update.js"; diff --git a/packages/@emulators/telegram/src/types/wire/media.ts b/packages/@emulators/telegram/src/types/wire/media.ts new file mode 100644 index 00000000..bab8fd7c --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/media.ts @@ -0,0 +1,74 @@ +export interface PhotoSize { + file_id: string; + file_unique_id: string; + width: number; + height: number; + file_size?: number; +} + +export type WirePhotoSize = PhotoSize; + +export interface TelegramDocument { + file_id: string; + file_unique_id: string; + file_name?: string; + mime_type?: string; + file_size?: number; + thumbnail?: PhotoSize; +} + +export type WireDocument = TelegramDocument; + +export interface TelegramAudio { + file_id: string; + file_unique_id: string; + duration: number; + performer?: string; + title?: string; + mime_type?: string; + file_size?: number; + file_name?: string; +} + +export type WireAudio = TelegramAudio; + +export interface TelegramVoice { + file_id: string; + file_unique_id: string; + duration: number; + mime_type?: string; + file_size?: number; +} + +export type WireVoice = TelegramVoice; + +export interface TelegramVideo { + file_id: string; + file_unique_id: string; + width: number; + height: number; + duration: number; + mime_type?: string; + file_size?: number; + file_name?: string; + thumbnail?: PhotoSize; +} + +export type WireVideo = TelegramVideo; + +export interface TelegramAnimation extends TelegramVideo {} + +export type WireAnimation = TelegramAnimation; + +export interface TelegramSticker { + file_id: string; + file_unique_id: string; + width: number; + height: number; + is_animated: boolean; + is_video: boolean; + emoji?: string; + set_name?: string; +} + +export type WireSticker = TelegramSticker; diff --git a/packages/@emulators/telegram/src/types/wire/message-entity.ts b/packages/@emulators/telegram/src/types/wire/message-entity.ts new file mode 100644 index 00000000..7b2e4997 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/message-entity.ts @@ -0,0 +1,31 @@ +export type MessageEntityType = + | "mention" + | "hashtag" + | "cashtag" + | "bot_command" + | "url" + | "email" + | "phone_number" + | "bold" + | "italic" + | "underline" + | "strikethrough" + | "spoiler" + | "code" + | "pre" + | "text_link" + | "text_mention" + | "custom_emoji" + | "blockquote" + | "expandable_blockquote"; + +export interface MessageEntity { + type: MessageEntityType; + offset: number; + length: number; + url?: string; + user?: { id: number; is_bot: boolean; first_name: string; username?: string }; + language?: string; +} + +export type WireMessageEntity = MessageEntity; diff --git a/packages/@emulators/telegram/src/types/wire/message.ts b/packages/@emulators/telegram/src/types/wire/message.ts new file mode 100644 index 00000000..a16d7074 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/message.ts @@ -0,0 +1,41 @@ +import type { WireChat } from "./chat.js"; +import type { + PhotoSize, + TelegramAnimation, + TelegramAudio, + TelegramDocument, + TelegramSticker, + TelegramVideo, + TelegramVoice, +} from "./media.js"; +import type { MessageEntity } from "./message-entity.js"; +import type { ReplyMarkup } from "./reply-markup.js"; +import type { WireBotAsUser, WireUser } from "./user.js"; + +// Bot API `Message` wire shape — exactly what serializeMessage emits. +// Only the fields the emulator actually produces are modelled; real +// Telegram ships many more (service messages, polls, contacts etc.) +// which are outside the emulator's scope. +export interface WireMessage { + message_id: number; + date: number; + chat: WireChat; + from?: WireUser | WireBotAsUser; + sender_chat?: WireChat; + message_thread_id?: number; + text?: string; + entities?: MessageEntity[]; + photo?: PhotoSize[]; + document?: TelegramDocument; + audio?: TelegramAudio; + voice?: TelegramVoice; + video?: TelegramVideo; + animation?: TelegramAnimation; + sticker?: TelegramSticker; + caption?: string; + caption_entities?: MessageEntity[]; + reply_to_message_id?: number; + reply_to_message?: WireMessage; + reply_markup?: ReplyMarkup; + edit_date?: number; +} diff --git a/packages/@emulators/telegram/src/types/wire/reaction-update.ts b/packages/@emulators/telegram/src/types/wire/reaction-update.ts new file mode 100644 index 00000000..11d86e1d --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/reaction-update.ts @@ -0,0 +1,29 @@ +import type { WireChat } from "./chat.js"; +import type { ReactionType } from "./reaction.js"; +import type { WireUser } from "./user.js"; + +// Bot API `MessageReactionUpdated` — per-user reaction delta. +export interface WireMessageReactionUpdated { + chat: WireChat; + message_id: number; + user?: WireUser; + actor_chat?: WireChat; + date: number; + old_reaction: ReactionType[]; + new_reaction: ReactionType[]; +} + +export interface WireReactionCount { + type: ReactionType; + total_count: number; +} + +// Bot API `MessageReactionCountUpdated` — anonymous aggregate emitted +// alongside the per-user variant so groups + anonymous admins can +// observe reaction totals without leaking authors. +export interface WireMessageReactionCountUpdated { + chat: WireChat; + message_id: number; + date: number; + reactions: WireReactionCount[]; +} diff --git a/packages/@emulators/telegram/src/types/wire/reaction.ts b/packages/@emulators/telegram/src/types/wire/reaction.ts new file mode 100644 index 00000000..8ecbf990 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/reaction.ts @@ -0,0 +1,5 @@ +export type ReactionType = + | { type: "emoji"; emoji: string } + | { type: "custom_emoji"; custom_emoji_id: string }; + +export type WireReactionType = ReactionType; diff --git a/packages/@emulators/telegram/src/types/wire/reply-markup.ts b/packages/@emulators/telegram/src/types/wire/reply-markup.ts new file mode 100644 index 00000000..e63f7b8d --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/reply-markup.ts @@ -0,0 +1,52 @@ +export interface InlineKeyboardButton { + text: string; + callback_data?: string; + url?: string; +} + +export interface InlineKeyboardMarkup { + inline_keyboard: InlineKeyboardButton[][]; +} + +export interface ReplyKeyboardButton { + text: string; +} + +export interface ReplyKeyboardMarkup { + keyboard: ReplyKeyboardButton[][]; + resize_keyboard?: boolean; + one_time_keyboard?: boolean; + selective?: boolean; +} + +export interface ForceReply { + force_reply: true; + selective?: boolean; +} + +export interface ReplyKeyboardRemove { + remove_keyboard: true; + selective?: boolean; +} + +export type ReplyMarkup = InlineKeyboardMarkup | ReplyKeyboardMarkup | ForceReply | ReplyKeyboardRemove; + +export type WireInlineKeyboardButton = InlineKeyboardButton; +export type WireInlineKeyboardMarkup = InlineKeyboardMarkup; +export type WireReplyKeyboardButton = ReplyKeyboardButton; +export type WireReplyKeyboardMarkup = ReplyKeyboardMarkup; +export type WireForceReply = ForceReply; +export type WireReplyKeyboardRemove = ReplyKeyboardRemove; +export type WireReplyMarkup = ReplyMarkup; + +export const isInlineKeyboardMarkup = (m: ReplyMarkup): m is InlineKeyboardMarkup => + "inline_keyboard" in m && Array.isArray((m as InlineKeyboardMarkup).inline_keyboard); + +export const isReplyKeyboardMarkup = (m: ReplyMarkup): m is ReplyKeyboardMarkup => + "keyboard" in m && Array.isArray((m as ReplyKeyboardMarkup).keyboard); + +export const isForceReply = (m: ReplyMarkup): m is ForceReply => + "force_reply" in m && (m as ForceReply).force_reply === true; + +export const isReplyKeyboardRemove = (m: ReplyMarkup): m is ReplyKeyboardRemove => + "remove_keyboard" in m && (m as ReplyKeyboardRemove).remove_keyboard === true; diff --git a/packages/@emulators/telegram/src/types/wire/update.ts b/packages/@emulators/telegram/src/types/wire/update.ts new file mode 100644 index 00000000..c3b12b74 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/update.ts @@ -0,0 +1,54 @@ +import type { UpdateType } from "../store/update.js"; +import type { WireMessage } from "./message.js"; +import type { WireCallbackQuery } from "./callback-query.js"; +import type { WireChatMemberUpdated } from "./chat-member-updated.js"; +import type { + WireMessageReactionCountUpdated, + WireMessageReactionUpdated, +} from "./reaction-update.js"; + +// Discriminated wrapper emitted over the wire as a Bot API `Update`. +// Every variant carries `update_id` + exactly one named payload key +// matching one of the UpdateType values. Consumers discriminate by +// key presence (`"message" in update`, etc.). +export type WireUpdate = + | { update_id: number; message: WireMessage } + | { update_id: number; edited_message: WireMessage } + | { update_id: number; channel_post: WireMessage } + | { update_id: number; edited_channel_post: WireMessage } + | { update_id: number; callback_query: WireCallbackQuery } + | { update_id: number; my_chat_member: WireChatMemberUpdated } + | { update_id: number; chat_member: WireChatMemberUpdated } + | { update_id: number; message_reaction: WireMessageReactionUpdated } + | { update_id: number; message_reaction_count: WireMessageReactionCountUpdated }; + +// Lookup: given an UpdateType, what payload does the dispatcher +// enqueue? Used to make Dispatcher.enqueue generic. +export type PayloadFor = T extends + | "message" + | "edited_message" + | "channel_post" + | "edited_channel_post" + ? WireMessage + : T extends "callback_query" + ? WireCallbackQuery + : T extends "my_chat_member" | "chat_member" + ? WireChatMemberUpdated + : T extends "message_reaction" + ? WireMessageReactionUpdated + : T extends "message_reaction_count" + ? WireMessageReactionCountUpdated + : never; + +// Central wrapping: the runtime value `{ update_id, [type]: payload }` +// is a valid WireUpdate variant but TS cannot narrow the computed-key +// construction across the discriminated union. Per TYPING_SPEC §5.3 +// this is the single sanctioned place for the cast — every caller +// remains fully typed via the generic signature. +export function wrapPayload( + update_id: number, + type: T, + payload: PayloadFor, +): WireUpdate { + return { update_id, [type]: payload } as unknown as WireUpdate; +} diff --git a/packages/@emulators/telegram/src/types/wire/user.ts b/packages/@emulators/telegram/src/types/wire/user.ts new file mode 100644 index 00000000..c3537f98 --- /dev/null +++ b/packages/@emulators/telegram/src/types/wire/user.ts @@ -0,0 +1,20 @@ +// Wire shape emitted when a `User` object appears on an update (Bot API +// `User`). Produced by serializeUser / serializeBotAsUser — which is +// why both bot-flavoured and user-flavoured fields are optional here. +export interface WireUser { + id: number; + is_bot: boolean; + first_name: string; + last_name?: string; + username?: string; + language_code?: string; +} + +// Bots always have is_bot: true and carry a username. The type keeps +// that invariant visible at the producing boundary. +export interface WireBotAsUser { + id: number; + is_bot: true; + first_name: string; + username: string; +} diff --git a/packages/@emulators/telegram/tsconfig.json b/packages/@emulators/telegram/tsconfig.json new file mode 100644 index 00000000..c8c92cbd --- /dev/null +++ b/packages/@emulators/telegram/tsconfig.json @@ -0,0 +1,8 @@ +{ + "extends": "../../../tsconfig.json", + "compilerOptions": { + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/packages/@emulators/telegram/tsup.config.ts b/packages/@emulators/telegram/tsup.config.ts new file mode 100644 index 00000000..cfe90ed8 --- /dev/null +++ b/packages/@emulators/telegram/tsup.config.ts @@ -0,0 +1,19 @@ +import { defineConfig } from "tsup"; +import { cpSync, mkdirSync } from "node:fs"; +import { resolve } from "node:path"; + +const copyFonts = async () => { + const src = resolve(__dirname, "../core/src/fonts"); + const dest = resolve(__dirname, "dist/fonts"); + mkdirSync(dest, { recursive: true }); + cpSync(src, dest, { recursive: true }); +}; + +export default defineConfig({ + entry: ["src/index.ts", "src/test.ts"], + format: ["esm"], + dts: true, + sourcemap: true, + noExternal: [/^@emulators\/core/], + onSuccess: copyFonts, +}); diff --git a/packages/@emulators/telegram/vitest.config.ts b/packages/@emulators/telegram/vitest.config.ts new file mode 100644 index 00000000..e2ec3329 --- /dev/null +++ b/packages/@emulators/telegram/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: true, + }, +}); diff --git a/packages/emulate/package.json b/packages/emulate/package.json index 24808655..5803b6e6 100644 --- a/packages/emulate/package.json +++ b/packages/emulate/package.json @@ -71,6 +71,7 @@ "@emulators/resend": "workspace:*", "@emulators/stripe": "workspace:*", "@emulators/clerk": "workspace:*", + "@emulators/telegram": "workspace:*", "tsup": "^8", "typescript": "^5.7" } diff --git a/packages/emulate/src/registry.ts b/packages/emulate/src/registry.ts index a102b955..c3a0cefe 100644 --- a/packages/emulate/src/registry.ts +++ b/packages/emulate/src/registry.ts @@ -27,6 +27,7 @@ const SERVICE_NAME_LIST = [ "stripe", "mongoatlas", "clerk", + "telegram", ] as const; export type ServiceName = (typeof SERVICE_NAME_LIST)[number]; export const SERVICE_NAMES: readonly ServiceName[] = SERVICE_NAME_LIST; @@ -408,6 +409,33 @@ export const SERVICE_REGISTRY: Record = { }, }, }, + telegram: { + label: "Telegram Bot API emulator", + endpoints: + "bots, users, chats (DM + group), text messages, commands, mentions, photos with file_id round-trip, callback queries, inline keyboards, webhook delivery, long polling", + async load() { + const mod = await import("@emulators/telegram"); + return { plugin: mod.telegramPlugin, seedFromConfig: mod.seedFromConfig }; + }, + defaultFallback() { + return { login: "admin", id: 1, scopes: [] }; + }, + initConfig: { + telegram: { + bots: [ + { + username: "emulate_bot", + name: "Emulate Bot", + token: "100001:EMULATE_DEFAULT_TOKEN", + commands: [{ command: "start", description: "Start the bot" }], + }, + ], + users: [{ first_name: "Tester", username: "tester" }], + chats: [{ type: "private", between: ["emulate_bot", "tester"] }], + }, + }, + }, + clerk: { label: "Clerk authentication and user management emulator", endpoints: diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a05e659f..965ed4a1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -31,13 +31,13 @@ importers: version: 8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) apps/web: dependencies: '@ai-sdk/react': specifier: ^3.0.118 - version: 3.0.153(react@19.2.4)(zod@4.3.6) + version: 3.0.153(react@19.2.4)(zod@3.25.76) '@mdx-js/loader': specifier: ^3.1.1 version: 3.1.1 @@ -55,10 +55,10 @@ importers: version: 1.37.0 ai: specifier: ^6.0.116 - version: 6.0.151(zod@4.3.6) + version: 6.0.151(zod@3.25.76) bash-tool: specifier: ^1.3.15 - version: 1.3.16(ai@6.0.151(zod@4.3.6))(just-bash@2.14.0) + version: 1.3.16(ai@6.0.151(zod@3.25.76))(just-bash@2.14.0) class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -67,7 +67,7 @@ importers: version: 2.1.1 geist: specifier: ^1.7.0 - version: 1.7.0(next@16.2.0(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) + version: 1.7.0(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)) just-bash: specifier: ^2.14.0 version: 2.14.0 @@ -328,6 +328,34 @@ importers: specifier: ^5 version: 5.9.3 + examples/telegram-grammy: + dependencies: + grammy: + specifier: ^1.29.0 + version: 1.42.0 + devDependencies: + '@emulators/core': + specifier: workspace:* + version: link:../../packages/@emulators/core + '@emulators/telegram': + specifier: workspace:* + version: link:../../packages/@emulators/telegram + '@hono/node-server': + specifier: ^1 + version: 1.19.13(hono@4.12.12) + hono: + specifier: ^4 + version: 4.12.12 + tsx: + specifier: ^4 + version: 4.21.0 + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + packages/@emulators/adapter-next: dependencies: '@emulators/core': @@ -336,7 +364,7 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 @@ -355,13 +383,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/aws: dependencies: @@ -374,13 +402,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/clerk: dependencies: @@ -396,13 +424,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/core: dependencies: @@ -415,13 +443,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/github: dependencies: @@ -434,13 +462,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/google: dependencies: @@ -456,13 +484,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/microsoft: dependencies: @@ -478,13 +506,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/mongoatlas: dependencies: @@ -497,13 +525,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/okta: dependencies: @@ -519,13 +547,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/resend: dependencies: @@ -538,13 +566,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/slack: dependencies: @@ -557,13 +585,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/stripe: dependencies: @@ -576,13 +604,35 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) + typescript: + specifier: ^5.7 + version: 5.9.3 + vitest: + specifier: ^4.1.0 + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) + + packages/@emulators/telegram: + dependencies: + '@emulators/core': + specifier: workspace:* + version: link:../core + hono: + specifier: ^4 + version: 4.12.12 + zod: + specifier: ^4.3.6 + version: 4.3.6 + devDependencies: + tsup: + specifier: ^8 + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/@emulators/vercel: dependencies: @@ -595,13 +645,13 @@ importers: devDependencies: tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 vitest: specifier: ^4.1.0 - version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + version: 4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) packages/emulate: dependencies: @@ -654,12 +704,15 @@ importers: '@emulators/stripe': specifier: workspace:* version: link:../@emulators/stripe + '@emulators/telegram': + specifier: workspace:* + version: link:../@emulators/telegram '@emulators/vercel': specifier: workspace:* version: link:../@emulators/vercel tsup: specifier: ^8 - version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3) + version: 8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3) typescript: specifier: ^5.7 version: 5.9.3 @@ -1098,6 +1151,9 @@ packages: '@floating-ui/utils@0.2.11': resolution: {integrity: sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==} + '@grammyjs/types@3.26.0': + resolution: {integrity: sha512-jlnyfxfev/2o68HlvAGRocAXgdPPX5QabG7jZlbqC2r9DZyWBfzTlg+nu3O3Fy4EhgLWu28hZ/8wr7DsNamP9A==} + '@hono/node-server@1.19.13': resolution: {integrity: sha512-TsQLe4i2gvoTtrHje625ngThGBySOgSK3Xo2XRYOdqGN1teR8+I7vchQC46uLJi8OF62YTYA3AhSpumtkhsaKQ==} engines: {node: '>=18.14.1'} @@ -2939,6 +2995,10 @@ packages: '@vitest/utils@4.1.3': resolution: {integrity: sha512-Pc/Oexse/khOWsGB+w3q4yzA4te7W4gpZZAvk+fr8qXfTURZUMj5i7kuxsNK5mP/dEB6ao3jfr0rs17fHhbHdw==} + abort-controller@3.0.0: + resolution: {integrity: sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==} + engines: {node: '>=6.5'} + accepts@2.0.0: resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} engines: {node: '>= 0.6'} @@ -3879,6 +3939,10 @@ packages: resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} engines: {node: '>= 0.6'} + event-target-shim@5.0.1: + resolution: {integrity: sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==} + engines: {node: '>=6'} + eventsource-parser@3.0.6: resolution: {integrity: sha512-Vo1ab+QXPzZ4tCa8SwIHJFaSzy4R6SHf7BY79rFBDf0idraZWAkYrDjDj8uWaSm3S2TK+hJ7/t1CEmZ7jXw+pg==} engines: {node: '>=18.0.0'} @@ -4122,6 +4186,10 @@ packages: graceful-readlink@1.0.1: resolution: {integrity: sha512-8tLu60LgxF6XpdbK8OW3FA+IfTNBn1ZHGHKF4KQbEeSkajYw5PlYJcKluntgegDPTg8UkHjpet1T82vk6TQ68w==} + grammy@1.42.0: + resolution: {integrity: sha512-1AdCge+AkjSdp2FwfICSFnVbl8Mq3KVHJDy+DgTI9+D6keJ0zWALPRKas5jv/8psiCzL4N2cEOcGW7O45Kn39g==} + engines: {node: ^12.20.0 || >=14.13.1} + graphql@16.13.1: resolution: {integrity: sha512-gGgrVCoDKlIZ8fIqXBBb0pPKqDgki0Z/FSKNiQzSGj2uEYHr1tq5wmBegGwJx6QB5S5cM0khSBpi/JFHMCvsmQ==} engines: {node: ^12.22.0 || ^14.16.0 || ^16.0.0 || >=17.0.0} @@ -5008,6 +5076,15 @@ packages: resolution: {integrity: sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==} engines: {node: '>= 0.4'} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -5902,6 +5979,9 @@ packages: resolution: {integrity: sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==} engines: {node: '>=16'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + tree-kill@1.2.2: resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} hasBin: true @@ -5957,6 +6037,11 @@ packages: typescript: optional: true + tsx@4.21.0: + resolution: {integrity: sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==} + engines: {node: '>=18.0.0'} + hasBin: true + tunnel-agent@0.6.0: resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==} @@ -6239,6 +6324,12 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-boxed-primitive@1.1.1: resolution: {integrity: sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==} engines: {node: '>= 0.4'} @@ -6348,28 +6439,28 @@ packages: snapshots: - '@ai-sdk/gateway@3.0.93(zod@4.3.6)': + '@ai-sdk/gateway@3.0.93(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) '@vercel/oidc': 3.1.0 - zod: 4.3.6 + zod: 3.25.76 - '@ai-sdk/provider-utils@4.0.23(zod@4.3.6)': + '@ai-sdk/provider-utils@4.0.23(zod@3.25.76)': dependencies: '@ai-sdk/provider': 3.0.8 '@standard-schema/spec': 1.1.0 eventsource-parser: 3.0.6 - zod: 4.3.6 + zod: 3.25.76 '@ai-sdk/provider@3.0.8': dependencies: json-schema: 0.4.0 - '@ai-sdk/react@3.0.153(react@19.2.4)(zod@4.3.6)': + '@ai-sdk/react@3.0.153(react@19.2.4)(zod@3.25.76)': dependencies: - '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) - ai: 6.0.151(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) + ai: 6.0.151(zod@3.25.76) react: 19.2.4 swr: 2.4.1(react@19.2.4) throttleit: 2.1.0 @@ -6789,6 +6880,8 @@ snapshots: '@floating-ui/utils@0.2.11': {} + '@grammyjs/types@3.26.0': {} + '@hono/node-server@1.19.13(hono@4.12.12)': dependencies: hono: 4.12.12 @@ -8568,14 +8661,14 @@ snapshots: chai: 6.2.2 tinyrainbow: 3.1.0 - '@vitest/mocker@4.1.3(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3))': + '@vitest/mocker@4.1.3(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3))': dependencies: '@vitest/spy': 4.1.3 estree-walker: 3.0.3 magic-string: 0.30.21 optionalDependencies: msw: 2.12.13(@types/node@22.19.17)(typescript@5.9.3) - vite: 8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3) + vite: 8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) '@vitest/pretty-format@4.1.3': dependencies: @@ -8601,6 +8694,10 @@ snapshots: convert-source-map: 2.0.0 tinyrainbow: 3.1.0 + abort-controller@3.0.0: + dependencies: + event-target-shim: 5.0.1 + accepts@2.0.0: dependencies: mime-types: 3.0.2 @@ -8614,13 +8711,13 @@ snapshots: agent-base@7.1.4: {} - ai@6.0.151(zod@4.3.6): + ai@6.0.151(zod@3.25.76): dependencies: - '@ai-sdk/gateway': 3.0.93(zod@4.3.6) + '@ai-sdk/gateway': 3.0.93(zod@3.25.76) '@ai-sdk/provider': 3.0.8 - '@ai-sdk/provider-utils': 4.0.23(zod@4.3.6) + '@ai-sdk/provider-utils': 4.0.23(zod@3.25.76) '@opentelemetry/api': 1.9.0 - zod: 4.3.6 + zod: 3.25.76 ajv-formats@3.0.1(ajv@8.18.0): optionalDependencies: @@ -8758,9 +8855,9 @@ snapshots: baseline-browser-mapping@2.10.9: {} - bash-tool@1.3.16(ai@6.0.151(zod@4.3.6))(just-bash@2.14.0): + bash-tool@1.3.16(ai@6.0.151(zod@3.25.76))(just-bash@2.14.0): dependencies: - ai: 6.0.151(zod@4.3.6) + ai: 6.0.151(zod@3.25.76) fast-glob: 3.3.3 yaml: 2.8.3 zod: 3.25.76 @@ -9479,7 +9576,7 @@ snapshots: eslint: 9.39.4(jiti@2.6.1) eslint-import-resolver-node: 0.3.9 eslint-import-resolver-typescript: 3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-jsx-a11y: 6.10.2(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react: 7.37.5(eslint@9.39.4(jiti@2.6.1)) eslint-plugin-react-hooks: 7.0.1(eslint@9.39.4(jiti@2.6.1)) @@ -9516,7 +9613,7 @@ snapshots: tinyglobby: 0.2.15 unrs-resolver: 1.11.1 optionalDependencies: - eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)) + eslint-plugin-import: 2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)) transitivePeerDependencies: - supports-color @@ -9531,7 +9628,7 @@ snapshots: transitivePeerDependencies: - supports-color - eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)))(eslint@9.39.4(jiti@2.6.1)): + eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.58.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint-import-resolver-typescript@3.10.1)(eslint@9.39.4(jiti@2.6.1)): dependencies: '@rtsao/scc': 1.1.0 array-includes: 3.1.9 @@ -9719,6 +9816,8 @@ snapshots: etag@1.8.1: {} + event-target-shim@5.0.1: {} + eventsource-parser@3.0.6: {} eventsource@3.0.7: @@ -9935,7 +10034,7 @@ snapshots: fuzzysort@3.1.0: {} - geist@1.7.0(next@16.2.0(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): + geist@1.7.0(next@16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)): dependencies: next: 16.2.0(@babel/core@7.29.0)(@opentelemetry/api@1.9.0)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) @@ -10012,6 +10111,16 @@ snapshots: graceful-readlink@1.0.1: {} + grammy@1.42.0: + dependencies: + '@grammyjs/types': 3.26.0 + abort-controller: 3.0.0 + debug: 4.4.3 + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + - supports-color + graphql@16.13.1: {} hachure-fill@0.5.2: {} @@ -11224,6 +11333,10 @@ snapshots: object.entries: 1.1.9 semver: 6.3.1 + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -11448,12 +11561,13 @@ snapshots: postal-mime@2.7.4: {} - postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8)(yaml@2.8.3): + postcss-load-config@6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3): dependencies: lilconfig: 3.1.3 optionalDependencies: jiti: 2.6.1 postcss: 8.5.8 + tsx: 4.21.0 yaml: 2.8.3 postcss-selector-parser@7.1.1: @@ -12427,6 +12541,8 @@ snapshots: dependencies: tldts: 7.0.26 + tr46@0.0.3: {} + tree-kill@1.2.2: {} trim-lines@3.0.1: {} @@ -12461,7 +12577,7 @@ snapshots: tslib@2.8.1: {} - tsup@8.5.1(jiti@2.6.1)(postcss@8.5.8)(typescript@5.9.3)(yaml@2.8.3): + tsup@8.5.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(typescript@5.9.3)(yaml@2.8.3): dependencies: bundle-require: 5.1.0(esbuild@0.27.4) cac: 6.7.14 @@ -12472,7 +12588,7 @@ snapshots: fix-dts-default-cjs-exports: 1.0.1 joycon: 3.1.1 picocolors: 1.1.1 - postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(yaml@2.8.3) + postcss-load-config: 6.0.1(jiti@2.6.1)(postcss@8.5.8)(tsx@4.21.0)(yaml@2.8.3) resolve-from: 5.0.0 rollup: 4.59.0 source-map: 0.7.6 @@ -12489,6 +12605,13 @@ snapshots: - tsx - yaml + tsx@4.21.0: + dependencies: + esbuild: 0.27.4 + get-tsconfig: 4.13.6 + optionalDependencies: + fsevents: 2.3.3 + tunnel-agent@0.6.0: dependencies: safe-buffer: 5.2.1 @@ -12707,7 +12830,7 @@ snapshots: '@types/unist': 3.0.3 vfile-message: 4.0.3 - vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3): + vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3): dependencies: lightningcss: 1.32.0 picomatch: 4.0.4 @@ -12719,15 +12842,16 @@ snapshots: esbuild: 0.27.4 fsevents: 2.3.3 jiti: 2.6.1 + tsx: 4.21.0 yaml: 2.8.3 transitivePeerDependencies: - '@emnapi/core' - '@emnapi/runtime' - vitest@4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)): + vitest@4.1.3(@opentelemetry/api@1.9.0)(@types/node@22.19.17)(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)): dependencies: '@vitest/expect': 4.1.3 - '@vitest/mocker': 4.1.3(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3)) + '@vitest/mocker': 4.1.3(msw@2.12.13(@types/node@22.19.17)(typescript@5.9.3))(vite@8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3)) '@vitest/pretty-format': 4.1.3 '@vitest/runner': 4.1.3 '@vitest/snapshot': 4.1.3 @@ -12738,13 +12862,13 @@ snapshots: magic-string: 0.30.21 obug: 2.1.1 pathe: 2.0.3 - picomatch: 4.0.3 + picomatch: 4.0.4 std-env: 4.0.0 tinybench: 2.9.0 tinyexec: 1.0.4 tinyglobby: 0.2.15 tinyrainbow: 3.1.0 - vite: 8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(yaml@2.8.3) + vite: 8.0.1(@emnapi/core@1.9.1)(@emnapi/runtime@1.9.1)(@types/node@22.19.17)(esbuild@0.27.4)(jiti@2.6.1)(tsx@4.21.0)(yaml@2.8.3) why-is-node-running: 2.3.0 optionalDependencies: '@opentelemetry/api': 1.9.0 @@ -12773,6 +12897,13 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-boxed-primitive@1.1.1: dependencies: is-bigint: 1.1.0 diff --git a/skills/telegram/SKILL.md b/skills/telegram/SKILL.md new file mode 100644 index 00000000..4d430251 --- /dev/null +++ b/skills/telegram/SKILL.md @@ -0,0 +1,128 @@ +--- +name: telegram +description: Emulated Telegram Bot API for local development and testing. Use when the user needs to end-to-end test a Telegram bot without creating a real bot or clicking through Telegram clients. Supports text messages, bot commands, group chats with mentions, photo uploads with file_id round-trip, callback queries + inline keyboards, webhook delivery with retry, long polling. Triggers include "Telegram bot", "Telegram Bot API", "emulate Telegram", "mock Telegram", "test Telegram webhook", "grammY", "telegraf", "bot e2e tests", or any task requiring a local Telegram Bot API. +allowed-tools: Bash(npx emulate:*), Bash(emulate:*), Bash(curl:*) +--- + +# Telegram Bot API Emulator + +Fully stateful Telegram Bot API emulation. Real grammY / telegraf / `@chat-adapter/telegram` SDKs connect unmodified. Simulates users sending messages and clicking buttons so bot code runs end-to-end without network. + +## Start + +```bash +# Telegram only — default port 4011 +npx emulate --service telegram +``` + +Or programmatically: + +```typescript +import { createEmulator } from "emulate"; + +const tg = await createEmulator({ service: "telegram", port: 4011 }); +// tg.url === 'http://localhost:4011' +``` + +## Test client + +```typescript +import { createTelegramTestClient } from "@emulators/telegram/test"; + +const tg = createTelegramTestClient("http://localhost:4011"); + +const bot = await tg.createBot({ username: "trip_test_bot", first_name: "Trip Test" }); +const user = await tg.createUser({ first_name: "Alice" }); +const dm = await tg.createPrivateChat({ botId: bot.bot_id, userId: user.id }); + +// User sends a text message — bot receives it via webhook or long polling. +await tg.sendUserMessage({ chatId: dm.id, userId: user.id, text: "/connect ABC" }); + +// User uploads a photo. +await tg.sendUserPhoto({ chatId: dm.id, userId: user.id, photoBytes: fs.readFileSync("test.jpg") }); + +// User taps an inline keyboard button on a message the bot sent. +await tg.clickInlineButton({ chatId: dm.id, userId: user.id, messageId: 42, callbackData: "confirm:yes" }); + +// Assert on what the bot sent. +const replies = await tg.getSentMessages({ chatId: dm.id }); +``` + +## Pointing your bot at the emulator + +### grammY + +```typescript +import { Bot } from "grammy"; +const bot = new Bot(process.env.BOT_TOKEN!, { + client: { apiRoot: process.env.TELEGRAM_API_ROOT ?? "https://api.telegram.org" }, +}); +``` + +Set `TELEGRAM_API_ROOT=http://localhost:4011` in tests. + +### telegraf + +```typescript +import { Telegraf } from "telegraf"; +const bot = new Telegraf(process.env.BOT_TOKEN!, { + telegram: { apiRoot: process.env.TELEGRAM_API_ROOT ?? "https://api.telegram.org" }, +}); +``` + +### @chat-adapter/telegram + +Use `mode: "polling"` and point at the emulator by injecting the base URL before the adapter is created. The adapter uses `https://api.telegram.org/bot` under the hood — override via environment or DI. + +## Webhook vs long polling + +The emulator supports both, per bot: + +```typescript +// Webhook: bot calls setWebhook({ url }) — the emulator POSTs Update JSON to `url` on user activity. +await fetch(`${tg.url}/bot${bot.token}/setWebhook`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ url: "http://localhost:3000/api/webhooks/telegram", secret_token: "sekret" }), +}); + +// Long polling: omit setWebhook; bot drains updates via getUpdates. +const res = await fetch(`${tg.url}/bot${bot.token}/getUpdates`); +``` + +Webhook delivery retries on 5xx up to 3 times (1s / 2s / 4s backoff). Terminal on 4xx. The `X-Telegram-Bot-Api-Secret-Token` header is sent when configured. + +## Seed config + +```yaml +telegram: + bots: + - username: trip_bot + first_name: Trip Bot + token: "100001:TRIP_BOT_TOKEN" + 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] +``` + +## Inspector + +Open `http://localhost:4011/` in a browser for a read-only view of bots, chats, messages, and the Update queue. Useful for debugging test failures. + +## Privacy rules in groups + +Matches real Telegram: bots in groups only see messages that mention them (`@bot_username`) or are addressed bot commands (`/command` or `/command@bot_username`). To see every message, set `can_read_all_group_messages: true` when creating the bot. + +## Non-goals + +Payments, games, Telegram Business API, Passport, TON wallets, BotFather account management. Out of scope forever. From 373bed667aa226675d2bfeaa8fec671fe7cbdfc3 Mon Sep 17 00:00:00 2001 From: Sergei Patrikeev <6849689+serejke@users.noreply.github.com> Date: Tue, 21 Apr 2026 18:16:11 +0100 Subject: [PATCH 2/2] feat(telegram): allow plain HTTP on loopback hosts for setWebhook 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. --- packages/@emulators/telegram/README.md | 4 ++-- .../telegram/src/__tests__/webhook.test.ts | 21 +++++++++++++++++++ .../telegram/src/routes/bot-api-delivery.ts | 18 +++++++++++++--- 3 files changed, 38 insertions(+), 5 deletions(-) diff --git a/packages/@emulators/telegram/README.md b/packages/@emulators/telegram/README.md index 61b2b09b..b84168bd 100644 --- a/packages/@emulators/telegram/README.md +++ b/packages/@emulators/telegram/README.md @@ -57,7 +57,7 @@ Full chat-SDK parity surface: | Area | Bot API methods | | ---------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | Identity | `getMe` | -| Delivery | `getUpdates` (with `allowed_updates` filter), `setWebhook` (HTTPS-only, with `secret_token` + `allowed_updates`), `deleteWebhook`, `getWebhookInfo` | +| Delivery | `getUpdates` (with `allowed_updates` filter), `setWebhook` (HTTPS for public URLs, plain HTTP for loopback hosts; with `secret_token` + `allowed_updates`), `deleteWebhook`, `getWebhookInfo` | | Messaging | `sendMessage`, `sendPhoto`, `sendDocument`, `sendVideo`, `sendAudio`, `sendVoice`, `sendAnimation`, `sendSticker`, `editMessageText`, `editMessageReplyMarkup`, `deleteMessage`, `sendChatAction` | | Formatting | `parse_mode = MarkdownV2` / `HTML` / `Markdown` (legacy v1) on text + caption surfaces, including `blockquote` / `expandable_blockquote` | | Streaming | `sendMessageDraft` — emulator-only extension for testing animated streamed replies (no real Bot API method; appends snapshots under `(chat_id, draft_id, bot_id)`) | @@ -78,7 +78,7 @@ Validation (matches real Telegram — rejects, does not trim): - Caption: > 1024 chars → `400 Bad Request: message caption is too long` - MarkdownV2 unescaped reserved char → `400 can't parse entities: character 'X' is reserved and must be escaped with the preceding '\'` - `message_thread_id` in non-supergroup → `400 Bad Request: message thread not found` -- `setWebhook` with non-HTTPS URL → `400 Bad Request: bad webhook: HTTPS url must be provided for webhook` +- `setWebhook` with non-HTTPS URL pointing at a non-loopback host → `400 Bad Request: bad webhook: HTTPS url must be provided for webhook`. Loopback hosts (`localhost`, `127.0.0.1`, `::1`) are allowed to use plain HTTP so hermetic test setups can run a receiver on a random free port without terminating TLS. - `sendMessage` with `reply_to_message_id` pointing at a missing message → `400 Bad Request: message to be replied not found` - Concurrent `getUpdates` for the same bot → `409 Conflict: terminated by other getUpdates request` (real-Telegram wording) diff --git a/packages/@emulators/telegram/src/__tests__/webhook.test.ts b/packages/@emulators/telegram/src/__tests__/webhook.test.ts index 24601616..1083a461 100644 --- a/packages/@emulators/telegram/src/__tests__/webhook.test.ts +++ b/packages/@emulators/telegram/src/__tests__/webhook.test.ts @@ -106,4 +106,25 @@ describe("Telegram webhook delivery", () => { const body = await json<{ ok: boolean }>(res); expect(body.ok).toBe(true); }); + + it("accepts plain-HTTP webhook URLs on loopback hosts", async () => { + const bot = createBot(tx.store, { username: "dev_bot" }); + + for (const url of [ + "http://localhost:9999/webhook", + "http://127.0.0.1:9999/webhook", + "http://[::1]:9999/webhook", + ]) { + const res = await postJson(tx.app, `/bot${bot.token}/setWebhook`, { url }); + expect(res.status, `expected ${url} accepted`).toBe(200); + } + + // Non-loopback HTTP is still rejected. + const rejected = await postJson(tx.app, `/bot${bot.token}/setWebhook`, { + url: "http://example.com/webhook", + }); + expect(rejected.status).toBe(400); + const rejBody = await json<{ description: string }>(rejected); + expect(rejBody.description).toContain("HTTPS url must be provided"); + }); }); diff --git a/packages/@emulators/telegram/src/routes/bot-api-delivery.ts b/packages/@emulators/telegram/src/routes/bot-api-delivery.ts index a743f9c3..3919195b 100644 --- a/packages/@emulators/telegram/src/routes/bot-api-delivery.ts +++ b/packages/@emulators/telegram/src/routes/bot-api-delivery.ts @@ -73,9 +73,12 @@ export function setWebhook( // Empty URL removes the webhook, matching real behaviour. return deleteWebhook(c, bot, store); } - // Real Telegram only allows HTTPS webhooks (and rejects localhost, though - // the emulator is lenient there for hermetic testing). - if (!/^https:\/\//i.test(url)) { + // Real Telegram requires HTTPS webhooks. The emulator keeps that check for + // non-loopback URLs so tests catch the real production validation, but + // allows plain HTTP on localhost/127.0.0.1/::1 so in-process test suites + // (e.g. grammy-emulate's webhook-mode mount) can run a receiver on a random + // free port without terminating TLS. + if (!/^https:\/\//i.test(url) && !isLoopbackUrl(url)) { return tgError(c, "Bad Request: bad webhook: HTTPS url must be provided for webhook", 400, 400); } @@ -94,6 +97,15 @@ export function deleteWebhook(c: Context, bot: TelegramBot, store: Store) { return okRaw(c, true); } +function isLoopbackUrl(url: string): boolean { + try { + const host = new URL(url).hostname; + return host === "localhost" || host === "127.0.0.1" || host === "[::1]" || host === "::1"; + } catch { + return false; + } +} + export function getWebhookInfo(c: Context, bot: TelegramBot) { return ok(c, { url: bot.webhook_url ?? "",