FSM: машина состояний

Учим бота вести диалог по шагам: задать вопрос, дождаться ответа, задать следующий — как заполнение анкеты, где поля идут по очереди.
FSM (машина состояний) — механизм aiogram, который запоминает, на каком шаге диалога находится пользователь, чтобы вести его по шагам. State (состояние) — это сам шаг, например «ждём имя» или «ждём возраст».

Зачем боту вообще что-то помнить

Представь: ты пишешь боту для своего игрового клана. Хочешь, чтобы он спрашивал у новичка имя, потом ник в игре, потом любимого героя — и в конце складывал всё в красивую анкету. Звучит просто, да? Но если ты вспомнишь, как мы писали хэндлеры в уроке про обработку сообщений, то заметишь проблему.

Каждый хэндлер у нас работал так: пришло сообщение — ответили — забыли. Бот как золотая рыбка: ответил и тут же потерял память. Когда пользователь пишет «Саша», бот понятия не имеет, на какой вопрос это ответ. Это имя? Ник? Город? Бот видит просто текст «Саша» и не помнит, что секунду назад сам спросил «Как тебя зовут?».

Можно, конечно, попробовать выкрутиться без FSM — завести где-нибудь глобальный словарь и руками записывать туда «такой-то пользователь сейчас на таком-то шаге». Многие новички так и делают в первый раз. Но очень быстро это превращается в кашу: ты сам пишешь и проверки «а на каком он шаге?», и хранение данных, и сброс в конце — и обязательно где-нибудь запутаешься, особенно когда диалогов станет несколько. FSM — это готовый, аккуратный механизм ровно для этой задачи, и пользоваться им проще, чем городить свой велосипед. Давай разберёмся, как он устроен.

Вот к чему мы придём в этом уроке — наш «Цыплёнок-помощник» научится вести пошаговый диалог:

Ты:        /anketa
Цыплёнок:  Привет! Давай заполним анкету. Как тебя зовут?
Ты:        Саша
Цыплёнок:  Отлично, Саша! Сколько тебе лет?
Ты:        15
Цыплёнок:  Готово! Саша, 15 лет. Анкета сохранена ✨

Результат: в чате бот по очереди задаёт вопросы, дожидается каждого ответа и в конце собирает всё вместе. Между сообщениями он помнит, на каком шаге вы находитесь. Именно за эту «память о шаге» и отвечает FSM.

Метафора: бот как анкета на бумаге

Самый простой способ понять FSM — представить бумажную анкету, которую заполняют по строчкам. Сверху строка «Имя», под ней «Возраст», ниже «Любимый герой». Ты не можешь заполнить третью строку, не пройдя первые две — ручка движется сверху вниз, строка за строкой.

FSM — это та самая ручка, которая помнит, на какой строке анкеты ты сейчас стоишь. Пока ручка на строке «Имя» — всё, что ты говоришь, записывается как имя. Как только имя записано, ручка спускается на строку «Возраст», и теперь любой твой ответ — это возраст.

В терминах aiogram строка анкеты называется состоянием (state). «Ждём имя» — одно состояние, «ждём возраст» — другое. А машина состояний (FSM) — это и есть та невидимая ручка, которая держит в уме: «для этого пользователя я сейчас на строке Возраст». У каждого пользователя своя ручка и своя анкета — Цыплёнок не перепутает твои ответы с ответами друга из соседнего чата.

Слово «состояние» звучит научно, но в жизни ты сталкиваешься с ним постоянно. Светофор — это машина состояний: красный, жёлтый, зелёный, и из каждого цвета есть строгий переход в следующий. Игра-квест на телефоне — тоже: ты на «уровне 3», и пока его не пройдёшь, на «уровень 4» не попадёшь. Музыкальный плеер живёт в состояниях «играет» и «на паузе», и кнопка делает разное в зависимости от того, в каком он сейчас. Бот с FSM работает по той же логике: он всегда знает, в каком «режиме» сейчас разговор с тобой, и реагирует на сообщения по-разному в зависимости от этого режима.

И ещё важная мысль: без FSM у бота всего одно «состояние» — никакого. Он просто стоит и слушает все команды сразу, как автомат с газировкой, где все кнопки нажимаются в любой момент. FSM добавляет к этому время и порядок: «сначала это, потом то». Именно поэтому диалоги, мастера настройки, пошаговые формы и игры почти всегда строят на состояниях.

Где FSM хранит, кто на каком шаге

Чтобы помнить состояние каждого пользователя, FSM использует хранилище состояний (storage). Самый простой вариант — память: бот держит шаги прямо в оперативной памяти, пока запущен. Это удобно для учёбы, но есть нюанс — если бота перезапустить, все «недозаполненные анкеты» забудутся. Для серьёзных проектов берут Redis, но нам пока хватит памяти.

from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage

bot = Bot(token=TOKEN)
dp = Dispatcher(storage=MemoryStorage())

Результат: при запуске бот заводит хранилище в памяти. Если ты раньше создавал Dispatcher() вообще без аргументов — aiogram тихо подставлял MemoryStorage сам, но честнее писать его явно, чтобы было видно, что мы пользуемся FSM.

Описываем шаги: StatesGroup и State

Прежде чем вести диалог, нужно перечислить все его шаги — как нарисовать строки на пустом бланке анкеты. В aiogram для этого есть класс StatesGroup (группа состояний) и объекты State (одно состояние).

from aiogram.fsm.state import StatesGroup, State

class Anketa(StatesGroup):
    name = State()   # ждём имя
    age = State()    # ждём возраст

Результат: мы описали анкету из двух строк. Anketa.name — это «ждём имя», Anketa.age — «ждём возраст». Сами по себе они ничего не делают — это просто метки, ярлычки для шагов. Думай о них как о подписанных коробках: пока ничего внутри нет, но мы знаем, что куда складывать.

Обрати внимание на стиль: класс называем с большой буквы (Anketa), наследуем от StatesGroup, а каждый шаг — это переменная, которой присваиваем State(). Скобки в State() обязательны: мы создаём объект-состояние.

Зачем вообще заворачивать состояния в класс, а не просто завести три отдельные переменные? Во-первых, так они логически сгруппированы: видно, что name и age — это шаги одной анкеты, а не случайные значения, разбросанные по файлу. Во-вторых, aiogram под капотом даёт каждому состоянию уникальное имя вида Anketa:name, чтобы не перепутать его с шагом из другого диалога. Когда позже у бота появится вторая анкета — скажем, опрос друзей про любимую игру, — ты заведёшь отдельную группу Opros(StatesGroup), и их шаги никогда не столкнутся, даже если назвать поля одинаково.

Порядок переменных в классе человеку подсказывает, в каком порядке идут шаги, но сам по себе он бота ни к чему не обязывает: переходы между шагами задаёшь ты в коде через set_state. То есть класс — это просто список «какие вообще бывают шаги», а маршрут «откуда куда» ты прокладываешь руками. Это удобно: при желании можно перепрыгивать через шаги, ветвиться или возвращаться назад.

Входим в состояние: set_state

Теперь самое главное — как поставить нашу воображаемую ручку на первую строку. Когда пользователь пишет /anketa, мы хотим сказать FSM: «всё, теперь мы ждём от этого человека имя». Делается это методом state.set_state.

aiogram сам передаёт в хэндлер специальный объект state типа FSMContext — это и есть наша ручка для конкретного пользователя. Достаточно дописать аргумент state: FSMContext в функцию-хэндлер, и aiogram подставит его автоматически.

Пример 1. Запускаем анкету

from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.types import Message

router = Router()

@router.message(Command("anketa"))
async def start_anketa(message: Message, state: FSMContext):
    await state.set_state(Anketa.name)
    await message.answer("Привет! Давай заполним анкету. Как тебя зовут?")

Результат: в чате на команду /anketa Цыплёнок спрашивает имя и одновременно ставит ручку на строку «имя» (Anketa.name). С этого момента бот помнит: следующий текст от этого пользователя — его имя.

Разберём по строчкам:

  • @router.message(Command("anketa")) — ловим команду /anketa, как обычный хэндлер из прошлого урока.
  • state: FSMContext — просим aiogram дать нам «ручку» этого пользователя.
  • await state.set_state(Anketa.name) — переводим пользователя в состояние «ждём имя». Метод асинхронный, поэтому await обязателен.
  • Дальше — обычный вопрос в чат.

Пример 2. Ловим ответ в нужном состоянии

Теперь нужен хэндлер, который сработает только когда мы ждём имя. Для этого в фильтр хэндлера передают нужное состояние — StateFilter(Anketa.name). Внутри мы сохраняем имя через state.update_data и переводим ручку на следующую строку.

from aiogram.filters import StateFilter

@router.message(StateFilter(Anketa.name))
async def get_name(message: Message, state: FSMContext):
    await state.update_data(name=message.text)
    await state.set_state(Anketa.age)
    await message.answer(f"Отлично, {message.text}! Сколько тебе лет?")

Результат: этот хэндлер срабатывает, только если пользователь сейчас в состоянии Anketa.name. Он запоминает текст как имя, переводит пользователя в Anketa.age и спрашивает возраст. Если человек напишет что угодно, не запустив /anketa, этот хэндлер промолчит — ведь ручка не стоит на строке «имя».

Метод state.update_data(name=...) — это карман FSM, куда складывают собранные данные. Можно вызывать его сколько угодно раз, новые данные просто добавляются к уже сохранённым. Карман у каждого пользователя свой и живёт ровно до тех пор, пока ты не вызовешь clear() — поэтому имя, записанное на первом шаге, спокойно дождётся последнего шага, где мы соберём из всего этого итоговую фразу.

Заметь важную деталь порядка строк внутри хэндлера: мы сначала сохраняем данные (update_data), и только потом меняем состояние (set_state). Логика подсказывает делать именно так — записал ответ на текущий вопрос, затем перевёл стрелку на следующий. Если перепутать местами, ничего страшного с этим конкретным кодом не случится, но привычка «сначала сохранить ответ — потом сдвинуть шаг» убережёт от путаницы в более сложных диалогах.

Пример 3. Завершаем диалог и достаём данные

Последний шаг — поймать возраст, достать всё накопленное через state.get_data и обязательно выйти из FSM методом state.clear, иначе бот так и будет считать, что ждёт от пользователя возраст.

@router.message(StateFilter(Anketa.age))
async def get_age(message: Message, state: FSMContext):
    await state.update_data(age=message.text)
    data = await state.get_data()
    await state.clear()
    await message.answer(
        f"Готово! {data['name']}, {data['age']} лет. Анкета сохранена."
    )

Результат: Цыплёнок сохраняет возраст, достаёт из кармана FSM словарь data с именем и возрастом, выводит итог и сбрасывает состояние. После state.clear() пользователь снова вне анкеты — его сообщения опять ловят обычные хэндлеры.

Важно понять, что state.get_data() возвращает обычный словарь Python. Чтобы почувствовать, как это выглядит без всякого Telegram, вот чистый сниппет на стандартной библиотеке — он показывает ту же логику «копим ответы в словарь, потом собираем фразу»:

data = {}
data["name"] = "Саша"
data["age"] = "15"

itog = f"Готово! {data['name']}, {data['age']} лет."
print(itog)
print("Ключи в кармане:", list(data.keys()))

Вывод:

Готово! Саша, 15 лет.
Ключи в кармане: ['name', 'age']

Как шаги связаны: маленькая схема

Если собрать три хэндлера вместе, получается такая цепочка переходов между состояниями:

Состояние сейчасЧто пишет пользовательЧто делает ботНовое состояние
нет (вне анкеты)/anketaспрашивает имяAnketa.name
Anketa.nameСашасохраняет имя, спрашивает возрастAnketa.age
Anketa.age15сохраняет возраст, показывает итогнет (clear)

Видишь? Это и есть «машина состояний» — набор шагов и правил, как из одного шага попасть в следующий. Слово «машина» тут не про железо, а про чёткий механизм: из каждого состояния есть понятный переход в другое.

Эта таблица — отличный приём, когда диалог разрастётся. Перед тем как писать код, нарисуй на бумаге или в заметках столбики «состояние / что приходит / что делаем / куда переходим». Так ты заранее увидишь все шаги и развилки, не запутаешься в порядке вопросов и точно не забудешь финальный сброс. Программисты называют такую табличку или схему «диаграммой состояний», и пользуются ей даже в больших проектах — от ботов до игровых движков.

Частые ошибки и подводные камни

На FSM спотыкаются почти все новички. Разберём грабли, на которые наступишь с большой вероятностью — и как их обойти.

1. Забыл state.clear() в конце

Самая популярная беда. Диалог закончился, итог показан, а ты не вызвал state.clear(). Пользователь остаётся в состоянии Anketa.age, и любое его следующее сообщение бот снова принимает за возраст. Человек пишет «спасибо», а бот отвечает «Готово! Саша, спасибо лет». Всегда сбрасывай состояние, когда диалог завершён.

2. Не создал storage у Dispatcher

Если создать диспетчер без хранилища в старых сборках или с самописным кодом, FSM может не работать и состояния будут теряться. Привыкай писать Dispatcher(storage=MemoryStorage()) явно — так понятнее и надёжнее.

3. Хэндлер без StateFilter перехватывает ответ

Допустим, у тебя есть «универсальный» хэндлер на любой текст (эхо-бот из прошлого урока). Если он зарегистрирован раньше и без фильтра состояния, он съест ответ «Саша» до того, как до него доберётся get_name. Порядок регистрации хэндлеров важен, а ещё лучше — вешай на анкетные хэндлеры StateFilter, чтобы они срабатывали строго в своём состоянии.

4. Перепутал State и StatesGroup

В set_state и StateFilter надо передавать конкретный шаг — Anketa.name, а не весь класс Anketa. Если передать класс целиком, фильтр поймёт это как «любое состояние из группы», и логика поплывёт. Целься в конкретную строку анкеты.

5. Забыл await

Все методы FSM (set_state, update_data, get_data, clear) асинхронные. Без await они не выполнятся по-настоящему — ты получишь странную «корутину» вместо результата, а состояние не сменится. Если помнишь аналогию из урока про асинхронность: await — это «дождись, пока официант сходит на кухню и вернётся». Без него ты уходишь, не дождавшись ответа.

6. Ждёшь, что MemoryStorage переживёт перезапуск

Пока бот запущен, MemoryStorage прекрасно помнит все недозаполненные анкеты. Но стоит остановить и снова запустить bot.py — и память чистая, как новый лист: все, кто был «в середине» диалога, начнут с нуля. Для учёбы это нормально, и пугаться не нужно. Просто помни об этом, когда будешь тестировать: если ты на середине анкеты перезапустил бота, а он «забыл» имя — это не баг, это особенность хранилища в памяти. В реальном проекте под такие случаи берут Redis, и тогда состояния переживают перезапуск.

Мини-практика: анкета новичка для клана

Теперь твоя очередь. Добавь Цыплёнку третий шаг — спроси у новичка ник в игре между именем и возрастом. План такой:

  1. В класс Anketa добавь новое состояние nick = State() — поставь его между name и age.
  2. В хэндлере get_name вместо перехода в Anketa.age переводи пользователя в Anketa.nick и спроси: «А какой у тебя ник в игре?».
  3. Напиши новый хэндлер с StateFilter(Anketa.nick): сохрани ник через update_data(nick=message.text), переведи в Anketa.age и спроси возраст.
  4. В финальном get_age добавь ник в итоговую фразу: f"{data['name']} (ник {data['nick']}), {data['age']} лет".

Когда заработает — попробуй усложнить: пусть на шаге возраста бот проверяет, что пришло именно число (message.text.isdigit()), и при ошибке вежливо переспрашивает, не меняя состояние. Это типичная «валидация поля анкеты» — ручка остаётся на той же строке, пока ответ не подойдёт.

Итоги

Сегодня Цыплёнок научился вести диалог по шагам и не терять нить разговора. Запомни главное:

  • FSM — это память бота о том, на каком шаге диалога находится каждый пользователь.
  • State — один шаг (строка анкеты), а StatesGroup — набор всех шагов одного диалога.
  • storage хранит, кто на каком шаге; для учёбы хватает MemoryStorage.
  • state.set_state(...) — войти в состояние, update_data — накопить данные, get_data — забрать их, clear — выйти из диалога.
  • Фильтр StateFilter(...) заставляет хэндлер срабатывать только в нужном состоянии.

В следующем уроке мы продолжим тему диалогов: научим Цыплёнка показывать кнопки прямо во время анкеты и аккуратно обрабатывать ситуации, когда пользователь хочет прервать заполнение командой /cancel. Машина состояний станет ещё живее.

Проверьте себя
1. Зачем боту нужен FSM (машина состояний)?
AЧтобы помнить, на каком шаге диалога находится каждый пользователь
BЧтобы ускорить отправку сообщений в Telegram
CЧтобы хранить токен бота в безопасности
DЧтобы бот мог работать сразу в нескольких чатах
2. Как правильно описать шаги диалога в aiogram?
AСоздать список строк с названиями шагов
BСоздать класс-наследник StatesGroup, а каждый шаг задать как State()
CЗаписать шаги в обычный словарь Python
DПередать шаги прямо в Dispatcher при создании
3. Что делает вызов await state.set_state(Anketa.name)?
AОтправляет пользователю сообщение с именем
BУдаляет все сохранённые данные пользователя
CПереводит пользователя в состояние «ждём имя»
DСоздаёт новый класс состояний
4. Почему важно вызвать state.clear() в конце диалога?
AИначе бот не сможет отправить итоговое сообщение
BИначе пользователь останется в последнем состоянии, и бот примет его следующее сообщение за ответ анкеты
CИначе данные из update_data не сохранятся
DЭто нужно только при работе с Redis
5. Зачем на анкетный хэндлер вешают StateFilter(Anketa.name)?
AЧтобы хэндлер срабатывал только когда пользователь в состоянии Anketa.name
BЧтобы запретить пользователю писать боту вне анкеты
CЧтобы автоматически сохранить имя в базу данных
DЧтобы хэндлер реагировал на любую команду
6. Что вернёт await state.get_data() после нескольких update_data?
AТолько последнее сохранённое значение
BТекст последнего сообщения пользователя
CОбычный словарь Python со всеми накопленными данными
DОбъект FSMContext для дальнейшей настройки