mirror of
https://github.com/aiogram/aiogram.git
synced 2025-12-06 07:50:32 +00:00
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
* 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>
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) -> 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())
|