From f1ca0e5271924580ac14731e76b96991d2edff31 Mon Sep 17 00:00:00 2001 From: Alex Root Junior Date: Sat, 7 Oct 2023 19:50:32 +0300 Subject: [PATCH] Docs + example --- aiogram/fsm/scene.py | 161 +++++++++- .../dispatcher/finite_state_machine/index.rst | 1 + .../dispatcher/finite_state_machine/scene.rst | 241 ++++++++++++++ examples/quiz_scene.py | 293 ++++++++++++++++++ 4 files changed, 695 insertions(+), 1 deletion(-) create mode 100644 docs/dispatcher/finite_state_machine/scene.rst create mode 100644 examples/quiz_scene.py diff --git a/aiogram/fsm/scene.py b/aiogram/fsm/scene.py index 8c236db4..08ea5b2f 100644 --- a/aiogram/fsm/scene.py +++ b/aiogram/fsm/scene.py @@ -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: diff --git a/docs/dispatcher/finite_state_machine/index.rst b/docs/dispatcher/finite_state_machine/index.rst index afa62bff..d14f282f 100644 --- a/docs/dispatcher/finite_state_machine/index.rst +++ b/docs/dispatcher/finite_state_machine/index.rst @@ -95,6 +95,7 @@ Read more .. toctree:: storages + scene .. _wiki: https://en.wikipedia.org/wiki/Finite-state_machine diff --git a/docs/dispatcher/finite_state_machine/scene.rst b/docs/dispatcher/finite_state_machine/scene.rst new file mode 100644 index 00000000..dfde1c09 --- /dev/null +++ b/docs/dispatcher/finite_state_machine/scene.rst @@ -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 diff --git a/examples/quiz_scene.py b/examples/quiz_scene.py new file mode 100644 index 00000000..aafdbd02 --- /dev/null +++ b/examples/quiz_scene.py @@ -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`