Когда бот должен провести пользователя через несколько шагов подряд - имя, телефон, адрес, подтверждение - обычными if-elif в одном хендлере это не решить нормально, и тут нужны fsm сценарии aiogram. Я собирал такие сценарии для ботов приёма заявок, для интеграций с СДЭК на статус доставки и для ботов, где после оплаты через T‑Bank нужно провести клиента дальше по воронке. В статье показываю рабочий подход на aiogram 3.x с конкретным кодом, а не абстрактную теорию.
Что такое FSM-сценарии в aiogram и когда они нужны
FSM - конечный автомат, finite state machine. У пользователя в диалоге с ботом есть текущее состояние (state), и в зависимости от него один и тот же текст сообщения обрабатывается по-разному. Написал “Иван” на шаге ввода имени - сохраняем как имя, написал то же самое на шаге ввода адреса - сохраняем как адрес. Без машины состояний это превращается в вложенные проверки текущего шага через глобальные переменные или костыли в базе, что на третьей итерации доработок превращается в нечитаемый код.
В aiogram 3 машина состояний встроена в фреймворк: StatesGroup, State, FSMContext и Storage работают из коробки, не нужно тащить сторонние библиотеки. Использую такие сценарии диалога бота везде, где логика длиннее одного вопроса-ответа:
- Многошаговая анкета или бриф на заявку
- Оформление заказа с адресом и способом доставки
- Пошаговая настройка бота самим клиентом (например, привязка канала)
- Сбор данных для оплаты и последующей интеграции с CRM
Если в диалоге максимум один-два вопроса без ветвления, городить FSM избыточно - хватит обычного хендлера с ожиданием следующего сообщения через фильтр по содержимому.
Как устроена машина состояний в aiogram: StatesGroup и FSMContext
Состояния описываются классом-наследником StatesGroup, каждый шаг - атрибут State(). Это просто фиксированные метки, никакой магии.
from aiogram.fsm.state import State, StatesGroup
class OrderForm(StatesGroup):
name = State()
phone = State()
address = State()
confirm = State()
Текущее состояние пользователя хранится не в классе, а в объекте FSMContext, который aiogram сам подставляет в хендлер, если он есть в сигнатуре функции. Через него же читаются и пишутся промежуточные данные диалога - это удобнее, чем тащить их отдельным словарём или базой на каждый шаг.
from aiogram.fsm.context import FSMContext
async def some_handler(message, state: FSMContext):
await state.set_state(OrderForm.name) # переключить шаг
await state.update_data(source="bot") # записать данные
data = await state.get_data() # прочитать всё накопленное
await state.clear() # сбросить состояние и данные
Роутер (Router) фильтрует входящие сообщения по текущему состоянию через StateFilter или напрямую передавая класс State как фильтр - это и есть основа маршрутизации сценариев диалога.
Пошаговый сценарий: от команды /start до сохранения данных
Соберу типовой сценарий приёма заявки - то, что реально ставлю клиентам чаще всего вместо формы на сайте.
from aiogram import Router, F
from aiogram.filters import Command
from aiogram.types import Message
from aiogram.fsm.context import FSMContext
router = Router()
@router.message(Command("start"))
async def start_order(message: Message, state: FSMContext):
await state.set_state(OrderForm.name)
await message.answer("Как вас зовут?")
@router.message(OrderForm.name)
async def process_name(message: Message, state: FSMContext):
await state.update_data(name=message.text)
await state.set_state(OrderForm.phone)
await message.answer("Укажите телефон для связи")
@router.message(OrderForm.phone)
async def process_phone(message: Message, state: FSMContext):
cleaned = message.text.replace("+", "").replace(" ", "")
if not cleaned.isdigit() or len(cleaned) 10:
await message.answer("Похоже, это не телефон. Введите номер ещё раз")
return
await state.update_data(phone=message.text)
await state.set_state(OrderForm.address)
await message.answer("Куда доставить заказ?")
@router.message(OrderForm.address)
async def process_address(message: Message, state: FSMContext):
await state.update_data(address=message.text)
data = await state.get_data()
await state.set_state(OrderForm.confirm)
await message.answer(
f"Проверьте данные: {data['name']}, {data['phone']}, {data['address']}. Всё верно? (да/нет)"
)
@router.message(OrderForm.confirm, F.text.lower() == "да")
async def confirm_order(message: Message, state: FSMContext):
data = await state.get_data()
# здесь запись в БД, отправка в CRM или в n8n-вебхук
await state.clear()
await message.answer("Заявка принята, свяжемся в течение часа")
Валидацию телефона на шаге process_phone я ставлю почти всегда - без неё в базу прилетают заявки с текстом вроде “перезвоните вечером” вместо номера, и это уже не вопрос кода, а вопрос сэкономленного времени менеджера. На проде такую заявку обычно сразу пробрасываю дальше - либо прямой записью в CRM через API, либо через вебхук в n8n, который уже сам раскладывает данные по нужным сервисам.
Бесплатный материал
🎁 Полезный скрипт в подарок
Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.
Без спама. Отписка в 1 клик.
Хранилище для состояний бота: MemoryStorage и Redis
Состояние диалога где-то должно физически лежать между сообщениями пользователя. По умолчанию aiogram использует MemoryStorage - данные живут в оперативной памяти процесса. Работает быстро и без настройки, но при перезапуске бота (а деплой с перезапуском случается регулярно) все незавершённые диалоги обнуляются, пользователь снова видит вопрос “как вас зовут” с нуля.
| Хранилище | Где живут данные | Переживает рестарт | Когда ставлю |
|---|---|---|---|
| MemoryStorage | Оперативная память процесса | Нет | Разработка, тесты, боты с редким трафиком без критичных сценариев |
| RedisStorage | Внешний Redis-сервер | Да | Прод-боты, несколько шагов оплаты, масштабирование на несколько инстансов |
| Своё хранилище (BaseStorage) | Любая БД по вашей реализации | Да | Когда нужно хранить состояние вместе с остальными данными в единой БД |
Подключение Redis занимает пару строк:
from aiogram import Dispatcher
from aiogram.fsm.storage.redis import RedisStorage
from redis.asyncio import Redis
redis = Redis(host="localhost", port=6379, db=0)
storage = RedisStorage(redis=redis)
dp = Dispatcher(storage=storage)
На проектах с оплатой (когда бот ждёт колбэк от эквайринга и должен вернуть пользователя в тот же шаг сценария через минуту-две после нажатия “оплатить”) ставлю Redis сразу, без вариантов - иначе рестарт бота посреди платежа обнуляет весь прогресс, и клиент решит, что деньги ушли в никуда. Часть готовых заготовок под такие сценарии я собрал в разделе готовых скриптов - можно взять рабочий каркас и адаптировать под конкретную интеграцию, а не писать FSM с нуля.
Отмена, шаг назад и обработка ошибок в сценарии
Любой многошаговый диалог должен уметь прерываться - пользователь передумал или ошибся и хочет начать заново. Обработчик отмены регистрирую с фильтром по команде, но без привязки к конкретному состоянию, чтобы он ловил /cancel на любом шаге:
@router.message(Command("cancel"))
async def cancel_handler(message: Message, state: FSMContext):
current_state = await state.get_state()
if current_state is None:
return
await state.clear()
await message.answer("Сценарий прерван, можно начать заново через /start")
Шаг назад делаю через хранение истории состояний в самих данных - при каждом переходе кладу предыдущий State в data, а по кнопке “назад” достаю его и возвращаюсь:
@router.message(F.text == "⬅ Назад")
async def go_back(message: Message, state: FSMContext):
data = await state.get_data()
prev = data.get("prev_state")
if prev:
await state.set_state(prev)
await message.answer("Вернулись на предыдущий шаг")
Отдельно ставлю фолбэк-хендлер на каждое состояние - на случай, если пользователь вместо текста прислал стикер или фото там, где бот ждёт номер телефона. Без такого фолбэка сообщение просто проваливается мимо всех хендлеров, и пользователь зависает в диалоге без ответа, думая, что бот сломался.
Частые ошибки при проектировании FSM-сценариев
За несколько десятков ботов на aiogram у меня накопился список повторяющихся проблем:
- Забыли зарегистрировать фильтр StateFilter(None) для сообщений вне сценария - бот путает случайный текст с шагом диалога
- Хранят промежуточные данные в глобальных переменных вместо FSMContext - работает на одном пользователе, ломается при параллельных диалогах
- Не чистят state после завершения или ошибки - пользователь застревает в шаге навсегда и не может начать заново даже через /start, если он тоже завязан на проверку состояния
- Слишком много состояний в одной StatesGroup для несвязанных сценариев - усложняет чтение кода зря, для независимых веток лучше отдельные группы
- Нет таймаута на сценарий - если пользователь бросил диалог на середине, данные годами лежат в Redis без дела
Последний пункт закрывается простой TTL-настройкой на ключах в Redis или периодической джобой на очистку. Тестирую сценарии вручную по всем веткам перед деплоем - прошёл путь до конца, прервал на каждом шаге через /cancel, отправил невалидные данные (эмодзи вместо телефона, пустое сообщение) - это дешевле, чем ловить баг репорты от живых пользователей на проде.
Автоматизация в мессенджере
Telegram-бот / Mini App
от 30 000 ₽
Подробнее →Частые вопросы
Чем FSM в aiogram отличается от обычных проверок if-elif в хендлере?
FSM переносит логику “на каком шаге сейчас пользователь” из ручных условий в фреймворк - роутер сам решает, какой хендлер вызвать, глядя на текущее состояние. При росте сценария до 5-7 шагов ручные if-elif превращаются в нечитаемый код, а с StatesGroup каждый шаг остаётся отдельной функцией.
Можно ли вести несколько независимых FSM-сценариев для одного пользователя одновременно?
В рамках одного FSMContext активно только одно состояние на пользователя (точнее, на пару user_id и chat_id). Для параллельных сценариев в разных чатах с ботом (например, личка и группа) это работает само по себе, а внутри одного чата параллельные сценарии обычно реализую через разные боты или через явное переключение контекста в данных состояния.
Как хранить FSM-состояния, если бот масштабируется на несколько инстансов?
MemoryStorage тут не подходит вообще - у каждого инстанса будет своя память, и пользователь может попасть на другой процесс между шагами. Нужен RedisStorage или своя реализация BaseStorage поверх общей БД, доступной всем инстансам бота.
Нужно ли вручную очищать state, если пользователь просто перестал отвечать?
Сам aiogram это не делает. Если используете RedisStorage, ставьте TTL на ключи состояний при их создании, чтобы брошенные диалоги не копились бесконечно. С MemoryStorage данные и так исчезают при рестарте процесса, но для долгоживущего бота лучше не полагаться на рестарты как на механизм очистки.