mirror of
https://github.com/aiogram/aiogram.git
synced 2025-12-14 02:52:12 +00:00
PoC Scenes (#1280)
* 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>
This commit is contained in:
parent
ce4e1a706d
commit
3d63bf3b99
14 changed files with 3234 additions and 23 deletions
301
examples/quiz_scene.py
Normal file
301
examples/quiz_scene.py
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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`
|
||||
203
examples/scene.py
Normal file
203
examples/scene.py
Normal file
|
|
@ -0,0 +1,203 @@
|
|||
from __future__ import annotations
|
||||
|
||||
from os import getenv
|
||||
from typing import TypedDict
|
||||
|
||||
from aiogram import Bot, Dispatcher, F, html
|
||||
from aiogram.filters import Command
|
||||
from aiogram.fsm.scene import After, Scene, SceneRegistry, on
|
||||
from aiogram.types import (
|
||||
CallbackQuery,
|
||||
InlineKeyboardButton,
|
||||
InlineKeyboardMarkup,
|
||||
KeyboardButton,
|
||||
Message,
|
||||
ReplyKeyboardMarkup,
|
||||
ReplyKeyboardRemove,
|
||||
)
|
||||
|
||||
BUTTON_CANCEL = KeyboardButton(text="❌ Cancel")
|
||||
BUTTON_BACK = KeyboardButton(text="🔙 Back")
|
||||
|
||||
|
||||
class FSMData(TypedDict, total=False):
|
||||
name: str
|
||||
language: str
|
||||
|
||||
|
||||
class CancellableScene(Scene):
|
||||
"""
|
||||
This scene is used to handle cancel and back buttons,
|
||||
can be used as a base class for other scenes that needs to support cancel and back buttons.
|
||||
"""
|
||||
|
||||
@on.message(F.text.casefold() == BUTTON_CANCEL.text.casefold(), after=After.exit())
|
||||
async def handle_cancel(self, message: Message):
|
||||
await message.answer("Cancelled.", reply_markup=ReplyKeyboardRemove())
|
||||
|
||||
@on.message(F.text.casefold() == BUTTON_BACK.text.casefold(), after=After.back())
|
||||
async def handle_back(self, message: Message):
|
||||
await message.answer("Back.")
|
||||
|
||||
|
||||
class LanguageScene(CancellableScene, state="language"):
|
||||
"""
|
||||
This scene is used to ask user what language he prefers.
|
||||
"""
|
||||
|
||||
@on.message.enter()
|
||||
async def on_enter(self, message: Message):
|
||||
await message.answer(
|
||||
"What language do you prefer?",
|
||||
reply_markup=ReplyKeyboardMarkup(
|
||||
keyboard=[[BUTTON_BACK, BUTTON_CANCEL]],
|
||||
resize_keyboard=True,
|
||||
),
|
||||
)
|
||||
|
||||
@on.message(F.text.casefold() == "python", after=After.exit())
|
||||
async def process_python(self, message: Message):
|
||||
await message.answer(
|
||||
"Python, you say? That's the language that makes my circuits light up! 😉"
|
||||
)
|
||||
await self.input_language(message)
|
||||
|
||||
@on.message(after=After.exit())
|
||||
async def input_language(self, message: Message):
|
||||
data: FSMData = await self.wizard.get_data()
|
||||
await self.show_results(message, language=message.text, **data)
|
||||
|
||||
async def show_results(self, message: Message, name: str, language: str) -> None:
|
||||
await message.answer(
|
||||
text=f"I'll keep in mind that, {html.quote(name)}, "
|
||||
f"you like to write bots with {html.quote(language)}.",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
|
||||
class LikeBotsScene(CancellableScene, state="like_bots"):
|
||||
"""
|
||||
This scene is used to ask user if he likes to write bots.
|
||||
"""
|
||||
|
||||
@on.message.enter()
|
||||
async def on_enter(self, message: Message):
|
||||
await message.answer(
|
||||
"Did you like to write bots?",
|
||||
reply_markup=ReplyKeyboardMarkup(
|
||||
keyboard=[
|
||||
[KeyboardButton(text="Yes"), KeyboardButton(text="No")],
|
||||
[BUTTON_BACK, BUTTON_CANCEL],
|
||||
],
|
||||
resize_keyboard=True,
|
||||
),
|
||||
)
|
||||
|
||||
@on.message(F.text.casefold() == "yes", after=After.goto(LanguageScene))
|
||||
async def process_like_write_bots(self, message: Message):
|
||||
await message.reply("Cool! I'm too!")
|
||||
|
||||
@on.message(F.text.casefold() == "no", after=After.exit())
|
||||
async def process_dont_like_write_bots(self, message: Message):
|
||||
await message.answer(
|
||||
"Not bad not terrible.\nSee you soon.",
|
||||
reply_markup=ReplyKeyboardRemove(),
|
||||
)
|
||||
|
||||
@on.message()
|
||||
async def input_like_bots(self, message: Message):
|
||||
await message.answer("I don't understand you :(")
|
||||
|
||||
|
||||
class NameScene(CancellableScene, state="name"):
|
||||
"""
|
||||
This scene is used to ask user's name.
|
||||
"""
|
||||
|
||||
@on.message.enter() # Marker for handler that should be called when a user enters the scene.
|
||||
async def on_enter(self, message: Message):
|
||||
await message.answer(
|
||||
"Hi there! What's your name?",
|
||||
reply_markup=ReplyKeyboardMarkup(keyboard=[[BUTTON_CANCEL]], resize_keyboard=True),
|
||||
)
|
||||
|
||||
@on.callback_query.enter() # different types of updates that start the scene also supported.
|
||||
async def on_enter_callback(self, callback_query: CallbackQuery):
|
||||
await callback_query.answer()
|
||||
await self.on_enter(callback_query.message)
|
||||
|
||||
@on.message.leave() # Marker for handler that should be called when a user leaves the scene.
|
||||
async def on_leave(self, message: Message):
|
||||
data: FSMData = await self.wizard.get_data()
|
||||
name = data.get("name", "Anonymous")
|
||||
await message.answer(f"Nice to meet you, {html.quote(name)}!")
|
||||
|
||||
@on.message(after=After.goto(LikeBotsScene))
|
||||
async def input_name(self, message: Message):
|
||||
await self.wizard.update_data(name=message.text)
|
||||
|
||||
|
||||
class DefaultScene(
|
||||
Scene,
|
||||
reset_data_on_enter=True, # Reset state data
|
||||
reset_history_on_enter=True, # Reset history
|
||||
callback_query_without_state=True, # Handle callback queries even if user in any scene
|
||||
):
|
||||
"""
|
||||
Default scene for the bot.
|
||||
|
||||
This scene is used to handle all messages that are not handled by other scenes.
|
||||
"""
|
||||
|
||||
start_demo = on.message(F.text.casefold() == "demo", after=After.goto(NameScene))
|
||||
|
||||
@on.message(Command("demo"))
|
||||
async def demo(self, message: Message):
|
||||
await message.answer(
|
||||
"Demo started",
|
||||
reply_markup=InlineKeyboardMarkup(
|
||||
inline_keyboard=[[InlineKeyboardButton(text="Go to form", callback_data="start")]]
|
||||
),
|
||||
)
|
||||
|
||||
@on.callback_query(F.data == "start", after=After.goto(NameScene))
|
||||
async def demo_callback(self, callback_query: CallbackQuery):
|
||||
await callback_query.answer(cache_time=0)
|
||||
await callback_query.message.delete_reply_markup()
|
||||
|
||||
@on.message.enter() # Mark that this handler should be called when a user enters the scene.
|
||||
@on.message()
|
||||
async def default_handler(self, message: Message):
|
||||
await message.answer(
|
||||
"Start demo?\nYou can also start demo via command /demo",
|
||||
reply_markup=ReplyKeyboardMarkup(
|
||||
keyboard=[[KeyboardButton(text="Demo")]],
|
||||
resize_keyboard=True,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def create_dispatcher() -> Dispatcher:
|
||||
dispatcher = Dispatcher()
|
||||
|
||||
# Scene registry should be the only one instance in your application for proper work.
|
||||
# It stores all available scenes.
|
||||
# You can use any router for scenes, not only `Dispatcher`.
|
||||
registry = SceneRegistry(dispatcher)
|
||||
# All scenes at register time converts to Routers and includes into specified router.
|
||||
registry.add(
|
||||
DefaultScene,
|
||||
NameScene,
|
||||
LikeBotsScene,
|
||||
LanguageScene,
|
||||
)
|
||||
|
||||
return dispatcher
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Recommended to use CLI instead of this snippet.
|
||||
# `aiogram run polling scene_example:create_dispatcher --token BOT_TOKEN --log-level info`
|
||||
dp = create_dispatcher()
|
||||
bot = Bot(token=getenv("TELEGRAM_TOKEN"))
|
||||
dp.run_polling()
|
||||
Loading…
Add table
Add a link
Reference in a new issue