🎬 Сцены¶
Scene это многошаговый диалог, где каждое сообщение пользователя обрабатывает новый, обычный хендлер aiogram. Это предпочтительный вариант для диалогов с request-scoped зависимостями, например с AsyncSession из SQLAlchemy.
В отличие от 💬 Диалоги (Conversations), сцена не приостанавливает хендлер, начавший диалог. Она хранит небольшой типизированный черновик в FSM-хранилище, завершает обработку апдейта и возобновляется на следующем подходящем апдейте. Поэтому ваш обычный middleware закрывает сессию БД, транзакцию или другой scoped-ресурс после каждого шага, и пока пользователь думает, ничего не удерживается.
Сцены построены на штатном StatesGroup из aiogram: каждый шаг это настоящий FSM-стейт, поэтому StateFilter и любой FSM-инструмент видят его как обычный стейт.
Пример¶
from aiogram import F, filters
from aiogram.fsm.state import State, StatesGroup
from aiogram.types import Message
from sqlalchemy.ext.asyncio import AsyncSession
from raito import Router
from raito.plugins.scenes import Scene, SceneData
router = Router(name="moderation")
class MuteData(SceneData):
username: str | None = None
minutes: int | None = None
class MuteStates(StatesGroup):
username = State()
minutes = State()
mute = router.scene(MuteStates, data=MuteData)
@mute.on_message.enter(filters.Command("mute"))
async def start(message: Message, scene: Scene[MuteData]) -> None:
await message.answer("Enter username:")
await scene.next()
@mute.on_message(MuteStates.username, F.text)
async def set_username(message: Message, scene: Scene[MuteData]) -> None:
if not (message.text or "").startswith("@"):
await message.answer("⚠️ Enter an @username")
return await scene.retry()
scene.data.username = message.text
await message.answer("Enter duration in minutes:")
await scene.next()
@mute.on_message(MuteStates.minutes, F.text)
async def set_minutes(
message: Message,
scene: Scene[MuteData],
session: AsyncSession,
) -> None:
if not (message.text or "").isdigit() or int(message.text) <= 0:
await message.answer("⚠️ Enter a positive whole number")
return await scene.retry()
scene.data.minutes = int(message.text)
await mute_user(session, scene.data.username, scene.data.minutes)
await message.answer("✅ User muted")
await scene.finish()
session выше передаётся обычным middleware вашего приложения. Она свежая для апдейта на шаге minutes и закрывается, когда хендлер завершается. Сцена никогда не хранит ни сессию, ни сообщение, ни ORM-объект, ни future.
Хендл scene¶
Любой хендлер может принять параметр scene: Scene[MuteData] это живой хендл текущего апдейта. Он несёт две вещи:
scene.dataэто типизированный, изменяемый черновик. Присваивайте ему напрямую (scene.data.username = ...); изменение сразу проверяется и на следующей навигации сохраняется в FSM-хранилище.глаголы навигации, которые сохраняют черновик и переключают FSM-стейт:
await scene.next()переходит к следующему шагу в порядке объявления (или к первому шагу, если вызван из входного хендлера).await scene.back()переходит к предыдущему шагу.await scene.goto(state)прыгает на конкретный шаг (ветвление).await scene.retry()остаётся на текущем шаге; сохраняет черновик.await scene.restart()сбрасывает черновик и начинает эту сцену заново.await scene.finish()/await scene.cancel()завершают сцену, очищая только её собственные данные в FSM.await scene.escape()отменяет сцену и пропускает апдейт к следующему подходящему хендлеру, вместо того чтобы поглотить его. См. Обработка постороннего сообщения.await scene.start(other, at=OtherStates.reason, **data)передаёт диалог другой сцене, засевая её черновик изdata(с проверкой по еёSceneData). Открывает первый шагotherили шагat, если он задан.
Навигация сама ничего не отправляет. Отвечайте тем сообщением, что у вас уже есть (await message.answer(...)), где нативно доступны все опции Telegram (reply_markup, parse_mode, медиа и прочее). Типичный шаг сначала отвечает, потом навигирует:
await message.answer("Enter minutes:", reply_markup=cancel_keyboard)
await scene.next()
scene.data это pydantic-модель SceneData, которая служит только типизированным JSON-сериализуемым черновиком. Задайте каждому полю значение по умолчанию, ведь сцена стартует с пустого черновика. Собирайте и проверяйте итоговую доменную команду на последнем шаге, а не навешивайте на SceneData валидаторы, зависящие от внешних ресурсов.
Если шаг не трогает черновик, достаточно scene: Scene (без [MuteData]). Хендлер может рядом со scene объявить state: FSMContext для прямого доступа к FSM: хендл этому не мешает.
События¶
Хендлеры регистрируются через декораторы по типам событий: scene.on_message, scene.on_callback_query и scene.on_edited_message. Префикс on_ повторяет router.on_pagination из Raito. Каждый вызывается со стейтом шага, чтобы обработать этот шаг, и имеет .enter для старта сцены. Поэтому сцена может начинаться с команды или с кнопки, а любой шаг может ждать текст или inline-кнопку:
from aiogram.types import CallbackQuery
from aiogram.utils.keyboard import InlineKeyboardBuilder
@mute.on_message.enter(filters.Command("mute")) # start on a command
async def start(message: Message, scene: Scene[MuteData]) -> None:
await message.answer("Enter username:")
await scene.next()
@mute.on_message(MuteStates.username, F.text)
async def set_username(message: Message, scene: Scene[MuteData]) -> None:
scene.data.username = message.text
keyboard = InlineKeyboardBuilder()
keyboard.button(text="Confirm", callback_data="confirm")
keyboard.button(text="Cancel", callback_data="cancel")
await message.answer("Mute this user?", reply_markup=keyboard.as_markup())
await scene.next()
@mute.on_callback_query(MuteStates.confirm, F.data == "confirm") # handle a tap
async def confirm(callback_query: CallbackQuery, scene: Scene[MuteData]) -> None:
await callback_query.message.edit_text("✅ Muted")
await scene.finish()
Каждый хендлер получает тот же хендл scene плюс своё событие под именем наблюдателя: message, callback_query или edited_message, ровно совпадающим с именем параметра, по тому же правилу, что и обычный хендлер @router.callback_query(). Его middleware работает на всех поддерживаемых наблюдателях, поэтому устаревшая сцена сбрасывается независимо от того, ждёт следующий шаг текст или нажатие. scene.on_message, scene.on_callback_query и scene.on_edited_message покрывают события, несущие FSM-стейт на пользователя.
Кроме конкретного шага, у каждого регистратора есть .any(*filters), который срабатывает на любом активном шаге сцены. Это для того, что должно работать независимо от того, где пользователь: например, постоянная кнопка «Отмена» или команда /cancel:
@mute.on_message.any(filters.Command("cancel"))
async def cancel_anywhere(scene: Scene[MuteData]) -> None:
await scene.cancel()
Как и любой хендлер сцены, .any() тоже конкурирует по порядку объявления: широкий фильтр шага (F.text), объявленный раньше, сработает первым, и .any() не выполнится. Объявляйте общесценовые .any() раньше шагов, которые они должны перехватывать.
Хранилище и роутинг¶
Сцены используют внедрённый
FSMContextи, значит, хранилище, заданное вDispatcher(storage=...).Raito.storageэто отдельное хранилище, и сцены не в нём.Шаги сцены это обычные message-хендлеры, сопоставляемые по порядку объявления, как любые хендлеры aiogram. Объявляйте хендлеры сцены раньше широкого
@router.message()на том же роутере или, как в примере, держите сцену на отдельном роутере. Между роутерами порядком управляют через порядок подключения или черезRouter(priority=...)из Raito.В остальном hot-reload прозрачен: aiogram опознаёт шаг по имени (
"MuteStates:username"), а не по объекту Python-класса, поэтому перезагрузка тела хендлера не мешает пользователю посреди сцены. Мешает только удаление или переименованиеState: следующее сообщение такого пользователя сбросит устаревшую сцену и пойдёт по обычному роутингу, с логом уровняDEBUGвraito.plugins.scenes.Пока сцена активна, она владеет FSM-стейтом своего чата/пользователя. Не смешивайте его с посторонними FSM-стейтами aiogram или с
raito.wait_forпо тому же ключу. Роутер не примет две сцены на одномStatesGroup.finishиcancelудаляют только собственные данные сцены в FSM. Прочие данные под тем же ключомFSMContextсохраняются.FSM-стейт на чат/пользователя всего один, поэтому запуск любой сцены (в том числе повторный вход в ту же самую) молча, без предупреждения заменяет то, что было активно. Это не особенность сцен: то же верно и для обычного FSM aiogram. Если случайно бросить один сценарий, начав другой, дорого, защитите входной хендлер сами:
@mute.on_message.enter(filters.Command("mute")) async def start(scene: Scene[MuteData], state: FSMContext) -> None: raw_state = await state.get_state() if raw_state is not None and not mute.steps.owns(raw_state): await message.answer("Finish or /cancel your current action first.") return await scene.next()