- PVSM.RU - https://www.pvsm.ru -

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 или сразу в базе.

Автор: whysafe

Источник [1]


Сайт-источник PVSM.RU: https://www.pvsm.ru

Путь до страницы источника: https://www.pvsm.ru/python/460561

Ссылки в тексте:

[1] Источник: https://habr.com/ru/articles/1097654/?utm_source=habrahabr&utm_medium=rss&utm_campaign=1097654