Telegram-бот — это программа, которая получает обновления от серверов Telegram и отвечает вызовами Bot API по HTTPS с токеном от @BotFather. Обновления приходят либо через getUpdates (long polling — удобно при разработке), либо через вебхук на ваш HTTPS-адрес с проверкой заголовка secret_token. В уроке бот на Node.js 24 и grammY принимает заявку кнопками и пересылает её менеджеру, а проверяется тестами без реального токена.
После материала вы сможете
- создать бота в @BotFather и безопасно хранить его токен в переменных окружения
- обрабатывать команду
/start, inline-кнопки сcallback_dataи кнопку запроса контакта - переключить бота с long polling на вебхук с
secret_tokenи зарегистрировать меню команд - обработать ошибки Bot API и ответ
429и проверить бота тестами без реального токена
Что знать заранее
Проверено на
Node.js 24 LTS (24.18)grammY 1.46 (Bot API 10.3)@grammyjs/auto-retry 2.0TypeScript 7.0Vitest 5.0
Сверено с документацией: 11 октября 2026 г.
Содержание
Как устроен Bot API
Урок для начинающих backend-разработчиков и тех, кто автоматизирует бизнес: нужно уметь запускать Node.js и понимать HTTP и JSON. Сценарий — бот салона или студии: клиент нажимает /start, выбирает услугу кнопкой, оставляет телефон, а менеджеры получают заявку в рабочий чат.
Бот в Telegram — это особый аккаунт, за которым стоит ваша программа. Клиент пишет боту, сообщение попадает на серверы Telegram, а ваша программа забирает его оттуда в виде объекта Update и отвечает вызовами методов Bot API. Каждый вызов — HTTPS-запрос вида https://api.telegram.org/bot<токен>/ИМЯ_МЕТОДА. Ответ всегда JSON с полем ok: при успехе результат лежит в result, при ошибке — пояснение в description и иногда подсказка в parameters, например сколько секунд подождать.
- 01Клиент в Telegram
- 02Серверы Telegram
- 03Ваш бот на Node.js
- 04Чат менеджеров
Писать запросы вручную можно — у Bot API нет сложной авторизации. Но удобнее библиотека: мы возьмём grammY 1.46, которая поддерживает Bot API 10.3, даёт типы для всех объектов и готовые адаптеры для вебхука. Код запускается в Node.js 24 без сборки.
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 и токен
Откройте @BotFather
Это официальный бот Telegram для создания ботов и управления ими.
Отправьте /newbot
BotFather спросит имя (его видят пользователи) и username. Username — от 5 до 32 символов, латиница, цифры и подчёркивание, и он обязан заканчиваться на
bot, напримерsalon_almaty_bot. Изменить username потом нельзя.Сохраните токен
Токен вида
123456789:AA…авторизует бота в Bot API. Положите его в.envна сервере и в менеджер паролей — не в код и не в чат.Заведите отдельного бота для тестов
Документация Telegram советует проверять изменения на втором боте со своим токеном, чтобы не мешать настоящим клиентам.
Узнайте id чата менеджеров
Добавьте бота в группу менеджеров и отправьте в группе команду, например
/help@имя_бота. Пока бот не запущен, откройтеgetUpdatesиз примера выше — в ответе будетchat.idгруппы.
# 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=8080Получение обновлений: getUpdates или вебхук
Способов получать обновления два, и они взаимоисключающие: пока установлен вебхук, getUpdates не работает. Неполученные обновления Telegram хранит не дольше 24 часов.
getUpdates (long polling)
- Бот сам спрашивает Telegram и ждёт ответа до
timeoutсекунд - Не нужны домен, HTTPS и открытый порт — работает с ноутбука
- Обновление подтверждено, когда следующий запрос пришёл с
offsetбольше егоupdate_id - Процесс бота должен работать постоянно
Вебхук (setWebhook)
- Telegram сам присылает POST с Update на ваш HTTPS-адрес
- Нужны TLS 1.2+ и порт 443, 80, 88 или 8443
- Заголовок
X-Telegram-Bot-Api-Secret-Tokenдоказывает, что запрос пришёл от вашего вебхука - При ответе не
2xxTelegram повторяет доставку — обработчик должен отвечать быстро
{
"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 }]
}
}Поле update_id растёт по порядку и помогает отбросить повтор, если одно обновление пришло дважды. В каждом обновлении заполнено не больше одного поля из списка: message, callback_query и другие. Через allowed_updates можно попросить только нужные типы — наш бот обрабатывает message и callback_query.
Проект и обработчики: команды, кнопки, заявка
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')срабатывает на/start: grammY смотрит на сущностьbot_commandв сообщенииInlineKeyboard— кнопки под сообщением. Нажатие не пишет текст в чат, а присылаетcallback_queryсcallback_data— до 64 байт, поэтому там короткий id услуги, а не названиеanswerCallbackQueryобязателен для каждого нажатия: пока его нет, на кнопке крутится индикатор загрузкиKeyboard().requestContact()— обычная клавиатура с кнопкой «Отправить номер», работает только в личных чатах.remove_keyboardубирает её после заявкиerrorBoundaryловит ошибки всех обработчиков: ошибка записывается в лог, а не роняет бота и не вызывает бесконечные повторы вебхука- Заявка сначала сохраняется, потом уходит менеджеру: если чат менеджеров недоступен, она не теряется
Запуск: long polling для разработки, вебхук для сервера
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 # разработка: long polling
node --env-file=.env src/set-webhook.ts # на сервере, один раз после деплоя
npx tsc -p . && npx vitest run # типы и тесты, токен не нуженРазработка
Оставьте
WEBHOOK_SECRETпустым и запуститеsrc/main.ts. Перед опросомbot.start()вызываетdeleteWebhook— это видно и в тесте режима polling.Сервер
Нужен домен с HTTPS. Обычно сертификат держит reverse proxy, например Nginx, и передаёт запросы на порт приложения.
Секрет
Сгенерируйте
WEBHOOK_SECRETкомандой выше и задайте его в окружении сервера.Регистрация вебхука
Запустите
src/set-webhook.ts: он вызываетsetWebhookсsecret_tokenиallowed_updatesи задаёт меню команд черезsetMyCommands.Проверка
getWebhookInfoпоказывает адрес вебхука и число обновлений в очереди; если очередь растёт, сервер не отвечает или отвечает ошибкой.
Ошибки и лимиты
Bot API вернул ok: false
- Как выглядит в grammY
GrammyErrorсerror_codeиdescription- Что делать
- Записать в лог; ошибки в запросе повтором не лечатся
Telegram недоступен по сети
- Как выглядит в grammY
HttpError- Что делать
- Повторить позже — это делает auto-retry
Превышен лимит, ответ 429
- Как выглядит в grammY
parameters.retry_after— сколько секунд ждать- Что делать
- Подождать и повторить: auto-retry делает это сам
Бот удалён из чата менеджеров
- Как выглядит в grammY
GrammyErrorс кодом403- Что делать
- Заявка уже сохранена; повторить отправку по расписанию
Обработчик не уложился в 10 секунд
- Как выглядит в grammY
- Ошибка тайм-аута в
webhookCallback - Что делать
- Перенести долгую работу в очередь
Лимиты, которые называет FAQ Telegram: в одном чате — не чаще одного сообщения в секунду (короткие всплески допустимы, потом начнутся ошибки 429), в группе — не больше 20 сообщений в минуту, массовые рассылки — около 30 сообщений в секунду. Документация grammY подчёркивает, что точные лимиты не опубликованы и меняются, поэтому единственная правильная реакция на 429 — подождать retry_after секунд и повторить.
Плагин auto-retry так и работает: перехватывает ответ с retry_after, ждёт и повторяет запрос, а заодно повторяет ответы 5xx и сетевые сбои. Параметры maxRetryAttempts и maxDelaySeconds ограничивают число попыток и максимальное ожидание, чтобы обработчик вебхука не завис.
- Токен и секрет вебхука — только в переменных окружения
- Каждое нажатие inline-кнопки получает
answerCallbackQuery - Заявка сохраняется до отправки уведомления
- Ошибки обработчиков пишутся в лог и не роняют процесс
- На
429бот ждётretry_after, а не повторяет сразу - Вебхук проверяет
secret_tokenи принимает толькоPOSTна своём пути - Долгие операции вынесены из обработчика
Проверка без реального токена
Чтобы проверить бота, не нужен ни токен, ни интернет. grammY позволяет указать свой адрес Bot API через apiRoot — так подключают локальный Bot API сервер, а в тестах — мок. Наш двойник имитирует нужные методы, записывает все вызовы и по команде возвращает 429 или 403. Тесты подают в bot.handleUpdate образцы Update и проверяют, что бот отправил в ответ.
// 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)Другой способ из документации grammY — трансформер: функция в bot.api.config.use, которая перехватывает вызовы API без HTTP-сервера. Мок-сервер честнее проверяет сериализацию запросов и работу auto-retry, трансформер проще и быстрее.
Упражнения и следующий шаг
Дальше: как хранить токены и секреты в окружении — в материале о секретах и переменных окружения; как вынести долгую работу из обработчика — в материале о фоновых задачах; как устроены вебхуки в целом — в отдельном руководстве.
Как проверено4 · пройдено1 · не запускалосьсверено 11 октября 2026 г.
- запущено, работает
.env.example, tsconfig.json, src/leads.ts, src/bot.ts, src/main.ts, src/webhook.ts, src/set-webhook.ts, test/fake-telegram.ts, test/bot.test.ts, exercise/retry.ts (решение упражнения)
временный проект в scratchpad\kb\verify\telegram-bot: grammy@1.46.0 (README: Bot API 10.3), @grammyjs/auto-retry@2.0.2, typescript@7.0.2, vitest@5.0.3, @types/node@26.6.5, Node 24.18.0; tsc -p . без ошибок (в TS 7 потребовалось "types": ["node"]); vitest run — 12/12: /start и inline-кнопки, answerCallbackQuery и request_contact, заявка и сообщение менеджеру, ввод телефона текстом и отказ, устаревшая кнопка, 429 с retry_after 1 → повтор через ≥1 с, 403 для чата менеджеров → заявка сохранена с notified: false, ошибка API в обработчике поймана errorBoundary, вебхук: без/с неверным секретом 401, чужой путь 404, верный секрет 200; set-webhook.ts как отдельный процесс → setWebhook с secret_token и allowed_updates, setMyCommands; main.ts в режиме polling как отдельный процесс → deleteWebhook до getUpdates, ответ на /start, подтверждение offset; решение упражнения retryNotifications
- запущено, работает
мутационная проверка тестов
без autoRetry падает тест 429; без secretToken — тест вебхука; без errorBoundary — тест ошибки в обработчике; без try/catch в notifyManager — тест недоступного чата менеджеров
- запущено, работает
src/main.ts в режиме вебхука
разовый smoke-скрипт: main.ts с WEBHOOK_SECRET и TELEGRAM_API_ROOT на локальный мок, POST /telegram без заголовка → 401, с заголовком → 200, мок получил getMe и sendMessage
- запущено, работает
команды: установка, генерация секрета, запуск с --env-file, вывод тестов
пакеты установлены теми же версиями; node -e randomBytes(32) → 64 символа из разрешённого алфавита secret_token; node --env-file=.env.example читает переменные, пустой WEBHOOK_SECRET даёт режим polling; вывод — реальный verbose-вывод vitest без времени
- не запускалось
curl к api.telegram.org (getMe, getUpdates, getWebhookInfo); создание бота в @BotFather; вебхук через интернет
нет реального токена и домена с HTTPS; сверено с документацией core.telegram.org
«Запущено» — код выполнен во временном проекте или контейнере, вывод в статье взят из прогона. «Частично» — проверено с заглушками вместо внешних сервисов. «Не запускалось» — нужны устройство, аккаунт или ключ; шаги сверены с официальной документацией. Файлы проверочного проекта и логи запусков сохранены в архиве InterLAB.