Большая часть моих заказов на ботов выглядит одинаково. Человек хочет, чтобы клиент нажал кнопку, ответил на несколько вопросов и заявка упала менеджеру. Имя, телефон, что нужно, удобное время. Звучит как задача на вечер, и по коду это правда вечер.
Проблемы начинаются потом, когда бот уже работает у заказчика. Ниже три истории, после которых я поменял свой шаблон. Все примеры на 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 или сразу в базе.
Автор: whysafe
