aiogram/examples/quiz_scene.py
Andrew df7b16d5b3
Some checks failed
Tests / tests (macos-latest, 3.10) (push) Has been cancelled
Tests / tests (macos-latest, 3.11) (push) Has been cancelled
Tests / tests (macos-latest, 3.12) (push) Has been cancelled
Tests / tests (macos-latest, 3.13) (push) Has been cancelled
Tests / tests (ubuntu-latest, 3.10) (push) Has been cancelled
Tests / tests (ubuntu-latest, 3.11) (push) Has been cancelled
Tests / tests (ubuntu-latest, 3.12) (push) Has been cancelled
Tests / tests (ubuntu-latest, 3.13) (push) Has been cancelled
Tests / tests (windows-latest, 3.10) (push) Has been cancelled
Tests / tests (windows-latest, 3.11) (push) Has been cancelled
Tests / tests (windows-latest, 3.12) (push) Has been cancelled
Tests / tests (windows-latest, 3.13) (push) Has been cancelled
Tests / pypy-tests (macos-latest, pypy3.10) (push) Has been cancelled
Tests / pypy-tests (macos-latest, pypy3.11) (push) Has been cancelled
Tests / pypy-tests (ubuntu-latest, pypy3.10) (push) Has been cancelled
Tests / pypy-tests (ubuntu-latest, pypy3.11) (push) Has been cancelled
EOL of Py3.9 (#1726)
* Drop py3.9 and pypy3.9

Add pypy3.11 (testing) into `tests.yml`

Remove py3.9 from matrix in `tests.yml`

Refactor not auto-gen code to be compatible with py3.10+, droping ugly 3.9 annotation.

Replace some `from typing` imports to `from collections.abc`, due to deprecation

Add `from __future__ import annotations` and `if TYPE_CHECKING:` where possible

Add some `noqa` to calm down Ruff in some places, if Ruff will be used as default linting+formatting tool in future

Replace some relative imports to absolute

Sort `__all__` tuples in `__init__.py` and some other `.py` files

Sort `__slots__` tuples in classes

Split raises into `msg` and `raise` (`EM101`, `EM102`) to not duplicate error message in the traceback

Add `Self` from `typing_extenstion` where possible

Resolve typing problem in `aiogram/filters/command.py:18`

Concatenate nested `if` statements

Convert `HandlerContainer` into a dataclass in `aiogram/fsm/scene.py`

Bump tests docker-compose.yml `redis:6-alpine` -> `redis:8-alpine`

Bump tests docker-compose.yml `mongo:7.0.6` -> `mongo:8.0.14`

Bump pre-commit-config `black==24.4.2` -> `black==25.9.0`

Bump pre-commit-config `ruff==0.5.1` -> `ruff==0.13.3`

Update Makefile lint for ruff to show fixes

Add `make outdated` into Makefile

Use `pathlib` instead of `os.path`

Bump `redis[hiredis]>=5.0.1,<5.3.0` -> `redis[hiredis]>=6.2.0,<7`

Bump `cryptography>=43.0.0` -> `cryptography>=46.0.0` due to security reasons

Bump `pytz~=2023.3` -> `pytz~=2025.2`

Bump `pycryptodomex~=3.19.0` -> `pycryptodomex~=3.23.0` due to security reasons

Bump linting and formatting tools

* Add `1726.removal.rst`

* Update aiogram/utils/dataclass.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update aiogram/filters/callback_data.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update 1726.removal.rst

* Remove `outdated` from Makefile

* Add `__slots__` to `HandlerContainer`

* Remove unused imports

* Add `@dataclass` with `slots=True` to `HandlerContainer`

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2025-10-06 19:19:23 +03:00

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) -> None:
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() -> 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() -> None:
dp = create_dispatcher()
bot = Bot(token=TOKEN)
await dp.start_polling(bot)
if __name__ == "__main__":
# Alternatively, you can use aiogram-cli:
# `aiogram run polling quiz_scene:create_dispatcher --log-level info --token BOT_TOKEN`
logging.basicConfig(level=logging.INFO)
asyncio.run(main())