Telegram Bot: FSM, middleware и продвинутые техники разработки
Если вы уже писали Telegram-бота — знаете что простые случаи просты, а сложные быстро превращаются в спагетти из if/else на message.text. Бот для записи на приём, для оформления заказа, для онбординга — всё это многошаговые диалоги где пользователь может нажать "Назад", ответить невпопад или просто пропасть на полчаса.
Эта статья о паттернах которые делают ботов предсказуемыми.
Webhook vs Long Polling: когда что выбирать
Оба подхода работают. Разница в сценарии использования.
Long Polling — бот сам опрашивает Telegram API каждые N секунд:
- Работает без публичного IP (отлично для разработки)
- Не требует SSL-сертификата
- Задержка ~0.5-2 секунды
- Нельзя запустить две копии бота одновременно (race condition на обновлениях)
Webhook — Telegram отправляет обновления на ваш HTTPS-эндпоинт:
- Требует публичный IP + SSL (Let's Encrypt подходит)
- Задержка ~50-100 мс
- Можно масштабировать горизонтально
- Требует nginx-конфига
# /etc/nginx/conf.d/telegram-bot.conf
server {
listen 443 ssl;
server_name bot.yourproject.kg;
ssl_certificate /etc/letsencrypt/live/bot.yourproject.kg/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/bot.yourproject.kg/privkey.pem;
location /webhook {
proxy_pass http://localhost:3000/webhook;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
# Проверяем что запрос с IP серверов Telegram
# allow 149.154.160.0/20;
# allow 91.108.4.0/22;
# deny all;
}
}
import { Telegraf } from 'telegraf';
const bot = new Telegraf(process.env.BOT_TOKEN!);
if (process.env.NODE_ENV === 'production') {
// Webhook
const webhookUrl = `https://bot.yourproject.kg/webhook`;
bot.telegram.setWebhook(webhookUrl);
// Telegraf встроенный Express middleware
const app = express();
app.use(bot.webhookCallback('/webhook'));
app.listen(3000);
} else {
// Long polling для разработки
bot.launch();
}
// Graceful shutdown
process.once('SIGINT', () => bot.stop('SIGINT'));
process.once('SIGTERM', () => bot.stop('SIGTERM'));
Middleware Pipeline: логирование, throttling, авторизация
Telegraf построен на middleware-цепочке — как Koa.js. Каждый use() добавляет обработчик который может вызвать next() или прервать цепочку.
import { Context, Middleware } from 'telegraf';
import Redis from 'ioredis';
const redis = new Redis();
// 1. Логирование всех входящих сообщений
const loggerMiddleware: Middleware<Context> = async (ctx, next) => {
const start = Date.now();
const userId = ctx.from?.id;
const text = ctx.message && 'text' in ctx.message ? ctx.message.text : '[media]';
console.log(`[${new Date().toISOString()}] User ${userId}: ${text}`);
await next();
const ms = Date.now() - start;
console.log(`[${new Date().toISOString()}] User ${userId} handled in ${ms}ms`);
};
// 2. Anti-spam throttling: не более 1 сообщения в секунду
const throttleMiddleware: Middleware<Context> = async (ctx, next) => {
const userId = ctx.from?.id;
if (!userId) return next();
const key = `throttle:${userId}`;
const requests = await redis.incr(key);
if (requests === 1) {
await redis.expire(key, 1); // окно 1 секунда
}
if (requests > 3) {
// Молча игнорируем — не отвечаем спамеру
return;
}
return next();
};
// 3. Авторизация для приватного бота
interface AuthContext extends Context {
isAdmin: boolean;
}
const authMiddleware: Middleware<AuthContext> = async (ctx, next) => {
const userId = ctx.from?.id;
const ADMIN_IDS = process.env.ADMIN_IDS?.split(',').map(Number) || [];
ctx.isAdmin = ADMIN_IDS.includes(userId!);
return next();
};
// 4. Middleware для инжекции пользователя из БД
const userMiddleware: Middleware<Context> = async (ctx, next) => {
if (!ctx.from) return next();
// Кэшируем в Redis на 5 минут
const cacheKey = `user:tg:${ctx.from.id}`;
const cached = await redis.get(cacheKey);
if (cached) {
(ctx as any).dbUser = JSON.parse(cached);
} else {
const user = await db.users.findOne({ telegramId: ctx.from.id });
if (user) {
await redis.setex(cacheKey, 300, JSON.stringify(user));
(ctx as any).dbUser = user;
}
}
return next();
};
// Регистрируем middleware в правильном порядке
bot.use(loggerMiddleware);
bot.use(throttleMiddleware);
bot.use(authMiddleware);
bot.use(userMiddleware);
FSM для онбординга: полный пример
Finite State Machine — это паттерн где бот находится в одном из конечного числа состояний, и реагирует по-разному в зависимости от текущего состояния. Без FSM вы пишете:
// ❌ Спагетти без FSM
bot.on('message', (ctx) => {
if (waitingForName[ctx.from.id]) {
// ...
} else if (waitingForPhone[ctx.from.id]) {
// ...
} else if (waitingForCity[ctx.from.id]) {
// ... и так для каждого шага
}
});
С FSM каждое состояние изолировано:
import { Scenes, session } from 'telegraf';
// Типы состояний онбординга
interface OnboardingWizardSession extends Scenes.WizardSessionData {
name?: string;
phone?: string;
city?: string;
}
type OnboardingContext = Scenes.WizardContext<OnboardingWizardSession>;
// Шаг 1: Запрашиваем имя
const step1Handler = new Scenes.WizardScene<OnboardingContext>(
'onboarding-wizard',
// Стартовый handler (вход в wizard)
async (ctx) => {
await ctx.reply('Добро пожаловать! Как вас зовут?', {
reply_markup: { remove_keyboard: true },
});
return ctx.wizard.next();
},
// Шаг 2: Получаем имя, запрашиваем телефон
async (ctx) => {
if (!ctx.message || !('text' in ctx.message)) {
await ctx.reply('Пожалуйста, введите ваше имя текстом.');
return; // остаёмся на этом шаге
}
const name = ctx.message.text.trim();
if (name.length < 2 || name.length > 50) {
await ctx.reply('Имя должно быть от 2 до 50 символов. Попробуйте ещё раз:');
return;
}
ctx.wizard.state.name = name;
await ctx.reply(`Приятно познакомиться, ${name}! Теперь поделитесь номером телефона:`, {
reply_markup: {
keyboard: [[{ text: '📱 Поделиться номером', request_contact: true }]],
resize_keyboard: true,
one_time_keyboard: true,
},
});
return ctx.wizard.next();
},
// Шаг 3: Получаем телефон, запрашиваем город
async (ctx) => {
let phone: string | undefined;
if (ctx.message && 'contact' in ctx.message && ctx.message.contact) {
// Пользователь поделился контактом через кнопку
phone = ctx.message.contact.phone_number;
} else if (ctx.message && 'text' in ctx.message) {
// Или ввёл вручную
const text = ctx.message.text.replace(/\D/g, '');
if (text.length >= 10) {
phone = text;
}
}
if (!phone) {
await ctx.reply('Не удалось распознать номер. Нажмите кнопку или введите номер вручную:');
return;
}
ctx.wizard.state.phone = phone;
await ctx.reply('Отлично! В каком городе вы находитесь?', {
reply_markup: {
keyboard: [
[{ text: 'Бишкек' }, { text: 'Ош' }],
[{ text: 'Jalal-Abad' }, { text: 'Каракол' }],
[{ text: 'Другой город' }],
],
resize_keyboard: true,
one_time_keyboard: true,
},
});
return ctx.wizard.next();
},
// Шаг 4: Получаем город, завершаем онбординг
async (ctx) => {
if (!ctx.message || !('text' in ctx.message)) {
await ctx.reply('Пожалуйста, выберите или введите город:');
return;
}
ctx.wizard.state.city = ctx.message.text;
const { name, phone, city } = ctx.wizard.state;
try {
// Сохраняем в БД
await db.users.upsert({
telegramId: ctx.from!.id,
telegramUsername: ctx.from!.username,
name,
phone,
city,
onboardedAt: new Date(),
});
// Инвалидируем кэш пользователя
await redis.del(`user:tg:${ctx.from!.id}`);
await ctx.reply(
`✅ Регистрация завершена!\n\nИмя: ${name}\nТелефон: ${phone}\nГород: ${city}`,
{ reply_markup: { remove_keyboard: true } }
);
} catch (error) {
console.error('Onboarding save error:', error);
await ctx.reply('Произошла ошибка. Попробуйте позже (/start)');
}
return ctx.scene.leave();
}
);
// Обработка кнопки "Назад" внутри wizard
step1Handler.action('back', async (ctx) => {
await ctx.answerCbQuery();
return ctx.wizard.back();
});
// Регистрируем сцену
const stage = new Scenes.Stage<OnboardingContext>([step1Handler]);
// session ДОЛЖЕН быть зарегистрирован ДО stage
bot.use(session());
bot.use(stage.middleware());
// Запуск онбординга
bot.command('start', async (ctx) => {
const existingUser = (ctx as any).dbUser;
if (existingUser?.onboardedAt) {
return ctx.reply(`С возвращением, ${existingUser.name}!`);
}
return ctx.scene.enter('onboarding-wizard');
});
Inline Keyboard: сериализация данных в 64 байта
Telegram ограничивает callback_data до 64 байт. Это значит нельзя передать в кнопку {"action":"buy","productId":"507f1f77bcf86cd799439011","userId":"user123"} — это уже 70+ байт.
Стратегия: минимальный идентификатор + восстановление контекста из Redis.
// Паттерн: action_type:id[:page]
// Примеры:
// "buy:42" — купить товар ID 42
// "cat:5:1" — категория 5, страница 1
// "order:abc123" — заказ с коротким ID
function encodeCallback(action: string, ...params: (string | number)[]): string {
const result = [action, ...params].join(':');
if (Buffer.byteLength(result, 'utf8') > 64) {
throw new Error(`callback_data too long: ${result} (${Buffer.byteLength(result, 'utf8')} bytes)`);
}
return result;
}
function decodeCallback(data: string): { action: string; params: string[] } {
const [action, ...params] = data.split(':');
return { action, params };
}
// Использование
async function sendProductCard(ctx: Context, product: Product) {
await ctx.reply(
`*${product.title}*\n${product.description}\n\nЦена: ${product.price} сом`,
{
parse_mode: 'Markdown',
reply_markup: {
inline_keyboard: [
[
{
text: '🛒 Купить',
callback_data: encodeCallback('buy', product.id), // "buy:42"
},
{
text: '❤️ В избранное',
callback_data: encodeCallback('fav', product.id), // "fav:42"
},
],
[
{
text: '◀️ Назад',
callback_data: encodeCallback('cat', product.categoryId, 1), // "cat:5:1"
},
],
],
},
}
);
}
// Обработчик callback_query
bot.on('callback_query', async (ctx) => {
if (!ctx.callbackQuery || !('data' in ctx.callbackQuery)) return;
await ctx.answerCbQuery(); // ВАЖНО: всегда отвечать, иначе спиннер не уйдёт
const { action, params } = decodeCallback(ctx.callbackQuery.data);
switch (action) {
case 'buy': {
const [productId] = params;
await handleBuyProduct(ctx, productId);
break;
}
case 'cat': {
const [categoryId, page] = params;
await showCategory(ctx, categoryId, parseInt(page));
break;
}
case 'fav': {
const [productId] = params;
await toggleFavorite(ctx, productId);
break;
}
}
});
file_id кэширование для медиа
Когда вы отправляете файл первый раз (inputFile / URL), Telegram возвращает file_id. Этот ID можно использовать повторно — файл не загружается снова, Telegram отдаёт его из своего CDN. Это критично для производительности при отправке одного изображения многим пользователям.
const FILE_ID_CACHE_KEY = 'telegram:file_ids';
async function sendImageToUser(
ctx: Context,
imageLocalPath: string,
caption: string
): Promise<void> {
// Проверяем кэш
const cachedFileId = await redis.hget(FILE_ID_CACHE_KEY, imageLocalPath);
if (cachedFileId) {
// Отправляем по file_id — мгновенно, без загрузки
await ctx.replyWithPhoto(cachedFileId, { caption });
return;
}
// Первый раз: загружаем файл
const message = await ctx.replyWithPhoto(
{ source: imageLocalPath },
{ caption }
);
// Кэшируем file_id (он постоянный для бота)
const fileId = message.photo[message.photo.length - 1].file_id;
await redis.hset(FILE_ID_CACHE_KEY, imageLocalPath, fileId);
}
// Для документов
async function sendDocumentCached(
ctx: Context,
filePath: string,
filename: string
): Promise<void> {
const cacheKey = `doc:${filePath}`;
const cached = await redis.get(cacheKey);
if (cached) {
await ctx.replyWithDocument(cached, { caption: filename });
return;
}
const message = await ctx.replyWithDocument(
{ source: filePath, filename },
{ caption: filename }
);
await redis.setex(cacheKey, 86400 * 7, message.document.file_id);
}
Inline Query: поиск в реальном времени
Inline query позволяет пользователю искать прямо из поля ввода, набирая @botusername запрос:
bot.on('inline_query', async (ctx) => {
const query = ctx.inlineQuery.query.trim();
if (query.length < 2) {
return ctx.answerInlineQuery([], {
cache_time: 0,
switch_pm_text: 'Начните вводить название товара',
switch_pm_parameter: 'start',
});
}
// Поиск в PostgreSQL с FTS
const products = await db.query<Product>(
`SELECT id, title, price, description, image_file_id
FROM products
WHERE to_tsvector('russian', title || ' ' || description) @@ plainto_tsquery('russian', $1)
AND active = true
LIMIT 10`,
[query]
);
const results = products.map((product) => ({
type: 'article' as const,
id: String(product.id),
title: product.title,
description: `${product.price} сом`,
thumbnail_url: product.image_url,
input_message_content: {
message_text: `*${product.title}*\nЦена: ${product.price} сом\n\n${product.description}`,
parse_mode: 'Markdown' as const,
},
reply_markup: {
inline_keyboard: [[
{ text: '🛒 Оформить заказ', callback_data: encodeCallback('order', product.id) },
]],
},
}));
await ctx.answerInlineQuery(results, {
cache_time: 30, // кэшируем результаты на 30 секунд
});
});
Graceful Shutdown
Важно корректно завершать работу — сохранить состояния FSM, дождаться обработки текущих запросов:
let isShuttingDown = false;
const shutdownMiddleware: Middleware<Context> = async (ctx, next) => {
if (isShuttingDown) {
await ctx.reply('Бот временно недоступен, попробуйте через минуту.');
return;
}
return next();
};
bot.use(shutdownMiddleware);
async function gracefulShutdown(signal: string) {
if (isShuttingDown) return;
isShuttingDown = true;
console.log(`${signal} received, shutting down gracefully...`);
// Останавливаем приём новых обновлений
bot.stop(signal);
// Ждём завершения текущих обработчиков (макс 10 секунд)
await new Promise(resolve => setTimeout(resolve, 10000));
// Закрываем соединения с БД
await db.end();
await redis.quit();
process.exit(0);
}
process.once('SIGINT', () => gracefulShutdown('SIGINT'));
process.once('SIGTERM', () => gracefulShutdown('SIGTERM'));
Telegram-боты — это одна из самых распространённых точек контакта бизнеса с клиентами в Кыргызстане. Правильно выстроенный FSM, middleware-цепочка и обработка edge cases делают бота надёжным инструментом, а не источником поддержки 24/7.
Нужен Telegram-бот для вашего бизнеса в Бишкеке — для записи, заказов, уведомлений или автоматизации? Команда Aunimeda разрабатывает боты с нуля. Посмотреть услуги или написать в WhatsApp.