Skip to content

Commit af03418

Browse files
authored
Merge pull request #28 from Aidenable/dev
v1.3.6: Conversations
2 parents 9b9d9dd + 6d75ce5 commit af03418

File tree

12 files changed

+378
-2
lines changed

12 files changed

+378
-2
lines changed

docs/source/conf.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
project = "Raito"
1515
copyright = "2025, Aiden"
1616
author = "Aiden"
17-
release = "1.3.4"
17+
release = "1.3.6"
1818

1919
# -- General configuration ---------------------------------------------------
2020
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
💬 Conversations
2+
================
3+
4+
Building multi-step dialogs in Telegram bots is often clunky.
5+
6+
Raito provides a lightweight way to wait for the **next user message** in a clean, linear style.
7+
8+
--------
9+
10+
Example
11+
-------
12+
13+
.. code-block:: python
14+
15+
from aiogram import F, Router, filters
16+
from aiogram.fsm.context import FSMContext
17+
from aiogram.types import Message
18+
19+
from raito import Raito
20+
21+
router = Router(name="mute")
22+
23+
24+
@router.message(filters.Command("mute"))
25+
async def mute(message: Message, raito: Raito, state: FSMContext) -> None:
26+
await message.answer("Enter username:")
27+
user = await raito.wait_for(state, F.text.regexp(r"@[\w]+"))
28+
29+
await message.answer("Enter duration (in minutes):")
30+
duration = await raito.wait_for(state, F.text.isdigit())
31+
32+
while not duration.number or duration.number < 0:
33+
await message.answer("⚠️ Duration cannot be negative")
34+
duration = await duration.retry()
35+
36+
await message.answer(f"{user.text} will be muted for {duration.number} minutes")
37+
38+
--------
39+
40+
How it works
41+
------------
42+
43+
- Each ``wait_for`` call registers a pending conversation in Raito’s internal registry
44+
- A conversation entry stores:
45+
46+
- A ``Future`` (to resume your handler later)
47+
- The filters to check incoming messages
48+
- When a message arrives:
49+
50+
- Raito checks for an active conversation bound to that ``FSMContext.key``
51+
- If filters match, the future is completed and returned to your handler

docs/source/plugins/index.rst

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,3 +12,4 @@ Plugins
1212
throttling
1313
pagination
1414
lifespan
15+
conversations
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import asyncio
2+
from pathlib import Path
3+
4+
from aiogram import Bot, Dispatcher
5+
6+
from raito import Raito
7+
8+
TOKEN = "TOKEN"
9+
HANDLERS_DIR = Path(__file__).parent / "handlers"
10+
DEBUG = False
11+
12+
bot = Bot(token=TOKEN)
13+
dispatcher = Dispatcher()
14+
raito = Raito(dispatcher, HANDLERS_DIR, developers=[], locales=["en"], production=not DEBUG)
15+
raito.init_logging()
16+
17+
18+
async def main() -> None:
19+
await raito.setup()
20+
await dispatcher.start_polling(bot)
21+
22+
23+
if __name__ == "__main__":
24+
asyncio.run(main())
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
from aiogram import F, Router, filters
2+
from aiogram.fsm.context import FSMContext
3+
from aiogram.types import Message
4+
5+
from raito import Raito
6+
from raito.plugins.roles import ADMINISTRATOR, DEVELOPER, MODERATOR
7+
8+
router = Router(name="mute")
9+
10+
11+
@router.message(filters.Command("mute"), DEVELOPER | ADMINISTRATOR | MODERATOR)
12+
async def mute(message: Message, raito: Raito, state: FSMContext) -> None:
13+
await message.answer("Enter username:")
14+
user = await raito.wait_for(state, F.text.regexp(r"@[\w]+"))
15+
16+
await message.answer("Enter duration (in minutes):")
17+
duration = await raito.wait_for(state, F.text.isdigit())
18+
19+
while not duration.number or duration.number < 0:
20+
await message.answer("⚠️ Duration cannot be negative")
21+
duration = await duration.retry()
22+
23+
await message.answer(f"✅ {user.text} will be muted for {duration.number} minutes")

pyproject.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
44

55
[project]
66
name = "raito"
7-
version = "1.3.5"
7+
version = "1.3.6"
88
description = "REPL, hot-reload, keyboards, pagination, and internal dev tools — all in one. That's Raito."
99
authors = [{ name = "Aiden", email = "aidenthedev@gmail.com" }]
1010
license = "MIT"

raito/core/raito.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,11 +6,19 @@
66
from typing import TYPE_CHECKING
77

88
from aiogram.dispatcher.event.event import EventObserver
9+
from aiogram.dispatcher.event.handler import CallbackType
10+
from aiogram.fsm.context import FSMContext
911
from aiogram.fsm.storage.memory import MemoryStorage
1012

1113
from raito.plugins.album.middleware import AlbumMiddleware
1214
from raito.plugins.commands.middleware import CommandMiddleware
1315
from raito.plugins.commands.registration import register_bot_commands
16+
from raito.plugins.conversations import (
17+
ConversationMiddleware,
18+
ConversationRegistry,
19+
Waiter,
20+
wait_for,
21+
)
1422
from raito.plugins.pagination import PaginationMode, PaginatorMiddleware, get_paginator
1523
from raito.plugins.roles import (
1624
BaseRoleProvider,
@@ -99,6 +107,7 @@ def __init__(
99107
)
100108

101109
self.command_parameters_error = EventObserver()
110+
self.registry = ConversationRegistry()
102111

103112
async def setup(self) -> None:
104113
"""Set up the Raito by loading routers and starting watchdog.
@@ -122,6 +131,7 @@ async def setup(self) -> None:
122131
self.dispatcher.callback_query.middleware(PaginatorMiddleware("raito__is_pagination"))
123132
self.dispatcher.message.middleware(CommandMiddleware())
124133
self.dispatcher.message.middleware(AlbumMiddleware())
134+
self.dispatcher.message.outer_middleware(ConversationMiddleware(self.registry))
125135

126136
await self.router_manager.load_routers(self.routers_dir)
127137
await self.router_manager.load_routers(ROOT_DIR / "handlers")
@@ -231,3 +241,18 @@ def init_logging(self, *mute_loggers: str) -> None:
231241
root_logger.handlers.clear()
232242
root_logger.addHandler(handler)
233243
root_logger.setLevel(logging.DEBUG if not self.production else logging.INFO)
244+
245+
async def wait_for(self, context: FSMContext, *filters: CallbackType) -> Waiter:
246+
"""Wait for the next message from user that matches given filters.
247+
248+
This function sets special state ``raito__conversation`` in FSM and
249+
suspends coroutine execution until user sends a message that passes
250+
all provided filters. Result is wrapped into :class:`Waiter`.
251+
252+
:param context: FSM context for current chat
253+
:param filters: Sequence of aiogram filters
254+
:return: Conversation result with text, parsed number and original message
255+
:raises RuntimeError: If handler object not found during filter execution
256+
:raises asyncio.CancelledError: If conversation was cancelled
257+
"""
258+
return await wait_for(self, context, *filters)
Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from .middleware import ConversationMiddleware
2+
from .registry import ConversationRegistry
3+
from .waiter import Waiter, wait_for
4+
5+
__all__ = (
6+
"ConversationMiddleware",
7+
"ConversationRegistry",
8+
"Waiter",
9+
"wait_for",
10+
)
Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
from __future__ import annotations
2+
3+
from collections.abc import Awaitable, Callable
4+
from typing import TYPE_CHECKING, Any, TypeVar
5+
6+
from aiogram.dispatcher.middlewares.base import BaseMiddleware
7+
from aiogram.fsm.context import FSMContext
8+
from aiogram.types import Message
9+
from typing_extensions import override
10+
11+
from raito.utils.helpers.filters import call_filters
12+
13+
from .registry import ConversationRegistry
14+
15+
if TYPE_CHECKING:
16+
from aiogram.types import TelegramObject
17+
18+
R = TypeVar("R")
19+
20+
21+
__all__ = ("ConversationMiddleware",)
22+
23+
24+
class ConversationMiddleware(BaseMiddleware):
25+
"""Middleware for conversation handling."""
26+
27+
def __init__(self, registry: ConversationRegistry) -> None:
28+
"""Initialize ConversationMiddleware.
29+
30+
:param registry: conversation registry
31+
"""
32+
self.registry = registry
33+
34+
@override
35+
async def __call__(
36+
self,
37+
handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[R]],
38+
event: TelegramObject,
39+
data: dict[str, Any],
40+
) -> R | None:
41+
"""Process message with conversation support.
42+
43+
:param handler: Next handler in the middleware chain
44+
:type handler: Callable
45+
:param event: Telegram event (Message)
46+
:type event: TelegramObject
47+
:param data: Additional data passed through the middleware chain
48+
:type data: dict[str, Any]
49+
:return: Handler result if not throttled, None if throttled
50+
"""
51+
if not isinstance(event, Message):
52+
return await handler(event, data)
53+
54+
context: FSMContext | None = data.get("state")
55+
if context is not None:
56+
state = await context.get_state()
57+
filters = self.registry.get_filters(context.key)
58+
59+
if state is None or filters is None:
60+
return await handler(event, data)
61+
62+
check = await call_filters(event, data, *filters)
63+
if not check:
64+
return await handler(event, data)
65+
66+
self.registry.resolve(context.key, event)
67+
68+
return await handler(event, data)
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
import asyncio
2+
from collections.abc import Sequence
3+
4+
from aiogram.dispatcher.event.handler import CallbackType
5+
from aiogram.fsm.storage.base import StorageKey
6+
from aiogram.types import Message
7+
from typing_extensions import NamedTuple
8+
9+
__all__ = ("ConversationRegistry",)
10+
11+
12+
class ConversationData(NamedTuple):
13+
"""Container for an active conversation.
14+
15+
Stores the Future object awaiting a message and the filters to apply.
16+
17+
:param future: asyncio.Future that will hold the incoming Message
18+
:param filters: Sequence of CallbackType filters to validate the message
19+
"""
20+
21+
future: asyncio.Future[Message]
22+
filters: Sequence[CallbackType]
23+
24+
25+
class ConversationRegistry:
26+
"""Registry for managing active conversations with users.
27+
28+
This class allows setting up a "wait for message" scenario where
29+
a handler can pause and wait for a specific message from a user,
30+
optionally filtered by aiogram filters.
31+
"""
32+
33+
STATE = "raito__conversation"
34+
35+
def __init__(self) -> None:
36+
"""Initialize the conversation registry."""
37+
self._conversations: dict[StorageKey, ConversationData] = {}
38+
39+
def listen(self, key: StorageKey, *filters: CallbackType) -> asyncio.Future[Message]:
40+
"""Start listening for a message with a specific StorageKey.
41+
42+
:param key: StorageKey identifying the conversation (user/chat/bot)
43+
:param filters: Optional filters to apply when the message arrives
44+
:return: Future that will resolve with the Message when received
45+
"""
46+
future = asyncio.get_running_loop().create_future()
47+
self._conversations[key] = ConversationData(future, filters)
48+
return future
49+
50+
def get_filters(self, key: StorageKey) -> Sequence[CallbackType] | None:
51+
"""Get the filters associated with an active conversation.
52+
53+
:param key: StorageKey identifying the conversation
54+
:return: Sequence of CallbackType filters or None if no conversation exists
55+
"""
56+
data = self._conversations.get(key)
57+
return data.filters if data else None
58+
59+
def resolve(self, key: StorageKey, message: Message) -> None:
60+
"""Complete the conversation with a received message.
61+
62+
:param key: StorageKey identifying the conversation
63+
:param message: Message object that satisfies the filters
64+
"""
65+
data = self._conversations.pop(key, None)
66+
if data and not data.future.done():
67+
data.future.set_result(message)
68+
69+
def cancel(self, key: StorageKey) -> None:
70+
"""Cancel an active conversation.
71+
72+
Cancels the Future and removes the conversation from the registry.
73+
74+
:param key: StorageKey identifying the conversation
75+
"""
76+
data = self._conversations.pop(key, None)
77+
if data and not data.future.done():
78+
data.future.cancel()

0 commit comments

Comments
 (0)