InquirerVeteran journo feted ‘MassKara Legacy Award’Bollywood HungamaEXCLUSIVE: Sunny Deol's Gabru set for 2027 release; Vishal Rana promises a NEVER-SEEN-BEFORE avatar: “Sunny sir says, ‘Iss baar dhai kilo ka haath nahin, dhai kilo ka HEART leke aaya hoon’!”The Jerusalem PostKatz says opposition will bear blame for future attacks on Israel after ignoring warningsRTP DesportoSp. Braga procura primeiros pontos na estreia de Franclim CarvalhoCNN TürkALTIN NEDEN DÜŞÜYOR? Gram altın, çeyrek altın ne kadar? 22 Ekim Kapalıçarşı ons altın, cumhuriyet altın fiyatı: Altında sert düşüş!PunchGuinea-Bissau stops controversial US-funded vaccine trial on newbornsInquirer EntertainmentStar Magic eyes collab with Chinoy pageant to cast winners in vertical shortsESPNTransfer rumors, news: Man United consider defensive clearout in summerSouth China Morning PostHong Kong port needs a new lease of life in value-driven eraThe South AfricanChiefs and Pirates in rare CAF Friday night double-header7sur7Meta prépare ses levées de fonds pour l’IA, Zuckerberg garde le contrôleZDF heuteEntdecken Sie das ZDF-Nachrichtenstudio
The Daily Newsstand · Free, Always
Thursday, October 22, 2026

aiogram 3 и FSM: три грабли, на которые я наступил в заказных ботах

Translate

Большая часть моих заказов на ботов выглядит одинаково. Человек хочет, чтобы клиент нажал кнопку, ответил на несколько вопросов и заявка упала менеджеру. Имя, телефон, что нужно, удобное время. Звучит как задача на вечер, и по коду это правда вечер.

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

View the original on Хабр →

KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.