A Telegram bot is a program that receives updates from Telegram’s servers and responds with Bot API calls over HTTPS using a token from @BotFather. Updates arrive either through getUpdates (long polling — handy during development) or through a webhook to your HTTPS endpoint that checks the secret_token header. In this tutorial a bot on Node.js 24 and grammY takes a request through buttons and forwards it to a manager, and it is verified by tests without a real token.
After this you will be able to
- Create a bot in @BotFather and keep its token safely in environment variables
- Handle the
/startcommand, inline buttons withcallback_dataand a contact request button - Switch the bot from long polling to a webhook with
secret_tokenand register the command menu - Handle Bot API errors and
429responses and test the bot without a real token
Know beforehand
Verified with
Node.js 24 LTS (24.18)grammY 1.46 (Bot API 10.3)@grammyjs/auto-retry 2.0TypeScript 7.0Vitest 5.0
Checked against docs: October 11, 2026
Contents
How the Bot API works
This tutorial is for beginner backend developers and people who automate a business: you need to be able to run Node.js and understand HTTP and JSON. The scenario is a bot for a salon or a studio: a customer taps /start, picks a service with a button, leaves a phone number, and the managers get the request in their work chat.
A Telegram bot is a special account backed by your program. A customer writes to the bot, the message lands on Telegram’s servers, and your program fetches it as an Update object and answers by calling Bot API methods. Each call is an HTTPS request of the form https://api.telegram.org/bot<token>/METHOD_NAME. The response is always JSON with an ok field: on success the data is in result, on failure there is an explanation in description and sometimes a hint in parameters, such as how many seconds to wait.
- 01Customer in Telegram
- 02Telegram servers
- 03Your Node.js bot
- 04Managers’ chat
You can write the requests by hand — the Bot API has no complicated authorisation. A library is more convenient, though: we’ll use grammY 1.46, which supports Bot API 10.3, provides types for every object and ready-made webhook adapters. The code runs on Node.js 24 without a build step.
curl "https://api.telegram.org/bot$BOT_TOKEN/getMe"
curl "https://api.telegram.org/bot$BOT_TOKEN/getUpdates?timeout=30"
curl "https://api.telegram.org/bot$BOT_TOKEN/getWebhookInfo"BotFather and the token
Open @BotFather
It is Telegram’s official bot for creating and managing bots.
Send /newbot
BotFather asks for a name (what users see) and a username. The username is 5–32 characters of Latin letters, digits and underscores and must end in
bot, for examplesalon_almaty_bot. It can’t be changed later.Save the token
A token like
123456789:AA…authorises the bot in the Bot API. Put it in.envon the server and in a password manager — not in the code and not in a chat.Create a separate bot for testing
The Telegram documentation suggests testing changes on a second bot with its own token so real customers aren’t affected.
Find the managers’ chat id
Add the bot to the managers’ group and send a command there, for example
/help@your_bot. While the bot isn’t running, opengetUpdatesfrom the example above — the group’schat.idwill be in the response.
# Copy to .env and never commit .env
BOT_TOKEN=123456789:REPLACE_WITH_TOKEN_FROM_BOTFATHER
MANAGER_CHAT_ID=-1001234567890
# Empty: long polling (development). Set it on the server to switch to the webhook.
WEBHOOK_SECRET=
PUBLIC_URL=https://bot.example.kz
PORT=8080Receiving updates: getUpdates or a webhook
There are two ways to receive updates and they are mutually exclusive: while a webhook is set, getUpdates doesn’t work. Telegram keeps undelivered updates for no longer than 24 hours.
getUpdates (long polling)
- The bot asks Telegram itself and waits for an answer up to
timeoutseconds - No domain, HTTPS or open port needed — works from a laptop
- An update is confirmed when the next request comes with an
offsethigher than itsupdate_id - The bot process must run all the time
Webhook (setWebhook)
- Telegram POSTs each Update to your HTTPS endpoint
- Requires TLS 1.2+ and port 443, 80, 88 or 8443
- The
X-Telegram-Bot-Api-Secret-Tokenheader proves the request came from your webhook - If you answer anything other than
2xx, Telegram redelivers — the handler must respond quickly
{
"update_id": 1,
"message": {
"message_id": 1,
"date": 1760000000,
"chat": { "id": 1001, "type": "private", "first_name": "Айгерим" },
"from": { "id": 1001, "is_bot": false, "first_name": "Айгерим" },
"text": "/start",
"entities": [{ "type": "bot_command", "offset": 0, "length": 6 }]
}
}The update_id field increases sequentially and helps you drop a repeat if the same update arrives twice. Each update has at most one of the optional fields filled in: message, callback_query and others. With allowed_updates you can ask for only the types you need — our bot handles message and callback_query.
The project and handlers: commands, buttons, a request
mkdir salon-bot && cd salon-bot
npm init -y && npm pkg set type=module
npm install grammy@1.46 @grammyjs/auto-retry@2
npm install -D typescript@7 vitest@5 @types/node{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"strict": true,
"noEmit": true,
"allowImportingTsExtensions": true,
"erasableSyntaxOnly": true,
"verbatimModuleSyntax": true,
"skipLibCheck": true,
"types": ["node"]
},
"include": ["src", "test"]
}export type Lead = {
id: number;
chatId: number;
name: string;
service: string;
phone: string;
createdAt: string;
notified: boolean; // has the manager received it
};
// In-memory storage keeps the tutorial short. In production it is a database table:
// a lead must survive a restart and a failed notification.
export function createLeadStore() {
const leads: Lead[] = [];
return {
add(data: Pick<Lead, 'chatId' | 'name' | 'service' | 'phone'>): Lead {
const lead = { ...data, id: leads.length + 1, createdAt: new Date().toISOString(), notified: false };
leads.push(lead);
return lead;
},
all: () => leads,
unnotified: () => leads.filter((l) => !l.notified),
};
}
export type LeadStore = ReturnType<typeof createLeadStore>;import { autoRetry } from '@grammyjs/auto-retry';
import { Bot, GrammyError, HttpError, InlineKeyboard, Keyboard, type Api, type BotConfig, type Context } from 'grammy';
import type { Lead, LeadStore } from './leads.ts';
export const SERVICES = [
{ id: 'haircut', title: 'Стрижка' },
{ id: 'manicure', title: 'Маникюр' },
{ id: 'consult', title: 'Консультация' },
];
const PHONE = /^\+?\d[\d ()-]{9,17}$/;
type Options = { managerChatId: number; leads: LeadStore; config?: BotConfig<Context> };
export function createBot(token: string, { managerChatId, leads, config }: Options) {
const bot = new Bot(token, config);
// On 429 waits retry_after seconds and repeats; also retries 5xx and network errors.
bot.api.config.use(autoRetry({ maxRetryAttempts: 3, maxDelaySeconds: 10 }));
// chat id → chosen service. A real bot keeps this in a database or the sessions plugin.
const drafts = new Map<number, string>();
// Every handler below runs inside this boundary: an error is logged, the update counts
// as handled, and the webhook gets 200 instead of an endless redelivery.
const safe = bot.errorBoundary((err) => {
const e = err.error;
const why = e instanceof GrammyError ? `Bot API: ${e.description}` : e instanceof HttpError ? 'network' : e;
console.error(`update ${err.ctx.update.update_id} failed:`, why);
});
const servicesKeyboard = () => {
const kb = new InlineKeyboard();
for (const s of SERVICES) kb.text(s.title, `svc:${s.id}`).row();
return kb;
};
safe.command('start', (ctx) =>
ctx.reply('Здравствуйте! Выберите услугу — администратор перезвонит и подберёт время.', {
reply_markup: servicesKeyboard(),
}),
);
safe.command('help', (ctx) => ctx.reply('/start — оставить заявку\n/cancel — отменить заявку'));
safe.command('cancel', (ctx) => {
drafts.delete(ctx.chat.id);
return ctx.reply('Заявка отменена. Начать заново — /start', { reply_markup: { remove_keyboard: true } });
});
safe.callbackQuery(/^svc:(\w+)$/, async (ctx) => {
const service = SERVICES.find((s) => s.id === ctx.match[1]);
if (!service || !ctx.chat) return ctx.answerCallbackQuery({ text: 'Эта услуга недоступна' });
drafts.set(ctx.chat.id, service.title);
await ctx.answerCallbackQuery(); // stops the loading spinner on the button
await ctx.reply(`${service.title}. Оставьте номер телефона — кнопкой ниже или сообщением.`, {
reply_markup: new Keyboard().requestContact('Отправить номер').resized().oneTime(),
});
});
// Buttons from old messages, or data we no longer understand.
safe.on('callback_query:data', (ctx) => ctx.answerCallbackQuery({ text: 'Кнопка устарела, нажмите /start' }));
safe.on(['message:contact', 'message:text'], async (ctx) => {
const service = drafts.get(ctx.chat.id);
if (!service) return ctx.reply('Чтобы оставить заявку, нажмите /start');
const phone = (ctx.message.contact?.phone_number ?? ctx.message.text ?? '').trim();
if (!PHONE.test(phone)) return ctx.reply('Не похоже на номер. Пример: +7 700 123 45 67');
drafts.delete(ctx.chat.id);
const lead = leads.add({ chatId: ctx.chat.id, name: ctx.from.first_name, service, phone });
await notifyManager(bot.api, managerChatId, lead); // the lead is saved first: a failure here loses nothing
await ctx.reply('Спасибо! Заявка принята, администратор свяжется с вами.', {
reply_markup: { remove_keyboard: true },
});
});
return bot;
}
export async function notifyManager(api: Api, managerChatId: number, lead: Lead) {
try {
// No parse_mode: the customer's name is plain text and can't break the markup.
await api.sendMessage(
managerChatId,
`Заявка #${lead.id}\nУслуга: ${lead.service}\nИмя: ${lead.name}\nТелефон: ${lead.phone}`,
);
lead.notified = true;
} catch (err) {
// e.g. the bot was removed from the managers' group. A scheduled job retries unnotified leads.
console.error(`lead ${lead.id}: manager not notified`, err instanceof GrammyError ? err.description : err);
}
}command('start')fires on/start: grammY looks at thebot_commandentity in the messageInlineKeyboardputs buttons under a message. A press doesn’t post text to the chat; it sends acallback_querywithcallback_dataof up to 64 bytes, so it carries a short service id, not the titleanswerCallbackQueryis required for every press: until it is called, the button shows a loading indicatorKeyboard().requestContact()is a regular keyboard with a “Share number” button that works in private chats only.remove_keyboardhides it after the requesterrorBoundarycatches errors from every handler: the error is logged instead of crashing the bot or causing endless webhook redeliveries- The request is saved first and sent to the manager second: if the managers’ chat is unavailable, nothing is lost
Running it: long polling for development, a webhook on the server
import { createBot } from './bot.ts';
import { createLeadStore } from './leads.ts';
import { createWebhookServer } from './webhook.ts';
const token = process.env.BOT_TOKEN; // from @BotFather, never in the code or the repository
const managerChatId = Number(process.env.MANAGER_CHAT_ID);
if (!token || !managerChatId) throw new Error('BOT_TOKEN and MANAGER_CHAT_ID are required');
const bot = createBot(token, {
managerChatId,
leads: createLeadStore(),
// Unset in production. Set it to use a local Bot API server or a mock.
config: { client: { apiRoot: process.env.TELEGRAM_API_ROOT } },
});
if (process.env.WEBHOOK_SECRET) {
// Production: Telegram pushes updates to us. The webhook itself is set by set-webhook.ts.
const port = Number(process.env.PORT ?? 8080);
createWebhookServer(bot, process.env.WEBHOOK_SECRET).listen(port, () =>
console.log(`webhook: http://localhost:${port}/telegram`),
);
} else {
// Development: long polling. bot.start() removes the webhook first: the two modes exclude each other.
bot.catch((err) => console.error('polling error:', err.error));
process.once('SIGINT', () => bot.stop());
process.once('SIGTERM', () => bot.stop());
await bot.start({
allowed_updates: ['message', 'callback_query'],
onStart: (me) => console.log(`@${me.username} is polling`),
});
}import { createServer } from 'node:http';
import { webhookCallback, type Bot } from 'grammy';
// Telegram sends every update as a POST with the X-Telegram-Bot-Api-Secret-Token header;
// webhookCallback answers 401 if the header doesn't match the secret passed to setWebhook.
export function createWebhookServer(bot: Bot, secretToken: string, path = '/telegram') {
const handleUpdate = webhookCallback(bot, 'http', { secretToken });
return createServer(async (req, res) => {
if (req.method !== 'POST' || req.url !== path) {
res.writeHead(404).end();
return;
}
try {
await handleUpdate(req, res);
} catch (err) {
// Timeout (10 s by default) or an error outside errorBoundary: Telegram will redeliver.
console.error('webhook failed:', err);
if (!res.headersSent) res.writeHead(500).end();
}
});
}import { Api } from 'grammy';
// Run once per deploy: node src/set-webhook.ts
const { BOT_TOKEN, PUBLIC_URL, WEBHOOK_SECRET, TELEGRAM_API_ROOT } = process.env;
if (!BOT_TOKEN || !PUBLIC_URL || !WEBHOOK_SECRET) {
throw new Error('BOT_TOKEN, PUBLIC_URL and WEBHOOK_SECRET are required');
}
const api = new Api(BOT_TOKEN, { apiRoot: TELEGRAM_API_ROOT });
await api.setWebhook(`${PUBLIC_URL}/telegram`, {
secret_token: WEBHOOK_SECRET, // 1–256 characters: A-Z, a-z, 0-9, _ and -
allowed_updates: ['message', 'callback_query'], // only what the bot handles
});
// The command menu next to the input field.
await api.setMyCommands([
{ command: 'start', description: 'Оставить заявку' },
{ command: 'cancel', description: 'Отменить заявку' },
{ command: 'help', description: 'Помощь' },
]);
const info = await api.getWebhookInfo();
console.log(`webhook: ${info.url}, pending updates: ${info.pending_update_count}`);node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"node --env-file=.env src/main.ts # development: long polling
node --env-file=.env src/set-webhook.ts # on the server, once per deploy
npx tsc -p . && npx vitest run # type check and tests, no token neededDevelopment
Leave
WEBHOOK_SECRETempty and runsrc/main.ts. Before polling,bot.start()callsdeleteWebhook— the polling-mode test shows this too.Server
You need a domain with HTTPS. Usually a reverse proxy such as Nginx holds the certificate and forwards requests to the app’s port.
Secret
Generate
WEBHOOK_SECRETwith the command above and set it in the server environment.Register the webhook
Run
src/set-webhook.ts: it callssetWebhookwithsecret_tokenandallowed_updatesand sets the command menu viasetMyCommands.Check
getWebhookInfoshows the webhook URL and the number of queued updates; if the queue grows, the server isn’t answering or answers with errors.
Errors and limits
The Bot API returned ok: false
- How it looks in grammY
GrammyErrorwitherror_codeanddescription- What to do
- Log it; a bad request won’t be fixed by retrying
Telegram is unreachable
- How it looks in grammY
HttpError- What to do
- Retry later — auto-retry does this
A limit was exceeded, response 429
- How it looks in grammY
parameters.retry_after— how many seconds to wait- What to do
- Wait and retry: auto-retry handles it
The bot was removed from the managers’ chat
- How it looks in grammY
GrammyErrorwith code403- What to do
- The request is already saved; resend on a schedule
A handler took longer than 10 seconds
- How it looks in grammY
- A timeout error in
webhookCallback - What to do
- Move long work into a queue
The limits named in the Telegram FAQ: in a single chat, no more than one message per second (short bursts are allowed, then 429 errors begin); in a group, no more than 20 messages per minute; bulk notifications, about 30 messages per second. The grammY documentation stresses that the exact limits are not published and change, so the only correct reaction to 429 is to wait retry_after seconds and retry.
The auto-retry plugin does exactly that: it intercepts a response with retry_after, waits and repeats the request, and also retries 5xx responses and network failures. The maxRetryAttempts and maxDelaySeconds options cap the number of attempts and the longest wait so the webhook handler doesn’t hang.
- The token and the webhook secret live only in environment variables
- Every inline button press gets
answerCallbackQuery - The request is saved before the notification is sent
- Handler errors are logged and don’t crash the process
- On
429the bot waitsretry_afterinstead of retrying immediately - The webhook checks
secret_tokenand accepts onlyPOSTon its path - Long operations are moved out of the handler
Testing without a real token
You need neither a token nor internet access to test the bot. grammY lets you set your own Bot API address via apiRoot — that’s how a local Bot API server is connected, and in tests, a mock. Our double imitates the methods we need, records every call and returns 429 or 403 on demand. The tests feed sample Updates into bot.handleUpdate and check what the bot sent back.
// A local imitation of the Bot API: POST /bot<token>/<method> → { ok, result }.
// It records every call and can be told to fail. Not Telegram — only what the bot uses.
import { createServer } from 'node:http';
import type { AddressInfo } from 'node:net';
type Call = { method: string; payload: Record<string, any> };
type Failure = { method: string; chatId?: number; times: number; status: number; body: object };
export async function startFakeTelegram() {
const calls: Call[] = [];
const failures: Failure[] = [];
const updates: { update_id: number }[] = []; // queue for getUpdates (long polling)
let webhookUrl = '';
let messageId = 0;
function result(method: string, p: Record<string, any>): unknown {
switch (method) {
case 'getMe':
return { id: 42, is_bot: true, first_name: 'Salon', username: 'salon_test_bot', can_join_groups: true, can_read_all_group_messages: false, supports_inline_queries: false };
case 'sendMessage':
return { message_id: ++messageId, date: Math.floor(Date.now() / 1000), chat: { id: p.chat_id, type: 'private' }, text: p.text };
case 'setWebhook':
webhookUrl = p.url;
return true;
case 'deleteWebhook':
webhookUrl = '';
return true;
case 'getUpdates': {
// An update is confirmed once getUpdates is called with a higher offset.
while (updates.length && updates[0].update_id < (p.offset ?? 0)) updates.shift();
return updates.slice(0, p.limit ?? 100);
}
case 'getWebhookInfo':
return { url: webhookUrl, has_custom_certificate: false, pending_update_count: 0 };
default:
return true; // answerCallbackQuery, setMyCommands
}
}
const server = createServer(async (req, res) => {
const chunks: Buffer[] = [];
for await (const c of req) chunks.push(c as Buffer);
const method = (req.url ?? '').split('/').pop() ?? '';
const payload = chunks.length ? JSON.parse(Buffer.concat(chunks).toString()) : {};
calls.push({ method, payload });
if (method === 'getUpdates' && updates.length === 0) await new Promise((r) => setTimeout(r, 200)); // a short "long poll"
const failure = failures.find((f) => f.method === method && (f.chatId === undefined || f.chatId === payload.chat_id) && f.times > 0);
if (failure) {
failure.times--;
res.writeHead(failure.status, { 'content-type': 'application/json' }).end(JSON.stringify(failure.body));
return;
}
res.writeHead(200, { 'content-type': 'application/json' }).end(JSON.stringify({ ok: true, result: result(method, payload) }));
});
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
return {
apiRoot: `http://127.0.0.1:${(server.address() as AddressInfo).port}`,
calls,
sent: (chatId?: number) => calls.filter((c) => c.method === 'sendMessage' && (chatId === undefined || c.payload.chat_id === chatId)),
pushUpdate: (u: { update_id: number }) => void updates.push(u),
/** Flood control: the next `times` calls get 429 with retry_after. */
floodWait(method: string, retryAfter: number, chatId?: number, times = 1) {
failures.push({ method, chatId, times, status: 429, body: { ok: false, error_code: 429, description: `Too Many Requests: retry after ${retryAfter}`, parameters: { retry_after: retryAfter } } });
},
/** Any other API error, e.g. 403 when the bot was removed from the chat (description is illustrative). */
fail(method: string, status: number, description: string, chatId?: number, times = 1) {
failures.push({ method, chatId, times, status, body: { ok: false, error_code: status, description } });
},
close: () => new Promise<void>((r) => server.close(() => r())),
};
}import { execFile, spawn } from 'node:child_process';
import type { AddressInfo } from 'node:net';
import { promisify } from 'node:util';
import type { Update } from 'grammy/types';
import { afterEach, beforeEach, expect, it, vi } from 'vitest';
import { createBot } from '../src/bot.ts';
import { createLeadStore, type LeadStore } from '../src/leads.ts';
import { createWebhookServer } from '../src/webhook.ts';
import { startFakeTelegram } from './fake-telegram.ts';
const TOKEN = '123456:TEST-TOKEN-NOT-REAL'; // a made-up token: no request reaches api.telegram.org
const MANAGERS = -100200300; // the managers' group
const USER = { id: 1001, is_bot: false, first_name: 'Айгерим' };
const chat = { id: USER.id, type: 'private' as const, first_name: USER.first_name };
let tg: Awaited<ReturnType<typeof startFakeTelegram>>;
let leads: LeadStore;
let bot: ReturnType<typeof createBot>;
let updateId = 0;
// Sample updates shaped like the Update objects from the Bot API reference.
const text = (t: string): Update => ({
update_id: ++updateId,
message: {
message_id: updateId, date: 1760000000, chat, from: USER, text: t,
...(t.startsWith('/') && { entities: [{ type: 'bot_command', offset: 0, length: t.split(' ')[0].length }] }),
},
});
const press = (data: string): Update => ({
update_id: ++updateId,
callback_query: { id: `cb${updateId}`, from: USER, chat_instance: 'ci1', data, message: { message_id: 1, date: 1760000000, chat, text: 'menu' } as never },
});
const contact = (phone: string): Update => ({
update_id: ++updateId,
message: { message_id: updateId, date: 1760000000, chat, from: USER, contact: { phone_number: phone, first_name: USER.first_name, user_id: USER.id } },
});
beforeEach(async () => {
tg = await startFakeTelegram();
leads = createLeadStore();
bot = createBot(TOKEN, { managerChatId: MANAGERS, leads, config: { client: { apiRoot: tg.apiRoot } } });
await bot.init(); // getMe goes to the fake
});
afterEach(() => tg.close());
it('/start shows the services as inline buttons', async () => {
await bot.handleUpdate(text('/start'));
const [msg] = tg.sent(USER.id);
const buttons = msg.payload.reply_markup.inline_keyboard.flat();
expect(buttons.map((b: { callback_data: string }) => b.callback_data)).toEqual(['svc:haircut', 'svc:manicure', 'svc:consult']);
});
it('a button press is answered and asks for the phone', async () => {
await bot.handleUpdate(press('svc:manicure'));
expect(tg.calls.find((c) => c.method === 'answerCallbackQuery')?.payload.callback_query_id).toBe(`cb${updateId}`);
const [ask] = tg.sent(USER.id);
expect(ask.payload.text).toMatch(/^Маникюр/);
expect(ask.payload.reply_markup.keyboard[0][0]).toEqual({ text: 'Отправить номер', request_contact: true });
});
it('a shared contact creates a lead and notifies the managers', async () => {
await bot.handleUpdate(press('svc:manicure'));
await bot.handleUpdate(contact('+77001234567'));
expect(leads.all()).toMatchObject([{ service: 'Маникюр', phone: '+77001234567', notified: true }]);
expect(tg.sent(MANAGERS)[0].payload.text).toBe('Заявка #1\nУслуга: Маникюр\nИмя: Айгерим\nТелефон: +77001234567');
expect(tg.sent(USER.id).at(-1)?.payload.reply_markup).toEqual({ remove_keyboard: true });
});
it('accepts a typed phone and rejects anything else', async () => {
await bot.handleUpdate(press('svc:haircut'));
await bot.handleUpdate(text('завтра после обеда'));
expect(tg.sent(USER.id).at(-1)?.payload.text).toMatch(/Не похоже на номер/);
await bot.handleUpdate(text('+7 700 123 45 67'));
expect(leads.all()).toHaveLength(1);
});
it('asks to press /start when there is no draft, answers stale buttons', async () => {
await bot.handleUpdate(text('+7 700 123 45 67'));
expect(tg.sent(USER.id)[0].payload.text).toMatch(/нажмите \/start/);
await bot.handleUpdate(press('old:button'));
expect(tg.calls.at(-1)).toMatchObject({ method: 'answerCallbackQuery', payload: { text: 'Кнопка устарела, нажмите /start' } });
});
it('waits retry_after seconds on 429 and repeats the request', async () => {
tg.floodWait('sendMessage', 1, MANAGERS);
await bot.handleUpdate(press('svc:consult'));
const started = Date.now();
await bot.handleUpdate(contact('+77001234567'));
expect(Date.now() - started).toBeGreaterThanOrEqual(1000);
expect(tg.sent(MANAGERS)).toHaveLength(2); // 429, then success
expect(leads.all()[0].notified).toBe(true);
});
it('keeps the lead when the managers chat is unavailable', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
tg.fail('sendMessage', 403, 'Forbidden: bot is not a member of the chat', MANAGERS);
await bot.handleUpdate(press('svc:consult'));
await bot.handleUpdate(contact('+77001234567'));
expect(leads.unnotified()).toHaveLength(1);
expect(tg.sent(USER.id).at(-1)?.payload.text).toMatch(/Заявка принята/);
expect(log).toHaveBeenCalledWith('lead 1: manager not notified', 'Forbidden: bot is not a member of the chat');
log.mockRestore();
});
it('an API error inside a handler is logged, not thrown', async () => {
const log = vi.spyOn(console, 'error').mockImplementation(() => {});
tg.fail('sendMessage', 400, 'Bad Request: example error', USER.id);
await expect(bot.handleUpdate(text('/start'))).resolves.toBeUndefined();
expect(log.mock.calls[0][1]).toBe('Bot API: Bad Request: example error');
log.mockRestore();
});
it('webhook: checks the secret token and handles the update', async () => {
const server = createWebhookServer(bot, 'test-secret_123');
await new Promise<void>((r) => server.listen(0, '127.0.0.1', r));
const url = `http://127.0.0.1:${(server.address() as AddressInfo).port}`;
const post = (path: string, headers: Record<string, string> = {}) =>
fetch(url + path, { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(text('/start')) });
expect((await post('/telegram')).status).toBe(401);
expect((await post('/telegram', { 'X-Telegram-Bot-Api-Secret-Token': 'wrong' })).status).toBe(401);
expect((await post('/other', { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret_123' })).status).toBe(404);
expect(tg.sent(USER.id)).toHaveLength(0);
expect((await post('/telegram', { 'X-Telegram-Bot-Api-Secret-Token': 'test-secret_123' })).status).toBe(200);
expect(tg.sent(USER.id)).toHaveLength(1);
await new Promise((r) => server.close(r));
});
it('set-webhook.ts registers the webhook and the command menu', async () => {
const { stdout } = await promisify(execFile)(process.execPath, ['src/set-webhook.ts'], {
env: { ...process.env, BOT_TOKEN: TOKEN, PUBLIC_URL: 'https://bot.example.test', WEBHOOK_SECRET: 'test-secret_123', TELEGRAM_API_ROOT: tg.apiRoot },
});
expect(stdout.trim()).toBe('webhook: https://bot.example.test/telegram, pending updates: 0');
expect(tg.calls.find((c) => c.method === 'setWebhook')?.payload).toEqual({
url: 'https://bot.example.test/telegram',
secret_token: 'test-secret_123',
allowed_updates: ['message', 'callback_query'],
});
expect(tg.calls.find((c) => c.method === 'setMyCommands')?.payload.commands).toHaveLength(3);
});
it('main.ts in polling mode: removes the webhook, then answers via getUpdates', async () => {
const child = spawn(process.execPath, ['src/main.ts'], {
env: { ...process.env, BOT_TOKEN: TOKEN, MANAGER_CHAT_ID: String(MANAGERS), TELEGRAM_API_ROOT: tg.apiRoot, WEBHOOK_SECRET: '' },
});
try {
tg.pushUpdate(text('/start'));
await vi.waitFor(() => expect(tg.sent(USER.id)).toHaveLength(1), { timeout: 5000 });
const methods = tg.calls.map((c) => c.method);
expect(methods.indexOf('deleteWebhook')).toBeLessThan(methods.indexOf('getUpdates'));
// the next poll confirms the processed update by passing a higher offset
await vi.waitFor(() => expect(tg.calls.filter((c) => c.method === 'getUpdates').at(-1)?.payload.offset).toBe(updateId + 1));
} finally {
child.kill('SIGTERM');
}
});$ npx vitest run --reporter=verbose
✓ test/exercise.test.ts > retryNotifications delivers leads that failed earlier
✓ test/bot.test.ts > /start shows the services as inline buttons
✓ test/bot.test.ts > a button press is answered and asks for the phone
✓ test/bot.test.ts > a shared contact creates a lead and notifies the managers
✓ test/bot.test.ts > accepts a typed phone and rejects anything else
✓ test/bot.test.ts > asks to press /start when there is no draft, answers stale buttons
✓ test/bot.test.ts > waits retry_after seconds on 429 and repeats the request
✓ test/bot.test.ts > keeps the lead when the managers chat is unavailable
✓ test/bot.test.ts > an API error inside a handler is logged, not thrown
✓ test/bot.test.ts > webhook: checks the secret token and handles the update
✓ test/bot.test.ts > set-webhook.ts registers the webhook and the command menu
✓ test/bot.test.ts > main.ts in polling mode: removes the webhook, then answers via getUpdates
Test Files 2 passed (2)
Tests 12 passed (12)Another approach from the grammY documentation is a transformer: a function in bot.api.config.use that intercepts API calls without an HTTP server. A mock server checks request serialisation and auto-retry more honestly; a transformer is simpler and faster.
Exercises and next steps
Next: how to keep tokens and secrets in the environment — in the article on secrets and environment variables; how to move long work out of a handler — in the article on background jobs; how webhooks work in general — in a separate guide.
How this was verified4 · passed1 · not runchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.