FSM в Aiogram 3: пошаговая форма, отмена и хранение
Как собрать FSM-сценарий в Aiogram 3: StatesGroup, проверка ввода, /cancel, данные между шагами и выбор MemoryStorage или Redis.
FSM в Aiogram 3 нужен, когда боту надо провести человека через несколько связанных шагов и помнить, какой ответ ждать дальше. Ниже соберём форму заявки: тема → описание → подтверждение. В примере есть проверка текста, команда /cancel и очистка состояния после завершения.
FSM не заменяет базу данных. Он хранит временное состояние диалога; созданную заявку нужно отдельно сохранять через сервис и repository.
Что будет в итоге
/request → тема → описание → подтверждение
↘ /cancel на любом шаге
Создайте бота через BotFather, подготовьте Python 3.10+ и установите ту же версию, что используется в туториале по Aiogram 3:
python -m pip install "aiogram==3.31.0"
Перед запуском передайте токен через BOT_TOKEN в окружении, не вставляйте его в исходник. Для краткого вводного маршрута подойдёт бесплатный практикум Aiogram; здесь сосредоточимся именно на переходах между состояниями.
Опишите состояния и переходы
Состояние — не текст предыдущего сообщения, а явный этап сценария. Handler с фильтром RequestForm.topic сработает только тогда, когда FSM ждёт тему.
from aiogram.fsm.state import State, StatesGroup
class RequestForm(StatesGroup):
topic = State()
details = State()
confirmation = State()
У FSM нет автоматического перехода к следующему шагу: обработчик сам сохраняет ответ, устанавливает новое состояние и отправляет следующий вопрос.
Реализуйте форму и отмену
from aiogram import F, Router
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message
router = Router()
class RequestForm(StatesGroup):
topic = State()
details = State()
confirmation = State()
@router.message(Command("cancel"))
async def cancel_request(message: Message, state: FSMContext) -> None:
if await state.get_state() is None:
await message.answer("Сейчас нет активной формы.")
return
await state.clear()
await message.answer("Форма отменена. Чтобы начать заново, отправьте /request.")
@router.message(Command("request"))
async def start_request(message: Message, state: FSMContext) -> None:
# Повторная команда начинает новую форму, а не продолжает старый черновик.
await state.clear()
await state.set_state(RequestForm.topic)
await message.answer("Коротко назовите тему обращения. Для отмены — /cancel.")
@router.message(RequestForm.topic, F.text)
async def receive_topic(message: Message, state: FSMContext) -> None:
topic = message.text.strip()
if not topic or len(topic) > 120:
await message.answer("Тема должна содержать от 1 до 120 символов. Попробуйте ещё раз.")
return
await state.update_data(topic=topic)
await state.set_state(RequestForm.details)
await message.answer("Опишите задачу в нескольких предложениях. Для отмены — /cancel.")
@router.message(RequestForm.topic)
async def reject_non_text_topic(message: Message) -> None:
await message.answer("На этом шаге отправьте тему обычным текстом.")
@router.message(RequestForm.details, F.text)
async def receive_details(message: Message, state: FSMContext) -> None:
details = message.text.strip()
if not details or len(details) > 2000:
await message.answer("Описание должно содержать от 1 до 2000 символов. Попробуйте ещё раз.")
return
data = await state.update_data(details=details)
await state.set_state(RequestForm.confirmation)
await message.answer(
f"Проверьте заявку:\nТема: {data['topic']}\nОписание: {data['details']}\n\n"
"Отправьте /confirm для подтверждения или /cancel для отмены."
)
@router.message(RequestForm.confirmation, Command("confirm"))
async def confirm_request(message: Message, state: FSMContext) -> None:
data = await state.get_data()
# В рабочем проекте передайте data сервису и сохраните заявку в БД здесь.
# Очищайте FSM только после успешного сохранения.
await message.answer(f"Заявка «{data['topic']}» подтверждена.")
await state.clear()
@router.message(RequestForm.confirmation)
async def repeat_confirmation(message: Message) -> None:
await message.answer("Отправьте /confirm для подтверждения или /cancel для отмены.")
@router.message(RequestForm.details)
async def reject_non_text_details(message: Message) -> None:
await message.answer("На этом шаге отправьте описание обычным текстом.")
Подключите router к Dispatcher через dispatcher.include_router(router). Для локального запуска достаточно точки входа:
import asyncio
import os
from aiogram import Bot, Dispatcher
async def main() -> None:
token = os.getenv("BOT_TOKEN")
if not token:
raise RuntimeError("Set BOT_TOKEN in the environment")
bot = Bot(token=token)
dispatcher = Dispatcher()
dispatcher.include_router(router)
await dispatcher.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
/cancel зарегистрирован раньше обработчиков состояний, поэтому команда не попадёт в обычный ввод описания. Общий обработчик «ответить на любое сообщение» лучше подключать после сценарных роутеров: иначе он может перехватить шаг формы.
Где хранится ответ между шагами
state.update_data() сохраняет промежуточные поля, а state.get_data() читает их. В примере финальный update_data() возвращает объединённые данные, поэтому отдельное чтение не требуется. На предыдущем шаге можно вызывать get_data(), если надо показать сводку или дать возможность исправить поле.
По умолчанию Aiogram использует MemoryStorage: он удобен для локальной проверки, но хранит данные в памяти процесса. После перезапуска они исчезнут. Для сценария, который должен переживать рестарт, настройте внешнее хранилище, например Redis. TTL можно назначить отдельно для состояния и данных. FSM-хранилище всё равно не является надёжным хранилищем заказов или заявок. Документация Aiogram: FSM storage.
Частые ошибки
- Забыли установить следующее состояние. После ответа пользователь снова попадает в тот же этап или теряется в общем обработчике.
- Не очистили старый сценарий при повторном старте. Начните новый процесс с
await state.clear(). - Команда отмены обрабатывается как ответ формы. Зарегистрируйте command handler раньше широких state handlers.
- В state data положили весь объект
Message. Храните только короткие сериализуемые значения, которые нужны следующему шагу. - Заявка очищена до записи в БД. Сначала сохраните запись; вызывайте
state.clear()после успешного ответа repository. - Использовали MemoryStorage в production и ждёте сохранения после рестарта. Подключите внешнее хранилище, а бизнес-данные сохраняйте отдельно.
Проверьте сценарий
- Отправьте
/requestи убедитесь, что бот ждёт тему. - Отправьте фото вместо текста: бот должен объяснить ожидаемый формат, не меняя шаг.
- Отправьте тему длиннее 120 символов: бот должен попросить исправить её.
- Отправьте корректную тему и описание; проверьте, что оба значения доступны финальному обработчику.
- Начните новую форму и отправьте
/cancelна каждом шаге. - Перезапустите polling: проверьте, что помнит выбранное вами хранилище, и не рассчитывайте на сохранность MemoryStorage.
Дальше можно разобрать структуру production-проекта на Aiogram 3 и полный путь от первого бота до FSM в практикуме с упражнениями. Для продуктового бота на реальных заявках FSM должен вызывать сервис, который валидирует бизнес-правила и сохраняет результат в БД; пример такого подхода есть в кейсе Telegram-проекта.
по теме
следующий шаг
Собрать первого Telegram-бота
Бесплатный практикум Aiogram 3 помогает пройти путь от первого ответа до FSM и деплоя на VPS.
поделиться
Нужна разработка Telegram-бота?
Разрабатываю ботов, Mini Apps, платежи и автоматизацию под ключ.