все статьи
12 октября 2026 г.·9 мин

FSM в Aiogram 3: пошаговая форма, отмена и хранение

Как собрать FSM-сценарий в Aiogram 3: StatesGroup, проверка ввода, /cancel, данные между шагами и выбор MemoryStorage или Redis.

Aiogram 3FSMPythonTelegram-ботПрактика

Автор: Shcoder, независимый разработчик

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 зарегистрирован раньше обработчиков состояний, поэтому команда не попадёт в обычный ввод описания. Общий обработчик «ответить на любое сообщение» лучше подключать после сценарных роутеров: иначе он может перехватить шаг формы.

Диаграмма переходов между состояниями FSM в Aiogram

Где хранится ответ между шагами

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 и ждёте сохранения после рестарта. Подключите внешнее хранилище, а бизнес-данные сохраняйте отдельно.

Проверьте сценарий

  1. Отправьте /request и убедитесь, что бот ждёт тему.
  2. Отправьте фото вместо текста: бот должен объяснить ожидаемый формат, не меняя шаг.
  3. Отправьте тему длиннее 120 символов: бот должен попросить исправить её.
  4. Отправьте корректную тему и описание; проверьте, что оба значения доступны финальному обработчику.
  5. Начните новую форму и отправьте /cancel на каждом шаге.
  6. Перезапустите polling: проверьте, что помнит выбранное вами хранилище, и не рассчитывайте на сохранность MemoryStorage.

Дальше можно разобрать структуру production-проекта на Aiogram 3 и полный путь от первого бота до FSM в практикуме с упражнениями. Для продуктового бота на реальных заявках FSM должен вызывать сервис, который валидирует бизнес-правила и сохраняет результат в БД; пример такого подхода есть в кейсе Telegram-проекта.

Собрать первого Telegram-бота

Бесплатный практикум Aiogram 3 помогает пройти путь от первого ответа до FSM и деплоя на VPS.

Начать бесплатный практикум

Нужна разработка Telegram-бота?

Разрабатываю ботов, Mini Apps, платежи и автоматизацию под ключ.