# telegram-bot-test-server

> A local test server for the Telegram Bot API, for testing bots: members, restrictions, join requests, invite links, multiple bots, channels, polls, business chats, Telegram Login, webhooks and callback buttons

Latest version **0.9.0** (published 2026-09-30) · MIT license · 0 weekly downloads

## Install

```sh
npm install telegram-bot-test-server
pnpm add telegram-bot-test-server
yarn add telegram-bot-test-server
bun add telegram-bot-test-server
```

Provides the command `telegram-bot-test-server`.

## Health

**Score 75/100 (B)** — status: active.

Positive: has types; esm support; no vulnerabilities; has provenance; recently updated; high maintenance score; high quality score.

Warnings: low downloads; pre 1.0.

## Facts

| | |
|---|---|
| Version | 0.9.0 |
| Published | 2026-09-30 |
| First published | 2026-09-28 |
| Weekly downloads | 0 |
| License | MIT |
| TypeScript types | bundled |
| Module format | ESM |
| Node | >=20 |
| Dependencies | 0 |
| Unpacked size | 277 KB |
| Known vulnerabilities | 0 |
| Install scripts | no |
| Provenance | attested (GitHub Actions) |
| Author | Anatoly Ben |
| Maintainers | anatolyb |
| Keywords | telegram, telegram-bot, bot-api, fake, mock, test-server, testing |

## Links

- npm: https://www.npmjs.com/package/telegram-bot-test-server
- Repository: https://github.com/anatolyben/telegram-bot-test-server
- Homepage: https://github.com/anatolyben/telegram-bot-test-server#readme
- Issues: https://github.com/anatolyben/telegram-bot-test-server/issues
- npm.io page: https://npm.io/package/telegram-bot-test-server

## Alternatives

- [pagerjs](https://npm.io/package/pagerjs.md) — 60 weekly downloads
- [whistle.savefor-mock](https://npm.io/package/whistle.savefor-mock.md) — 4 weekly downloads
- [@crvouga/mockingbird-service-fcm](https://npm.io/package/@crvouga/mockingbird-service-fcm.md) — 0 weekly downloads
- [jabber](https://npm.io/package/jabber.md) — 0 weekly downloads
- [@simulacrum/ldap-simulator](https://npm.io/package/@simulacrum/ldap-simulator.md) — 0 weekly downloads

## Recent versions

- 0.9.0 (latest) — 2026-09-30
- 0.8.1 — 2026-09-29
- 0.8.0 — 2026-09-29
- 0.7.0 — 2026-09-29
- 0.6.0 — 2026-09-28
- 0.5.0 — 2026-09-28
- 0.4.0 — 2026-09-28
- 0.3.1 — 2026-09-28
- 0.3.0 — 2026-09-28
- 0.2.0 — 2026-09-28
- 0.1.0 — 2026-09-28

## README

# telegram-bot-test-server

`telegram-bot-test-server` is a local test server for the Telegram Bot API, for testing bots that
manage groups. It keeps everything in memory.

Point your bot's Bot API base URL at it instead of `https://api.telegram.org`. It answers the way
Telegram does and keeps the state a group bot depends on: members and their status, restrictions and
bans, messages and deletions, invite links, join requests and profile photos. Your test plays the
other side: users join, leave, post, ask to join, press buttons and message the bot, and the server
sends your bot the same updates Telegram would, by webhook or through `getUpdates` polling.

Nothing here talks to Telegram, so tests need no real accounts, phone numbers or groups, and can run
as often as they like in CI.

The project is intentionally narrow and early-stage.

## Install

```sh
npm install --save-dev telegram-bot-test-server
```

Requires Node.js 20 or newer. No runtime dependencies.

## Quick start

A grammY bot that deletes links and bans whoever posted them, tested end to end:

```js
import { Bot } from "grammy";
import { startTestServer } from "telegram-bot-test-server";

const GROUP = -1001000000001;
const server = await startTestServer({
  botToken: "123456:TEST",
  chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
});

// Your bot, unchanged except for where it sends Bot API calls.
const bot = new Bot("123456:TEST", { client: { apiRoot: server.origin } });
bot.on("message:text", async (ctx) => {
  if (ctx.message.entities?.some((entity) => entity.type === "url")) {
    await ctx.deleteMessage();
    await ctx.banChatMember(ctx.from.id);
  }
});
bot.start();

// Telegram's side, played by the test.
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
const spam = await server.post(GROUP, ann, "cheap followers at example.com");

// Then check what the bot did. It runs asynchronously, so wait for the result:
// with Vitest, `await expect.poll(async () => (await server.getMember(GROUP, ann)).status).toBe("kicked")`.
(await server.getMember(GROUP, ann)).status; // "kicked"
(await server.getMessage(GROUP, spam)).deleted; // true

await bot.stop();
await server.stop();
```

The same works with other libraries; only the base URL option differs:

| Library       | Point it at the server                                                  |
| ------------- | ----------------------------------------------------------------------- |
| grammY        | `new Bot(token, { client: { apiRoot: server.origin } })`                |
| Telegraf      | `new Telegraf(token, { telegram: { apiRoot: server.origin } })`         |
| Anything else | Replace `https://api.telegram.org` with `server.origin` in its settings |

Both grammY and Telegraf are tested against the server, with polling and with a webhook.

## Test actions

`startTestServer()` returns the server with these actions. Each resolves once the update it causes
has been handed to the bot (sent to its webhook, or queued for `getUpdates`); what the bot does in
response happens after that, so wait for the outcome rather than checking it immediately.

| Action                                                                                           | What happens                                                                                                                                                                |
| ------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `createUser({ first_name, last_name, username, language_code, bio, is_bot, is_premium })`        | A new Telegram user; returns their id. All fields optional.                                                                                                                 |
| `connectBusiness({ ownerId, rights, id, isEnabled, botId })`                                     | The owner connects a bot to their business account, or changes the connection with `id`; the bot gets `business_connection`. Returns `{ connection, update_id }`.           |
| `getBusinessConnection(connectionId)`                                                            | The `BusinessConnection`.                                                                                                                                                   |
| `sayInBusinessChat(connectionId, userId, sender, text)`                                          | `"person"` writes to the owner, or `"owner"` answers by hand; the bot gets `business_message`. Returns `{ message_id, date, update_id }`.                                   |
| `getBusinessChat(connectionId, userId)`                                                          | The business chat, newest first: `[{ direction: "inbound" \| "owner" \| "bot", deleted, message }]`.                                                                        |
| `redeliverUpdate(updateId)`                                                                      | Telegram delivers that update again, byte for byte, to the same bot's webhook.                                                                                              |
| `updateProfile(userId, fields)`                                                                  | The user changes their name, username or bio.                                                                                                                               |
| `addProfilePhoto(userId, bytes)`                                                                 | The user adds a profile photo.                                                                                                                                              |
| `join(chatId, userId)`                                                                           | The user joins the group.                                                                                                                                                   |
| `joinByLink(inviteLink, userId)`                                                                 | The user opens an invite link: joins, or files a join request if the link requires approval.                                                                                |
| `leave(chatId, userId)`                                                                          | The user leaves.                                                                                                                                                            |
| `post(chatId, userId, text)`                                                                     | The user posts a message; returns its `message_id`. Also takes `{ text, photo, media, caption, replyTo, threadId, forwardFrom }`. Fails if the user is not allowed to post. |
| `postAlbum(chatId, userId, items)`                                                               | The user posts 2 to 10 photos or videos as one album (`media_group_id`).                                                                                                    |
| `editMessage(chatId, messageId, userId, { text, caption })`                                      | The author edits their message; bots get `edited_message`.                                                                                                                  |
| `react(chatId, messageId, userId, emoji)`                                                        | The user reacts to a message, or takes the reaction back with `null`.                                                                                                       |
| `pressButton(chatId, messageId, userId, data)`                                                   | The user presses an inline button; resolves with the bot's `answerCallbackQuery` answer.                                                                                    |
| `postGuestBotReply(chatId, userId, botUsername, text)`                                           | The user calls a guest bot (Bot API 10.0 guest mode); its answer appears in the group from that bot, with `guest_bot_caller_user` set.                                      |
| `sendDirectMessage(userId, text)`                                                                | The user messages the bot privately.                                                                                                                                        |
| `pressDirectButton(userId, messageId, data)`                                                     | The user presses a button in their private chat with the bot.                                                                                                               |
| `getMessages(chatId)`, `getMessage(chatId, id)`                                                  | The chat's messages, and whether one was deleted.                                                                                                                           |
| `getDirectMessages(userId)`                                                                      | The private chat between the user and the bot.                                                                                                                              |
| `getMember(chatId, userId)`                                                                      | The member as `getChatMember` returns them: status, restrictions, ban.                                                                                                      |
| `getJoinRequests(chatId)`                                                                        | User ids waiting for approval.                                                                                                                                              |
| `addBot({ token, username, firstName, loginClientSecret })`                                      | Another bot, with its own webhook or update queue; it is in no chat yet.                                                                                                    |
| `approveLogin(authUrl, userId)`                                                                  | The user logs in on the Telegram Login page for that `/auth` URL; returns the `redirect_uri` URL with `code` and `state`.                                                   |
| `cancelLogin(authUrl)`                                                                           | The user cancels; returns the `redirect_uri` URL with `error=access_denied` and `state`.                                                                                    |
| `createChat({ ownerId, title, type, ownerName, isForum })`                                       | A new supergroup, forum (`isForum`), basic group (`type: "group"`) or channel (`type: "channel"`) with no bot in it; returns its id.                                        |
| `addBotViaLink(chatId, botId, { by, startParameter, rights })`                                   | A person adds the bot through its `startgroup` link: it joins (as an administrator with `rights`), then `/start@<bot> <startParameter>` is posted from the person.          |
| `migrateToSupergroup(chatId, { by })`                                                            | The creator or an administrator upgrades a basic group; returns the supergroup's id.                                                                                        |
| `renameChat(chatId, { by, title })`, `changeChatPhoto(chatId, { by, bytes })`                    | A person with `can_change_info` renames the chat or sets its photo.                                                                                                         |
| `setBotMembership(chatId, botId, { status, rights, by })`                                        | The owner adds, promotes, demotes or removes a bot; the bot gets `my_chat_member`.                                                                                          |
| `createTopic(chatId, name)`, `renameTopic(chatId, threadId, name)`                               | A forum topic is created or renamed, with Telegram's service message; `createTopic` returns its `message_thread_id`.                                                        |
| `getChat(chatId)`                                                                                | The chat, its pinned message ids and its members.                                                                                                                           |
| `failNext({ method, chatId, botId, times, errorCode, description, retryAfter, dropAfterApply })` | The next matching Bot API calls fail with that error, or (`dropAfterApply`) take effect and never answer.                                                                   |
| `clearFailures()`                                                                                | Drop failure rules not used up.                                                                                                                                             |
| `getCalls()`                                                                                     | Every Bot API call received, with the bot that made it, and any unsupported methods called.                                                                                 |
| `stop()`                                                                                         | Shut the server down.                                                                                                                                                       |

## Use from any language

```sh
npx telegram-bot-test-server --token 123456:TEST --port 8081 --config chats.json
```

| Flag                 | Default         | Meaning                                                   |
| -------------------- | --------------- | --------------------------------------------------------- |
| `--token`            | required        | The bot's token.                                          |
| `--port`, `--host`   | 8081, 127.0.0.1 | Where to listen.                                          |
| `--username`         | `fake_test_bot` | The bot's username.                                       |
| `--config`           | none            | A JSON file with `chats` and `publicChats`.               |
| `--unimplemented-ok` | off             | Answer `true` to unsupported methods instead of an error. |

`chats.json` holds `{ "chats": [...], "publicChats": [...] }` in the same shape as the options
below. Point your bot's Bot API base URL at `http://127.0.0.1:8081` and drive the same test actions
over HTTP through the control API described below, from Python, Go or anything else.

## Options

| Option                       | Default          | Meaning                                                                                      |
| ---------------------------- | ---------------- | -------------------------------------------------------------------------------------------- |
| `botToken`                   | required         | `<numeric id>:<secret>`. Calls with any other token get 401.                                 |
| `port`, `host`               | `0`, `127.0.0.1` | Where to listen. Port 0 picks a free port.                                                   |
| `botUsername`                | `fake_test_bot`  | Returned by `getMe`.                                                                         |
| `botName`                    | `Fake Test Bot`  | Returned by `getMe`.                                                                         |
| `supportsJoinRequestQueries` | `false`          | A guard bot: join requests reach it with a `query_id` to answer.                             |
| `loginClientSecret`          | random           | The first bot's Telegram Login client secret.                                                |
| `chats`                      | `[]`             | Supergroups `{ id, title, ownerId, ownerName? }`. The bot is an administrator.               |
| `publicChats`                | `[]`             | Channels, groups and bots `{ username, type, title? }` resolvable by `getChat("@username")`. |
| `unimplemented`              | `"error"`        | What an unsupported method returns: a 404 error naming it, or `"ok"` for `true`.             |
| `log`                        | none             | Receives one line per notable event (unsupported methods, webhook failures).                 |

## Supported Bot API methods

These read or change the server's state:

`getMe`, `getUpdates`, `setWebhook`, `deleteWebhook`, `getWebhookInfo`, `getChat`, `getChatMember`,
`getChatAdministrators`, `getChatMemberCount`, `getUserProfilePhotos`, `getFile`, `sendMessage`,
`sendPhoto`, `sendDocument`, `sendVideo`, `sendAnimation`, `sendSticker`, `sendPoll`, `stopPoll`,
`forwardMessage`, `copyMessage`, `editMessageText`, `editMessageReplyMarkup`, `editMessageCaption`,
`editMessageMedia`, `pinChatMessage`, `unpinChatMessage`, `unpinAllChatMessages`, `leaveChat`,
`deleteMessage`, `deleteMessages`, `sendVoice`, `sendAudio`, `sendVideoNote`, `sendMediaGroup`,
`sendLocation`, `sendVenue`, `sendContact`, `sendDice`, `sendChatAction`, `setMessageReaction`,
`deleteMessageReaction`, `restrictChatMember`, `banChatMember`, `unbanChatMember`,
`promoteChatMember`, `setChatAdministratorCustomTitle`, `setChatPermissions`, `setChatTitle`,
`setChatDescription`, `setChatPhoto`, `deleteChatPhoto`, `approveChatJoinRequest`,
`declineChatJoinRequest`, `answerChatJoinRequestQuery`, `createChatInviteLink`,
`exportChatInviteLink`, `editChatInviteLink`, `revokeChatInviteLink`, `answerCallbackQuery`,
`setMyCommands`, `deleteMyCommands`, `getMyCommands`, `getBusinessConnection`, and `sendMessage`
with `business_connection_id`.

These are accepted and return success without changing anything: `setMyDescription`,
`setMyShortDescription`, `setChatMenuButton`, `setMyDefaultAdministratorRights`.

Any other method returns a 404 error that names it, so a test cannot pass against behaviour the
server does not have. Methods are added when a real bot needs them; the goal is not full coverage of
the Bot API. Method names are case-insensitive, and parameters are accepted as a query string, JSON
or multipart form data, as with Telegram.

## Behaves like Telegram

The details a moderation bot depends on, each covered by a test:

- **Permissions.** Unspecified permissions are false, and unless `use_independent_chat_permissions`
  is set, broader permissions imply narrower ones (`can_send_other_messages` implies media and text).
  A member needs both their own permission and the chat's default from `setChatPermissions` to post;
  a photo needs `can_send_photos`, not only `can_send_messages`.
- **Restrictions stick.** A restricted user who leaves and rejoins is still restricted.
- **Protected members.** Restricting or banning the chat owner, an administrator or the bot itself
  fails with Telegram's error.
- **Unbanning.** `unbanChatMember` without `only_if_banned` removes a current member, as the docs
  guarantee.
- **Editing.** Only the bot's own messages can be edited; an edit that changes nothing fails with
  `message is not modified`; an edit without `reply_markup` removes the inline keyboard, after which
  its buttons can no longer be pressed.
- **Private chats.** The bot cannot message a user who has not written to it first (403).
- **Ephemeral messages.** A send with `ephemeral_message_parameters` (Bot API 10.2) returns a message
  with `receiver_user` and `ephemeral_message_id`. Unlike Telegram, which gives it `message_id` 0, it
  keeps an ordinary message id, so tests can find it in the chat and press its buttons. The
  `editEphemeralMessage…` and `deleteEphemeralMessage` methods are not modelled.
- **Callback queries.** Answering a query that was never sent fails.
- **Invite links.** Exporting a new primary link revokes the previous one; joining through a
  revoked link fails.
- **Entities.** Member messages and captions carry `bot_command`, `mention`, `email` and `url`
  entities with UTF-16 offsets, in groups and in private chats.
- **Media.** Sent photos, documents, videos, animations and stickers carry the fields the Bot API
  requires and resolve through `getFile`. `editMessageMedia` replaces a message's media with an
  upload (`attach://`) or a held `file_id`.
- **More than one bot.** Each bot has its own webhook or update queue and its own membership and
  rights in each chat. A bot posts only where it is a member (a channel needs `can_post_messages`),
  edits and stops only its own messages and polls, pins only with `can_pin_messages` (a channel's
  `can_edit_messages`), and deletes others' messages only with `can_delete_messages`. Being added,
  promoted or removed reaches that bot as `my_chat_member` and the chat's other bots as
  `chat_member`; only the bot that sent a message hears its buttons pressed. Users write privately
  only to the first bot, so no other bot can message them (403).
- **Polls.** `sendPoll` needs a question and 2 to 12 options and keeps `is_anonymous`,
  `allows_multiple_answers`, `description` and an attached photo; `stopPoll` closes a poll once.
- **Forwards and copies.** A forward carries `forward_origin`; a copy does not. A bot cannot forward
  from a chat it is not in.
- **Pins.** Pinned messages are kept, newest first, and `getChat` returns the latest as
  `pinned_message`.
- **What members send.** Besides text and photos, members post videos, animations (which carry a
  `document` too), stickers, voice notes, audio, video notes and documents, each needing its own
  permission (`can_send_videos`, `can_send_voice_notes`, ...), plus albums sharing a
  `media_group_id` and forwards with `forward_origin` (a user, a hidden user or a channel post).
  An edit by the author reaches bots as `edited_message` with `edit_date`.
- **Reactions.** A member's reaction reaches the chat's administrator bots as `message_reaction`,
  only when they list it in `allowed_updates`, as on Telegram. A bot sets at most one reaction, and
  removes a member's with `deleteMessageReaction` and `can_delete_messages`.
- **Administrators and chat settings.** `promoteChatMember` needs `can_promote_members` and grants
  only rights the bot holds; the bot can then edit and title the administrators it promoted.
  `setChatTitle`, `setChatDescription`, `setChatPhoto` and `deleteChatPhoto` need `can_change_info`,
  refuse a change that changes nothing, and post Telegram's service messages.
- **Join request queries (Bot API 10.x).** A guard bot (`supportsJoinRequestQueries`) gets each join
  request with a `query_id`, which it answers with `answerChatJoinRequestQuery`
  (`chat_join_request_query_id`, `result`: `approve`, `decline` or `queue`).
- **Business connections** ([Bot API](https://core.telegram.org/bots/api#businessconnection),
  [connected business bots](https://core.telegram.org/api/bots/connected-business-bots)). An owner
  connects the bot to their account; the bot gets `business_connection` on every change, and
  `business_message` for each message in the owner's private chats while the connection is enabled,
  from the person or from the owner answering by hand. `sendMessage` with `business_connection_id`
  answers as the owner, with `sender_business_bot` set. It needs an enabled connection, `can_reply`,
  and a message from the person in the last 24 hours (`BUSINESS_PEER_USAGE_MISSING` otherwise, as
  [documented](https://core.telegram.org/method/messages.sendMessage)). An unknown connection is
  `BUSINESS_CONNECTION_INVALID`. Unverified: the error for a disabled connection (treated as
  invalid) and for a missing `can_reply` (`403 BOT_ACCESS_FORBIDDEN`). The connected bot may also
  message the owner's private chat (`user_chat_id`). `getMe` reports `can_connect_to_business`.
- **Redelivery.** A test can have Telegram deliver any update again, byte for byte, as it does when a
  webhook does not confirm one.
- **Adding the bot through a link** ([links](https://core.telegram.org/api/links#group-channel-bot-links),
  [deep linking](https://core.telegram.org/bots/features#deep-linking)). With admin rights
  requested, only the creator or an administrator with `can_promote_members` may add it; without,
  anyone who can add members (`can_invite_users`). Otherwise the person gets `CHAT_ADMIN_REQUIRED`.
  The bot gets `my_chat_member` from the person, the chat's other bots `chat_member`, all bots the
  `new_chat_members` message, and then the person's `/start@<bot> <parameter>` with a
  `bot_command` entity, as `messages.startBot` posts. An administrator's existing rights are
  combined with the requested ones, and `/start` is still posted. Unverified: Telegram does not
  document whether `my_chat_member` or the `/start` message arrives first; this server sends
  `my_chat_member` first.
- **Service messages about the bot itself.** A bot gets the `new_chat_members` and
  `left_chat_member` messages that name it, as the
  [Message](https://core.telegram.org/bots/api#message) fields say it "may be the bot itself".
- **Basic groups and the upgrade** ([migration](https://core.telegram.org/api/channel#migration)).
  A basic group has a negative id without the `-100` prefix. The creator or an administrator can
  upgrade it: a new supergroup takes its members, administrators and bots, the old chat posts
  `migrate_to_chat_id` and the new one `migrate_from_chat_id`, and every later Bot API call to the
  old id fails with `400 Bad Request: group chat was upgraded to a supergroup chat` and
  `parameters.migrate_to_chat_id` ([ResponseParameters](https://core.telegram.org/bots/api#responseparameters)).
  Unverified: whether bots also get `my_chat_member` on the upgrade; this server sends none.
- **People changing the chat.** A person with `can_change_info` renames the chat or sets its photo,
  with the same `new_chat_title` and `new_chat_photo` service messages as `setChatTitle` and
  `setChatPhoto`; `getChat` returns the title and a `ChatPhoto`, and `getFile` serves the photo.
- **Forum topics.** In a forum, a send to a `message_thread_id` that is not a topic fails with
  `message thread not found`. A member's message in a topic that answers nothing replies to the
  topic's creation message, as on Telegram.

### Telegram Login (OpenID Connect)

The server also answers at oauth.telegram.org's paths, so an app logs in against it by changing only
the origin: `GET /.well-known/openid-configuration`, `GET /.well-known/jwks.json`, `GET /auth` (a
page with a "Log in as ..." button per test user, and Cancel) and `POST /token`. It follows
[Telegram's docs](https://core.telegram.org/bots/telegram-login) and its
[discovery document](https://oauth.telegram.org/.well-known/openid-configuration):

- The client id is the bot id and each bot has a client secret. `/token` takes it by HTTP Basic, as
  the docs show, or in the form (`client_secret_post`, which the discovery document lists).
- `/auth` needs `response_type=code` and the `openid` scope. PKCE is recommended, not required, with
  `S256` or `plain`, as the discovery document lists. An unknown `client_id`, a bad
  `response_type`, a missing `openid` or a bad challenge method gets a 400 page, never a redirect.
- A code works once, only with the same `redirect_uri`, and only with a `code_verifier` that
  matches its challenge; otherwise `/token` answers `invalid_grant`, and a wrong secret
  `invalid_client` (401). Codes expire after 60 seconds (unverified: Telegram does not document
  how long).
- The ID token is signed RS256 with the published key and names it in `kid`. It has `iss`
  (`https://oauth.telegram.org`), `aud` (the bot id), `sub`, `iat`, `exp` (an hour later, as
  `expires_in: 3600` says) and `nonce` when the app sent one. `sub` is an opaque id that stays the
  same for a user, not their Telegram id, as in Telegram's example. The `profile` scope adds `id`,
  `name`, `given_name`, `family_name`, `preferred_username` and `picture` (served by this server).
- `telegram:bot_access` lets the bot message the user afterwards, as documented.

## Owner accounts (GramJS)

Some apps also read a user's **own** Telegram account through GramJS (MTProto): their dialog list,
folders and history. The owner client stands in for GramJS's `TelegramClient` for exactly the calls
below, answering from owner state a test seeds on the server. It is **not** MTProto: there is no wire
protocol, encryption, phone login or real session, and it never contacts Telegram. Never give it,
or a test built on it, a real phone number, session string or API hash.

```js
import {
  createOwnerClient,
  ownerApi,
  startTestServer,
} from "telegram-bot-test-server";

const server = await startTestServer({ botToken: "123456:TEST-TOKEN" });
const owner = await server.createOwner({ firstName: "Ana" });
const { id: group } = await server.addOwnerDialog(owner.id, {
  kind: "supergroup",
  id: 701,
  title: "Builders",
});
await server.addOwnerMessages(owner.id, group, [
  { id: 1, date: 1_700_000_000, text: "hi" },
]);

const client = createOwnerClient({ origin: server.origin, userId: owner.id });
await client.connect();
const dialogs = await client.getDialogs({ folder: 0, limit: 50 }); // [{ id: -1000000000701, ... }]
const history = await client.getMessages(await client.getEntity(group), {
  limit: 20,
});
const { filters } = await client.invoke(
  new ownerApi.messages.GetDialogFilters({}),
);
```

### Putting it in place of GramJS

The client answers over HTTP, so a test process and an app process can share one server. In the
app's tests, construct `createOwnerClient({ origin, userId })` where the app would construct its
`TelegramClient`, for example by giving the app's lifecycle a subclass of its MTProto adapter whose
start-up assigns the owner client (and `ownerApi` as the request namespace) instead of connecting to
Telegram. Everything above that point (routes, authentication, cursors, normalization and error
mapping) stays the app's real code. Production code needs no change.

### The client

| Method                                                                                    | Answers                                                                                                                                                                                                                                                              |
| ----------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `connect()`, `disconnect()`, `destroy()`                                                  | Before `connect()` and after `disconnect()`, every call fails with GramJS's "Cannot send requests while disconnected".                                                                                                                                               |
| `isUserAuthorized()`                                                                      | `false` after `updateOwner(id, { authorized: false })`; calls then fail with `AUTH_KEY_UNREGISTERED` (401).                                                                                                                                                          |
| `getMe()`                                                                                 | The owner as a `User` with `self: true`.                                                                                                                                                                                                                             |
| `getEntity(peer)`, `getInputEntity(peer)`                                                 | A peer id (number or string), `"me"`, a `@username`, or a GramJS entity/peer object, as a `User`, `Chat` or `Channel` / `InputPeerUser`, `InputPeerChat`, `InputPeerChannel` or `InputPeerSelf`. Unknown peers fail with GramJS's "Could not find the input entity". |
| `getDialogs({ folder, archived, limit, ignorePinned, offsetDate, offsetId, offsetPeer })` | GramJS `Dialog` shapes: `id`, `entity`, `inputEntity`, `name`/`title`, `date`, `message`, `pinned`, `folderId`/`archived`, `unreadCount`, `isUser`/`isGroup`/`isChannel`, and the raw `dialog` with `notifySettings.muteUntil`. The array has a `total`.             |
| `getMessages(entity, { limit, offsetId, ids })`                                           | `Message` / `MessageService` shapes: `id`, `message`/`rawText`/`text`, `date`, `editDate`, `out`, `fromId`, `senderId`, `sender`, `peerId`, `chatId`, `chat`, `replyTo`/`replyToMsgId`, `media`, `action`. The array has a `total`.                                  |
| `invoke(new ownerApi.messages.GetDialogFilters({}))`                                      | `messages.DialogFilters` with `DialogFilterDefault` and `DialogFilter`s (`title` as `TextWithEntities`, `emoticon`, flags, include/exclude/pinned `InputPeer`s) in their order. GramJS's own `Api.messages.GetDialogFilters` request works too.                      |
| `markAsRead()`, `sendMessage()`                                                           | Reserved: they reject with code `OWNER_CLIENT_NOT_MODELLED`.                                                                                                                                                                                                         |

Anything else fails loudly and never answers a generic success: another client method (code
`OWNER_CLIENT_UNSUPPORTED`), another `invoke` request, or a `getDialogs`/`getMessages` option not
listed above (`filter`, `search`, `minId`, ...).

Peer ids follow the Bot API's: a user's id, `-<id>` for a basic group, `-100<id>` for a supergroup or
channel. Entities carry the raw id, as GramJS's do. Ids are plain numbers; GramJS uses big-integer
objects, which give the same `String()` and `Number()`. The client accepts GramJS peer objects whose
ids are big-integer objects or native `BigInt`.

### Order and paging

- **Folders.** `folder: 0` (or none) is the main list, `folder: 1` the archive; `archived: true` means
  folder 1, as in GramJS.
- **Dialogs** are newest first by their newest message's date, then its id, then the peer id, so equal
  timestamps still have one order. Pinned dialogs lead the **first** page only, most recently pinned
  first. A page with `offsetDate`/`offsetId`/`offsetPeer` continues strictly after that position,
  never repeating it, and never includes pinned dialogs; `ignorePinned` leaves them out of a first
  page too. An offset whose dialog has since moved or been deleted still resumes after its
  position.
- **History** is newest first by message id. `offsetId` returns messages strictly older than it.
  Deleted messages are skipped. `ids` returns the messages in the order asked, with `undefined` for
  a missing or deleted one, as GramJS does. No `limit` returns everything.
- The server owns only these provider semantics; any cursor an app builds on top stays the app's.

### Owner test actions

| Action                                                                                                                                                | What happens                                                                                                                                           |
| ----------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `createOwner({ userId, firstName, lastName, username })`                                                                                              | An owner account; returns `{ id }`.                                                                                                                    |
| `updateOwner(ownerId, { authorized })`                                                                                                                | Revoke or restore the owner's authorization.                                                                                                           |
| `getOwner(ownerId)`                                                                                                                                   | The owner's dialogs (`id`, `kind`, `folder`, `pinned`, `mute_until`, `unread_count`, message count) and filter order.                                  |
| `addOwnerUser(ownerId, { id, firstName, lastName, username, bot })`                                                                                   | Someone who can send in the owner's groups; returns `{ id }`.                                                                                          |
| `addOwnerDialog(ownerId, { kind, id, title, firstName, lastName, username, participantsCount, folder, pinned, muted, muteUntil, unreadCount, date })` | A `private`, `bot`, `group`, `supergroup` or `channel` conversation; returns `{ id }`, its peer id.                                                    |
| `updateOwnerDialog(ownerId, peerId, { folder, pinned, muted, muteUntil, unreadCount })`                                                               | Move between main and archive, pin, mute, set the unread count.                                                                                        |
| `addOwnerMessages(ownerId, peerId, [{ id, date, fromId, out, text, action, replyTo, media, editDate }])`                                              | Messages with explicit ids and times; `action` makes a service message; `media` is `{ type: "photo" \| "document", id, fileName?, mimeType?, size? }`. |
| `editOwnerMessage(ownerId, peerId, messageId, { text, editDate })`, `deleteOwnerMessage(ownerId, peerId, messageId)`                                  | Edit or delete a message.                                                                                                                              |
| `setOwnerFilter(ownerId, { id, title, emoticon, includePeers, excludePeers, pinnedPeers, contacts, groups, ... })`                                    | Create or change a custom filter (id 2 or more).                                                                                                       |
| `orderOwnerFilters(ownerId, ids)`, `deleteOwnerFilter(ownerId, id)`                                                                                   | Reorder (every id once, `0` for the default) or delete.                                                                                                |
| `failOwnerCall(ownerId, { method, peerId, times, delayMs, preset, seconds, errorMessage, code })`                                                     | Delay or fail the next matching calls (below).                                                                                                         |
| `clearOwnerFaults(ownerId)`, `getOwnerCalls(ownerId)`                                                                                                 | Drop pending faults; every call the owner client made.                                                                                                 |
| `resetOwners()`                                                                                                                                       | Remove every owner, without restarting the server.                                                                                                     |

Every owner is separate: two owners may use the same peer and message ids and keep their own
folders, pins, unread counts, history, filters, faults and calls. No action or route shows one
owner's state under another.

The same actions over HTTP, under `/_fake/owners` (snake_case bodies):

| Route                                                            | Effect                                                                                                                                        |
| ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
| `POST owners`                                                    | `{ user_id?, first_name, last_name?, username? }` → `{ id }`.                                                                                 |
| `GET owners/:id`, `POST owners/:id`, `DELETE owners/:id`         | The owner's state; `{ authorized }`; remove the owner.                                                                                        |
| `DELETE owners`                                                  | Remove every owner.                                                                                                                           |
| `POST owners/:id/users`                                          | `{ id?, first_name, last_name?, username?, bot? }` → `{ id }`.                                                                                |
| `POST owners/:id/dialogs`                                        | `{ kind, id?, title?, first_name?, username?, participants_count?, folder?, pinned?, muted?, mute_until?, unread_count?, date? }` → `{ id }`. |
| `POST owners/:id/dialogs/:peerId`                                | `{ folder?, pinned?, muted?, mute_until?, unread_count? }`.                                                                                   |
| `POST owners/:id/dialogs/:peerId/messages`                       | `{ messages: [{ id, date, from_id?, out?, text?, action?, reply_to?, media?, edit_date? }] }` → `{ ids }`.                                    |
| `POST`, `DELETE owners/:id/dialogs/:peerId/messages/:messageId`  | Edit `{ text, edit_date? }`, or delete.                                                                                                       |
| `POST owners/:id/filters`, `DELETE owners/:id/filters/:filterId` | `{ id, title, emoticon?, include_peers?, exclude_peers?, pinned_peers?, contacts?, ... }`, or delete.                                         |
| `POST owners/:id/filters/order`                                  | `{ ids: [3, 0, 2] }`.                                                                                                                         |
| `POST`, `DELETE owners/:id/faults`                               | `{ method, peer_id?, times?, delay_ms?, preset?, seconds?, error_message?, code? }`, or clear.                                                |
| `GET owners/:id/calls`                                           | `[{ owner_id, method, args, at, outcome, error_message?, duration_ms }]`.                                                                     |

The owner client itself calls `POST /_owner/:ownerId/:method`; tests use the client, not this route.

### Delays and failures

`failOwnerCall` applies to one call (`times`, default 1: once) of `method`, optionally only for one
`peerId`, and can first wait `delayMs` (at most 30 s), so calls complete out of order.

| Preset                                 | What the caller gets                                                                                   |
| -------------------------------------- | ------------------------------------------------------------------------------------------------------ |
| `flood_wait`                           | A `FloodWaitError`: `errorMessage` `"FLOOD"`, `code` 420, `seconds` (default 30), as GramJS builds it. |
| `permission_denied`                    | An `RPCError` `CHAT_WRITE_FORBIDDEN`, code 403.                                                        |
| `reconnect_required`                   | An `RPCError` `AUTH_KEY_UNREGISTERED`, code 401.                                                       |
| `stale_entity`                         | An `RPCError` `PEER_ID_INVALID`, code 400.                                                             |
| `malformed_page`                       | A `getDialogs`/`getMessages` answer whose entries lack their entity and message fields.                |
| `dropped`                              | The call runs, then the connection closes unanswered; the client rejects with `TIMEOUT`.               |
| (none, with `errorMessage` and `code`) | Any other `RPCError`, e.g. `CHANNEL_PRIVATE`, 400.                                                     |

`getOwnerCalls` records every call with its outcome (`ok`, `error`, `malformed`, `dropped`); any
field named like a session, token, hash, key, secret, password or phone is recorded as
`[redacted]`.

### Not modelled, and unverified

- Not modelled: MTProto, login, updates and event handlers (`addEventHandler`), sending, reading
  and deleting from the client, media downloads, drafts, forum topics, reactions and forwards in
  history, `messages.getDialogFilters` for chatlists, and every other GramJS method.
- Unverified: Telegram does not document the order of dialogs with equal dates (this server breaks
  ties by message id, then peer id), whether a cursor page without `excludePinned` repeats pinned
  dialogs (here it does not), or what GramJS raises for a response lost mid-call (here `TIMEOUT`).
  Unread counts are what a test sets; they are not derived from messages.

## Update delivery

- With a webhook set, updates are delivered in order to its URL, with the
  `X-Telegram-Bot-Api-Secret-Token` header when a secret was set.
- Without one, updates queue for `getUpdates`, which supports `offset`, `limit`, `allowed_updates`
  and long polling with `timeout`. As on Telegram, calling it while a webhook is set fails with 409,
  and updates queued before a webhook is set are delivered to it.
- `allowed_updates`, from `setWebhook` or `getUpdates`, is respected. As on Telegram,
  `chat_member`, `message_reaction` and `message_reaction_count` updates are only sent when
  explicitly requested.
- When the bot restricts, bans, unbans or approves a member, the server sends the resulting
  `chat_member` update back to the bot, as Telegram does. Nothing is sent when nothing changed.
- Joining, leaving and an approved join request produce both a `chat_member` update and the
  `new_chat_members` / `left_chat_member` service message.

A Bot API call returns before the updates it causes are delivered, as on Telegram, so tests should
wait for the update rather than expect it immediately.

## Control API

The test actions above, over HTTP, for tests written in other languages. All routes live under
`/_fake/` and take and return JSON.

| Route                                                  | Effect                                                                                                                                                                                                                                                                   |
| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `POST users`                                           | Create a user `{ first_name?, last_name?, username?, language_code?, bio?, is_bot?, is_premium? }`; returns `{ id }`.                                                                                                                                                    |
| `POST business/connections`                            | Connect `{ owner_id, rights, id?, is_enabled?, bot_id? }`, or change the connection with `id`; returns `{ connection, update_id }`.                                                                                                                                      |
| `GET business/connections/:id`                         | The `BusinessConnection`.                                                                                                                                                                                                                                                |
| `POST business/connections/:id/chats/:userId/messages` | `{ sender: "person" \| "owner", text }`; returns `{ message_id, date, update_id }`.                                                                                                                                                                                      |
| `GET business/connections/:id/chats/:userId/messages`  | The business chat, newest first, as `[{ direction, deleted, message }]`.                                                                                                                                                                                                 |
| `POST updates/:updateId/redeliver`                     | Deliver that update again to its bot's webhook; 404 for an unknown update, 409 when the bot has no webhook. Returns `{ update_id }`.                                                                                                                                     |
| `GET users/:id`                                        | The user, with bio and photos.                                                                                                                                                                                                                                           |
| `POST users/:id/profile`                               | Change `first_name`, `last_name`, `bio` or `username`.                                                                                                                                                                                                                   |
| `POST users/:id/photos`                                | Add a profile photo `{ base64 }`.                                                                                                                                                                                                                                        |
| `DELETE users/:id/photos/:fileId`                      | Remove a profile photo.                                                                                                                                                                                                                                                  |
| `POST chats/:id/join`                                  | The user `{ user_id }` joins.                                                                                                                                                                                                                                            |
| `POST chats/:id/leave`                                 | The user `{ user_id }` leaves.                                                                                                                                                                                                                                           |
| `POST chats/:id/messages`                              | The user posts `{ user_id, text }`, `{ user_id, photo_base64, caption? }` or `{ user_id, media: { type, base64, file_name?, mime_type? }, caption? }`, optionally `reply_to`, `message_thread_id` or `forward_from: { user_id \| sender_name \| chat_id, message_id? }`. |
| `POST chats/:id/albums`                                | The user posts an album `{ user_id, items: [{ type: "photo" \| "video", base64, caption? }] }`; returns `{ media_group_id, message_ids }`.                                                                                                                               |
| `POST chats/:id/messages/:messageId/edit`              | The author `{ user_id }` edits the `text` or `caption`.                                                                                                                                                                                                                  |
| `POST chats/:id/messages/:messageId/reactions`         | The user `{ user_id, emoji }` reacts, or takes the reaction back with `emoji: null`.                                                                                                                                                                                     |
| `GET chats/:id/messages`                               | Messages not deleted, newest first.                                                                                                                                                                                                                                      |
| `GET chats/:id/messages/:messageId`                    | `{ exists, deleted, message, reactions }`, reactions by user id.                                                                                                                                                                                                         |
| `POST chats/:id/messages/:messageId/callback`          | The user `{ user_id, data }` presses an inline button; returns the bot's answer.                                                                                                                                                                                         |
| `GET chats/:id/members/:userId`                        | The member as `getChatMember` would return it.                                                                                                                                                                                                                           |
| `GET chats/:id/join-requests`                          | User ids with a pending join request.                                                                                                                                                                                                                                    |
| `POST invites/:hash/join`                              | The user `{ user_id }` opens `https://t.me/+<hash>`: joins, or files a join request if the link requires one.                                                                                                                                                            |
| `POST invites/:hash/check`                             | Whether the user `{ user_id }` is in the link's chat.                                                                                                                                                                                                                    |
| `POST chats/:id/guest-bot-reply`                       | A guest bot answers the user `{ caller_user_id, bot_username, text }` in the group; returns `{ message_id }`.                                                                                                                                                            |
| `POST users/:id/dm`                                    | The user sends the bot a direct message `{ text }`.                                                                                                                                                                                                                      |
| `GET users/:id/dm`                                     | The private chat's messages, newest first.                                                                                                                                                                                                                               |
| `POST users/:id/dm/:messageId/callback`                | The user presses a button in the private chat `{ data }`.                                                                                                                                                                                                                |
| `GET bot`                                              | The first bot's user, with its `login_client_secret`.                                                                                                                                                                                                                    |
| `GET webhook`                                          | The first bot's registered webhook.                                                                                                                                                                                                                                      |
| `POST bots`                                            | Add a bot `{ token, username, first_name?, login_client_secret? }`; it is in no chat yet.                                                                                                                                                                                |
| `POST login/approve`                                   | The user `{ auth_url, user_id }` logs in; returns `{ redirect_url }` with the code and state.                                                                                                                                                                            |
| `POST login/cancel`                                    | The user cancels `{ auth_url }`; returns `{ redirect_url }` with `error=access_denied`.                                                                                                                                                                                  |
| `GET bots`                                             | Every bot, with its webhook URL and `login_client_secret`.                                                                                                                                                                                                               |
| `POST chats`                                           | Create `{ owner_id, title?, type?: "supergroup" \| "channel", owner_name?, is_forum? }`; returns the chat.                                                                                                                                                               |
| `GET chats/:id`                                        | The chat with its pinned message ids and members.                                                                                                                                                                                                                        |
| `POST chats/:id/bots`                                  | Add, promote, demote or remove a bot `{ bot_id, status?, rights?, by? }`, as the owner would.                                                                                                                                                                            |
| `POST chats/:id/bots` with `start_parameter`           | A person `{ by?, bot_id, start_parameter, rights? }` adds the bot through its `startgroup` link.                                                                                                                                                                         |
| `POST chats/:id/migrate`                               | Upgrade a basic group `{ by? }`; returns the new supergroup.                                                                                                                                                                                                             |
| `POST chats/:id/title`                                 | A person renames the chat `{ by?, title }`.                                                                                                                                                                                                                              |
| `POST chats/:id/photo`                                 | A person sets the chat photo `{ by?, base64 }`.                                                                                                                                                                                                                          |
| `POST chats/:id/topics`                                | Create a forum topic `{ name, by? }`; returns `{ message_thread_id, name }`.                                                                                                                                                                                             |
| `POST chats/:id/topics/:threadId/edit`                 | Rename a topic `{ name, by? }`.                                                                                                                                                                                                                                          |
| `GET chats/:id/topics`                                 | The forum's topics.                                                                                                                                                                                                                                                      |
| `POST failures`                                        | Fail the next calls `{ method, chat_id?, bot_id?, times?, error_code?, description?, retry_after?, drop_after_apply? }`.                                                                                                                                                 |
| `GET failures`, `DELETE failures`                      | The failure rules still waiting, or clear them.                                                                                                                                                                                                                          |
| `GET calls`                                            | Every Bot API call received, with the bot that made it, and the unsupported methods called.                                                                                                                                                                              |

A button press waits up to 10 seconds for the bot to call `answerCallbackQuery` and returns
`{ answered, text, show_alert }`.

## What it does not do

- Inline mode, payments, games, sticker sets, reaction counts, votes in polls, or Telegram's rate limits
  (a test makes a call fail with a 429 through `POST failures` instead). Channels have no
  subscribers and forum topics cannot be closed or deleted.
- `parse_mode` formatting: text is stored exactly as sent, tags and all.
- Expiry: restrictions and bans with an `until_date` never lift on their own.
- Webhook retries: an update the webhook rejects, or does not answer within 10 seconds, is logged and
  dropped rather than retried.
- In Telegram Login: the `phone` scope's `phone_number` (test users have no phone numbers), the
  ES256, EdDSA and ES256K signing options (only the default RS256), the redirect URLs registered
  with BotFather (any `redirect_uri` is accepted), the `telegram-login.js` popup and native SDKs, and
  the legacy Login Widget's hash check. Telegram has no UserInfo endpoint, and neither does this
  server.
- Persistence. All state lives in memory and is lost when the server stops.
- Anything security-related. It is a test tool: bind it to localhost and never expose it to a
  network you do not control.

## Changes

- **0.9.0**: owner accounts: `createOwnerClient`, a test stand-in for the GramJS `TelegramClient`
  subset an app uses on a user's own account (dialogs, folders, history, dialog filters), with owner
  controls, paging, delays and failures, and a calls ledger. The Bot API side is unchanged: existing
  tests and imports keep working, and the new exports are additions.
- **0.8.1**: describes the package as a local test server.
- **0.8.0**: Telegram Login (OpenID Connect): discovery, keys, the login page, the token endpoint,
  signed ID tokens, a login client secret per bot, and `approveLogin` / `cancelLogin` for tests
  without a browser.
- **0.7.0**: adding the bot through a `startgroup` link, basic groups and their upgrade to a
  supergroup, people renaming the chat and changing its photo, and a bot's own `new_chat_members`
  and `left_chat_member` messages. Tests now cover `getChatMemberCount` following joins and
  leaves, and `leaveChat` sending `my_chat_member`.
- **0.6.0**: business connections and business chats (`business_connection`,
  `business_message`, `sendMessage` with `business_connection_id`, `getBusinessConnection`),
  `is_bot` and `is_premium` on test users, `can_connect_to_business` on `getMe`, and update
  redelivery.
- **0.5.0**: members post videos, voice notes, stickers, documents and other media, albums and
  forwards, edit their messages and react; `sendMediaGroup`, `sendVoice`, `sendAudio`,
  `sendVideoNote`, `sendLocation`, `sendVenue`, `sendContact`, `sendDice`, `sendChatAction`,
  `promoteChatMember`, `setChatAdministratorCustomTitle`, chat title, description and photo,
  `editChatInviteLink`, `setMessageReaction`, `deleteMessageReaction` and
  `answerChatJoinRequestQuery`; the command-line flags are documented.
- **0.4.0**: more than one bot, chats and forum topics created during a run, bot membership and
  administrator rights, `sendPoll`, `stopPoll`, `forwardMessage`, `copyMessage`, `editMessageMedia`,
  real pin state, and failures a test asks for.

## Development

```sh
pnpm install
pnpm test
```

## Status

This is an early-stage project with a deliberately small scope, and the public API may still change.

## License

MIT

---
_Source: https://npm.io/package/telegram-bot-test-server · Machine-readable twin of the npm.io package page. Health data is recomputed on every publish._
