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.age | 15 | сохраняет возраст, показывает итог | нет (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, и тогда состояния переживают перезапуск.
Мини-практика: анкета новичка для клана
Теперь твоя очередь. Добавь Цыплёнку третий шаг — спроси у новичка ник в игре между именем и возрастом. План такой:
- В класс
Anketaдобавь новое состояниеnick = State()— поставь его междуnameиage. - В хэндлере
get_nameвместо перехода вAnketa.ageпереводи пользователя вAnketa.nickи спроси: «А какой у тебя ник в игре?». - Напиши новый хэндлер с
StateFilter(Anketa.nick): сохрани ник черезupdate_data(nick=message.text), переведи вAnketa.ageи спроси возраст. - В финальном
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. Машина состояний станет ещё живее.