Docs + example

This commit is contained in:
Alex Root Junior 2023-10-07 19:50:32 +03:00
parent d4248c5672
commit f1ca0e5271
No known key found for this signature in database
GPG key ID: 074C1D455EBEA4AC
4 changed files with 695 additions and 1 deletions

View file

@ -212,12 +212,19 @@ class HandlerContainer:
@dataclass()
class SceneConfig:
state: Optional[str]
"""Scene state"""
filters: Dict[str, List[CallbackType]]
"""Global scene filters"""
handlers: List[HandlerContainer]
"""Scene handlers"""
actions: Dict[SceneAction, Dict[str, CallableObject]]
"""Scene actions"""
reset_data_on_enter: Optional[bool] = None
"""Reset scene data on enter"""
reset_history_on_enter: Optional[bool] = None
"""Reset scene history on enter"""
callback_query_without_state: Optional[bool] = None
"""Allow callback query without state"""
async def _empty_handler(*args: Any, **kwargs: Any) -> None:
@ -424,6 +431,19 @@ class Scene:
class SceneWizard:
"""
A class that represents a wizard for managing scenes in a Telegram bot.
Instance of this class is passed to each scene as a parameter.
So, you can use it to transition between scenes, get and set data, etc.
.. note::
This class is not meant to be used directly. Instead, it should be used
as a parameter in the scene constructor.
"""
def __init__(
self,
scene_config: SceneConfig,
@ -433,6 +453,16 @@ class SceneWizard:
event: TelegramObject,
data: Dict[str, Any],
):
"""
A class that represents a wizard for managing scenes in a Telegram bot.
:param scene_config: The configuration of the scene.
:param manager: The scene manager.
:param state: The FSMContext object for storing the state of the scene.
:param update_type: The type of the update event.
:param event: The TelegramObject represents the event.
:param data: Additional data for the scene.
"""
self.scene_config = scene_config
self.manager = manager
self.state = state
@ -443,6 +473,14 @@ class SceneWizard:
self.scene: Optional[Scene] = None
async def enter(self, **kwargs: Any) -> None:
"""
Enter method is used to transition into a scene in the SceneWizard class.
It sets the state, clears data and history if specified,
and triggers entering event of the scene.
:param kwargs: Additional keyword arguments.
:return: None
"""
loggers.scene.debug("Entering scene %r", self.scene_config.state)
if self.scene_config.reset_data_on_enter:
await self.state.set_data({})
@ -452,27 +490,65 @@ class SceneWizard:
await self._on_action(SceneAction.enter, **kwargs)
async def leave(self, _with_history: bool = True, **kwargs: Any) -> None:
"""
Leaves the current scene.
This method is used to exit a scene and transition to the next scene.
:param _with_history: Whether to include history in the snapshot. Defaults to True.
:param kwargs: Additional keyword arguments.
:return: None
"""
loggers.scene.debug("Leaving scene %r", self.scene_config.state)
if _with_history:
await self.manager.history.snapshot()
await self._on_action(SceneAction.leave, **kwargs)
async def exit(self, **kwargs: Any) -> None:
"""
Exit the current scene and enter the default scene/state.
:param kwargs: Additional keyword arguments.
:return: None
"""
loggers.scene.debug("Exiting scene %r", self.scene_config.state)
await self.manager.history.clear()
await self._on_action(SceneAction.exit, **kwargs)
await self.manager.enter(None, _check_active=False, **kwargs)
async def back(self, **kwargs: Any) -> None:
"""
This method is used to go back to the previous scene.
:param kwargs: Keyword arguments that can be passed to the method.
:return: None
"""
loggers.scene.debug("Back to previous scene from scene %s", self.scene_config.state)
await self.leave(_with_history=False, **kwargs)
new_scene = await self.manager.history.rollback()
await self.manager.enter(new_scene, _check_active=False, **kwargs)
async def retake(self, **kwargs: Any) -> None:
"""
This method allows to re-enter the current scene.
:param kwargs: Additional keyword arguments to pass to the scene.
:return: None
"""
await self.goto(self.scene_config.state, **kwargs)
async def goto(self, scene: Union[Type[Scene], str], **kwargs: Any) -> None:
"""
The `goto` method transitions to a new scene.
It first calls the `leave` method to perform any necessary cleanup
in the current scene, then calls the `enter` event to enter the specified scene.
:param scene: The scene to transition to. Can be either a `Scene` instance
or a string representing the scene.
:param kwargs: Additional keyword arguments to pass to the `enter`
method of the scene manager.
:return: None
"""
await self.leave(**kwargs)
await self.manager.enter(scene, _check_active=False, **kwargs)
@ -502,23 +578,51 @@ class SceneWizard:
return True
async def set_data(self, data: Dict[str, Any]) -> None:
"""
Sets custom data in the current state.
:param data: A dictionary containing the custom data to be set in the current state.
:return: None
"""
await self.state.set_data(data=data)
async def get_data(self) -> Dict[str, Any]:
"""
This method returns the data stored in the current state.
:return: A dictionary containing the data stored in the scene state.
"""
return await self.state.get_data()
async def update_data(
self, data: Optional[Dict[str, Any]] = None, **kwargs: Any
) -> Dict[str, Any]:
"""
This method updates the data stored in the current state
:param data: Optional dictionary of data to update.
:param kwargs: Additional key-value pairs of data to update.
:return: Dictionary of updated data
"""
if data:
kwargs.update(data)
return await self.state.update_data(data=kwargs)
async def clear_data(self) -> None:
"""
Clears the data.
:return: None
"""
await self.set_data({})
class ScenesManager:
"""
The ScenesManager class is responsible for managing scenes in an application.
It provides methods for entering and exiting scenes, as well as retrieving the active scene.
"""
def __init__(
self,
registry: SceneRegistry,
@ -561,6 +665,15 @@ class ScenesManager:
_check_active: bool = True,
**kwargs: Any,
) -> None:
"""
Enters the specified scene.
:param scene_type: Optional Type[Scene] or str representing the scene type to enter.
:param _check_active: Optional bool indicating whether to check if
there is an active scene to exit before entering the new scene. Defaults to True.
:param kwargs: Additional keyword arguments to pass to the scene's wizard.enter() method.
:return: None
"""
if _check_active:
active_scene = await self._get_active_scene()
if active_scene is not None:
@ -576,6 +689,12 @@ class ScenesManager:
await scene.wizard.enter(**kwargs)
async def close(self, **kwargs: Any) -> None:
"""
Close method is used to exit the currently active scene in the ScenesManager.
:param kwargs: Additional keyword arguments passed to the scene's exit method.
:return: None
"""
scene = await self._get_active_scene()
if not scene:
return
@ -583,7 +702,16 @@ class ScenesManager:
class SceneRegistry:
"""
A class that represents a registry for scenes in a Telegram bot.
"""
def __init__(self, router: Router) -> None:
"""
Initialize a new instance of the SceneRegistry class.
:param router: The router instance used for scene registration.
"""
self.router = router
self._scenes: Dict[Optional[str], Type[Scene]] = {}
@ -635,6 +763,22 @@ class SceneRegistry:
return await handler(event, data)
def add(self, *scenes: Type[Scene], router: Optional[Router] = None) -> None:
"""
This method adds the specified scenes to the router.
If a router is not provided, it uses the default router stored
in the SceneRegistry instance.
The scenes are included in the router by calling the `as_router()`
method on each scene and passing the router as a parameter to this method.
If a scene with the same state already exists in the registry, a SceneException is raised.
:param scenes: A variable length parameter that accepts one or more types of scenes.
These scenes are instances of the Scene class.
:param router: An optional parameter that specifies the router
to which the scenes should be added. If not provided, the scenes will be
added to the default router stored in the SceneRegistry instance.
:return: None
"""
if router is None:
router = self.router
@ -649,6 +793,20 @@ class SceneRegistry:
router.include_router(scene.as_router())
def get(self, scene: Optional[Union[Type[Scene], str]]) -> Type[Scene]:
"""
This method returns the registered Scene object for the specified scene.
The scene parameter can be either a Scene object or a string representing
the name of the scene. If a Scene object is provided, the state attribute
of the SceneConfig object associated with the Scene object will be used as the scene name.
If None or an invalid type is provided, a SceneException will be raised.
If the specified scene is not registered in the SceneRegistry object,
a SceneException will be raised.
:param scene: A Scene object or a string representing the name of the scene.
:return: The registered Scene object corresponding to the given scene parameter.
"""
if inspect.isclass(scene) and issubclass(scene, Scene):
scene = scene.__scene_config__.state
if scene is not None and not isinstance(scene, str):
@ -708,7 +866,8 @@ class ObserverMarker:
class OnMarker:
"""
The `_On` class is used as a marker class to define different types of events in the Scenes.
The `OnMarker` class is used as a marker class to define different
types of events in the Scenes.
Attributes:

View file

@ -95,6 +95,7 @@ Read more
.. toctree::
storages
scene
.. _wiki: https://en.wikipedia.org/wiki/Finite-state_machine

View file

@ -0,0 +1,241 @@
=============
Scenes Wizard
=============
.. versionadded:: 3.2
.. warning::
This feature is experimental and may be changed in future versions.
**aiogram's** basics API is easy to use and powerful,
allowing the implementation of simple interactions such as triggering a command or message
for a response.
However, certain tasks require a dialogue between the user and the bot.
This is where Scenes come into play.
Understanding Scenes
====================
A Scene in **aiogram** is like an abstract, isolated namespace or room that a user can be
ushered into via the code. When a user is inside a Scene, all other global commands or
message handlers are isolated, and they stop responding to user actions.
Scenes provide a structure for more complex interactions,
effectively isolating and managing contexts for different stages of the conversation.
They allow you to control and manage the flow of the conversation in a more organized manner.
Scene Lifecycle
---------------
Each Scene can be "entered", "left" of "exited", allowing for clear transitions between different
stages of the conversation.
For instance, in a multi-step form filling interaction, each step could be a Scene -
the bot guides the user from one Scene to the next as they provide the required information.
Scene Listeners
---------------
Scenes have their own hooks which are command or message listeners that only act while
the user is within the Scene.
These hooks react to user actions while the user is 'inside' the Scene,
providing the responses or actions appropriate for that context.
When the user is ushered from one Scene to another, the actions and responses change
accordingly as the user is now interacting with the set of listeners inside the new Scene.
These 'Scene-specific' hooks or listeners, detached from the global listening context,
allow for more streamlined and organized bot-user interactions.
Scene Interactions
------------------
Each Scene is like a self-contained world, with interactions defined within the scope of that Scene.
As such, only the handlers defined within the specific Scene will react to user's input during
the lifecycle of that Scene.
Scene Benefits
--------------
Scenes can help manage more complex interaction workflows and enable more interactive and dynamic
dialogs between the user and the bot.
This offers great flexibility in handling multi-step interactions or conversations with the users.
How to use Scenes
=================
For example we have a quiz bot, which asks the user a series of questions and then displays the results.
Lets start with the data models, in this example simple data models are used to represent
the questions and answers, in real life you would probably use a database to store the data.
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:lines: 18-94
:caption: Questions list
Then, we need to create a Scene class that will represent the quiz game scene:
.. note::
Keyword argument passed into class definition describes the scene name - is the same as state of the scene.
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:pyobject: QuizScene
:emphasize-lines: 1
:lines: -7
:caption: Quiz Scene
Also we need to define a handler that helps to start the quiz game:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Start command handler
:lines: 251-253
Once the scene is defined, we need to register it in the SceneRegistry:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:pyobject: create_dispatcher
:caption: Registering the scene
So, now we can implement the quiz game logic, each question is sent to the user one by one,
and the user's answer is checked at the end of all questions.
Now we need to write an entry point for the question handler:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Question handler entry point
:pyobject: QuizScene.on_enter
Once scene is entered, we should expect the user's answer, so we need to write a handler for it,
this handler should expect the text message, save the answer and retake
the question handler for the next question:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Answer handler
:pyobject: QuizScene.answer
When user answer with unknown message, we should expect the text message again:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Unknown message handler
:pyobject: QuizScene.unknown_message
When all questions are answered, we should show the results to the user, as you can see in the code below,
we use `await self.wizard.exit()` to exit from the scene when questions list is over in the `QuizScene.on_enter` handler.
Thats means that we need to write an exit handler to show the results to the user:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Show results handler
:pyobject: QuizScene.on_exit
Also we can implement a actions to exit from the quiz game or go back to the previous question:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Exit handler
:pyobject: QuizScene.exit
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Back handler
:pyobject: QuizScene.back
Now we can run the bot and test the quiz game:
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Run the bot
:lines: 282-
Complete them all
.. literalinclude:: ../../../examples/quiz_scene.py
:language: python
:caption: Quiz Example
Components
==========
- :class:`aiogram.fsm.scene.Scene` - represents a scene, contains handlers
- :class:`aiogram.fsm.scene.SceneRegistry` - container for all scenes in the bot, used to register scenes and resolve them by name
- :class:`aiogram.fsm.scene.ScenesManager` - manages scenes for each user, used to enter, leave and resolve current scene for user
- :class:`aiogram.fsm.scene.SceneConfig` - scene configuration, used to configure scene
- :class:`aiogram.fsm.scene.SceneWizard` - scene wizard, used to interact with user in scene from active scene handler
- Markers - marker for scene handlers, used to mark scene handlers
.. autoclass:: aiogram.fsm.scene.Scene
:members:
.. autoclass:: aiogram.fsm.scene.SceneRegistry
:members:
.. autoclass:: aiogram.fsm.scene.ScenesManager
:members:
.. autoclass:: aiogram.fsm.scene.SceneConfig
:members:
.. autoclass:: aiogram.fsm.scene.SceneWizard
:members:
Markers
-------
Markers are similar to the Router event registering mechanism,
but they are used to mark scene handlers in the Scene class.
It can be imported from :code:`from aiogram.fsm.scene import on` and should be used as decorator.
Allowed event types:
- message
- edited_message
- channel_post
- edited_channel_post
- inline_query
- chosen_inline_result
- callback_query
- shipping_query
- pre_checkout_query
- poll
- poll_answer
- my_chat_member
- chat_member
- chat_join_request
Each event type can be filtered in the same way as in the Router.
Also each event type can be marked as scene entry point, exit point or leave point.
If you want to mark the scene can be entered from message or inline query,
you should use :code:`on.message` or :code:`on.inline_query` marker:
.. code-block:: python
class MyScene(Scene, name="my_scene"):
@on.message.enter()
async def on_enter(self, message: types.Message):
pass
@on.callback_query.enter()
async def on_enter(self, callback_query: types.CallbackQuery):
pass
Scene has only tree points for transitions:
- enter point - when user enters to the scene
- leave point - when user leaves the scene and the enter another scene
- exit point - when user exits from the scene

293
examples/quiz_scene.py Normal file
View file

@ -0,0 +1,293 @@
import asyncio
import logging
from dataclasses import dataclass, field
from os import getenv
from typing import Any
from aiogram import Router, html, F, Dispatcher, Bot
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
from aiogram.fsm.scene import Scene, on, SceneRegistry, ScenesManager
from aiogram.fsm.storage.memory import SimpleEventIsolation
from aiogram.types import Message, KeyboardButton, ReplyKeyboardRemove
from aiogram.utils.formatting import as_section, as_numbered_list, Bold, as_list, as_key_value
from aiogram.utils.keyboard import ReplyKeyboardBuilder
TOKEN = getenv("BOT_TOKEN")
@dataclass
class Answer:
"""
Represents an answer to a question.
"""
text: str
"""The answer text"""
is_correct: bool = False
"""Indicates if the answer is correct"""
@dataclass
class Question:
"""
Class representing a quiz with a question and a list of answers.
"""
text: str
"""The question text"""
answers: list[Answer]
"""List of answers"""
correct_answer: str = field(init=False)
def __post_init__(self):
self.correct_answer = next(answer.text for answer in self.answers if answer.is_correct)
# Fake data, in real application you should use a database or something else
QUESTIONS = [
Question(
text="What is the capital of France?",
answers=[
Answer("Paris", is_correct=True),
Answer("London"),
Answer("Berlin"),
Answer("Madrid"),
],
),
Question(
text="What is the capital of Spain?",
answers=[
Answer("Paris"),
Answer("London"),
Answer("Berlin"),
Answer("Madrid", is_correct=True),
],
),
Question(
text="What is the capital of Germany?",
answers=[
Answer("Paris"),
Answer("London"),
Answer("Berlin", is_correct=True),
Answer("Madrid"),
],
),
Question(
text="What is the capital of England?",
answers=[
Answer("Paris"),
Answer("London", is_correct=True),
Answer("Berlin"),
Answer("Madrid"),
],
),
Question(
text="What is the capital of Italy?",
answers=[
Answer("Paris"),
Answer("London"),
Answer("Berlin"),
Answer("Rome", is_correct=True),
],
),
]
class QuizScene(Scene, state="quiz"):
"""
This class represents a scene for a quiz game.
It inherits from Scene class and is associated with the state "quiz".
It handles the logic and flow of the quiz game.
"""
@on.message.enter()
async def on_enter(self, message: Message, state: FSMContext, step: int | None = 0) -> Any:
"""
Method triggered when the user enters the quiz scene.
It displays the current question and answer options to the user.
:param message:
:param state:
:param step: Scene argument, can be passed to the scene using the wizard
:return:
"""
if not step:
# This is the first step, so we should greet the user
await message.answer("Welcome to the quiz!")
try:
quiz = QUESTIONS[step]
except IndexError:
# This error means that the question's list is over
return await self.wizard.exit()
markup = ReplyKeyboardBuilder()
markup.add(*[KeyboardButton(text=answer.text) for answer in quiz.answers])
if step > 0:
markup.button(text="🔙 Back")
markup.button(text="🚫 Exit")
await state.update_data(step=step)
return await message.answer(
text=QUESTIONS[step].text,
reply_markup=markup.adjust(2).as_markup(resize_keyboard=True),
)
@on.message.exit()
async def on_exit(self, message: Message, state: FSMContext) -> None:
"""
Method triggered when the user exits the quiz scene.
It calculates the user's answers, displays the summary, and clears the stored answers.
:param message:
:param state:
:return:
"""
data = await state.get_data()
answers = data.get("answers", {})
correct = 0
incorrect = 0
user_answers = []
for step, quiz in enumerate(QUESTIONS):
answer = answers.get(step)
is_correct = answer == quiz.correct_answer
if is_correct:
correct += 1
else:
incorrect += 1
if answer is None:
answer = "no answer"
user_answers.append(f"{quiz.text} ({'' if is_correct else ''} {html.quote(answer)})")
content = as_list(
as_section(
Bold("Your answers:"),
as_numbered_list(*user_answers),
),
"",
as_section(
Bold("Summary:"),
as_list(
as_key_value("Correct", correct),
as_key_value("Incorrect", incorrect),
),
),
)
await message.answer(**content.as_kwargs(), reply_markup=ReplyKeyboardRemove())
await state.set_data({})
@on.message(F.text == "🔙 Back")
async def back(self, message: Message, state: FSMContext) -> None:
"""
Method triggered when the user selects the "Back" button.
It allows the user to go back to the previous question.
:param message:
:param state:
:return:
"""
data = await state.get_data()
step = data["step"]
previous_step = step - 1
if previous_step < 0:
# In case when the user tries to go back from the first question,
# we just exit the quiz
return await self.wizard.exit()
return await self.wizard.back(step=previous_step)
@on.message(F.text == "🚫 Exit")
async def exit(self, message: Message) -> None:
"""
Method triggered when the user selects the "Exit" button.
It exits the quiz.
:param message:
:return:
"""
await self.wizard.exit()
@on.message(F.text)
async def answer(self, message: Message, state: FSMContext) -> None:
"""
Method triggered when the user selects an answer.
It stores the answer and proceeds to the next question.
:param message:
:param state:
:return:
"""
data = await state.get_data()
step = data["step"]
answers = data.get("answers", {})
answers[step] = message.text
await state.update_data(answers=answers)
await self.wizard.retake(step=step + 1)
@on.message()
async def unknown_message(self, message: Message) -> None:
"""
Method triggered when the user sends a message that is not a command or an answer.
It asks the user to select an answer.
:param message: The message received from the user.
:return: None
"""
await message.answer("Please select an answer.")
quiz_router = Router(name=__name__)
# Add handler that initializes the scene
quiz_router.message.register(QuizScene.as_handler(), Command("quiz"))
@quiz_router.message(Command("start"))
async def command_start(message: Message, scenes: ScenesManager):
await scenes.close()
await message.answer(
"Hi! This is a quiz bot. To start the quiz, use the /quiz command.",
reply_markup=ReplyKeyboardRemove(),
)
def create_dispatcher():
# Event isolation is needed to correctly handle fast user responses
dispatcher = Dispatcher(
events_isolation=SimpleEventIsolation(),
)
dispatcher.include_router(quiz_router)
# To use scenes, you should create a SceneRegistry and register your scenes there
scene_registry = SceneRegistry(dispatcher)
# ... and then register a scene in the registry
# by default, Scene will be mounted to the router that passed to the SceneRegistry,
# but you can specify the router explicitly using the `router` argument
scene_registry.add(QuizScene, router=quiz_router)
return dispatcher
async def main():
dispatcher = create_dispatcher()
bot = Bot(TOKEN)
await dispatcher.start_polling(bot)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO)
asyncio.run(main())
# Alternatively, you can use aiogram-cli:
# `aiogram run polling quiz_scene:create_dispatcher --log-level info --token BOT_TOKEN`