aiogram/examples/quiz_scene.py

303 lines
8.5 KiB
Python
Raw Normal View History

2023-10-07 19:50:32 +03:00
import asyncio
import logging
from dataclasses import dataclass, field
from os import getenv
from typing import Any
2023-11-17 00:30:34 +02:00
from aiogram import Bot, Dispatcher, F, Router, html
2023-10-07 19:50:32 +03:00
from aiogram.filters import Command
from aiogram.fsm.context import FSMContext
2023-11-17 00:30:34 +02:00
from aiogram.fsm.scene import Scene, SceneRegistry, ScenesManager, on
2023-10-07 19:50:32 +03:00
from aiogram.fsm.storage.memory import SimpleEventIsolation
2023-11-17 00:30:34 +02:00
from aiogram.types import KeyboardButton, Message, ReplyKeyboardRemove
from aiogram.utils.formatting import (
Bold,
as_key_value,
as_list,
as_numbered_list,
as_section,
)
2023-10-07 19:50:32 +03:00
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
2023-11-17 00:42:06 +02:00
icon = ""
2023-10-07 19:50:32 +03:00
else:
incorrect += 1
2023-11-17 00:42:06 +02:00
icon = ""
2023-10-07 19:50:32 +03:00
if answer is None:
answer = "no answer"
2023-11-17 00:42:06 +02:00
user_answers.append(f"{quiz.text} ({icon} {html.quote(answer)})")
2023-10-07 19:50:32 +03:00
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"))
2023-10-13 00:00:36 +03:00
quiz_router.include_scene(QuizScene)
2023-10-07 19:50:32 +03:00
@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
2023-10-13 00:00:36 +03:00
scene_registry.add(QuizScene)
2023-10-07 19:50:32 +03:00
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`