telegram-bot-test-server
v0.15.2
Published
A local Telegram Bot API server for end-to-end tests: groups, members, moderation, buttons, webhooks and a live chat viewer. No Telegram account needed.
Maintainers
Readme
telegram-bot-test-server
A local Telegram Bot API server for end-to-end tests.
Develop and test your bot offline: no Telegram account, no phone number, no real groups.
Install · Quick start · Guide · Viewer · Reference
What you can test
- Moderation: deleting messages, bans, mutes with an end time, and admin rights, with Telegram's own refusals and error texts.
- Members: joins, leaves, join requests, invite links, and people posting text, media, contacts, locations and polls.
- Buttons: inline keyboards, button presses, URL buttons and
/startlinks. - Delivery: webhooks and
getUpdatespolling, several bots in one group, and what each bot was sent. - Time: a clock your test controls, which your app can follow too.
- Watching: a live viewer of every chat, and recordings you can open later.
It never talks to Telegram and runs as often as you like in CI.
Start: Install · Quick start · How it works
Recipes: Delete a spam link and ban · Approve a join request after a button press · Check that a mute ends · Prove a bot did not receive something · Report a scenario to the viewer
Guide: Write a test · Make users act · Check what the bot did · Message log and delivered updates · Webhooks and polling · More than one bot · Channels, basic groups and forums · Failures, flood control and time · Testing time-based app logic · Snapshots · Watch the chats in a browser · Record a scenario · Report scenarios · Telegram Login · Owner accounts (GramJS) · Other languages
Reference ([docs/reference.md][ref]): [Options][ref-options] · [Test actions][ref-actions] · [Wait conditions][ref-waits] · [Control API][ref-control] · [Supported Bot API methods][ref-methods] · [Call receipts][ref-receipts] · [Viewer][ref-viewer]
More: Development · License
Install
npm install --save-dev --save-exact telegram-bot-test-serverRequires Node.js 20 or newer. No runtime dependencies. Everything lives in memory and is gone when the server stops. The control API answers anyone who can reach its port, so keep it on localhost.
Quick start
A grammY bot that deletes links and bans whoever posted them, tested end to end:
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);
}
});
// Resolve once the bot is polling.
await new Promise((resolve) => bot.start({ onStart: resolve }));
// 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 wait for what the bot did: it runs asynchronously.
await server.waitFor({
kind: "message",
chatId: GROUP,
messageId: spam,
deleted: true,
});
await server.waitFor({
kind: "member",
chatId: GROUP,
userId: ann,
status: "kicked",
});
await bot.stop();
await server.stop();With grammy installed, save it as quickstart.mjs and run node quickstart.mjs. It exits 0
once the bot has deleted the message and banned Ann. Like every example here, it is an ES module:
use .mjs, or "type": "module" in your package.json.
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. A bot written in another language, such as Python, points at the server the same way; its tests drive the server over HTTP (Other languages).
How it works
Your bot sends its Bot API calls to server.origin instead of https://api.telegram.org, and the
server answers the way Telegram does, keeping 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 through test actions, methods such as server.join,
server.post and server.pressButton: 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.
Your bot handles those updates in its own time, so the test then waits for what it expects with
server.waitFor, or reads the state with server.getMessages, server.getMember and
server.getCalls.
Recipes
Short Vitest tests for common jobs, with a grammY bot. Each one runs as it is. Put this setup at the top of the test file, then paste any recipe under it.
// recipes.test.js
import { Bot, InlineKeyboard } from "grammy";
import { afterEach, expect, test } from "vitest";
import { startTestServer } from "telegram-bot-test-server";
const TOKEN = "123456:TEST";
const BOT_ID = 123456; // the number before the colon in the token
const GROUP = -1001000000001;
let server;
let bot;
// A server with GROUP in it, and a bot pointed at it. Add handlers, then start().
async function setup(options = {}) {
server = await startTestServer({
botToken: TOKEN,
chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
...options,
});
bot = new Bot(TOKEN, { client: { apiRoot: server.origin } });
}
// Resolves once the bot is polling.
function start(options = {}) {
return new Promise((resolve) => bot.start({ ...options, onStart: resolve }));
}
afterEach(async () => {
if (bot?.isRunning()) await bot.stop();
await server?.stop();
});Delete a spam link and ban
test("deletes a link and bans whoever posted it", async () => {
await setup();
bot.on("message:entities:url", async (ctx) => {
await ctx.deleteMessage();
await ctx.banChatMember(ctx.from.id);
});
await start();
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");
await server.waitFor({ kind: "message", chatId: GROUP, messageId: spam, deleted: true });
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "kicked" });
});Approve a join request after a button press
The bot asks each requester to press a button in a private chat, then approves them.
test("approves a join request once the button is pressed", async () => {
await setup();
bot.on("chat_join_request", (ctx) =>
ctx.api.sendMessage(ctx.from.id, "Press the button to join.", {
reply_markup: new InlineKeyboard().text("I'm human", `approve:${ctx.chat.id}`),
}),
);
bot.callbackQuery(/^approve:(-\d+)$/, async (ctx) => {
await ctx.api.approveChatJoinRequest(Number(ctx.match[1]), ctx.from.id);
await ctx.answerCallbackQuery("Welcome!");
});
await start();
const link = await bot.api.createChatInviteLink(GROUP, { creates_join_request: true });
const bob = await server.createUser({ first_name: "Bob" });
await server.joinByLink(link.invite_link, bob);
// The bot's private message to Bob, found by its button.
const { message } = await server.waitFor({
kind: "message",
chatId: bob,
buttonData: `approve:${GROUP}`,
});
const answer = await server.pressDirectButton(bob, message.message_id, `approve:${GROUP}`);
expect(answer.text).toBe("Welcome!");
await server.waitFor({ kind: "joinRequest", chatId: GROUP, userId: bob, state: "approved" });
});Check that a mute ends
The bot mutes newcomers for an hour. A manual clock lets the test skip the hour.
test("a newcomer's mute ends after an hour", async () => {
await setup({ clock: { now: Date.now() } });
bot.on("message:new_chat_members", async (ctx) => {
for (const member of ctx.message.new_chat_members) {
await ctx.restrictChatMember(
member.id,
{ can_send_messages: false },
{ until_date: ctx.message.date + 60 * 60 }, // the message date follows the server clock
);
}
});
await start();
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "restricted" });
await server.advanceTime(2 * 60 * 60 * 1000); // two hours later
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "member" });
});Prove a bot did not receive something
getBotUpdates lists every update the server sent a bot. Here the bot asks only for
my_chat_member updates, so the group's messages never reach it.
test("a bot that asks only for my_chat_member gets no messages", async () => {
await setup();
await start({ allowed_updates: ["my_chat_member"] });
// allowed_updates applies from the bot's first getUpdates, so wait for that call.
await server.waitFor({ kind: "call", botId: BOT_ID, method: "getUpdates" });
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.post(GROUP, ann, "hello");
await server.setBotMembership(GROUP, BOT_ID, { status: "member" }); // demoted
const { updates } = await server.getBotUpdates(BOT_ID, { chatId: GROUP });
expect(updates.map((update) => update.type)).toEqual(["my_chat_member"]);
});Report a scenario to the viewer
A test can tell the server where a scenario starts and how it ends. The viewer and recordings then show it among the chats, and a failure points at the messages involved.
// Reports a scenario's start and result. A failure names the messages it stored.
async function reported(scenarioId, body) {
const run = { runId: "local", scenarioId };
await server.startScenario({ ...run, chats: [GROUP] });
const mark = await server.getMessageLog(GROUP);
try {
await body();
} catch (error) {
const log = await server.getMessageLog(GROUP, { since: mark.cursor, includeDeleted: true });
const evidence = log.messages.map((entry) => ({ kind: "message", seq: entry.seq }));
const failure = { message: error.message.slice(0, 200), evidence };
await server.finishScenario({ ...run, result: "failed", failure });
throw error;
}
await server.finishScenario({ ...run, result: "passed" });
}
test("says hello, reported to the viewer", async () => {
await setup({ recordDir: "test-results/recordings" });
bot.on("message:text", (ctx) => ctx.reply("Hi!"));
await start();
await server.startRecording("says-hello");
await reported("says hello", async () => {
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.post(GROUP, ann, "hello");
await server.waitFor({ kind: "message", chatId: GROUP, botId: BOT_ID, text: "Hi!" });
});
const { files } = await server.stopRecording("says-hello");
console.log(files.html); // open it in a browser: the scenario's cards sit among the messages
const { scenarios } = await server.getScenarios({ runId: "local" });
expect(scenarios.map((each) => each.result)).toEqual(["passed"]);
});Guide
Write a test
A test starts a server, points the bot at it, plays the users, waits for the outcome and stops both. This one uses Vitest and a grammY bot that greets new members; any test runner works the same way.
// greeting.test.js
import { Bot } from "grammy";
import { afterEach, beforeEach, expect, test } from "vitest";
import { startTestServer } from "telegram-bot-test-server";
const TOKEN = "123456:TEST";
const BOT_ID = 123456; // the number before the colon in the token
const GROUP = -1001000000001;
let server;
let bot;
beforeEach(async () => {
server = await startTestServer({
botToken: TOKEN,
chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
});
bot = new Bot(TOKEN, { client: { apiRoot: server.origin } });
bot.on("message:new_chat_members", (ctx) =>
ctx.reply(`Welcome, ${ctx.message.new_chat_members[0].first_name}!`),
);
// Resolve once the bot is polling, so afterEach never stops it mid-start.
await new Promise((resolve) => bot.start({ onStart: resolve }));
});
afterEach(async () => {
await bot.stop();
await server.stop();
});
test("greets a new member", async () => {
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
const greeting = await server.waitFor({
kind: "message",
chatId: GROUP,
botId: BOT_ID,
text: "Welcome, Ann!",
});
expect(greeting.message.from.username).toBe("example_bot");
});What each part does:
startTestServerlistens on127.0.0.1at a free port and returns the server;server.originis its base URL.chatscreates supergroups in which the bot is an administrator. Pick any ids; a supergroup's id starts with-100.ownerIdis the chat creator's user id.- The bot's user id is the number before the colon in its token. Waits, failure rules and call
receipts name a bot by it (
botId). createUserreturns a new user's id;joinmakes them a member.waitForresolves as soon as the condition holds and fails after a second by default, so a test never sleeps. Check what the bot did also shows how to check that the bot did nothing.- A fresh server per test keeps tests apart. To share one, restore a snapshot between tests instead.
The rest of this guide shows parts of tests like this one, with server, bot, BOT_ID and
GROUP from it.
Make users act
Test actions play people on Telegram. Each one resolves once the update it causes has been handed to
the bot: its first webhook attempt has finished (with any call the webhook answered with), it waits
behind an update the webhook refused, or it is queued for getUpdates.
A webhook gets a minute to answer, so one that never answers holds the action for a minute.
What the bot does in response happens after that, so wait for the outcome rather than checking it
immediately. An action fails where Telegram would refuse the person: post fails if the user is not
allowed to post, for example.
const ann = await server.createUser({ first_name: "Ann", username: "ann" });
await server.join(GROUP, ann);
const hello = await server.post(GROUP, ann, "Hello!");
await server.post(GROUP, ann, { text: "Is anyone here?", replyTo: hello });
await server.sendDirectMessage(ann, "/start"); // a private message to the bot
const photo = Buffer.from("...image bytes..."); // e.g. await readFile("receipt.jpg")
await server.sendDirectMessage(ann, { photo, caption: "my receipt" });
await server.leave(GROUP, ann);Members vote in polls. The bot that sent the poll gets a poll update with the new counts, and a
poll_answer saying who chose what unless the poll is anonymous; other bots get neither, as on
Telegram:
// Your bot sent a poll with sendPoll; pollMessageId is its message_id.
await server.vote(GROUP, pollMessageId, ann, [0]); // Ann picks the first option
await server.vote(GROUP, pollMessageId, ann, []); // and takes her vote backA press on an inline button resolves with the bot's answerCallbackQuery answer:
// Your bot answers /menu with a message that has an inline keyboard.
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.post(GROUP, ann, "/menu");
const { message } = await server.waitFor({
kind: "message",
chatId: GROUP,
botId: BOT_ID,
text: "Pick one",
});
const answer = await server.pressButton(GROUP, message.message_id, ann, "rules");
expect(answer).toMatchObject({ answered: true, text: "Thanks!" });The last argument is the button's callback_data, not its label; the press fails at once if the
message has no button with that data. If the bot does not answer within 10 seconds, the press
resolves { answered: false }. Vitest and Jest stop a test after 5 seconds by default, so give a
test that may see no answer a longer timeout. Or start the press without awaiting it, then wait for
the bot's answerCallbackQuery call with waitFor, which fails after a second.
A bot can get the same press twice: when its webhook does not confirm an update, Telegram sends the
same update again, with the same update_id and callback query id. deliverTwice does that, so a
test can check that the bot acts on the press once:
// Your bot, on a webhook, counts a "vote" press once per callback query id.
await server.pressButton(GROUP, message.message_id, ann, "vote", {
deliverTwice: true,
});
expect(votes.get(ann)).toBe(1); // your app's own stateThe press resolves with the bot's answer once the webhook has answered both deliveries. It needs a webhook: a polling bot gets an update again only by not confirming it, so the press is refused.
A user can also open a URL button, named by its text or by its index (row by row, from 0). A link
to the bot does what Telegram's app does with it: https://t.me/<bot>?start=<parameter> sends
/start <parameter> from the user in their private chat with the bot, and a startgroup or
startchannel link adds the bot to the chat addToChatId names. Any other URL changes nothing and
comes back, for the test to follow:
// Your bot's welcome message has a button to https://t.me/example_bot?start=verify.
await server.openUrlButton(GROUP, message.message_id, ann, "I'm human");
// Your bot got "/start verify" from Ann, and answers her privately.
await server.waitFor({
kind: "message",
chatId: ann,
botId: BOT_ID,
contains: "verified",
});The test can also call the Bot API as the bot, for example to make an invite link, and then have a user open it:
// Your bot approves join requests.
const link = await bot.api.createChatInviteLink(GROUP, {
creates_join_request: true,
});
const bob = await server.createUser({ first_name: "Bob" });
await server.joinByLink(link.invite_link, bob); // { chat_id, status: "requested" }
await server.waitFor({
kind: "joinRequest",
chatId: GROUP,
userId: bob,
state: "approved",
});Members also share contacts and locations, and post an earlier file again, which keeps its
file_unique_id. Their text can carry entities the test gives, such as a text_link, or a
phone_number, which Telegram's server marks and this server does not find:
const bob = await server.createUser({ first_name: "Bob" });
await server.join(GROUP, bob);
await server.post(GROUP, bob, { contact: { phoneNumber: "+15550100", firstName: "Bob" } });
await server.post(GROUP, bob, { location: { latitude: 51.5, longitude: -0.12 } });
await server.post(GROUP, bob, {
text: "Call +1 212 555 0123",
entities: [{ type: "phone_number", offset: 5, length: 15 }],
});
const first = await server.post(GROUP, bob, { photo });
const [size] = (await server.getMessage(GROUP, first)).message.photo;
await server.post(GROUP, bob, { fileId: size.file_id }); // the same file_unique_idPeople promote and demote members as in Telegram's apps: the owner, or an administrator with
can_promote_members, grants the rights they choose (by names who acts; the owner by default).
The chat's administrator bots get chat_member. An administrator with is_anonymous posts as the
group, and a member with Telegram Premium may post as a channel they created:
await server.promoteMember(GROUP, bob, {
rights: { is_anonymous: true, can_delete_messages: true },
});
await server.post(GROUP, bob, "Read the rules"); // sender_chat is the group
await server.demoteMember(GROUP, bob);
const cy = await server.createUser({ first_name: "Cy", is_premium: true });
await server.join(GROUP, cy);
const news = await server.createChat({ type: "channel", title: "News", ownerId: cy });
await server.post(GROUP, cy, { text: "Follow us", sendAs: news }); // sender_chat is NewsSuch a post's from is @GroupAnonymousBot or @Channel_Bot, as the Bot API writes it. Waits,
failure rules and the message log still name the person who
posted it.
Reactions follow TDLib's rule: an anonymous administrator reacts as the group, and only the owner
may. So react refuses any other anonymous administrator, and a group whose owner stays anonymous
(createChat({ ownerAnonymous: true })) sends message_reaction with actor_chat, not user:
const olga = await server.createUser({ first_name: "Olga" });
const club = await server.createChat({ ownerId: olga, ownerAnonymous: true });
const notice = await server.post(club, olga, "Meeting at six"); // sender_chat is the group
await server.react(club, notice, olga, "👍"); // message_reaction has actor_chatNo source shows how Telegram shows the service messages of an anonymous owner or administrator,
so their pins, title and photo changes, topics, and adding or removing members fail in a test.
So in a group whose owner is anonymous, setBotMembership needs by, an administrator who is not
anonymous: setBotMembership(club, botId, { by: ada }).
A bot bans a channel that members post as with banChatSenderChat. Until unbanChatSenderChat, the
channel's owner posts on behalf of none of their channels there.
People delete messages too. A member deletes their own message, and an administrator with
can_delete_messages (in a basic group, any administrator) anyone's. In their private chat with a
bot, a user deletes any message, for both sides. Bots get no update for a deletion, as on Telegram;
the message log and the viewer name who deleted it:
const typo = await server.post(GROUP, ann, "helo");
await server.deleteMessage(GROUP, typo, ann); // { message_id, deleted: true }In a business chat, deleteBusinessMessage deletes a message for both sides, and the connected bot
gets deleted_business_messages.
Users can also post photos, media, albums and forwards (of a user, a channel or a supergroup), edit their messages, react, pin, press buttons in private chats and on ephemeral messages, change their profile, and rename the chat or change its photo. [Test actions][ref-actions] lists them all.
Check what the bot did
waitFor waits for an outcome. It checks the condition when it is called and again whenever the
server's state changes; it never polls. It resolves with a copy of what matched:
// Your bot deletes links and bans whoever posted them, as in the quick start.
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");
// A message, by its id or by its author and exact text.
await server.waitFor({ kind: "message", chatId: GROUP, messageId: spam, deleted: true });
// A member's status in the chat.
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "kicked" });
// A Bot API call the bot made. Receipts keep parameters as text.
const ban = await server.waitFor({
kind: "call",
botId: BOT_ID,
method: "banChatMember",
outcome: "succeeded",
});
expect(ban.params.user_id).toBe(String(ann));A message can also be found by part of its text or caption, by a pattern, or by one of its inline buttons, with or without its author:
// Your bot answers /menu with "Pick a topic" and a "Rules" button (callback_data "rules").
await server.post(GROUP, ann, "/menu");
await server.waitFor({ kind: "message", chatId: GROUP, contains: "Pick" });
await server.waitFor({ kind: "message", chatId: GROUP, matches: /pick a topic/i });
await server.waitFor({
kind: "message",
chatId: GROUP,
buttonText: "Rules",
buttonData: "rules",
});These find the oldest message that matches every field given, ephemeral messages included. To
skip messages from earlier in the test, add since, a cursor from the
message log.
A wait fails after 1000 ms by default; { timeoutMs } sets 1 to 30000 ms of wall time. Its error
names the exact expectation, what was observed (or up to eight matching requests) and the work still
outstanding, in at most 8000 characters with credentials redacted.
The outstanding work counts what is in progress: controls (test actions), http (open HTTP
requests to the server), owners (owner client calls), deliveries (webhook attempts queued or
running), polls (open long polls), delayedOrNetworkRequests (webhook connections and response
delays) and waits (waits, this one included). A polling bot always has a long poll open, so http:
1 and polls: 1 are normal.
[Wait conditions][ref-waits] lists every condition.
Once the outcome is there, read the state directly:
const messages = await server.getMessages(GROUP); // newest first, deleted ones left out
const { deleted } = await server.getMessage(GROUP, spam);
const member = await server.getMember(GROUP, ann); // as getChatMember returns it
const { calls } = await server.getCalls(); // every Bot API call, in orderMessages are kept the way Telegram returns them: parse_mode formatting becomes plain text plus
entities, and text is trimmed, so wait for the plain text the user would see. getMessages and
getDirectMessages include service messages, such as new_chat_members for each join and
pinned_message for each pin.
getCalls() keeps requests as they came, fixture secrets included, so do not dump it
indiscriminately. It returns copies, and so do getMessage, getMessages, getEphemeralMessage
and getDirectMessages: changing what they return changes nothing on the server.
To show that the bot did not act, without sleeping, give it something it does act on afterwards, in the same chat, and wait for that:
// Your bot deletes links, as in the quick start.
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
const hello = await server.post(GROUP, ann, "Hello, no links here");
// A later message the bot acts on marks the point where it has handled the first.
const marker = await server.post(GROUP, ann, "see example.com");
await server.waitFor({ kind: "message", chatId: GROUP, messageId: marker, deleted: true });
expect((await server.getMessage(GROUP, hello)).deleted).toBe(false);One chat's messages reach the bot in order, so this holds for a bot that handles them one at a
time: grammY's bot.start(), or any webhook that answers after its handler has run, as grammY's
and Telegraf's do. Telegraf's polling handles each batch of updates at once, so there the marker
is not enough. A webhook bot can instead await server.drainDeliveries(), which returns once the
webhook has answered every update sent so far.
Or wait until there is nothing left to do: every update confirmed by the bots, no Bot API call,
webhook attempt or test action in progress, and no call for ms milliseconds of wall time:
const hello = await server.post(GROUP, ann, "Hello, no links here");
await server.waitFor({ kind: "quiet", ms: 200 }, { timeoutMs: 2000 });
expect((await server.getMessage(GROUP, hello)).deleted).toBe(false);grammY's bot.start() and Telegraf's polling confirm an update with their next getUpdates, after
handling it, and their webhooks answer after their handlers, so the wait holds while such a bot
works on an update.
The server sees only what reaches it: work a bot does after it confirmed an update, such as a job
started in the background, looks quiet once ms has passed without a call.
botIds limits the wait to some bots; leave out a bot that is not running, since it never confirms
its updates. A deleted bot is not counted.
Message log and delivered updates
Every message the server stores, in any chat, gets the next number of one server-wide sequence,
its seq; so do events, edits and deletions. getMessageLog reads a chat's messages after a mark
on that sequence, deleted ones too when asked, so "everything since my mark" stays exact when the
bot deletes messages:
// Your bot deletes links, as in the quick start.
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
const mark = await server.getMessageLog(GROUP); // mark.cursor: the latest seq
await server.post(GROUP, ann, "hello");
const spam = await server.post(GROUP, ann, "cheap followers at example.com");
await server.waitFor({ kind: "message", chatId: GROUP, messageId: spam, deleted: true });
const { messages } = await server.getMessageLog(GROUP, {
since: mark.cursor,
includeDeleted: true,
});
console.log(messages.map((entry) => [entry.message.text, entry.deleted_by?.bot_id ?? null]));
// [["hello", null], ["cheap followers at example.com", 123456]]- It returns
{ chat_id, epoch, cursor, messages }, oldest first. Passcursoras the nextsinceto read on from there. - Each entry has the stored
message(with the first bot's file ids, and in a basic group the chat's own message id, with each bot's inbot_message_ids), itsseq,at(server time),author(the user id of who posted it, also when the message names only a chat: a channel post, or a post on behalf of a chat),request_id(the Bot API call that stored it, ornullfor a test action),after_request(how many Bot API requests had arrived when it was stored; the viewer places calls by it),ephemeral, anddeletedwithdeleted_by:{ seq, bot_id, user_id, method, request_id, at }of the deletion, ornull. A bot's deletion has itsbot_idandmethod; a person's has theiruser_id, andbot_idandmethodarenull.edited_byhas the same fields for the latest edit, or isnull. A poll that has votes hasvotesby user id. - A message stored before the mark but edited after it is listed too, its
edited_by.seqafter the mark. WithoutincludeDeleted, only messages not deleted are listed. With it, a message stored before the mark but deleted after it is listed too. - A user id instead of a chat id reads the user's private chat with a bot, and each entry has the
chat's
bot_id.botIdnames the bot, a deleted bot's too; it is needed when the user has private chats with more than one bot, as their message ids overlap. - After a
restore, the sequence goes back with the state. Pass the mark'sepochand a read from an older epoch fails instead of mixing the two.
getBotUpdates lists the updates the server sent a bot, as the bot got them, so a test can prove
what a bot did not receive. The recipe shows it.
- As on Telegram,
allowed_updatesapplies from thegetUpdatescall that carries it, and updates made before that call still arrive. grammY'sonStartruns before its firstgetUpdates, so wait for that call, as above, before users act. A long poll'soffsetandallowed_updatesapply as soon as it arrives, so a plain call wait is enough. - Each test action resolves once the updates it causes are handed to the bots, so a read right after it already lists them.
type(one type or a list),chatIdandsince(anupdate_id) narrow the list.- Each entry has
update_id,type,chat_id(nullfor updates about no chat, such aspoll),at,state,receivedandupdate, the exact JSON the bot was sent.stateispendinguntil the bot confirms the update, thendelivered, ordroppedwhen it expired or was discarded, such as bydrop_pending_updates.receivedsays whethergetUpdatesreturned it or a webhook was sent it.
waitFor({ kind: "update", botId, type, chatId, afterUpdateId, state }) waits for such an update,
for example state: "delivered" once the bot has confirmed it.
Webhooks and polling
A bot that polls needs nothing more: its getUpdates calls get the updates waiting for it. A bot
that uses a webhook calls setWebhook with its URL, as it would on Telegram, and the server then
posts each update there. Like a Bot API server run with --local, this one takes http URLs, any
port and local addresses.
import http from "node:http";
import { Bot, webhookCallback } from "grammy";
import { startTestServer } from "telegram-bot-test-server";
const BOT_ID = 123456;
const GROUP = -1001000000001;
const server = await startTestServer({
botToken: "123456:TEST",
chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
});
const bot = new Bot("123456:TEST", { client: { apiRoot: server.origin } });
bot.on("message:text", (ctx) => ctx.reply(`You said: ${ctx.message.text}`));
// The bot's webhook, on a free local port.
const receiver = http.createServer(webhookCallback(bot, "http"));
await new Promise((resolve) => receiver.listen(0, "127.0.0.1", resolve));
await bot.api.setWebhook(`http://127.0.0.1:${receiver.address().port}/`);
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.post(GROUP, ann, "hello");
await server.waitFor({
kind: "message",
chatId: GROUP,
botId: BOT_ID,
text: "You said: hello",
});
await server.stop();
receiver.close();Updates reach the bot as Telegram sends them:
- Each bot has its own update queue. An update stays pending until the bot confirms it, with a
getUpdatesoffset or by answering its webhook with a 2XX status. It expires a day after it happened, and a button press after 150 seconds. - A webhook that does not answer 2XX within a minute gets the update again: at once, then after 2,
4, 8 ... seconds up to a random 60 to 120, or after its
Retry-After.getWebhookInforeports the pending count and the last error. Make your webhook answer even when a handler throws. - Updates wait in queues keyed as Telegram keys them: messages by chat;
chat_memberupdates, join requests and button presses by user;my_chat_memberupdates and reactions by chat, apart from the messages. A queue's updates reach a webhook one at a time, in order; different queues go out at once, up tomax_connections(default 40). So a join'schat_memberupdate can arrive while the chat's messages are still on their way.getUpdatesreturns all of them in order. - A webhook may answer an update with a Bot API call, as Telegraf does by default; the call runs.
- Without
allowed_updates, a bot gets every update butchat_member,message_reactionandmessage_reaction_count.chat_memberandmessage_reactionreach only administrator bots, andchat_join_requestonly bots withcan_invite_users. getUpdatesfails with 409 while a webhook is set, and a new long poll ends the one before it with 409, so run one poller per bot.
The full rules, with Telegram's error texts, are under [Update delivery][behavior-delivery].
Three server methods help with webhooks. drainDeliveries({ botId?, timeoutMs? }) waits until the
server's queued and in-flight webhook attempts have settled, retries still due and calls a webhook
answered with included. It does not empty getUpdates queues, check that the webhook answered 2XX,
or wait for what the bot does after answering.
getDeliveries() lists each attempt with its update and bot id, attempt number (each retry is one),
epoch (the number of restores before it), when it was queued, started and completed, its status
and outcome. Sent updates and this list are kept until a restore.
redeliverUpdate(updateId, { botId }) has Telegram deliver an update again, byte for byte, callback
queries included, as it does when a webhook does not confirm one. Each bot numbers its own updates,
and bots added in the same second start at the same number, so with more than one bot always pass
botId. It sends the saved update, so do not restore an earlier snapshot between the steps of a
replay.
More than one bot
The bot named by botToken is the first bot. addBot adds another, with its own webhook or update
queue; it is in no chat until added:
const second = await server.addBot({ token: "654321:SECOND", username: "second_bot" });
await server.setBotMembership(GROUP, second.id, {
status: "administrator",
rights: { can_restrict_members: false },
});
const secondBot = new Bot("654321:SECOND", { client: { apiRoot: server.origin } });setBotMembership adds, promotes, demotes or removes a bot as the chat's owner would. Without
status the bot becomes an administrator; rights grants or withholds rights, such as
{ can_post_messages: false }. The bot gets my_chat_member, the chat's administrator bots
chat_member, and a group a service message when the bot joins or leaves.
deleteBot(second.id) deletes a bot that addBot added. It leaves every chat it is in, as when a
bot leaves: its status there becomes left, the chat's administrator bots get chat_member, and a
group gets a left_chat_member service message, which getMessageLog and getBotUpdates list.
Its token then gets 401 Unauthorized. A waiting getUpdates answers at once with what is pending,
and the next call gets the 401, so a polling library stops with an error (grammY's bot.start()
rejects): have the app catch it. The first bot can't be deleted.
Each bot has its own membership and rights in each chat, its own update_id sequence, its own
file_ids, and hears only the button presses on keyboards it put on messages. getMember answers
as getChatMember would to the first bot, and the control API shows messages with the first bot's
file_ids. Name a bot with botId in waits, failure rules and connectBusiness. More under
[More than one bot][behavior-bots].
Each bot has its own private chat with a user, as on Telegram, and all of a bot's private chats
share one message id sequence with its view of basic groups. A bot reaches only its own: it can
message a user who wrote to it, or a join requester for five minutes, and gets 403 otherwise. Other
calls, getChat and sendChatAction among them, find the chat of any user the bot knows, empty if
they never wrote, and get 400 chat not found for anyone else. The private-chat actions take
{ botId } (bot_id over HTTP), and the first bot is the default:
await server.sendDirectMessage(ann, "/start", { botId: second.id });
const replies = await server.getDirectMessages(ann, { botId: second.id });
await server.waitFor({ kind: "message", chatId: ann, botId: second.id, text: "Welcome!" });A start link to any bot opens that bot's chat. In a message wait on a private chat, botId names
the bot's chat, and the author when userId does not.
A bot runs in privacy mode with the privacyMode option (the first bot) or addBot({ privacyMode:
true }). Off by default.
In a group where such a bot is not an administrator, it gets only what the
[Bot API docs][privacy-docs] list: service messages, commands meant for it (/help@your_bot),
general commands (/help) when it was the last bot to send a message to the group, and replies to
messages meant for it. A message reaches only one such bot: a reply before an explicit command, and
that before a general one. It gets the edits of only the messages it received. getMe says
can_read_all_group_messages: false. More under [Privacy mode][behavior-privacy].
Channels, basic groups and forums
chats creates supergroups. createChat makes a supergroup, a forum (isForum), a basic group
(type: "group") or a channel (type: "channel") during a test, with no bot in it, and returns its
id. Add a bot with setBotMembership, or with addBotViaLink as a person who opens the bot's
startgroup or startchannel link.
const OWNER = 5000000001;
const channel = await server.createChat({
type: "channel",
title: "News",
ownerId: OWNER,
});
await server.setBotMembership(channel, BOT_ID, {
rights: { can_post_messages: true },
});
await server.post(channel, OWNER, "First post"); // bots get it as channel_post
const forum = await server.createChat({ title: "Help", ownerId: OWNER, isForum: true });
const topic = await server.createTopic(forum, "Questions");
await server.post(forum, OWNER, { text: "How do I start?", threadId: topic });Channels. Every message in a channel comes from the channel (
sender_chat, nofrom), and bots get it aschannel_post, its edits asedited_channel_post. Only the creator and administrators withcan_post_messagespost;post()refuses anyone else withCHAT_WRITE_FORBIDDEN. Details under [Channels][behavior-channels].Basic groups have a negative id without the
-100prefix.migrateToSupergroup(chatId)upgrades one as its creator or an administrator would and returns the new supergroup's id; later calls to the old id fail as Telegram's do ([Basic groups and the upgrade][behavior-upgrade]). As on Telegram, each bot numbers a basic group's messages from its own sequence, the one its private chats use, so two bots know one message by different ids, and neither by the id test actions use. Test actions, waits, failure rules, the message log and the viewer name a basic group's message by the chat's own count, the idpostreturns.getMessageand the message log give the id each bot knows it by, inbot_message_ids:const basic = await server.createChat({ type: "group", ownerId: OWNER }); await server.setBotMembership(basic, BOT_ID); const hello = await server.post(basic, OWNER, "hello"); const { bot_message_ids } = await server.getMessage(basic, hello); // The id the bot got in its update, and names in its own calls. const botsId = bot_message_ids[BOT_ID];Forums.
createTopicandrenameTopiccreate and rename topics with Telegram's service message, andposttakes athreadId. A send to a thread that is not a topic fails withmessage thread not found. Bots close, reopen, rename and delete topics, and close, reopen, hide, unhide and rename the General topic, with Telegram's rights, errors and service messages. A call without the right on a topic the bot never sent to gets 404, as no source gives Telegram's answer. A closed topic refuses messages, withTOPIC_CLOSED, from everyone but administrators withcan_manage_topicsand the topic's creator. Details under [Forum topics][behavior-topics].
Failures, flood control and time
Injected failures
failNext(rule) makes the next matching Bot API calls fail, so a test can check how the bot copes:
// The bot's next deleteMessage in the group fails as Telegram would refuse it.
await server.failNext({
method: "deleteMessage",
chatId: GROUP,
errorCode: 400,
description: "Bad Request: message can't be deleted",
});
// Its next sendMessage gets a 429 with retry_after 3, and the Retry-After header.
await server.failNext({ method: "sendMessage", errorCode: 429, retryAfter: 3 });A rule counts only calls with its method and, where given, its chatId, botId, userId and
messageId; other calls do not use it up. userId is the user the call is about: its user_id, an
ephemeral message's receiver, or the author of the message deleteMessage deletes.
messageId also matches one id in a deleteMessages list; in a basic group it is the chat's own
id, whatever id the bot gave. attempt: 2 starts at the second matching call after the rule is
added, and times (default 1) is how many matching calls in a row it applies to.
- With
errorCode, the call fails before it runs. Withoutdescription, the error reads as Telegram's does for its code (Bad Request,Forbidden,Conflict, ...). A 429 needsretryAfter, a whole number of seconds, andfailNextrejects a 429 rule without it. The call then readsToo Many Requests: retry after Nand carries theRetry-Afterheader, as on Telegram. - With
dropAfterApply, the call takes effect once, then the connection closes without an answer. delayMs(at most 30 seconds) delays the answer; on its own, the call runs normally. Delays end when the server stops.
clearFailures() drops the rules not used up.
Flood control
Off by default. With floodControl: true, the server limits a bot's sends the way Telegram does
once the bot goes over the limits Telegram publishes in its
Bots FAQ:
- one message a second in a chat. Telegram says it may allow short bursts above this; here an album is such a burst, sent once the chat's last message is a second old;
- 20 messages a minute in a group or supergroup;
- 30 messages a second from one bot across all its chats (paid broadcasts are not modeled).
These numbers are Telegram's published guidance, not its internal algorithm, which Telegram does not publish. A bot that stays within them here can still be limited differently on Telegram.
Each bot has its own limits. A call that sends messages counts them after its parameters are
checked: sendMessage, the other send… methods, forwardMessage and copyMessage count one
message, and sendMediaGroup counts every message of the album. Edits, deletions,
sendChatAction and business messages (business_connection_id) are not counted.
A send over a limit has to wait. As Telegram's Bot API server does, this server holds a send that
has to wait 8 seconds or less, then sends it and answers 200: the bot sees only the delay. If the
send then has to wait again, the waits add up. A send whose waits would come to more than 8 seconds
stores nothing, does not count, and gets HTTP 429, a Retry-After header and
{
"ok": false,
"error_code": 429,
"description": "Too Many Requests: retry after 40",
"parameters": { "retry_after": 40 }
}retry_after is the seconds of its last wait, rounded up to whole seconds as Telegram gives them.
The limits run on the server clock, so on a manual clock a held send is answered only once a test
moves the clock past its wait with advanceTime. On a running clock it is also answered when real
time gets there. While a send is held, snapshot() and restore() refuse, as for a response delay;
a snapshot keeps and restores the recent sends.
floodControl also applies a limit of the Bot API server itself: a setWebhook with a URL less
than a second after the previous one gets 429 Too Many Requests: retry after 1 before anything
else is checked, even when the earlier call was refused. deleteWebhook and setWebhook with an
empty URL are not limited.
Time
By default the server runs on real time. With clock: { now: milliseconds } it keeps a manual
clock that only advanceTime(ms) moves; getClock() returns { mode, now, scheduled }. Start it
at Date.now() when your bot computes dates, such as an until_date, from its own clock:
// With clock: { now: Date.now() } in the options.
// Your bot mutes newcomers for an hour.
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "restricted" });
await server.advanceTime(2 * 60 * 60 * 1000); // two hours later
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "member" });A bot that lifts a mute itself calls restrictChatMember with every permission true, as the Bot
API docs say; the member is then a member again.
A manual clock stands still between advances. Messages posted a minute apart in real time get the same date, and anything that counts by dates, such as a rate window, sees no time pass.
A running clock keeps moving instead. With clock: { offset: milliseconds }, the server's time is
real time plus the offset; { offset: 0 } starts on real time, and a negative offset starts in the
past. advanceTime(ms) adds ms to the
offset, so time jumps forward, and it keeps moving between jumps. getClock() then returns { mode:
"running", now, offset, scheduled }:
// With clock: { offset: 0 } in the options.
await server.post(GROUP, ann, "first"); // dated now
await server.advanceTime(24 * 60 * 60 * 1000);
await server.post(GROUP, ann, "second"); // dated a day later, and the clock keeps movingAdvances run one at a time, and each runs what has come due, in deadline order: restriction and ban
expiry, response delays (delayMs, and owner call delays), sends held by flood control, webhook
retries and delayed getUpdates conflicts. On a running clock, these also come due as real time
passes. Message, login and business dates, the five minutes a bot may message a join requester, and
flood control follow this clock too.
A restore sets a manual clock back to the snapshot's time, and a running clock's offset back to the snapshot's offset. Real time is never rewound, so after a restore a running clock is later than the snapshot by the real time that passed.
The global Date, timers and your app's jobs are untouched. Webhook connections and their
one-minute timeout, long polling and waitFor deadlines use wall time, so an advance does not wait
for deliveries or for your bot to act. To have your app follow this clock, see
Testing time-based app logic.
Testing time-based app logic
advanceTime moves only the server's clock. Your app still reads its own Date.now(), so a
nightly job, a "three strikes in 30 days" rule or a mute the app lifts itself cannot be tested by
moving time, unless the app reads the server's clock in tests. The package ships a helper for
that, fakeClockNow(): the server's time when the TELEGRAM_FAKE_CLOCK_URL environment variable
names a server, and Date.now() otherwise. The app calls it wherever it read Date.now().
This bot reminds the chat an hour after /remind, and the test moves time instead of waiting:
import http from "node:http";
import { Bot } from "grammy";
import { startTestServer } from "telegram-bot-test-server";
import {
fakeClockHandler,
fakeClockNow,
refreshFakeClock,
} from "telegram-bot-test-server/clock";
const BOT_ID = 123456;
const GROUP = -1001000000001;
const HOUR = 60 * 60 * 1000;
// The app takes the server's clock pushes here.
const receiver = http.createServer(fakeClockHandler);
await new Promise((resolve) => receiver.listen(0, "127.0.0.1", resolve));
const server = await startTestServer({
botToken: "123456:TEST",
chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
clock: { now: Date.now() },
clockWebhook: `http://127.0.0.1:${receiver.address().port}/`,
});
process.env.TELEGRAM_FAKE_CLOCK_URL = server.origin;
await refreshFakeClock(); // once, before the app reads the clock
// The app: it reads fakeClockNow() where it read Date.now().
const bot = new Bot("123456:TEST", { client: { apiRoot: server.origin } });
const reminders = [];
bot.command("remind", async (ctx) => {
reminders.push({ chatId: ctx.chat.id, at: fakeClockNow() + HOUR });
await ctx.reply("I will remind you in an hour.");
});
// Its scheduler looks for due reminders every second.
const scheduler = setInterval(async () => {
const due = reminders.filter((reminder) => reminder.at <= fakeClockNow());
for (const reminder of due) {
reminders.splice(reminders.indexOf(reminder), 1);
await bot.api.sendMessage(reminder.chatId, "Reminder!");
}
}, 1000);
await new Promise((resolve) => bot.start({ onStart: resolve }));
// The test.
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
await server.post(GROUP, ann, "/remind");
await server.waitFor({ kind: "message", chatId: GROUP, botId: BOT_ID, contains: "an hour" });
await server.advanceTime(HOUR); // resolves once the app has the new time
await server.waitFor(
{ kind: "message", chatId: GROUP, botId: BOT_ID, text: "Reminder!" },
{ timeoutMs: 2000 },
);
clearInterval(scheduler);
await bot.stop();
await server.stop();
receiver.close();What the app has to do:
- Read the clock with
fakeClockNow(). It is synchronous and reads a cached time, so a call is cheap. WithTELEGRAM_FAKE_CLOCK_URLunset it returnsDate.now(), so production code can call it too. The variable takes the server's origin or its/_fake/clockURL, and is read on every call. - Call
await refreshFakeClock()once at start, after setting the variable. It readsGET /_fake/clock, caches the time and returns it. Until the app has the time of the server the variable names, from a read or a push,fakeClockNow()throws, so a missed setup fails instead of mixing clocks. On a server running on real time,fakeClockNow()returnsDate.now(). - A running clock keeps moving in the app too. For it, the cache holds the server's offset, not
a time, and
fakeClockNow()returnsDate.now()plus the offset. Its time moves on between reads, and only a jump or a restore needs a new read or push. This assumes the app and the server read the same system clock, as they do on one computer. - Keep the time current, in one of two ways. With the
clockWebhookoption and a manual or running clock, the server posts{ now, mode }to that URL, andoffsetfor a running clock, after eachadvanceTimeand eachrestore. They resolve only once the post has finished, so the app has the new time when the test goes on. MountfakeClockHandlerthere (a Node(request, response)handler that takes a POST on any path), or pass the parsed body toreceiveFakeClock(body)if your framework has read it already. A push counts as the time of the server the variable names when the push arrives, so set the variable before the firstadvanceTime. Without the option, callawait refreshFakeClock()before time-dependent work. - Expect timers to run on wall time. Moving the clock fires none of them; a scheduler that
checks
fakeClockNow()as it runs, as above, sees the new time on its next check. Or have the test run the app's job afteradvanceTime.
An app in another process needs the route's address before the server starts, and the server's origin after. So, in this order:
- Start the app. It serves
fakeClockHandlerin its own HTTP server, on a free port, and tells the test the port, over IPC or on its output. - Start the server with
clockWebhookset to that route. - Send the app
server.origin. The app setsprocess.env.TELEGRAM_FAKE_CLOCK_URLto it, callsawait refreshFakeClock(), then starts its bot with the origin as its Bot API root.
With a fixed port for the app's route, start the server first and spawn the app with
TELEGRAM_FAKE_CLOCK_URL in its environment.
An app in another language reads GET <origin>/_fake/clock, which answers { mode, now, scheduled
}, and uses now while mode is manual. While mode is running, the answer also has
offset, and the app adds it to its own clock.
A push that fails, or takes over 2 seconds, goes to the log option as clock webhook failed:
<reason> and fails nothing. The bot's own webhook deliveries stay as Telegram sends them.
Snapshots
A snapshot saves the server's state so a test can return to it, instead of starting a fresh server. A snapshot needs an idle server, so this bot makes its calls without polling:
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 }],
});
const bot = new Bot("123456:TEST", { client: { apiRoot: server.origin } });
const ann = await server.createUser({ first_name: "Ann" });
await server.join(GROUP, ann);
const saved = await server.snapshot();
await bot.api.banChatMember(GROUP, ann);
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "kicked" });
await server.restore(saved); // Ann is a member again
await server.waitFor({ kind: "member", chatId: GROUP, userId: ann, status: "member" });
await server.releaseSnapshot(saved);
await server.stop();In a real test, trigger your app instead of calling the Bot API directly, and wait for the same condition.
snapshot() returns an opaque handle that only this server accepts. snapshot() and
restore(handle) need the server to be idle: no Bot API, control or owner request in progress, no
long poll, no webhook attempt (an update waiting for a retry counts until it is delivered, dropped
or its webhook removed), no response delay, no send held by flood control and no clock advance.
Pending restriction and ban expiries and unused failure rules are fine.
Drain deliveries and finish requests before taking a snapshot; restore fails with outstanding work
rather than mix it with the restored state.
restore puts back users, bots, chats (groups, private and business chats, owner accounts), file
bytes, members, messages, invite links and join requests, id counters, call receipts, failure rules
and how far they have counted, sent updates with their bytes and queues, webhook and
allowed_updates settings, login codes and flood control's recent sends. Expiries are scheduled
again from the restored members.
A snapshot can be restored any number of times; releaseSnapshot(handle) frees it. A restore
cancels waits in progress and returns { restored: true, epoch }, where epoch counts the restores
so far; it keeps later request_ids apart from earlier ones although ids and receipts go back. The
origin and the login signing key stay the same. Nothing outside the server is restored: your webhook
receiver, its sockets and timers, your database and your app's state.
Between independent scenarios, drop unused failure rules with clearFailures(), then restore a
snapshot or start a fresh server.
Watch the chats in a browser
The viewer draws the server's chats the way Telegram's apps draw them, live, as the test runs. Above, a book club group on a phone, seen as its member Carol.
Turn it on with ui: true. The server then serves the viewer at server.viewerUrl:
const server = await startTestServer({
botToken: "123456:TEST",
chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
ui: true,
});
console.log(server.viewerUrl); // http://127.0.0.1:54321/_fake/uiFrom the command line, --ui prints the address. The viewer answers only on this computer. It never
changes the server's state, and Bot API answers and updates stay the same.
To watch a test by eye, give the server a fixed port and open the address before the test runs.
Keep the server running until you have looked, for example with await page.pause() in a
Playwright test. To look at a fast test afterwards, record it.
It opens on Activity: every chat in one feed, in the order things happened. Messages are bubbles, as in Telegram. Each Bot API call is a bubble from the bot, with the method, the outcome and Telegram's error text. A change to a member is a centered line, such as "Ann joined". A deleted message stays, grayed and marked with who deleted it.
The toolbar switches to the chat list, one chat, its members, or the chats as one member sees them. Click a call to mark what it touched. The view lives in the URL, so a link opens the same view. The [viewer reference][ref-viewer] lists everything it shows, its URL parameters, the data attributes for Playwright and its routes.
Watch several servers at once
When several servers run at the same time, for example one per parallel test worker, watch them on one page:
npx telegram-bot-test-server watch # http://127.0.0.1:8090/Every server started with ui: true on this computer appears as its own panel, side by side with
draggable dividers, and disappears when it stops. Give a server a name (--name on the command
line) to label its panel. --port changes the page's port, and --exit-when-idle <seconds> closes
it once no server has run for that long.
Record a scenario
A recording keeps what happened in Telegram between two points of a test, as one HTML page that opens offline, with no server running, and its JSON twin. A test report can link each scenario to its page:
const server = await startTestServer({
botToken: "123456:TEST",
chats: [{ id: GROUP, title: "Test Group", ownerId: 5000000001 }],
recordDir: "test-results/recordings",
});
await server.startRecording("spam-is-removed", { chats: [GROUP] });
// The scenario: users act, the bot answers.
const { files } = await server.stopRecording("spam-is-removed");
console.log(files.html); // /…/test-results/recordings/spam-is-removed.htmlOr one recording per test, named after it:
// Declare these hooks after the ones that start and stop the server. Vitest runs afterEach
// hooks in reverse order, so each recording then stops before server.stop().
const nameOf = (task) =>
task.name.replace(/[^A-Za-z0-9._-]+/g, "-").replace(/^[-._]+/, "").slice(0, 100);
beforeEach(async ({ task }) => {
await server.startRecording(nameOf(task));
});
afterEach(async ({ task }) => {
await server.stopRecording(nameOf(task));
});startRecording(name, { chats })starts one. The name is letters, digits,.,_and-, starting with a letter or digit; it also names the files.chatsnames chats as the viewer'schatparameter does: a group id,<user id>:<bot id>, a user id (their chat with the first bot) orcalls. Without it, the recording holds every chat something happened in. A chat may be named before it exists; one that still does not exist at the stop is listed inmissing_chats. Any number of recordings run at once, each under its own name.stopRecording(name)returns{ name, html, json, files }:htmlis the page as text andjsonits twin as an object;filescomes only withrecordDir. They hold every message and event stored after the start, deleted ones included, and every Bot API call received after it, with the members and each message's reactions as they are at the stop. Older messages that a recorded call names (a fo
