aiogram 3 и FSM: три грабли, на которые я наступил в заказных ботах
Большая часть моих заказов на ботов выглядит одинаково. Человек хочет, чтобы клиент нажал кнопку, ответил на несколько вопросов и заявка упала менеджеру. Имя, телефон, что нужно, удобное время. Звучит как задача на вечер, и по коду это правда вечер.
Проблемы начинаются потом, когда бот уже работает у заказчика. Ниже три истории, после которых я поменял свой шаблон. Все примеры на aiogram 3.
Грабли первая: MemoryStorage в проде
В документации и туториалах почти везде стоит так:
from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.memory import MemoryStorage
dp = Dispatcher(storage=MemoryStorage())Для разработки нормально. Но MemoryStorage держит состояния в оперативке процесса. Перезапустил бота, и все, кто был посреди анкеты, оказались в пустоте.
У меня это вылезло так. Я выкатил правку текста в одном сообщении, сделал docker compose up -d --build, всё поднялось за пару секунд. Через час заказчик пишет: клиент жалуется, что ввёл телефон, а бот молчит. Клиент начал анкету до деплоя, закончил после. Бот уже не помнил, что ждёт от него телефон, и просто проигнорировал сообщение.
Лечится переездом в Redis:
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilder
storage = RedisStorage.from_url(
"redis://redis:6379/0",
key_builder=DefaultKeyBuilder(with_bot_id=True),
state_ttl=60 * 60 * 24,
data_ttl=60 * 60 * 24,
)
dp = Dispatcher(storage=storage)Зависимость ставится так: pip install "aiogram[redis]".
with_bot_id=True я включаю всегда. Бывает, что на одном сервере и одном Redis крутятся два бота одного заказчика, например основной и тестовый. Без bot_id в ключе они делят состояния одного и того же пользователя, и отлаживать это неприятно.
TTL тоже не для красоты. Без него ключи живут вечно, и через полгода в Redis лежат десятки тысяч брошенных анкет.
Грабли вторая: /cancel, который сохранился как имя
Типичная анкета:
from aiogram import Router, F
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 Order(StatesGroup):
name = State()
phone = State()
@router.message(Command("order"))
async def start_order(message: Message, state: FSMContext):
await state.set_state(Order.name)
await message.answer("Как к вам обращаться?")
@router.message(Order.name, F.text)
async def got_name(message: Message, state: FSMContext):
await state.update_data(name=message.text)
await state.set_state(Order.phone)
await message.answer("Телефон для связи?")
@router.message(Command("cancel"))
async def cancel(message: Message, state: FSMContext):
await state.clear()
await message.answer("Отменил. Если что, /order")Выглядит нормально. Пользователь на шаге с именем пишет /cancel, и в заявку менеджеру приходит человек по имени «/cancel».
Причина в том, что в aiogram 3 обработчики внутри роутера проверяются в порядке регистрации. Первым подходит got_name: состояние совпало, F.text тоже, команда ведь тоже текст. До cancel очередь просто не доходит.
Я на этом попался по старой привычке. В aiogram 2 хендлер без фильтра состояния по умолчанию срабатывал только вне состояний. В тройке он ловит любое состояние, так что порядок регистрации решает всё.
Исправление: служебные команды регистрировать первыми. Я вынес их в отдельный роутер и подключаю его раньше остальных:
dp.include_router(common_router) # /start, /cancel, /help
dp.include_router(order_router)Дополнительно в шагах, где ждём свободный текст, отсекаю команды:
@router.message(Order.name, F.text, ~F.text.startswith("/"))Это страховка на случай, если кто-то потом добавит команду не в тот роутер.
Грабли третья: бот молчит, если прислали не то
Человек на шаге «Телефон для связи?» жмёт кнопку отправки контакта, или кидает фото, или голосовое. Хендлер ждёт F.text, ничего не подходит, апдейт уходит в никуда. Пользователь видит, что бот завис, и закрывает чат.
Теперь в каждом состоянии у меня есть «ловец» без фильтра по типу, зарегистрированный после основного хендлера:
@router.message(Order.phone, F.contact)
async def got_contact(message: Message, state: FSMContext):
await finish(message, state, phone=message.contact.phone_number)
@router.message(Order.phone, F.text)
async def got_phone_text(message: Message, state: FSMContext):
await finish(message, state, phone=message.text)
@router.message(Order.phone)
async def phone_fallback(message: Message):
await message.answer("Не понял. Напишите номер текстом или нажмите кнопку ниже.")И в самом конце, последним роутером, общий хендлер на сообщения вне всяких сценариев:
@fallback_router.message()
async def unknown(message: Message):
await message.answer("Начать заявку: /order")Он же спасает ситуацию, когда TTL истёк. Человек вернулся через двое суток, дописал телефон, состояния уже нет, но бот хотя бы отвечает и предлагает начать заново, а не молчит.
Что в итоге в моём шаблоне
RedisStorage с
with_bot_id=Trueи TTL сутки, Redis рядом в docker composeслужебные команды в отдельном роутере, подключённом первым
у каждого состояния есть fallback без фильтра по типу контента
последний роутер отвечает на всё, что не попало никуда
Ничего из этого не сложное. Но каждый пункт появился после конкретной жалобы от живого заказчика, а не из чтения документации. Документацию, честно говоря, стоило читать внимательнее с самого начала.
Если у вас есть свои грабли с FSM, напишите в комментариях. Особенно интересно, кто как хранит промежуточные данные в длинных сценариях: в FSM data или сразу в базе.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.