mirror of
https://github.com/aiogram/aiogram.git
synced 2025-12-14 10:53:23 +00:00
* Base implementation
* Small refactoring + added possibility to specify post-action on handlers
* Move scene properties to config object
* Revise aiogram/scenes with wizard-based design pattern
Modified files in aiogram/scenes to incorporate the Wizard design pattern. Files affected are _marker.py, _registry.py, _wizard.py and __init__.py. The changes introduced a SceneWizard Class and ScenesManager, both of which aid in controlling navigation between different scenes or states. This helps clarifying the codebase, streamline scene transitions and offer more control over the app flow.
* Added example
* Small optimizations
* Replace ValueError with SceneException in scenes. Added error safety in scene resolver.
* str
* Added possibility to reset context on scene entered and to handle callback query in any state
* Remove inline markup in example
* Small changes
* Docs + example
* Small refactoring
* Remove scene inclusion methods from router
The methods for including scenes as sub-routers have been removed from the router.py file. Instead, the SceneRegistry class is now set to register scenes by default upon initializing. This streamlines the scene management process by removing redundant routers and making registration automatic.
* Init tests
* Small fix in tests
* Add support for State instance in the scene
The aiogram FSM scene now allows the use of State instance as an argument, enabling more customization. Modified the 'as_handler' method to receive **kwargs arguments, allowing passing of attributes to the handler. An additional type check has been also added to ensure the 'scene' is either a subclass of Scene or a string.
* Fixed test
* Expand test coverage for test_fsm module
The commit enhances tests for the test_fsm module to improve code reliability. It includes additional unit tests for the ObserverDecorator and ActionContainer classes and introduces new tests for the SceneHandlerWrapper class. This ensures the correct functionality of the decorator methods, the action container execution, and the handler wrapper.
* Reformat code
* Fixed long line in the example
* Skip some tests on PyPy
* Change mock return_value
* Compatibility...
* Compatibility...
* Compatibility...
* Added base changes description
* Scenes Tests (#1369)
* ADD tests for `SceneRegistry`
* ADD tests for `ScenesManager`
* ADD Changelog
* Revert "ADD Changelog"
This reverts commit 6dd9301252.
* Remove `@pytest.mark.asyncio`, Reformat code
* Scenes Tests. Part 2 (#1371)
* ADD tests for `SceneWizard`
* ADD tests for `Scene`
* Refactor ObserverDecorator to use on.message syntax in test_scene.py
Cover `Scene::__init_subclass__::if isinstance(value, ObserverDecorator):`
* Refactor `HistoryManager` in `aiogram/fsm/scene.py`
Removed condition that checked if 'history' is empty before calling 'update_data' in 'Scene'.
* ADD tests for `HistoryManager`
* Small changes in the documentation
* Small changes in the documentation
* Small changes in the documentation
---------
Co-authored-by: Andrew <11490628+andrew000@users.noreply.github.com>
301 lines
8.5 KiB
Python
301 lines
8.5 KiB
Python
import asyncio
|
|
import logging
|
|
from dataclasses import dataclass, field
|
|
from os import getenv
|
|
from typing import Any
|
|
|
|
from aiogram import Bot, Dispatcher, F, Router, html
|
|
from aiogram.filters import Command
|
|
from aiogram.fsm.context import FSMContext
|
|
from aiogram.fsm.scene import Scene, SceneRegistry, ScenesManager, on
|
|
from aiogram.fsm.storage.memory import SimpleEventIsolation
|
|
from aiogram.types import KeyboardButton, Message, ReplyKeyboardRemove
|
|
from aiogram.utils.formatting import (
|
|
Bold,
|
|
as_key_value,
|
|
as_list,
|
|
as_numbered_list,
|
|
as_section,
|
|
)
|
|
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
|
|
icon = "✅"
|
|
else:
|
|
incorrect += 1
|
|
icon = "❌"
|
|
if answer is None:
|
|
answer = "no answer"
|
|
user_answers.append(f"{quiz.text} ({icon} {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)
|
|
|
|
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`
|