Как делать11 октября 2026 г.7 мин чтения
API и интеграцииШаг 6 из 10

Telegram-бот: Bot API, команды и вебхук

Коротко

Telegram-бот — это программа, которая получает обновления от серверов Telegram и отвечает вызовами Bot API по HTTPS с токеном от @BotFather. Обновления приходят либо через getUpdates (long polling — удобно при разработке), либо через вебхук на ваш HTTPS-адрес с проверкой заголовка secret_token. В уроке бот на Node.js 24 и grammY принимает заявку кнопками и пересылает её менеджеру, а проверяется тестами без реального токена.

Пошаговый урокУровень: НачальныйTelegram-ботыПрактика: ~1,5–3 ч

После материала вы сможете

  • создать бота в @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 г.

Содержание
  1. 01Как устроен Bot API
  2. 02BotFather и токен
  3. 03Получение обновлений: getUpdates или вебхук
  4. 04Проект и обработчики: команды, кнопки, заявка
  5. 05Запуск: long polling для разработки, вебхук для сервера
  6. 06Ошибки и лимиты
  7. 07Проверка без реального токена
  8. 08Упражнения и следующий шаг

Как устроен Bot API

Урок для начинающих backend-разработчиков и тех, кто автоматизирует бизнес: нужно уметь запускать Node.js и понимать HTTP и JSON. Сценарий — бот салона или студии: клиент нажимает /start, выбирает услугу кнопкой, оставляет телефон, а менеджеры получают заявку в рабочий чат.

Бот в Telegram — это особый аккаунт, за которым стоит ваша программа. Клиент пишет боту, сообщение попадает на серверы Telegram, а ваша программа забирает его оттуда в виде объекта Update и отвечает вызовами методов Bot API. Каждый вызов — HTTPS-запрос вида https://api.telegram.org/bot<токен>/ИМЯ_МЕТОДА. Ответ всегда JSON с полем ok: при успехе результат лежит в result, при ошибке — пояснение в description и иногда подсказка в parameters, например сколько секунд подождать.

  1. 01Клиент в Telegram
  2. 02Серверы Telegram
  3. 03Ваш бот на Node.js
  4. 04Чат менеджеров
Бот не общается с клиентом напрямую: всё идёт через серверы и Bot API.

Писать запросы вручную можно — у Bot API нет сложной авторизации. Но удобнее библиотека: мы возьмём grammY 1.46, которая поддерживает Bot API 10.3, даёт типы для всех объектов и готовые адаптеры для вебхука. Код запускается в Node.js 24 без сборки.

bash
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"
Прямые вызовы Bot API: токен — в адресе после слова bot (в проверке не запускались — нет реального токена)

BotFather и токен

  1. Откройте @BotFather

    Это официальный бот Telegram для создания ботов и управления ими.

  2. Отправьте /newbot

    BotFather спросит имя (его видят пользователи) и username. Username — от 5 до 32 символов, латиница, цифры и подчёркивание, и он обязан заканчиваться на bot, например salon_almaty_bot. Изменить username потом нельзя.

  3. Сохраните токен

    Токен вида 123456789:AA… авторизует бота в Bot API. Положите его в .env на сервере и в менеджер паролей — не в код и не в чат.

  4. Заведите отдельного бота для тестов

    Документация Telegram советует проверять изменения на втором боте со своим токеном, чтобы не мешать настоящим клиентам.

  5. Узнайте id чата менеджеров

    Добавьте бота в группу менеджеров и отправьте в группе команду, например /help@имя_бота. Пока бот не запущен, откройте getUpdates из примера выше — в ответе будет chat.id группы.

.env.example
# 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
Настройки — из окружения; .env — в .gitignore

Получение обновлений: 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 доказывает, что запрос пришёл от вашего вебхука
  • При ответе не 2xx Telegram повторяет доставку — обработчик должен отвечать быстро
JSON
{
  "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 с командой /start — именно такую форму используют тесты

Поле update_id растёт по порядку и помогает отбросить повтор, если одно обновление пришло дважды. В каждом обновлении заполнено не больше одного поля из списка: message, callback_query и другие. Через allowed_updates можно попросить только нужные типы — наш бот обрабатывает message и callback_query.

Проект и обработчики: команды, кнопки, заявка

bash
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
Проект: grammY, плагин auto-retry, TypeScript для проверки типов и Vitest
tsconfig.json
{
  "compilerOptions": {
    "target": "es2024",
    "module": "nodenext",
    "strict": true,
    "noEmit": true,
    "allowImportingTsExtensions": true,
    "erasableSyntaxOnly": true,
    "verbatimModuleSyntax": true,
    "skipLibCheck": true,
    "types": ["node"]
  },
  "include": ["src", "test"]
}
Только для проверки типов: Node 24 сам запускает .ts-файлы
src/leads.ts
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>;
Хранилище заявок: в памяти для урока, таблица в базе — в работе
src/bot.ts
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);
  }
}
Вся логика бота: команды, inline-кнопки, запрос контакта, уведомление менеджеру, ошибки
  • command('start') срабатывает на /start: grammY смотрит на сущность bot_command в сообщении
  • InlineKeyboard — кнопки под сообщением. Нажатие не пишет текст в чат, а присылает callback_query с callback_data — до 64 байт, поэтому там короткий id услуги, а не название
  • answerCallbackQuery обязателен для каждого нажатия: пока его нет, на кнопке крутится индикатор загрузки
  • Keyboard().requestContact() — обычная клавиатура с кнопкой «Отправить номер», работает только в личных чатах. remove_keyboard убирает её после заявки
  • errorBoundary ловит ошибки всех обработчиков: ошибка записывается в лог, а не роняет бота и не вызывает бесконечные повторы вебхука
  • Заявка сначала сохраняется, потом уходит менеджеру: если чат менеджеров недоступен, она не теряется

Запуск: long polling для разработки, вебхук для сервера

src/main.ts
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`),
  });
}
Одна точка входа, два режима: без WEBHOOK_SECRET — long polling, с ним — вебхук
src/webhook.ts
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();
    }
  });
}
Сервер вебхука на node:http: один путь, только POST, секретный токен проверяет grammY
src/set-webhook.ts
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}`);
Регистрация вебхука и меню команд
bash
node -e "console.log(require('node:crypto').randomBytes(32).toString('hex'))"
Случайный secret_token: 64 шестнадцатеричных символа укладываются в разрешённый алфавит
bash
node --env-file=.env src/main.ts          # разработка: long polling
node --env-file=.env src/set-webhook.ts   # на сервере, один раз после деплоя
npx tsc -p . && npx vitest run            # типы и тесты, токен не нужен
Node 24 сам читает .env через --env-file
  1. Разработка

    Оставьте WEBHOOK_SECRET пустым и запустите src/main.ts. Перед опросом bot.start() вызывает deleteWebhook — это видно и в тесте режима polling.

  2. Сервер

    Нужен домен с HTTPS. Обычно сертификат держит reverse proxy, например Nginx, и передаёт запросы на порт приложения.

  3. Секрет

    Сгенерируйте WEBHOOK_SECRET командой выше и задайте его в окружении сервера.

  4. Регистрация вебхука

    Запустите src/set-webhook.ts: он вызывает setWebhook с secret_token и allowed_updates и задаёт меню команд через setMyCommands.

  5. Проверка

    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 и проверяют, что бот отправил в ответ.

test/fake-telegram.ts
// 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())),
  };
}
Локальный двойник Bot API: записывает вызовы, отдаёт getUpdates, по команде возвращает 429 и другие ошибки
test/bot.test.ts
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');
  }
});
Тесты обработчиков на образцах Update, а также вебхука, set-webhook.ts и режима polling
terminal
$ 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.

Расскажите о задаче — мы изучим запрос и свяжемся с вами

Есть задача?
Обсудим и оценим — бесплатно.

Расскажите о продукте или проблеме. Предложим архитектуру, срок и стоимость первой версии после изучения задачи.

  • Оценка объёма и рисков до старта
  • NDA — по вашему или нашему шаблону
  • Старт работ в течение недели

Не присылайте через форму пароли, ключи доступа и исходный код — безопасную передачу доступа обсудим отдельно.

Нажимая кнопку, вы соглашаетесь с обработкой персональных данных.