О насБлогКонтакты
Backend17 апреля 2026 г. 9 мин 7

Telegram Bot: FSM, middleware и продвинутые техники разработки

AunimedaAunimeda
📋 Содержание

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.

Читайте также

Redis: структуры данных для реальных задач — с примерами кодаaunimeda
Backend

Redis: структуры данных для реальных задач — с примерами кода

Практическое руководство по Redis: String + INCR для rate limiter, Hash для кэша объектов, List как job queue, Sorted Set для лидербордов, Set для онлайн-пользователей. Полный код на Node.js с ioredis, сравнение потребления памяти.

PostgreSQL на VPS: от 10 до 1000 запросов в секунду без смены железаaunimeda
Backend

PostgreSQL на VPS: от 10 до 1000 запросов в секунду без смены железа

Практическое руководство по настройке PostgreSQL на VPS с 4 CPU и 8 ГБ RAM: правильные параметры postgresql.conf с расчётами, частичные и covering индексы, настройка autovacuum, PgBouncer для connection pooling. Реальные цифры: 23 мс → 1.2 мс на запрос.

Как перейти с PHP 5.6 на PHP 7.0 без поломки приложения (2016)aunimeda
Backend

Как перейти с PHP 5.6 на PHP 7.0 без поломки приложения (2016)

PHP 7.0 давал в 2 раза более высокую производительность по сравнению с PHP 5.6. Но миграция ломала старый код: mysql_* функции удалены, изменилась обработка ошибок, несовместимые изменения в типах. Мы мигрировали 4 проекта без простоя — вот точный чеклист.

Нужна IT-разработка для вашего бизнеса?

Разрабатываем сайты, мобильные приложения и AI-решения для бизнеса в Кыргызстане. Бесплатная консультация.

Получить консультацию Все статьи