Большая часть моих заказов на ботов выглядит одинаково. Человек хочет, чтобы клиент нажал кнопку, ответил на несколько вопросов и заявка упала менеджеру. Имя, телефон, что нужно, удобное время. Звучит как задача на вечер, и по коду это правда вечер.
Проблемы начинаются потом, когда бот уже работает у заказчика. Ниже три истории, после которых я поменял свой шаблон. Все примеры на aiogram 3.
Грабли первая: MemoryStorage в проде
В документации и туториалах почти везде стоит так:
from aiogram import Bot, Dispatcherfrom aiogram.fsm.storage.memory import MemoryStoragedp = Dispatcher(storage=MemoryStorage())
Для разработки нормально. Но MemoryStorage держит состояния в оперативке процесса. Перезапустил бота, и все, кто был посреди анкеты, оказались в пустоте.
У меня это вылезло так. Я выкатил правку текста в одном сообщении, сделал docker compose up -d --build, всё поднялось за пару секунд. Через час заказчик пишет: клиент жалуется, что ввёл телефон, а бот молчит. Клиент начал анкету до деплоя, закончил после. Бот уже не помнил, что ждёт от него телефон, и просто проигнорировал сообщение.
Лечится переездом в Redis:
from aiogram.fsm.storage.redis import RedisStorage, DefaultKeyBuilderstorage = 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, Ffrom aiogram.filters import Commandfrom aiogram.fsm.context import FSMContextfrom aiogram.fsm.state import State, StatesGroupfrom aiogram.types import Messagerouter = 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, /helpdp.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 или сразу в базе.
ссылка на оригинал статьи https://habr.com/ru/articles/1097654/