├── tests ├── .gitkeep └── test_example.py ├── raito ├── utils │ ├── __init__.py │ ├── helpers │ │ ├── __init__.py │ │ ├── truncate.py │ │ ├── safe_partial.py │ │ ├── filters.py │ │ ├── command_help.py │ │ ├── retry_method.py │ │ └── code_evaluator.py │ ├── filters │ │ ├── __init__.py │ │ └── command.py │ ├── types.py │ ├── const.py │ ├── ascii │ │ └── __init__.py │ ├── storages │ │ ├── __init__.py │ │ ├── sql │ │ │ ├── __init__.py │ │ │ ├── sqlite.py │ │ │ └── postgresql.py │ │ └── json.py │ ├── configuration.py │ ├── errors.py │ └── loggers.py ├── plugins │ ├── throttling │ │ ├── __init__.py │ │ ├── flag.py │ │ └── middleware.py │ ├── album │ │ ├── __init__.py │ │ └── middleware.py │ ├── keyboards │ │ ├── __init__.py │ │ └── dynamic.py │ ├── conversations │ │ ├── __init__.py │ │ ├── waiter.py │ │ ├── middleware.py │ │ └── registry.py │ ├── commands │ │ ├── __init__.py │ │ ├── flags.py │ │ └── middleware.py │ ├── roles │ │ ├── data.py │ │ ├── providers │ │ │ ├── json.py │ │ │ ├── memory.py │ │ │ ├── redis.py │ │ │ ├── __init__.py │ │ │ ├── protocol.py │ │ │ ├── sql │ │ │ │ ├── __init__.py │ │ │ │ ├── sqlite.py │ │ │ │ ├── postgresql.py │ │ │ │ └── sqlalchemy.py │ │ │ └── base.py │ │ ├── __init__.py │ │ ├── roles.py │ │ ├── constraint.py │ │ └── filter.py │ ├── pagination │ │ ├── paginators │ │ │ ├── __init__.py │ │ │ └── protocol.py │ │ ├── enums.py │ │ ├── __init__.py │ │ ├── data.py │ │ ├── decorator.py │ │ └── util.py │ └── lifespan │ │ └── decorator.py ├── core │ ├── __init__.py │ └── routers │ │ ├── __init__.py │ │ ├── base_router.py │ │ ├── parser.py │ │ └── loader.py ├── __init__.py ├── rt.py └── handlers │ ├── management │ ├── load.py │ ├── unload.py │ ├── reload.py │ └── list.py │ ├── roles │ ├── staff.py │ ├── revoke.py │ └── assign.py │ ├── system │ ├── bash.py │ ├── stats.py │ └── eval.py │ └── help.py ├── .python-version ├── docs ├── source │ ├── _static │ │ ├── .gitkeep │ │ └── help-command.png │ ├── _templates │ │ └── .gitkeep │ ├── utils │ │ ├── index.rst │ │ ├── truncate.rst │ │ ├── retry.rst │ │ ├── suppress_not_modified.rst │ │ └── logging.rst │ ├── plugins │ │ ├── index.rst │ │ ├── lifespan.rst │ │ ├── album.rst │ │ ├── throttling.rst │ │ ├── conversations.rst │ │ ├── hot_reload.rst │ │ ├── pagination.rst │ │ └── commands.rst │ ├── conf.py │ ├── installation.rst │ ├── quick_start.rst │ └── index.rst ├── requirements.txt ├── make.bat └── Makefile ├── .github ├── assets │ └── roles.png ├── workflows │ ├── deploy.yml │ ├── test-deploy.yml │ └── ci.yml └── FUNDING.yml ├── examples ├── 01-base-bot │ ├── handlers │ │ └── start.py │ └── __main__.py ├── 04-throttling │ ├── handlers │ │ ├── start.py │ │ └── export.py │ └── __main__.py ├── 11-webhook-bot │ ├── handlers │ │ ├── start.py │ │ └── events │ │ │ └── lifespan.py │ └── __main__.py ├── 06-keyboards │ ├── handlers │ │ ├── throw.py │ │ ├── start.py │ │ ├── events │ │ │ └── lifespan.py │ │ ├── faq.py │ │ └── leaderboard.py │ └── __main__.py ├── 05-roles │ ├── handlers │ │ ├── start.py │ │ ├── moderation │ │ │ ├── ban.py │ │ │ └── unban.py │ │ ├── events │ │ │ └── lifespan.py │ │ └── dev │ │ │ └── eval.py │ └── __main__.py ├── 03-commands │ ├── handlers │ │ ├── start.py │ │ ├── random.py │ │ ├── ping.py │ │ └── events │ │ │ └── lifespan.py │ └── __main__.py ├── 08-custom-roles │ ├── roles │ │ ├── filters.py │ │ └── manager.py │ ├── handlers │ │ ├── admin.py │ │ └── start.py │ └── __main__.py ├── 02-lifespan │ ├── __main__.py │ └── handlers │ │ └── lifespan.py ├── 12-conversations │ ├── __main__.py │ └── handlers │ │ └── mute.py ├── 10-album │ ├── __main__.py │ └── handlers │ │ └── start.py ├── 09-pagination │ ├── handlers │ │ ├── start.py │ │ ├── remove.py │ │ ├── add.py │ │ └── emoji_list.py │ └── __main__.py └── 07-storages │ ├── __main__.py │ └── handlers │ └── start.py ├── .readthedocs.yaml ├── SECURITY.md ├── .pre-commit-config.yaml ├── LICENSE ├── pyproject.toml └── .gitignore /tests/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /raito/utils/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /.python-version: -------------------------------------------------------------------------------- 1 | 3.12 2 | -------------------------------------------------------------------------------- /docs/source/_static/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/source/_templates/.gitkeep: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /raito/utils/helpers/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /raito/plugins/throttling/__init__.py: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /docs/requirements.txt: -------------------------------------------------------------------------------- 1 | furo 2 | sphinx 3 | sphinx-autodoc-typehints 4 | -------------------------------------------------------------------------------- /raito/core/__init__.py: -------------------------------------------------------------------------------- 1 | from .raito import Raito 2 | 3 | __all__ = ("Raito",) 4 | -------------------------------------------------------------------------------- /.github/assets/roles.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidenable/Raito/HEAD/.github/assets/roles.png -------------------------------------------------------------------------------- /raito/utils/filters/__init__.py: -------------------------------------------------------------------------------- 1 | from .command import RaitoCommand 2 | 3 | __all__ = ("RaitoCommand",) 4 | -------------------------------------------------------------------------------- /raito/__init__.py: -------------------------------------------------------------------------------- 1 | from raito import rt 2 | 3 | from .core.raito import Raito 4 | 5 | __all__ = ("Raito", "rt") 6 | -------------------------------------------------------------------------------- /raito/plugins/album/__init__.py: -------------------------------------------------------------------------------- 1 | from .middleware import AlbumMiddleware 2 | 3 | __all__ = ("AlbumMiddleware",) 4 | -------------------------------------------------------------------------------- /raito/utils/types.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | __all__ = ("StrOrPath",) 4 | 5 | StrOrPath = str | Path 6 | -------------------------------------------------------------------------------- /docs/source/_static/help-command.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Aidenable/Raito/HEAD/docs/source/_static/help-command.png -------------------------------------------------------------------------------- /raito/utils/const.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | __all__ = ("ROOT_DIR",) 4 | 5 | ROOT_DIR = Path(__file__).parent.parent 6 | -------------------------------------------------------------------------------- /tests/test_example.py: -------------------------------------------------------------------------------- 1 | def test_example() -> None: 2 | """Placeholder test to ensure pytest runs successfully.""" 3 | assert True 4 | -------------------------------------------------------------------------------- /docs/source/utils/index.rst: -------------------------------------------------------------------------------- 1 | Utils 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | suppress_not_modified 8 | logging 9 | retry 10 | truncate 11 | -------------------------------------------------------------------------------- /raito/plugins/keyboards/__init__.py: -------------------------------------------------------------------------------- 1 | from .dynamic import dynamic_keyboard as dynamic 2 | from .static import static_keyboard as static 3 | 4 | __all__ = ( 5 | "dynamic", 6 | "static", 7 | ) 8 | -------------------------------------------------------------------------------- /raito/utils/ascii/__init__.py: -------------------------------------------------------------------------------- 1 | from .tree import AsciiTree, TreeChars, TreeNode, dot_paths_to_tree 2 | 3 | __all__ = ( 4 | "AsciiTree", 5 | "TreeChars", 6 | "TreeNode", 7 | "dot_paths_to_tree", 8 | ) 9 | -------------------------------------------------------------------------------- /raito/utils/helpers/truncate.py: -------------------------------------------------------------------------------- 1 | def truncate(text: str, max_length: int, ellipsis: str = "...") -> str: 2 | if len(text) <= max_length: 3 | return text 4 | return text[: max_length - len(ellipsis)] + ellipsis 5 | -------------------------------------------------------------------------------- /docs/source/plugins/index.rst: -------------------------------------------------------------------------------- 1 | Plugins 2 | ===== 3 | 4 | .. toctree:: 5 | :maxdepth: 2 6 | 7 | hot_reload 8 | keyboards 9 | commands 10 | roles 11 | album 12 | throttling 13 | pagination 14 | lifespan 15 | conversations 16 | -------------------------------------------------------------------------------- /docs/source/utils/truncate.rst: -------------------------------------------------------------------------------- 1 | ✂️ truncate 2 | ============================= 3 | 4 | Truncates a string to a given length. 5 | 6 | .. code-block:: python 7 | 8 | from raito.utils.helpers.truncate import truncate 9 | 10 | truncate("Hello, world!", 8) # Returns "Hello..." 11 | -------------------------------------------------------------------------------- /raito/utils/storages/__init__.py: -------------------------------------------------------------------------------- 1 | from .json import JSONStorage 2 | from .sql import get_postgresql_storage, get_redis_storage, get_sqlite_storage 3 | 4 | __all__ = ( 5 | "JSONStorage", 6 | "get_postgresql_storage", 7 | "get_redis_storage", 8 | "get_sqlite_storage", 9 | ) 10 | -------------------------------------------------------------------------------- /examples/01-base-bot/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | 4 | router = Router(name="start") 5 | 6 | 7 | @router.message(filters.CommandStart()) 8 | async def start(message: Message) -> None: 9 | await message.answer("Hello!") 10 | -------------------------------------------------------------------------------- /examples/04-throttling/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | 4 | router = Router(name="start") 5 | 6 | 7 | @router.message(filters.CommandStart()) 8 | async def start(message: Message) -> None: 9 | await message.answer("Hello!") 10 | -------------------------------------------------------------------------------- /examples/11-webhook-bot/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | 4 | router = Router(name="start") 5 | 6 | 7 | @router.message(filters.CommandStart()) 8 | async def start(message: Message) -> None: 9 | await message.answer("Hello!") 10 | -------------------------------------------------------------------------------- /examples/06-keyboards/handlers/throw.py: -------------------------------------------------------------------------------- 1 | from aiogram import F, Router 2 | from aiogram.types import Message 3 | 4 | router = Router(name="throw") 5 | 6 | 7 | @router.message(F.text == "🏀 Throw a ball") 8 | async def throw(message: Message) -> None: 9 | await message.answer_dice(emoji="🏀") 10 | -------------------------------------------------------------------------------- /raito/core/routers/__init__.py: -------------------------------------------------------------------------------- 1 | from .base_router import BaseRouter 2 | from .loader import RouterLoader 3 | from .manager import RouterManager 4 | from .parser import RouterParser 5 | 6 | __all__ = ( 7 | "BaseRouter", 8 | "RouterLoader", 9 | "RouterManager", 10 | "RouterParser", 11 | ) 12 | -------------------------------------------------------------------------------- /raito/plugins/conversations/__init__.py: -------------------------------------------------------------------------------- 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 | ) 11 | -------------------------------------------------------------------------------- /raito/plugins/commands/__init__.py: -------------------------------------------------------------------------------- 1 | from .flags import description, hidden, params 2 | from .middleware import CommandMiddleware 3 | from .registration import register_bot_commands 4 | 5 | __all__ = ( 6 | "CommandMiddleware", 7 | "description", 8 | "hidden", 9 | "params", 10 | "register_bot_commands", 11 | ) 12 | -------------------------------------------------------------------------------- /raito/plugins/roles/data.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | 3 | __all__ = ("RoleData",) 4 | 5 | 6 | @dataclass 7 | class RoleData: 8 | slug: str 9 | name: str 10 | description: str 11 | emoji: str 12 | 13 | @property 14 | def label(self) -> str: 15 | return f"{self.emoji} {self.name}" 16 | -------------------------------------------------------------------------------- /examples/05-roles/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | 4 | from raito import rt 5 | 6 | router = Router(name="start") 7 | 8 | 9 | @router.message(filters.CommandStart()) 10 | @rt.description("Start command") 11 | async def start(message: Message) -> None: 12 | await message.answer("Hello!") 13 | -------------------------------------------------------------------------------- /examples/03-commands/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | 4 | from raito import rt 5 | 6 | router = Router(name="start") 7 | 8 | 9 | @router.message(filters.CommandStart()) 10 | @rt.description("Start command") 11 | async def start(message: Message) -> None: 12 | await message.answer("Hello!") 13 | -------------------------------------------------------------------------------- /docs/source/utils/retry.rst: -------------------------------------------------------------------------------- 1 | 🔁 retry 2 | ============================= 3 | 4 | Retries a coroutine if Telegram raises ``RetryAfter``. 5 | 6 | .. code-block:: python 7 | 8 | from raito import rt 9 | 10 | await rt.retry(bot.send_message(...)) 11 | 12 | --------- 13 | 14 | Arguments: 15 | 16 | - ``max_attempts`` – total number of tries (default: 5) 17 | - ``additional_delay`` – extra seconds to wait beyond ``retry_after`` 18 | -------------------------------------------------------------------------------- /raito/plugins/pagination/paginators/__init__.py: -------------------------------------------------------------------------------- 1 | from .base import BasePaginator 2 | from .inline import InlinePaginator 3 | from .list import ListPaginator 4 | from .photo import PhotoPaginator 5 | from .protocol import IPaginator 6 | from .text import TextPaginator 7 | 8 | __all__ = ( 9 | "BasePaginator", 10 | "IPaginator", 11 | "InlinePaginator", 12 | "ListPaginator", 13 | "PhotoPaginator", 14 | "TextPaginator", 15 | ) 16 | -------------------------------------------------------------------------------- /raito/plugins/pagination/enums.py: -------------------------------------------------------------------------------- 1 | from enum import IntEnum, unique 2 | 3 | __all__ = ("PaginationMode",) 4 | 5 | 6 | @unique 7 | class PaginationMode(IntEnum): 8 | """Pagination display modes. 9 | 10 | :cvar INLINE: inline keyboard pagination 11 | :cvar TEXT: text-based pagination 12 | :cvar PHOTO: photo pagination 13 | :cvar LIST: list pagination 14 | """ 15 | 16 | INLINE = 0 17 | TEXT = 1 18 | PHOTO = 2 19 | LIST = 3 20 | -------------------------------------------------------------------------------- /docs/source/utils/suppress_not_modified.rst: -------------------------------------------------------------------------------- 1 | ✏️ SuppressNotModifiedError 2 | ============================= 3 | 4 | Editing messages with the same content raises ``TelegramBadRequest``. 5 | 6 | Use this context manager to silently suppress ``"message is not modified"`` errors: 7 | 8 | .. code-block:: python 9 | 10 | from raito.utils.errors import SuppressNotModifiedError 11 | 12 | with SuppressNotModifiedError(): 13 | await message.edit_text("same text") 14 | -------------------------------------------------------------------------------- /examples/08-custom-roles/roles/filters.py: -------------------------------------------------------------------------------- 1 | from raito.plugins.roles.constraint import RoleConstraint 2 | from raito.plugins.roles.filter import RoleFilter 3 | 4 | DUROV = RoleConstraint( 5 | RoleFilter( 6 | slug="durov", 7 | name="Pavel Durov", 8 | description="Exclusive role for @monk", 9 | emoji="🔒", 10 | ) 11 | ) 12 | 13 | EXTENDED_ROLES = [i.filter.data for i in [DUROV]] 14 | EXTENDED_ROLES_BY_SLUG = {role.slug: role for role in EXTENDED_ROLES} 15 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/json.py: -------------------------------------------------------------------------------- 1 | from raito.utils.storages.json import JSONStorage 2 | 3 | from .base import BaseRoleProvider 4 | from .protocol import IRoleProvider 5 | 6 | __all__ = ("JSONRoleProvider",) 7 | 8 | 9 | class JSONRoleProvider(BaseRoleProvider, IRoleProvider): 10 | """JSON-based role provider for testing and development.""" 11 | 12 | def __init__(self, storage: JSONStorage) -> None: 13 | """Initialize JSONRoleProvider.""" 14 | super().__init__(storage=storage) 15 | -------------------------------------------------------------------------------- /examples/08-custom-roles/handlers/admin.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | from roles.filters import DUROV 4 | 5 | from raito import rt 6 | from raito.plugins.roles import ADMINISTRATOR, DEVELOPER, OWNER 7 | 8 | router = Router(name="admin") 9 | 10 | 11 | @router.message(filters.Command("admin"), DEVELOPER | OWNER | ADMINISTRATOR | DUROV) 12 | @rt.description("Admin menu") 13 | async def admin(message: Message) -> None: 14 | await message.answer("⚙️ Admin-Menu") 15 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/memory.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.storage.memory import MemoryStorage 2 | 3 | from .base import BaseRoleProvider 4 | from .protocol import IRoleProvider 5 | 6 | __all__ = ("MemoryRoleProvider",) 7 | 8 | 9 | class MemoryRoleProvider(BaseRoleProvider, IRoleProvider): 10 | """Simple in-memory role provider for testing and development.""" 11 | 12 | def __init__(self, storage: MemoryStorage) -> None: 13 | """Initialize MemoryRoleProvider.""" 14 | super().__init__(storage=storage) 15 | -------------------------------------------------------------------------------- /examples/04-throttling/handlers/export.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | 3 | from aiogram import Router, filters 4 | from aiogram.types import Message 5 | 6 | from raito import rt 7 | 8 | router = Router(name="start") 9 | 10 | 11 | @router.message(filters.Command("export")) 12 | @rt.description("Export something") 13 | @rt.limiter(5, mode="user") 14 | async def export(message: Message): 15 | msg = await message.answer("📦 Exporting your data, please wait...") 16 | await sleep(5) 17 | await msg.edit_text("❇️ Done! https://example.com/data.csv") 18 | -------------------------------------------------------------------------------- /examples/03-commands/handlers/random.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from aiogram import Router, filters 4 | from aiogram.types import Message 5 | 6 | from raito import rt 7 | 8 | router = Router(name="random") 9 | 10 | 11 | @router.message(filters.Command("random")) 12 | @rt.description("Generate a number between two given values") 13 | @rt.params(num1=int, num2=int) 14 | async def random(message: Message, num1: int, num2: int) -> None: 15 | low, high = sorted((num1, num2)) 16 | result = randint(low, high) 17 | 18 | await message.answer(f"🎲 {result}") 19 | -------------------------------------------------------------------------------- /examples/03-commands/handlers/ping.py: -------------------------------------------------------------------------------- 1 | from time import perf_counter 2 | 3 | from aiogram import Router, filters 4 | from aiogram.types import Message 5 | 6 | from raito import rt 7 | 8 | router = Router(name="ping") 9 | 10 | 11 | @router.message(filters.Command("ping")) 12 | @rt.description("Check bot responsiveness") 13 | @rt.hidden 14 | async def ping(message: Message) -> None: 15 | started_at = perf_counter() 16 | msg = await message.answer("🔍 Measuring ping...") 17 | elapsed_ms = (perf_counter() - started_at) * 1000 18 | 19 | await msg.edit_text(f"🏓 Pong! {elapsed_ms:.1f}ms") 20 | -------------------------------------------------------------------------------- /examples/01-base-bot/__main__.py: -------------------------------------------------------------------------------- 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()) 25 | -------------------------------------------------------------------------------- /examples/02-lifespan/__main__.py: -------------------------------------------------------------------------------- 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("aiogram.dispatcher") 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()) 25 | -------------------------------------------------------------------------------- /examples/05-roles/handlers/moderation/ban.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Router, filters 2 | from aiogram.types import Message 3 | 4 | from raito import rt 5 | from raito.plugins.roles import ADMINISTRATOR, DEVELOPER, MODERATOR, OWNER 6 | 7 | router = Router(name="ban") 8 | 9 | 10 | @router.message(filters.Command("ban"), DEVELOPER | OWNER | ADMINISTRATOR | MODERATOR) 11 | @rt.description("Ban a chat member by ID") 12 | @rt.params(user_id=int) 13 | async def ban(message: Message, user_id: int, bot: Bot) -> None: 14 | await bot.ban_chat_member(chat_id=message.chat.id, user_id=user_id) 15 | await message.answer("🛑 User has been banned successfully.") 16 | -------------------------------------------------------------------------------- /examples/06-keyboards/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters 2 | from aiogram.types import Message 3 | 4 | from raito import rt 5 | 6 | router = Router(name="start") 7 | 8 | 9 | @rt.keyboard.static(inline=False) 10 | def start_markup() -> list: 11 | return [ 12 | ["🏀 Throw a ball"], 13 | [["📄 FAQ"], ["🏆 Leaderboard"]], 14 | ] 15 | 16 | 17 | @router.message(filters.CommandStart()) 18 | @rt.description("Show the main menu") 19 | async def start(message: Message) -> None: 20 | await message.answer( 21 | "Welcome to the 🏀 Basketball Championship!", 22 | reply_markup=start_markup(), 23 | ) 24 | -------------------------------------------------------------------------------- /examples/05-roles/handlers/moderation/unban.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, Router, filters 2 | from aiogram.types import Message 3 | 4 | from raito import rt 5 | from raito.plugins.roles import ADMINISTRATOR, DEVELOPER, MODERATOR, OWNER 6 | 7 | router = Router(name="unban") 8 | 9 | 10 | @router.message(filters.Command("unban"), DEVELOPER | OWNER | ADMINISTRATOR | MODERATOR) 11 | @rt.description("Unban a chat member by ID") 12 | @rt.params(user_id=int) 13 | async def unban(message: Message, user_id: int, bot: Bot) -> None: 14 | await bot.unban_chat_member(chat_id=message.chat.id, user_id=user_id) 15 | await message.answer("❇️ User has been unbanned successfully.") 16 | -------------------------------------------------------------------------------- /.readthedocs.yaml: -------------------------------------------------------------------------------- 1 | # Read the Docs configuration file 2 | # See https://docs.readthedocs.io/en/stable/config-file/v2.html for details 3 | 4 | # Required 5 | version: 2 6 | 7 | # Set the OS, Python version, and other tools you might need 8 | build: 9 | os: ubuntu-24.04 10 | tools: 11 | python: "3.12" 12 | 13 | # Build documentation in the "docs/" directory with Sphinx 14 | sphinx: 15 | configuration: docs/source/conf.py 16 | # Optionally, but recommended, 17 | # declare the Python requirements required to build your documentation 18 | # See https://docs.readthedocs.io/en/stable/guides/reproducible-builds.html 19 | python: 20 | install: 21 | - requirements: docs/requirements.txt 22 | -------------------------------------------------------------------------------- /raito/plugins/pagination/__init__.py: -------------------------------------------------------------------------------- 1 | from .data import PaginationCallbackData 2 | from .decorator import on_pagination 3 | from .enums import PaginationMode 4 | from .middleware import PaginatorMiddleware 5 | from .paginators import ( 6 | BasePaginator, 7 | InlinePaginator, 8 | ListPaginator, 9 | PhotoPaginator, 10 | TextPaginator, 11 | ) 12 | from .util import get_paginator 13 | 14 | __all__ = ( 15 | "BasePaginator", 16 | "InlinePaginator", 17 | "ListPaginator", 18 | "PaginationCallbackData", 19 | "PaginationMode", 20 | "PaginatorMiddleware", 21 | "PhotoPaginator", 22 | "TextPaginator", 23 | "get_paginator", 24 | "on_pagination", 25 | ) 26 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/redis.py: -------------------------------------------------------------------------------- 1 | from aiogram.fsm.storage.redis import RedisStorage 2 | 3 | from .base import BaseRoleProvider 4 | from .protocol import IRoleProvider 5 | 6 | __all__ = ("RedisRoleProvider",) 7 | 8 | 9 | class RedisRoleProvider(BaseRoleProvider, IRoleProvider): 10 | """Redis-based role provider. 11 | 12 | Redis storage required :code:`redis` package installed (:code:`pip install raito[redis]`) 13 | """ 14 | 15 | def __init__(self, storage: RedisStorage) -> None: 16 | """Initialize RedisRoleProvider.""" 17 | super().__init__(storage=storage) 18 | 19 | self.storage: RedisStorage 20 | self.storage.key_builder = self.key_builder 21 | -------------------------------------------------------------------------------- /examples/02-lifespan/handlers/lifespan.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from aiogram import Bot, Dispatcher, Router 4 | 5 | from raito import Raito, rt 6 | 7 | router = Router(name="lifespan") 8 | 9 | 10 | @rt.lifespan(router) 11 | async def lifespan(raito: Raito, bot: Bot, dispatcher: Dispatcher) -> AsyncGenerator: 12 | bot_info = await bot.get_me() 13 | rt.log.info("🚀 Launching bot : [@%s] %s", bot_info.username, bot_info.full_name) 14 | 15 | rt.debug("Registering commands...") 16 | await raito.register_commands(bot) 17 | 18 | yield 19 | 20 | rt.log.debug("Closing dispatcher storage...") 21 | await dispatcher.storage.close() 22 | 23 | rt.log.info("👋 Bye!") 24 | -------------------------------------------------------------------------------- /examples/03-commands/handlers/events/lifespan.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from aiogram import Bot, Dispatcher, Router 4 | 5 | from raito import Raito, rt 6 | 7 | router = Router(name="lifespan") 8 | 9 | 10 | @rt.lifespan(router) 11 | async def lifespan(raito: Raito, bot: Bot, dispatcher: Dispatcher) -> AsyncGenerator: 12 | bot_info = await bot.get_me() 13 | rt.log.info("🚀 Launching bot : [@%s] %s", bot_info.username, bot_info.full_name) 14 | 15 | rt.debug("Registering commands...") 16 | await raito.register_commands(bot) 17 | 18 | yield 19 | 20 | rt.log.debug("Closing dispatcher storage...") 21 | await dispatcher.storage.close() 22 | 23 | rt.log.info("👋 Bye!") 24 | -------------------------------------------------------------------------------- /examples/05-roles/handlers/events/lifespan.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from aiogram import Bot, Dispatcher, Router 4 | 5 | from raito import Raito, rt 6 | 7 | router = Router(name="lifespan") 8 | 9 | 10 | @rt.lifespan(router) 11 | async def lifespan(raito: Raito, bot: Bot, dispatcher: Dispatcher) -> AsyncGenerator: 12 | bot_info = await bot.get_me() 13 | rt.log.info("🚀 Launching bot : [@%s] %s", bot_info.username, bot_info.full_name) 14 | 15 | rt.debug("Registering commands...") 16 | await raito.register_commands(bot) 17 | 18 | yield 19 | 20 | rt.log.debug("Closing dispatcher storage...") 21 | await dispatcher.storage.close() 22 | 23 | rt.log.info("👋 Bye!") 24 | -------------------------------------------------------------------------------- /examples/06-keyboards/handlers/events/lifespan.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from aiogram import Bot, Dispatcher, Router 4 | 5 | from raito import Raito, rt 6 | 7 | router = Router(name="lifespan") 8 | 9 | 10 | @rt.lifespan(router) 11 | async def lifespan(raito: Raito, bot: Bot, dispatcher: Dispatcher) -> AsyncGenerator: 12 | bot_info = await bot.get_me() 13 | rt.log.info("🚀 Launching bot : [@%s] %s", bot_info.username, bot_info.full_name) 14 | 15 | rt.debug("Registering commands...") 16 | await raito.register_commands(bot) 17 | 18 | yield 19 | 20 | rt.log.debug("Closing dispatcher storage...") 21 | await dispatcher.storage.close() 22 | 23 | rt.log.info("👋 Bye!") 24 | -------------------------------------------------------------------------------- /examples/06-keyboards/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | 13 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 14 | dispatcher = Dispatcher() 15 | raito = Raito(dispatcher, HANDLERS_DIR, developers=[], locales=["en"], production=not DEBUG) 16 | raito.init_logging() 17 | 18 | 19 | async def main() -> None: 20 | await raito.setup() 21 | await dispatcher.start_polling(bot) 22 | 23 | 24 | if __name__ == "__main__": 25 | asyncio.run(main()) 26 | -------------------------------------------------------------------------------- /examples/12-conversations/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.fsm.storage.memory import DisabledEventIsolation 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | 13 | bot = Bot(token=TOKEN) 14 | dispatcher = Dispatcher(events_isolation=DisabledEventIsolation()) 15 | raito = Raito(dispatcher, HANDLERS_DIR, developers=[], locales=["en"], production=not DEBUG) 16 | raito.init_logging() 17 | 18 | 19 | async def main() -> None: 20 | await raito.setup() 21 | await dispatcher.start_polling(bot) 22 | 23 | 24 | if __name__ == "__main__": 25 | asyncio.run(main()) 26 | -------------------------------------------------------------------------------- /docs/source/utils/logging.rst: -------------------------------------------------------------------------------- 1 | 📝 ColoredFormatter 2 | ============================= 3 | 4 | Raito uses an adaptive logging for better terminal output. 5 | 6 | Features: 7 | 8 | - Colored tags based on log level 9 | - Timestamps depending on terminal width 10 | - Optional mute filter: ``MuteLoggersFilter`` 11 | 12 | To enable logging: 13 | 14 | .. code-block:: python 15 | 16 | raito.init_logging("aiogram.event") 17 | 18 | This replaces the root logger with color formatting and optional muted loggers. 19 | 20 | Usage 21 | ~~~~~ 22 | 23 | .. code-block:: python 24 | 25 | from raito import rt 26 | 27 | rt.debug("Debug") 28 | 29 | rt.log.info("Hello, %s", "John") 30 | rt.log.warn("Warning") 31 | rt.log.error("Error") 32 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | ## Reporting a Vulnerability 4 | 5 | If you find a potential security issue in Raito, please do **not** open a public issue. 6 | 7 | Instead, report it privately by opening a GitHub issue and checking the `This is a security vulnerability` box, 8 | or by contacting the maintainer directly via GitHub. 9 | 10 | ## Bug Bounties 11 | 12 | We appreciate all responsible reports of security problems, but Raito is a personal open source project — 13 | there is no bug bounty program at this time. 14 | 15 | ## Vulnerability Disclosures 16 | 17 | Critical security issues will be disclosed through GitHub’s 18 | [security advisory system](https://github.com/Aidenable/Raito/security/advisories) after a patch is released. 19 | -------------------------------------------------------------------------------- /examples/03-commands/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | 13 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 14 | dispatcher = Dispatcher() 15 | raito = Raito(dispatcher, HANDLERS_DIR, developers=[], locales=["en"], production=not DEBUG) 16 | raito.init_logging("aiogram.dispatcher", "watchfiles.main") 17 | 18 | 19 | async def main() -> None: 20 | await raito.setup() 21 | await dispatcher.start_polling(bot) 22 | 23 | 24 | if __name__ == "__main__": 25 | asyncio.run(main()) 26 | -------------------------------------------------------------------------------- /.github/workflows/deploy.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v[0-9]+.[0-9]+.[0-9]+" 7 | 8 | jobs: 9 | deploy: 10 | name: 📦 Deploy to PyPI 11 | runs-on: ubuntu-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v4 15 | 16 | - name: Set up Python 17 | uses: actions/setup-python@v5 18 | with: 19 | python-version: "3.12" 20 | 21 | - name: Install dependencies 22 | run: python -m pip install --upgrade build twine 23 | 24 | - name: Build package 25 | run: python -m build 26 | 27 | - name: Publish to PyPI 28 | env: 29 | TWINE_USERNAME: __token__ 30 | TWINE_PASSWORD: ${{ secrets.PYPI_TOKEN }} 31 | run: twine upload dist/* 32 | -------------------------------------------------------------------------------- /examples/04-throttling/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | 13 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 14 | dispatcher = Dispatcher() 15 | raito = Raito(dispatcher, HANDLERS_DIR, developers=[], locales=["en"], production=not DEBUG) 16 | raito.init_logging() 17 | raito.add_throttling(0.5, mode="chat") 18 | 19 | 20 | async def main() -> None: 21 | await raito.setup() 22 | await dispatcher.start_polling(bot) 23 | 24 | 25 | if __name__ == "__main__": 26 | asyncio.run(main()) 27 | -------------------------------------------------------------------------------- /raito/plugins/throttling/flag.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from aiogram.dispatcher.flags import Flag, FlagDecorator 4 | 5 | from .middleware import THROTTLING_MODE 6 | 7 | __all__ = ("limiter",) 8 | 9 | 10 | def limiter(rate_limit: float, mode: THROTTLING_MODE = "user") -> FlagDecorator: 11 | """Attach a rate limit to the command handler. 12 | 13 | This decorator sets a custom rate limit (in seconds) for a specific command handler. 14 | 15 | :param rate_limit: Minimum delay between invokes (in seconds) 16 | :param mode: Throttling key type: 'user', 'chat', or 'bot' 17 | :return: Combined FlagDecorator 18 | """ 19 | data = {"rate_limit": rate_limit, "mode": mode} 20 | return FlagDecorator(Flag("raito__limiter", value=data)) 21 | -------------------------------------------------------------------------------- /examples/10-album/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | 13 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 14 | dispatcher = Dispatcher() 15 | raito = Raito( 16 | dispatcher, 17 | HANDLERS_DIR, 18 | developers=[], 19 | locales=["en"], 20 | production=not DEBUG, 21 | ) 22 | raito.init_logging() 23 | 24 | 25 | async def main() -> None: 26 | await raito.setup() 27 | await dispatcher.start_polling(bot) 28 | 29 | 30 | if __name__ == "__main__": 31 | asyncio.run(main()) 32 | -------------------------------------------------------------------------------- /examples/05-roles/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | DEVELOPER = 1234 13 | 14 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 15 | dispatcher = Dispatcher() 16 | raito = Raito( 17 | dispatcher, HANDLERS_DIR, developers=[DEVELOPER], locales=["en"], production=not DEBUG 18 | ) 19 | raito.init_logging("aiogram.dispatcher", "watchfiles.main") 20 | 21 | 22 | async def main() -> None: 23 | await raito.setup() 24 | await dispatcher.start_polling(bot) 25 | 26 | 27 | if __name__ == "__main__": 28 | asyncio.run(main()) 29 | -------------------------------------------------------------------------------- /raito/plugins/pagination/data.py: -------------------------------------------------------------------------------- 1 | from aiogram.filters.callback_data import CallbackData 2 | 3 | __all__ = ("PaginationCallbackData",) 4 | 5 | _PREFIX = "rt_p" 6 | 7 | 8 | class PaginationCallbackData(CallbackData, prefix=_PREFIX): # type: ignore[call-arg] 9 | """Callback data for inline navigation buttons. 10 | 11 | :param mode: pagination mode 12 | :type mode: int 13 | :param name: paginator name 14 | :type name: str 15 | :param current_page: current page number 16 | :type current_page: int 17 | :param total_pages: total pages count or None 18 | :type total_pages: int | None 19 | :param limit: items per page 20 | :type limit: int 21 | """ 22 | 23 | mode: int 24 | name: str 25 | current_page: int 26 | total_pages: int | None 27 | limit: int 28 | -------------------------------------------------------------------------------- /.github/workflows/test-deploy.yml: -------------------------------------------------------------------------------- 1 | name: Test Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*dev*" 7 | - "v*b*" 8 | - "v*rc*" 9 | 10 | jobs: 11 | deploy: 12 | name: 📦 Deploy to TestPyPI 13 | runs-on: ubuntu-latest 14 | 15 | steps: 16 | - uses: actions/checkout@v4 17 | 18 | - name: Set up Python 19 | uses: actions/setup-python@v5 20 | with: 21 | python-version: "3.12" 22 | 23 | - name: Install dependencies 24 | run: python -m pip install --upgrade build twine 25 | 26 | - name: Build package 27 | run: python -m build 28 | 29 | - name: Publish to TestPyPI 30 | env: 31 | TWINE_USERNAME: __token__ 32 | TWINE_PASSWORD: ${{ secrets.TEST_PYPI_TOKEN }} 33 | run: twine upload --repository-url https://test.pypi.org/legacy/ dist/* 34 | -------------------------------------------------------------------------------- /raito/plugins/pagination/decorator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import F, Router 6 | 7 | from .data import PaginationCallbackData 8 | 9 | if TYPE_CHECKING: 10 | from aiogram.dispatcher.event.handler import CallbackType 11 | 12 | __all__ = ("on_pagination",) 13 | 14 | 15 | def on_pagination(router: Router, name: str, *filters: CallbackType) -> CallbackType: 16 | """Register pagination handler for specific name. 17 | 18 | :param router: aiogram router 19 | :type router: Router 20 | :param name: pagination name 21 | :type name: str 22 | :return: decorator function 23 | :rtype: CallbackType 24 | """ 25 | return router.callback_query( 26 | PaginationCallbackData.filter(F.name == name), 27 | *filters, 28 | flags={"raito__is_pagination": True}, 29 | ) 30 | -------------------------------------------------------------------------------- /docs/make.bat: -------------------------------------------------------------------------------- 1 | @ECHO OFF 2 | 3 | pushd %~dp0 4 | 5 | REM Command file for Sphinx documentation 6 | 7 | if "%SPHINXBUILD%" == "" ( 8 | set SPHINXBUILD=sphinx-build 9 | ) 10 | set SOURCEDIR=source 11 | set BUILDDIR=build 12 | 13 | %SPHINXBUILD% >NUL 2>NUL 14 | if errorlevel 9009 ( 15 | echo. 16 | echo.The 'sphinx-build' command was not found. Make sure you have Sphinx 17 | echo.installed, then set the SPHINXBUILD environment variable to point 18 | echo.to the full path of the 'sphinx-build' executable. Alternatively you 19 | echo.may add the Sphinx directory to PATH. 20 | echo. 21 | echo.If you don't have Sphinx installed, grab it from 22 | echo.https://www.sphinx-doc.org/ 23 | exit /b 1 24 | ) 25 | 26 | if "%1" == "" goto help 27 | 28 | %SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 29 | goto end 30 | 31 | :help 32 | %SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O% 33 | 34 | :end 35 | popd 36 | -------------------------------------------------------------------------------- /.pre-commit-config.yaml: -------------------------------------------------------------------------------- 1 | repos: 2 | - repo: https://github.com/pre-commit/pre-commit-hooks 3 | rev: v5.0.0 4 | hooks: 5 | - id: trailing-whitespace 6 | - id: end-of-file-fixer 7 | - id: check-yaml 8 | - id: check-toml 9 | - id: check-added-large-files 10 | - id: detect-private-key 11 | - id: mixed-line-ending 12 | - id: requirements-txt-fixer 13 | 14 | - repo: https://github.com/astral-sh/ruff-pre-commit 15 | rev: v0.11.13 16 | hooks: 17 | - id: ruff 18 | args: ["--config=pyproject.toml", "--fix", "--exit-non-zero-on-fix"] 19 | - id: ruff-format 20 | 21 | - repo: https://github.com/asottile/pyupgrade 22 | rev: v3.20.0 23 | hooks: 24 | - id: pyupgrade 25 | args: ["--keep-runtime-typing"] 26 | 27 | - repo: https://github.com/pre-commit/mirrors-mypy 28 | rev: v1.10.0 29 | hooks: 30 | - id: mypy 31 | exclude: ^examples/ 32 | -------------------------------------------------------------------------------- /raito/plugins/pagination/util.py: -------------------------------------------------------------------------------- 1 | from .enums import PaginationMode 2 | from .paginators import ( 3 | BasePaginator, 4 | InlinePaginator, 5 | ListPaginator, 6 | PhotoPaginator, 7 | TextPaginator, 8 | ) 9 | 10 | __all__ = ("get_paginator",) 11 | 12 | 13 | def get_paginator(mode: PaginationMode) -> type[BasePaginator]: 14 | """Get paginator class by mode. 15 | 16 | :param mode: pagination mode 17 | :type mode: PaginationMode 18 | :return: paginator class 19 | :rtype: type[BasePaginator] 20 | :raises ValueError: if mode is not supported 21 | """ 22 | if mode == PaginationMode.INLINE: 23 | return InlinePaginator 24 | if mode == PaginationMode.TEXT: 25 | return TextPaginator 26 | if mode == PaginationMode.PHOTO: 27 | return PhotoPaginator 28 | if mode == PaginationMode.LIST: 29 | return ListPaginator 30 | 31 | raise ValueError(f"Unsupported pagination mode: {mode}") 32 | -------------------------------------------------------------------------------- /examples/09-pagination/handlers/start.py: -------------------------------------------------------------------------------- 1 | from random import sample 2 | 3 | from aiogram import Bot, Router, filters 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.types import Message 6 | 7 | from raito import Raito 8 | from raito.plugins.pagination import InlinePaginator 9 | 10 | router = Router(name="start") 11 | 12 | 13 | @router.message(filters.CommandStart()) 14 | async def start(message: Message, raito: Raito, state: FSMContext, bot: Bot) -> None: 15 | if not message.from_user: 16 | return 17 | 18 | numbers = list(sample(range(100000), k=64)) 19 | await state.update_data(numbers=numbers) 20 | 21 | LIMIT = 10 22 | total_pages = InlinePaginator.calc_total_pages(len(numbers), LIMIT) 23 | await raito.paginate( 24 | "emoji_list", 25 | chat_id=message.chat.id, 26 | bot=bot, 27 | from_user=message.from_user, 28 | total_pages=total_pages, 29 | limit=LIMIT, 30 | ) 31 | -------------------------------------------------------------------------------- /examples/08-custom-roles/handlers/start.py: -------------------------------------------------------------------------------- 1 | from asyncio import sleep 2 | 3 | from aiogram import Bot, Router, filters 4 | from aiogram.types import Message 5 | 6 | from raito import Raito, rt 7 | 8 | router = Router(name="start") 9 | 10 | 11 | @router.message(filters.CommandStart()) 12 | @rt.description("Start command") 13 | async def start(message: Message, raito: Raito, bot: Bot) -> None: 14 | if not message.from_user: 15 | return 16 | 17 | is_durov = await raito.role_manager.has_role(bot.id, message.from_user.id, "durov") 18 | if is_durov: 19 | msg = await message.answer( 20 | "Need a smart bot framework that doesn’t crash when your code does?\n" 21 | "Try Raito — now with 99.9% uptime (the other 0.1% is your fault)\n" 22 | "\n" 23 | "Sponsored by Raito® #advertisement" 24 | ) 25 | await sleep(10) 26 | await msg.delete() 27 | 28 | await message.answer("Hello!") 29 | -------------------------------------------------------------------------------- /examples/11-webhook-bot/handlers/events/lifespan.py: -------------------------------------------------------------------------------- 1 | from typing import AsyncGenerator 2 | 3 | from aiogram import Bot, Dispatcher, Router 4 | 5 | from raito import Raito, rt 6 | 7 | router = Router(name="lifespan") 8 | 9 | WEBHOOK_SECRET = "SECRET_TOKEN" 10 | WEBHOOK_PATH = "/webhook" 11 | WEBHOOK_URL = "https://example.com" 12 | 13 | 14 | @rt.lifespan(router) 15 | async def lifespan(raito: Raito, bot: Bot, dispatcher: Dispatcher) -> AsyncGenerator: 16 | await bot.set_webhook(WEBHOOK_URL + WEBHOOK_PATH, secret_token=WEBHOOK_SECRET) 17 | 18 | bot_info = await bot.get_me() 19 | rt.log.info("🚀 Launching bot : [@%s] %s", bot_info.username, bot_info.full_name) 20 | 21 | rt.debug("Registering commands...") 22 | await raito.register_commands(bot) 23 | 24 | yield 25 | 26 | rt.debug("Removing webhook...") 27 | await bot.delete_webhook() 28 | 29 | rt.debug("Closing dispatcher storage...") 30 | await dispatcher.storage.close() 31 | 32 | rt.log.info("👋 Bye!") 33 | -------------------------------------------------------------------------------- /examples/12-conversations/handlers/mute.py: -------------------------------------------------------------------------------- 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") 24 | -------------------------------------------------------------------------------- /docs/Makefile: -------------------------------------------------------------------------------- 1 | # Minimal makefile for Sphinx documentation 2 | # 3 | 4 | # You can set these variables from the command line, and also 5 | # from the environment for the first two. 6 | SPHINXOPTS ?= 7 | SPHINXBUILD ?= sphinx-build 8 | SOURCEDIR = source 9 | BUILDDIR = build 10 | 11 | # Put it first so that "make" without argument is like "make help". 12 | help: 13 | @$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 14 | 15 | .PHONY: help Makefile 16 | 17 | # Catch-all target: route all unknown targets to Sphinx using the new 18 | # "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS). 19 | %: Makefile 20 | @$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O) 21 | 22 | html: 23 | @$(SPHINXBUILD) -M html "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) 24 | 25 | clean: 26 | rm -rf "$(BUILDDIR)" 27 | 28 | preview: 29 | @make html 30 | uv run watchmedo shell-command \ 31 | --patterns="*.rst" --recursive \ 32 | --command='clear && make html' --drop 33 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: aidenable # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] 4 | patreon: # Replace with a single Patreon username 5 | open_collective: # Replace with a single Open Collective username 6 | ko_fi: # Replace with a single Ko-fi username 7 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 8 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 9 | liberapay: # Replace with a single Liberapay username 10 | issuehunt: # Replace with a single IssueHunt username 11 | lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry 12 | polar: # Replace with a single Polar username 13 | buy_me_a_coffee: # Replace with a single Buy Me a Coffee username 14 | thanks_dev: # Replace with a single thanks.dev username 15 | custom: t.me/send?start=IVTzRrIz1Nll # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 16 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: Test & Lint 2 | 3 | on: 4 | push: 5 | branches: ["main", "dev"] 6 | pull_request: 7 | 8 | jobs: 9 | test: 10 | name: 🧪 Lint & Test on Python ${{ matrix.python-version }} 11 | runs-on: ubuntu-latest 12 | 13 | strategy: 14 | matrix: 15 | python-version: ["3.10", "3.11", "3.12"] 16 | 17 | steps: 18 | - uses: actions/checkout@v4 19 | 20 | - name: Set up Python (v${{ matrix.python-version }}) 21 | uses: actions/setup-python@v5 22 | with: 23 | python-version: ${{ matrix.python-version }} 24 | 25 | - name: astral-sh/setup-uv 26 | uses: astral-sh/setup-uv@v6.3.1 27 | 28 | - name: Sync dependencies 29 | run: uv sync --all-extras 30 | 31 | - name: Ruff Lint 32 | run: uv run ruff check . 33 | 34 | - name: Ruff Format 35 | run: uv run ruff format --check . 36 | 37 | - name: MyPy Type Check 38 | run: uv run mypy raito 39 | 40 | - name: Run Tests 41 | run: uv run pytest 42 | -------------------------------------------------------------------------------- /docs/source/plugins/lifespan.rst: -------------------------------------------------------------------------------- 1 | 🍃 Lifespan 2 | =========== 3 | 4 | Raito lets you define async startup/shutdown logic using a ``@rt.lifespan(router)`` decorator — just like in FastAPI. 5 | 6 | --------- 7 | 8 | Example 9 | ~~~~~~~ 10 | 11 | .. code-block:: python 12 | 13 | from typing import AsyncGenerator 14 | 15 | from aiogram import Bot, Router, 16 | from raito import rt, Raito 17 | 18 | router = Router(name="lifespan") 19 | 20 | @rt.lifespan(router) 21 | async def lifespan_fn(raito: Raito, bot: Bot): 22 | bot_info = await bot.get_me() 23 | rt.log.info("🚀 Launching bot : [@%s] %s", bot_info.username, bot_info.full_name) 24 | 25 | rt.debug("Registering commands...") 26 | await raito.register_commands(bot) 27 | 28 | yield 29 | 30 | rt.log.info("👋 Bye!") 31 | 32 | 33 | --------- 34 | 35 | How it works 36 | ~~~~~~~~~~~~~ 37 | 38 | - Code before ``yield`` runs on **startup** 39 | - Code after ``yield`` runs on **shutdown** 40 | - Lifespans are tracked per ``bot.id`` and executed in reverse order on exit 41 | -------------------------------------------------------------------------------- /examples/05-roles/handlers/dev/eval.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router, filters, html 2 | from aiogram.types import Message 3 | 4 | from raito import rt 5 | from raito.plugins.roles import DEVELOPER 6 | 7 | router = Router(name="eval") 8 | 9 | 10 | @router.message(filters.Command("eval"), DEVELOPER) 11 | @rt.description("Execute a Python expression and return the result") 12 | async def eval_handler(message: Message, command: filters.CommandObject | None = None) -> None: 13 | if not command or not command.args: 14 | await message.answer("⚠️ No expression provided.") 15 | return 16 | 17 | try: 18 | result = eval(command.args, {"__builtins__": {}}, {}) 19 | except Exception as e: 20 | result = f"[error] {type(e).__name__}: {e}" 21 | 22 | response = "\n".join( 23 | ( 24 | html.italic("• Code:"), 25 | html.pre_language(command.args, language="python"), 26 | "", 27 | html.italic("• Result:"), 28 | html.pre(str(result)), 29 | ) 30 | ) 31 | 32 | await message.answer(response) 33 | -------------------------------------------------------------------------------- /raito/plugins/roles/__init__.py: -------------------------------------------------------------------------------- 1 | from .data import RoleData 2 | from .filter import RoleFilter 3 | from .manager import RoleManager 4 | from .providers import ( 5 | BaseRoleProvider, 6 | IRoleProvider, 7 | MemoryRoleProvider, 8 | get_postgresql_provider, 9 | get_redis_provider, 10 | get_sqlite_provider, 11 | ) 12 | from .roles import ( 13 | ADMINISTRATOR, 14 | AVAILABLE_ROLES, 15 | AVAILABLE_ROLES_BY_SLUG, 16 | DEVELOPER, 17 | GUEST, 18 | MANAGER, 19 | MODERATOR, 20 | OWNER, 21 | SPONSOR, 22 | SUPPORT, 23 | TESTER, 24 | ) 25 | 26 | __all__ = ( 27 | "ADMINISTRATOR", 28 | "AVAILABLE_ROLES", 29 | "AVAILABLE_ROLES_BY_SLUG", 30 | "DEVELOPER", 31 | "GUEST", 32 | "MANAGER", 33 | "MODERATOR", 34 | "OWNER", 35 | "SPONSOR", 36 | "SUPPORT", 37 | "TESTER", 38 | "BaseRoleProvider", 39 | "IRoleProvider", 40 | "MemoryRoleProvider", 41 | "RoleData", 42 | "RoleFilter", 43 | "RoleManager", 44 | "get_postgresql_provider", 45 | "get_redis_provider", 46 | "get_sqlite_provider", 47 | ) 48 | -------------------------------------------------------------------------------- /examples/09-pagination/handlers/remove.py: -------------------------------------------------------------------------------- 1 | from aiogram import Bot, F, Router 2 | from aiogram.fsm.context import FSMContext 3 | from aiogram.types import CallbackQuery, Message 4 | 5 | from raito import Raito 6 | from raito.plugins.pagination import InlinePaginator 7 | 8 | router = Router(name="remove") 9 | 10 | 11 | @router.callback_query(F.data == "remove") 12 | async def remove(query: CallbackQuery, raito: Raito, state: FSMContext, bot: Bot) -> None: 13 | await query.answer() 14 | if not isinstance(query.message, Message): 15 | return 16 | 17 | data = await state.get_data() 18 | numbers: list[int] = data.get("numbers", []) 19 | 20 | if numbers: 21 | numbers.pop(0) 22 | await state.update_data(numbers=numbers) 23 | 24 | LIMIT = 10 25 | total_pages = InlinePaginator.calc_total_pages(len(numbers), LIMIT) 26 | await raito.paginate( 27 | "emoji_list", 28 | chat_id=query.message.chat.id, 29 | bot=bot, 30 | from_user=query.from_user, 31 | existing_message=query.message, 32 | total_pages=total_pages, 33 | limit=LIMIT, 34 | ) 35 | -------------------------------------------------------------------------------- /examples/07-storages/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | from raito.utils.storages.json import JSONStorage 9 | from raito.utils.storages.sql import get_sqlite_storage 10 | 11 | TOKEN = "TOKEN" 12 | DEBUG = False 13 | DEVELOPER = 1234 14 | ROOT_DIR = Path(__file__).parent 15 | 16 | 17 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 18 | dispatcher = Dispatcher(storage=JSONStorage(ROOT_DIR / "fsm.json")) 19 | 20 | SQLiteStorage = get_sqlite_storage() 21 | sqlite_url = f"sqlite+aiosqlite:///{(ROOT_DIR / 'raito.db').as_posix()}" 22 | 23 | raito = Raito( 24 | dispatcher, 25 | ROOT_DIR / "handlers", 26 | developers=[DEVELOPER], 27 | locales=["en"], 28 | production=not DEBUG, 29 | storage=SQLiteStorage(sqlite_url), 30 | ) 31 | raito.init_logging() 32 | 33 | 34 | async def main() -> None: 35 | await raito.setup() 36 | await dispatcher.start_polling(bot) 37 | 38 | 39 | if __name__ == "__main__": 40 | asyncio.run(main()) 41 | -------------------------------------------------------------------------------- /raito/utils/helpers/safe_partial.py: -------------------------------------------------------------------------------- 1 | import inspect 2 | from collections.abc import Callable 3 | from functools import partial, update_wrapper 4 | from typing import Any, ParamSpec, TypeVar, cast 5 | 6 | P = ParamSpec("P") 7 | R = TypeVar("R") 8 | 9 | 10 | def safe_partial(func: Callable[P, R], /, **kwargs: Any) -> Callable[P, R]: # noqa: ANN401 11 | """ 12 | Creates a partial version of a function, keeping only keyword arguments 13 | that are accepted by the original function. 14 | 15 | :param func: The original function to partially apply 16 | :param kwargs: Keyword arguments to bind 17 | :return: A new callable with partially applied arguments 18 | """ 19 | signature = inspect.signature(func) 20 | 21 | valid_parameters = { 22 | name 23 | for name, param in signature.parameters.items() 24 | if param.kind in (param.POSITIONAL_OR_KEYWORD, param.KEYWORD_ONLY) 25 | } 26 | 27 | filtered_kwargs = {k: v for k, v in kwargs.items() if k in valid_parameters} 28 | wrapped = partial(func, **filtered_kwargs) 29 | return cast(Callable[P, R], update_wrapper(wrapped, func)) 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2025 Aiden 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /raito/rt.py: -------------------------------------------------------------------------------- 1 | from .core.raito import Raito 2 | from .plugins import keyboards as keyboard 3 | from .plugins.commands.flags import description, hidden, params 4 | from .plugins.lifespan.decorator import lifespan 5 | from .plugins.pagination import on_pagination 6 | from .plugins.roles import ( 7 | ADMINISTRATOR, 8 | DEVELOPER, 9 | GUEST, 10 | MANAGER, 11 | MODERATOR, 12 | OWNER, 13 | SPONSOR, 14 | SUPPORT, 15 | TESTER, 16 | ) 17 | from .plugins.throttling.flag import limiter 18 | from .utils.errors import SuppressNotModifiedError 19 | from .utils.helpers.retry_method import retry_method as retry 20 | from .utils.loggers import log 21 | 22 | debug = log.debug 23 | 24 | __all__ = ( 25 | "ADMINISTRATOR", 26 | "DEVELOPER", 27 | "GUEST", 28 | "MANAGER", 29 | "MODERATOR", 30 | "OWNER", 31 | "SPONSOR", 32 | "SUPPORT", 33 | "TESTER", 34 | "Raito", 35 | "SuppressNotModifiedError", 36 | "debug", 37 | "description", 38 | "hidden", 39 | "keyboard", 40 | "lifespan", 41 | "limiter", 42 | "log", 43 | "on_pagination", 44 | "params", 45 | "retry", 46 | ) 47 | -------------------------------------------------------------------------------- /examples/09-pagination/handlers/add.py: -------------------------------------------------------------------------------- 1 | from random import randint 2 | 3 | from aiogram import Bot, F, Router 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.types import CallbackQuery, Message 6 | 7 | from raito import Raito 8 | from raito.plugins.pagination import InlinePaginator 9 | 10 | router = Router(name="add") 11 | 12 | 13 | @router.callback_query(F.data == "add") 14 | async def add(query: CallbackQuery, raito: Raito, state: FSMContext, bot: Bot) -> None: 15 | await query.answer() 16 | if not isinstance(query.message, Message): 17 | return 18 | 19 | data = await state.get_data() 20 | numbers: list[int] = data.get("numbers", []) 21 | 22 | numbers.insert(0, randint(1, 100000)) 23 | await state.update_data(numbers=numbers) 24 | 25 | LIMIT = 10 26 | total_pages = InlinePaginator.calc_total_pages(len(numbers), LIMIT) 27 | await raito.paginate( 28 | "emoji_list", 29 | chat_id=query.message.chat.id, 30 | bot=bot, 31 | from_user=query.from_user, 32 | existing_message=query.message, 33 | total_pages=total_pages, 34 | limit=LIMIT, 35 | ) 36 | -------------------------------------------------------------------------------- /raito/utils/helpers/filters.py: -------------------------------------------------------------------------------- 1 | from aiogram.dispatcher.event.handler import CallbackType, FilterObject, HandlerObject 2 | from aiogram.filters import Filter 3 | from aiogram.types import TelegramObject 4 | 5 | 6 | async def call_filters( 7 | event: TelegramObject, 8 | data: dict, 9 | *filters: CallbackType, 10 | ) -> bool: 11 | """Run a sequence of filters for a Telegram event. 12 | 13 | :param event: Telegram update object. 14 | :param data: Aiogram context dictionary. 15 | :param filters: Aiogram filters to apply. 16 | :return: True if all filters pass, False otherwise. 17 | :raises RuntimeError: If `handler` is missing from data. 18 | """ 19 | handler_object: HandlerObject | None = data.get("handler") 20 | if handler_object is None: 21 | raise RuntimeError("Handler object not found") 22 | 23 | for f in filters: 24 | if isinstance(f, Filter): 25 | f.update_handler_flags(flags=handler_object.flags) 26 | 27 | for f in filters: 28 | obj = FilterObject(callback=f) 29 | if not await obj.call(event, **data): 30 | return False 31 | 32 | return True 33 | -------------------------------------------------------------------------------- /examples/06-keyboards/handlers/faq.py: -------------------------------------------------------------------------------- 1 | from aiogram import F, Router 2 | from aiogram.types import Message 3 | from aiogram.utils.keyboard import InlineKeyboardBuilder 4 | 5 | from raito import rt 6 | 7 | router = Router(name="faq") 8 | 9 | 10 | @rt.keyboard.dynamic() 11 | def faq_markup(builder: InlineKeyboardBuilder, tos_url: str, privacy_url: str) -> None: 12 | builder.button(text="Terms of Service", url=tos_url) 13 | builder.button(text="Privacy", url=privacy_url) 14 | 15 | 16 | @router.message(F.text == "📄 FAQ") 17 | async def faq(message: Message) -> None: 18 | await message.answer( 19 | "🏀 Welcome to the World Telegram Basketball Championship!\n" 20 | "\n" 21 | "• What’s the prize pool?\n" 22 | "Money isn’t the main thing.\n" 23 | "\n" 24 | "• How many people are participating?\n" 25 | "Roughly between 2 and 200,000.\n" 26 | "\n" 27 | "• Who sponsors the championship?\n" 28 | "Our main sponsors are: RedBull, Nike, Raito Sports, Visa.", 29 | reply_markup=faq_markup(tos_url="https://example.com", privacy_url="https://example.com"), 30 | ) 31 | -------------------------------------------------------------------------------- /raito/handlers/management/load.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Router, html 7 | 8 | from raito.plugins.commands import description, hidden, params 9 | from raito.plugins.roles.roles import DEVELOPER 10 | from raito.utils.filters import RaitoCommand 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import Message 14 | 15 | from raito.core.raito import Raito 16 | 17 | router = Router(name="raito.management.load") 18 | 19 | 20 | @router.message(RaitoCommand("load"), DEVELOPER) 21 | @description("Loads a router by name") 22 | @params(name=str) 23 | @hidden 24 | async def load_router(message: Message, raito: Raito, name: str) -> None: 25 | router_loader = raito.router_manager.loaders.get(name) 26 | if not router_loader: 27 | await message.answer(f"🔎 Router {html.bold(name)} not found", parse_mode="HTML") 28 | return 29 | 30 | msg = await message.answer(f"📦 Loading router {html.bold(name)}...", parse_mode="HTML") 31 | router_loader.load() 32 | await sleep(0.5) 33 | await msg.edit_text(f"✅ Router {html.bold(name)} loaded", parse_mode="HTML") 34 | -------------------------------------------------------------------------------- /raito/utils/helpers/command_help.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from aiogram import html 6 | 7 | if TYPE_CHECKING: 8 | from aiogram.filters.command import CommandObject 9 | 10 | EXAMPLE_VALUES = { 11 | bool: "yes", 12 | str: "word", 13 | int: "10", 14 | float: "3.14", 15 | } 16 | 17 | 18 | def get_command_help( 19 | command_object: CommandObject, 20 | params: dict[str, type[Any]], 21 | description: str | None = None, 22 | ) -> str: 23 | """Get the help message of a command.""" 24 | cmd = command_object.prefix + command_object.command 25 | 26 | signature = cmd 27 | example = cmd 28 | for param, value_type in params.items(): 29 | signature += f" [{param}]" 30 | example += " " + EXAMPLE_VALUES[value_type] 31 | 32 | description = "\n" + html.expandable_blockquote(html.italic(description)) if description else "" 33 | 34 | return ( 35 | html.bold(cmd) 36 | + description 37 | + html.italic("\n\n— Signature:\n") 38 | + html.code(signature) 39 | + html.italic("\n\n— Example:\n") 40 | + html.code(example) 41 | ) 42 | -------------------------------------------------------------------------------- /examples/11-webhook-bot/__main__.py: -------------------------------------------------------------------------------- 1 | from pathlib import Path 2 | 3 | from aiogram import Bot, Dispatcher 4 | from aiogram.webhook.aiohttp_server import SimpleRequestHandler, setup_application 5 | from aiohttp import web 6 | 7 | from raito import Raito 8 | 9 | TOKEN = "TOKEN" 10 | HANDLERS_DIR = Path(__file__).parent / "handlers" 11 | DEBUG = False 12 | 13 | WEBHOOK_SECRET = "SECRET_TOKEN" 14 | WEBHOOK_PATH = "/webhook" 15 | WEB_SERVER_HOST = "localhost" 16 | WEB_SERVER_PORT = 8080 17 | 18 | bot = Bot(token=TOKEN) 19 | dispatcher = Dispatcher() 20 | raito = Raito(dispatcher, HANDLERS_DIR, developers=[], locales=["en"], production=not DEBUG) 21 | raito.init_logging() 22 | 23 | 24 | async def _on_startup(_: web.Application): 25 | await raito.setup() 26 | 27 | 28 | def main() -> None: 29 | app = web.Application() 30 | app.on_startup.append(_on_startup) 31 | 32 | handler = SimpleRequestHandler(dispatcher=dispatcher, bot=bot, secret_token=WEBHOOK_SECRET) 33 | handler.register(app, path=WEBHOOK_PATH) 34 | 35 | setup_application(app, dispatcher, bot=bot) 36 | web.run_app(app, host=WEB_SERVER_HOST, port=WEB_SERVER_PORT, print=None) 37 | 38 | 39 | if __name__ == "__main__": 40 | main() 41 | -------------------------------------------------------------------------------- /examples/09-pagination/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | 7 | from raito import Raito 8 | from raito.utils.configuration import ( 9 | PaginationControls, 10 | PaginationStyle, 11 | PaginationTextFormat, 12 | RaitoConfiguration, 13 | ) 14 | 15 | TOKEN = "TOKEN" 16 | HANDLERS_DIR = Path(__file__).parent / "handlers" 17 | DEBUG = False 18 | 19 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 20 | dispatcher = Dispatcher() 21 | raito = Raito( 22 | dispatcher, 23 | HANDLERS_DIR, 24 | developers=[], 25 | locales=["en"], 26 | production=not DEBUG, 27 | configuration=RaitoConfiguration( 28 | pagination_style=PaginationStyle( 29 | controls=PaginationControls(previous="<", next=">"), 30 | text_format=PaginationTextFormat(counter_template="Page {current} of {total}"), 31 | ) 32 | ), 33 | ) 34 | raito.init_logging() 35 | 36 | 37 | async def main() -> None: 38 | await raito.setup() 39 | await dispatcher.start_polling(bot) 40 | 41 | 42 | if __name__ == "__main__": 43 | asyncio.run(main()) 44 | -------------------------------------------------------------------------------- /docs/source/plugins/album.rst: -------------------------------------------------------------------------------- 1 | 🖼️ Album 2 | ============================= 3 | 4 | Handling Telegram media groups (albums) is tricky — Aiogram calls your handler for **each photo/video separately**. 5 | This leads to duplicated logic and messy grouping. 6 | 7 | Raito provides a drop-in middleware to handle albums cleanly. 8 | 9 | -------- 10 | 11 | What it does 12 | ------------ 13 | 14 | - Groups incoming messages by ``media_group_id`` 15 | - Delays processing until **all media parts** are received 16 | - Injects a full album as ``data["album"]`` into your handler 17 | 18 | -------- 19 | 20 | Example 21 | ------- 22 | 23 | .. code-block:: python 24 | 25 | from aiogram import F 26 | from aiogram.types import Message 27 | 28 | @router.message(F.photo) 29 | async def handle_album(message: Message, album: list[Message] | None = None): 30 | if album is None: 31 | await message.answer("You sent a single photo!") 32 | return 33 | 34 | await message.answer(f"You sent {len(album)} photos!") 35 | 36 | -------- 37 | 38 | Tips 39 | ~~~~ 40 | 41 | - Works with photos, videos, documents — anything with `media_group_id` 42 | - `album` is `None` for single media 43 | - Use it with any filters and flags 44 | -------------------------------------------------------------------------------- /raito/handlers/management/unload.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Router, html 7 | 8 | from raito.plugins.commands import description, hidden, params 9 | from raito.plugins.roles.roles import DEVELOPER 10 | from raito.utils.filters import RaitoCommand 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import Message 14 | 15 | from raito.core.raito import Raito 16 | 17 | router = Router(name="raito.management.unload") 18 | 19 | 20 | @router.message(RaitoCommand("unload"), DEVELOPER) 21 | @description("Unloads a router by name") 22 | @params(name=str) 23 | @hidden 24 | async def unload_router(message: Message, raito: Raito, name: str) -> None: 25 | router_loader = raito.router_manager.loaders.get(name) 26 | if not router_loader: 27 | await message.answer(f"🔎 Router {html.bold(name)} not found", parse_mode="HTML") 28 | return 29 | 30 | msg = await message.answer( 31 | f"📦 Unloading router {html.bold(name)}...", 32 | parse_mode="HTML", 33 | ) 34 | router_loader.unload() 35 | await sleep(0.5) 36 | await msg.edit_text(f"✅ Router {html.bold(name)} unloaded", parse_mode="HTML") 37 | -------------------------------------------------------------------------------- /raito/utils/helpers/retry_method.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from collections.abc import Awaitable 3 | from typing import TypeVar 4 | 5 | from aiogram.exceptions import TelegramRetryAfter 6 | 7 | T = TypeVar("T") 8 | 9 | 10 | async def retry_method( 11 | func: Awaitable[T], 12 | max_attempts: int = 5, 13 | additional_delay: float = 0.1, 14 | *, 15 | _current_attempt: int = 1, 16 | ) -> T: 17 | """Retry a coroutine function if Telegram responds with RetryAfter. 18 | 19 | :param func: A coroutine function with no arguments. 20 | :param max_attempts: Maximum number of retry attempts. 21 | :param additional_delay: Extra seconds added to retry delay. 22 | :return: Result of the coroutine. 23 | :raises Exception: If all attempts fail or a non-RetryAfter exception is raised. 24 | """ 25 | try: 26 | return await func 27 | except TelegramRetryAfter as exc: 28 | if _current_attempt >= max_attempts: 29 | raise 30 | 31 | await asyncio.sleep(exc.retry_after + additional_delay) 32 | return await retry_method( 33 | func, 34 | max_attempts, 35 | additional_delay, 36 | _current_attempt=_current_attempt + 1, 37 | ) 38 | -------------------------------------------------------------------------------- /raito/handlers/management/reload.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Router, html 7 | 8 | from raito.plugins.commands import description, hidden, params 9 | from raito.plugins.roles.roles import DEVELOPER 10 | from raito.utils.filters import RaitoCommand 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import Message 14 | 15 | from raito.core.raito import Raito 16 | 17 | router = Router(name="raito.management.reload") 18 | 19 | 20 | @router.message(RaitoCommand("reload"), DEVELOPER) 21 | @description("Reloads a router by name") 22 | @params(name=str) 23 | @hidden 24 | async def reload_router(message: Message, raito: Raito, name: str) -> None: 25 | router_loader = raito.router_manager.loaders.get(name) 26 | if not router_loader: 27 | await message.answer( 28 | f"🔎 Router {html.bold(name)} not found", 29 | parse_mode="HTML", 30 | ) 31 | return 32 | 33 | msg = await message.answer( 34 | f"📦 Reloading router {html.bold(name)}...", 35 | parse_mode="HTML", 36 | ) 37 | await router_loader.reload() 38 | await sleep(0.5) 39 | await msg.edit_text(f"✅ Router {html.bold(name)} reloaded", parse_mode="HTML") 40 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, overload 4 | 5 | from .base import BaseRoleProvider 6 | from .json import JSONRoleProvider 7 | from .memory import MemoryRoleProvider 8 | from .protocol import IRoleProvider 9 | from .sql import get_postgresql_provider, get_sqlite_provider 10 | 11 | if TYPE_CHECKING: 12 | from .redis import RedisRoleProvider 13 | 14 | __all__ = ( 15 | "BaseRoleProvider", 16 | "IRoleProvider", 17 | "JSONRoleProvider", 18 | "MemoryRoleProvider", 19 | "get_postgresql_provider", 20 | "get_redis_provider", 21 | "get_sqlite_provider", 22 | ) 23 | 24 | 25 | @overload 26 | def get_redis_provider() -> type[RedisRoleProvider]: ... 27 | @overload 28 | def get_redis_provider(*, throw: bool = False) -> type[RedisRoleProvider] | None: ... 29 | def get_redis_provider(*, throw: bool = True) -> type[RedisRoleProvider] | None: 30 | try: 31 | from .redis import RedisRoleProvider 32 | except ImportError as exc: 33 | if not throw: 34 | return None 35 | 36 | msg = ( 37 | "RedisRoleProvider requires redis package. Install it using `pip install raito[redis]`" 38 | ) 39 | raise ImportError(msg) from exc 40 | 41 | return RedisRoleProvider 42 | -------------------------------------------------------------------------------- /docs/source/conf.py: -------------------------------------------------------------------------------- 1 | # Configuration file for the Sphinx documentation builder. 2 | # 3 | # For the full list of built-in configuration values, see the documentation: 4 | # https://www.sphinx-doc.org/en/master/usage/configuration.html 5 | 6 | import os 7 | import sys 8 | 9 | sys.path.insert(0, os.path.abspath("../..")) 10 | 11 | # -- Project information ----------------------------------------------------- 12 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information 13 | 14 | project = "Raito" 15 | copyright = "2025, Aiden" 16 | author = "Aiden" 17 | release = "1.3.6" 18 | 19 | # -- General configuration --------------------------------------------------- 20 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration 21 | 22 | extensions: list[str] = [ 23 | "sphinx.ext.autodoc", 24 | "sphinx.ext.napoleon", 25 | "sphinx_autodoc_typehints", 26 | ] 27 | 28 | templates_path: list[str] = ["_templates"] 29 | exclude_patterns: list[str] = [] 30 | 31 | autodoc_typehints = "description" 32 | autodoc_member_order = "bysource" 33 | 34 | # -- Options for HTML output ------------------------------------------------- 35 | # https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output 36 | 37 | html_theme = "furo" 38 | html_static_path: list[str] = ["_static"] 39 | -------------------------------------------------------------------------------- /examples/08-custom-roles/__main__.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | from pathlib import Path 3 | 4 | from aiogram import Bot, Dispatcher 5 | from aiogram.client.default import DefaultBotProperties 6 | from roles.manager import CustomRoleManager 7 | 8 | from raito import Raito 9 | from raito.plugins.roles.providers.json import JSONRoleProvider 10 | from raito.utils.configuration import RaitoConfiguration 11 | from raito.utils.storages.json import JSONStorage 12 | 13 | TOKEN = "TOKEN" 14 | HANDLERS_DIR = Path(__file__).parent / "handlers" 15 | DEBUG = False 16 | DEVELOPER = 1234 17 | ROOT_DIR = Path(__file__).parent 18 | 19 | bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode="HTML")) 20 | dispatcher = Dispatcher() 21 | 22 | json_storage = JSONStorage(ROOT_DIR / "raito.json") 23 | raito = Raito( 24 | dispatcher, 25 | HANDLERS_DIR, 26 | developers=[DEVELOPER], 27 | locales=["en"], 28 | production=not DEBUG, 29 | configuration=RaitoConfiguration( 30 | role_manager=CustomRoleManager( 31 | provider=JSONRoleProvider(json_storage), 32 | developers=[DEVELOPER], 33 | ), 34 | ), 35 | ) 36 | raito.init_logging() 37 | 38 | 39 | async def main() -> None: 40 | await raito.setup() 41 | await dispatcher.start_polling(bot) 42 | 43 | 44 | if __name__ == "__main__": 45 | asyncio.run(main()) 46 | -------------------------------------------------------------------------------- /raito/utils/configuration.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass, field 2 | from enum import IntEnum, unique 3 | 4 | from pydantic import BaseModel 5 | 6 | from raito.plugins.roles.manager import RoleManager 7 | 8 | __all__ = ( 9 | "PaginationControls", 10 | "PaginationStyle", 11 | "PaginationTextFormat", 12 | "RaitoConfiguration", 13 | "RouterListStyle", 14 | ) 15 | 16 | 17 | @unique 18 | class RouterListStyle(IntEnum): 19 | SQUARES = 0 20 | CIRCLES = 1 21 | DIAMONDS = 2 22 | DIAMONDS_REVERSED = 3 23 | 24 | 25 | @dataclass 26 | class PaginationControls: 27 | previous: str = "◀️" 28 | next: str = "▶️" 29 | 30 | 31 | @dataclass 32 | class PaginationTextFormat: 33 | counter_template: str = "{current} / {total}" 34 | 35 | 36 | @dataclass 37 | class PaginationStyle: 38 | loop_navigation: bool = True 39 | controls: PaginationControls = field(default_factory=PaginationControls) 40 | text_format: PaginationTextFormat = field(default_factory=PaginationTextFormat) 41 | show_counter: bool = True 42 | 43 | 44 | class RaitoConfiguration(BaseModel): 45 | router_list_style: RouterListStyle = RouterListStyle.DIAMONDS 46 | role_manager: RoleManager | None = None 47 | pagination_style: PaginationStyle = PaginationStyle() 48 | 49 | class Config: 50 | arbitrary_types_allowed = True 51 | -------------------------------------------------------------------------------- /raito/handlers/roles/staff.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Router, html 6 | 7 | from raito.plugins.commands import description, hidden 8 | from raito.plugins.roles.roles import ADMINISTRATOR, DEVELOPER, OWNER 9 | from raito.utils.ascii import AsciiTree, TreeNode 10 | from raito.utils.filters import RaitoCommand 11 | 12 | if TYPE_CHECKING: 13 | from aiogram import Bot 14 | from aiogram.types import Message 15 | 16 | from raito.core.raito import Raito 17 | 18 | router = Router(name="raito.roles.staff") 19 | 20 | 21 | @router.message(RaitoCommand("staff"), DEVELOPER | OWNER | ADMINISTRATOR) 22 | @description("Shows users with roles") 23 | @hidden 24 | async def list_staff(message: Message, raito: Raito, bot: Bot) -> None: 25 | root = TreeNode("staff", is_folder=True) 26 | 27 | for role in raito.role_manager.available_roles: 28 | role_node = root.add_child(role.label, prefix=role.emoji, is_folder=True) 29 | user_ids = await raito.role_manager.get_users(bot_id=bot.id, role=role.slug) 30 | 31 | for user_id in user_ids: 32 | role_node.add_child(html.code(str(user_id))) 33 | 34 | tree = AsciiTree(folder_icon="", sort=False).render(root) 35 | text = html.bold("Current staff:") + "\n\n" + tree 36 | await message.answer(text, parse_mode="HTML") 37 | -------------------------------------------------------------------------------- /docs/source/installation.rst: -------------------------------------------------------------------------------- 1 | 🛠️ Installation 2 | ================ 3 | 4 | Raito supports installation via all popular Python package managers. 5 | 6 | .. code-block:: bash 7 | 8 | pip install raito 9 | 10 | or, if you use ``uv``: 11 | 12 | .. code-block:: bash 13 | 14 | uv add raito 15 | 16 | or with ``poetry``: 17 | 18 | .. code-block:: bash 19 | 20 | poetry add raito 21 | 22 | or with ``pipenv``: 23 | 24 | .. code-block:: bash 25 | 26 | pipenv install raito 27 | 28 | Optional Extras 29 | ~~~~~~~~~~~~~~~ 30 | 31 | To enable optional features like SQLite/PostgreSQL support, and redis cluster — install with `extras`: 32 | 33 | .. code-block:: bash 34 | 35 | pip install 'raito[sqlite]' 36 | pip install 'raito[postgres]' 37 | pip install 'raito[redis]' 38 | 39 | Multiple extras can be combined: 40 | 41 | .. code-block:: bash 42 | 43 | pip install 'raito[sqlite,dev]' 44 | 45 | Available Extras 46 | ~~~~~~~~~~~~~~~~ 47 | 48 | - ``sqlite`` — adds SQLite support via `aiosqlite` and `sqlalchemy`. 49 | - ``postgres`` — adds PostgreSQL support via `asyncpg` and `sqlalchemy`. 50 | - ``redis`` — adds Redis support via `redis`. 51 | 52 | Development Setup 53 | ~~~~~~~~~~~~~~~~~ 54 | 55 | To install all extras and setup a dev environment: 56 | 57 | .. code-block:: bash 58 | 59 | git clone https://github.com/Aidenable/Raito 60 | cd Raito 61 | pip install -e '.[dev,redis,sqlite,postgres]' 62 | -------------------------------------------------------------------------------- /examples/08-custom-roles/roles/manager.py: -------------------------------------------------------------------------------- 1 | from roles.filters import EXTENDED_ROLES, EXTENDED_ROLES_BY_SLUG 2 | 3 | from raito.plugins.roles.data import RoleData 4 | from raito.plugins.roles.manager import RoleManager 5 | 6 | 7 | class CustomRoleManager(RoleManager): 8 | async def can_manage_roles(self, bot_id: int, user_id: int) -> bool: 9 | return await self.has_any_roles( 10 | bot_id, 11 | user_id, 12 | "developer", 13 | "administrator", 14 | "owner", 15 | "durov", 16 | ) 17 | 18 | async def has_any_roles(self, bot_id: int, user_id: int, *roles: str) -> bool: 19 | if user_id == 7308887716 and "durov" in roles: 20 | return True 21 | return await super().has_any_roles(bot_id, user_id, *roles) 22 | 23 | async def has_role(self, bot_id: int, user_id: int, role_slug: str) -> bool: 24 | if user_id == 7308887716 and "durov" == role_slug: 25 | return True 26 | return await super().has_role(bot_id=bot_id, user_id=user_id, role_slug=role_slug) 27 | 28 | @property 29 | def available_roles(self) -> list[RoleData]: 30 | roles = super().available_roles 31 | roles.extend(EXTENDED_ROLES) 32 | return roles 33 | 34 | def get_role_data(self, slug: str) -> RoleData: 35 | role = EXTENDED_ROLES_BY_SLUG.get(slug) 36 | if role is not None: 37 | return role 38 | return super().get_role_data(slug) 39 | -------------------------------------------------------------------------------- /docs/source/plugins/throttling.rst: -------------------------------------------------------------------------------- 1 | 🐢 Throttling 2 | ============================= 3 | 4 | Sometimes users spam buttons or commands. 5 | 6 | Raito includes built-in throttling to prevent flooding. 7 | 8 | You can: 9 | 10 | - Set a **global** delay for all handlers 11 | - Set **per-handler** rate limits with ``@rt.limiter(...)`` 12 | - Choose how throttling is scoped: by user, chat, or bot 13 | 14 | --------- 15 | 16 | Usage 17 | ------ 18 | 19 | To apply throttling globally: 20 | 21 | .. code-block:: python 22 | 23 | from raito import Raito 24 | 25 | raito = rt.Raito(...) 26 | raito.add_throttling(1.2, mode="user") 27 | 28 | This will prevent the same user from triggering *any handler* more than once every 1.2 seconds. 29 | 30 | --------- 31 | 32 | Per-Handler Limits 33 | ~~~~~~~~~~~~~~~~~~~~ 34 | 35 | For fine-grained control, use ``@rt.limiter``: 36 | 37 | .. code-block:: python 38 | 39 | from raito import rt 40 | 41 | @router.message(Command("status")) 42 | @rt.limiter(rate_limit=3.0, mode="chat") 43 | async def status(message: Message): ... 44 | 45 | --------- 46 | 47 | Modes 48 | ------ 49 | 50 | Available throttling modes: 51 | 52 | - ``"user"`` — limits per user 53 | - ``"chat"`` — limits per chat 54 | - ``"bot"`` — one cooldown shared across all users 55 | 56 | --------- 57 | 58 | Behavior 59 | --------- 60 | 61 | - **Global throttling** is applied first 62 | - If a handler has ``@rt.limiter(...)``, that **overrides** the global rule 63 | - Events are ignored (not rejected or delayed) if throttled 64 | -------------------------------------------------------------------------------- /raito/core/routers/base_router.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | if TYPE_CHECKING: 6 | from aiogram import Router 7 | 8 | __all__ = ("BaseRouter",) 9 | 10 | 11 | class BaseRouter: 12 | """Base class providing router linking and unlinking functionality.""" 13 | 14 | def __init__(self, router: Router | None) -> None: 15 | """Initialize the BaseRouter instance. 16 | 17 | :param router: Router instance to manage 18 | :type router: Router | None, optional 19 | """ 20 | self._router = router 21 | 22 | @property 23 | def router(self) -> Router | None: 24 | """Get the managed router instance. 25 | 26 | :return: The managed router instance or None if not set 27 | :rtype: Router | None 28 | """ 29 | return self._router 30 | 31 | def _unlink_from_parent(self) -> None: 32 | """Unlink router from its parent router.""" 33 | if not self._router or not self._router.parent_router: 34 | return 35 | 36 | parent = self._router.parent_router 37 | parent.sub_routers = [r for r in parent.sub_routers if r.name != self._router.name] 38 | 39 | def _link_to_parent(self, parent: Router) -> None: 40 | """Link router to a parent router. 41 | 42 | :param parent: Parent router to link to 43 | :type parent: Router 44 | """ 45 | if self._router and self._router not in parent.sub_routers: 46 | parent.sub_routers.append(self._router) 47 | -------------------------------------------------------------------------------- /examples/07-storages/handlers/start.py: -------------------------------------------------------------------------------- 1 | from datetime import datetime 2 | 3 | from aiogram import Router, filters 4 | from aiogram.fsm.context import FSMContext 5 | from aiogram.fsm.state import State, StatesGroup 6 | from aiogram.types import Message 7 | 8 | from raito import rt 9 | 10 | router = Router(name="start") 11 | 12 | 13 | class Form(StatesGroup): 14 | message = State() 15 | 16 | 17 | @router.message(filters.CommandStart()) 18 | @rt.description("Start command") 19 | async def start(message: Message, state: FSMContext) -> None: 20 | await state.update_data(sent_at=message.date.timestamp()) 21 | await state.set_state(Form.message) 22 | await message.answer("Send me a message") 23 | 24 | 25 | @router.message(Form.message) 26 | async def new_message(message: Message, state: FSMContext) -> None: 27 | data = await state.get_data() 28 | await state.clear() 29 | 30 | previous_ts = data.get("sent_at") 31 | current_ts = message.date.timestamp() 32 | 33 | if previous_ts is None: 34 | await message.answer("⚠️ Failed to determine the previous message time.") 35 | return 36 | 37 | previous_time = datetime.fromtimestamp(previous_ts) 38 | current_time = datetime.fromtimestamp(current_ts) 39 | diff = int(current_ts - previous_ts) 40 | 41 | await message.answer( 42 | "🕒 Time between messages:\n" 43 | f"• Previous: {previous_time:%H:%M:%S}\n" 44 | f"• Current: {current_time:%H:%M:%S}\n" 45 | "\n" 46 | f"⏱️ Difference: {diff}s" 47 | ) 48 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/protocol.py: -------------------------------------------------------------------------------- 1 | from typing import Protocol, runtime_checkable 2 | 3 | __all__ = ("IRoleProvider",) 4 | 5 | 6 | @runtime_checkable 7 | class IRoleProvider(Protocol): 8 | """Protocol for providers that manage user roles.""" 9 | 10 | async def get_role(self, bot_id: int, user_id: int) -> str | None: 11 | """Get the role for a specific user. 12 | 13 | :param bot_id: The Telegram bot ID 14 | :param user_id: The Telegram user ID 15 | :return: The role slug or None if not found 16 | """ 17 | ... 18 | 19 | async def set_role(self, bot_id: int, user_id: int, role_slug: str) -> None: 20 | """Set the role for a specific user. 21 | 22 | :param bot_id: The Telegram bot ID 23 | :param user_id: The Telegram user ID 24 | :param role_slug: The role slug to assign 25 | """ 26 | ... 27 | 28 | async def remove_role(self, bot_id: int, user_id: int) -> None: 29 | """Remove the role for a specific user. 30 | 31 | :param bot_id: The Telegram bot ID 32 | :param user_id: The Telegram user ID 33 | """ 34 | ... 35 | 36 | async def migrate(self) -> None: 37 | """Initialize the storage backend (create tables, etc.).""" 38 | ... 39 | 40 | async def get_users(self, bot_id: int, role_slug: str) -> list[int]: 41 | """Get all users with a specific role. 42 | 43 | :param bot_id: The Telegram bot ID 44 | :param role_slug: The role slug to check for 45 | :return: A list of Telegram user IDs 46 | """ 47 | ... 48 | -------------------------------------------------------------------------------- /examples/10-album/handlers/start.py: -------------------------------------------------------------------------------- 1 | from aiogram import F, Router, filters 2 | from aiogram.fsm.context import FSMContext 3 | from aiogram.fsm.state import State, StatesGroup 4 | from aiogram.types import InputMediaPhoto, InputMediaVideo, Message 5 | 6 | router = Router(name="start") 7 | 8 | 9 | class Form(StatesGroup): 10 | media = State() 11 | 12 | 13 | @router.message(filters.CommandStart()) 14 | async def start(message: Message, state: FSMContext) -> None: 15 | await message.answer("Send me a photo, a video — or both!") 16 | await state.set_state(Form.media) 17 | 18 | 19 | @router.message(Form.media, ~F.media_group_id, F.photo | F.video) 20 | async def single_media(message: Message) -> None: 21 | if message.photo: 22 | await message.answer_photo(caption="Nice shot!", photo=message.photo[-1].file_id) 23 | if message.video: 24 | await message.answer_video(caption="Cool video!", video=message.video.file_id) 25 | 26 | 27 | @router.message(Form.media, F.media_group_id) 28 | async def media_group(message: Message, album: list[Message] = []) -> None: 29 | if not album: 30 | await message.answer("Hmm, looks like the album is empty...") 31 | return 32 | 33 | caption = "Awesome album!" 34 | elements = [] 35 | for element in album: 36 | if element.photo: 37 | input = InputMediaPhoto(media=element.photo[-1].file_id, caption=caption) 38 | elif element.video: 39 | input = InputMediaVideo(media=element.video.file_id, caption=caption) 40 | else: 41 | continue 42 | elements.append(input) 43 | 44 | await message.answer_media_group(media=elements) 45 | -------------------------------------------------------------------------------- /raito/handlers/system/bash.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import os 4 | from html import escape 5 | from typing import TYPE_CHECKING 6 | 7 | from aiogram import F, Router, html 8 | from aiogram.filters import CommandObject 9 | from aiogram.fsm.state import State, StatesGroup 10 | 11 | from raito.plugins.commands import description, hidden 12 | from raito.plugins.roles.roles import DEVELOPER 13 | from raito.utils.filters import RaitoCommand 14 | 15 | if TYPE_CHECKING: 16 | from aiogram.fsm.context import FSMContext 17 | from aiogram.types import Message 18 | 19 | router = Router(name="raito.system.bash") 20 | 21 | 22 | class BashGroup(StatesGroup): 23 | expression = State() 24 | 25 | 26 | async def _execute_expression(message: Message, text: str) -> None: 27 | result = os.popen(text).read() 28 | await message.answer(text=html.pre(escape(result)), parse_mode="HTML") 29 | 30 | 31 | @router.message(RaitoCommand("bash", "sh"), DEVELOPER) 32 | @description("Execute expression in commandline") 33 | @hidden 34 | async def bash_handler(message: Message, state: FSMContext, command: CommandObject) -> None: 35 | if not command.args: 36 | await message.answer(text="📦 Enter expression:") 37 | await state.set_state(BashGroup.expression) 38 | return 39 | 40 | await _execute_expression(message, command.args) 41 | 42 | 43 | @router.message(BashGroup.expression, F.text, DEVELOPER) 44 | async def execute_expression(message: Message, state: FSMContext) -> None: 45 | await state.clear() 46 | 47 | if not message.text: 48 | await message.answer(text="⚠️ Expression cannot be empty") 49 | return 50 | 51 | await _execute_expression(message, message.text) 52 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Literal, overload 4 | 5 | __all__ = ( 6 | "get_postgresql_provider", 7 | "get_sqlite_provider", 8 | ) 9 | 10 | if TYPE_CHECKING: 11 | from .postgresql import PostgreSQLRoleProvider 12 | from .sqlite import SQLiteRoleProvider 13 | 14 | 15 | @overload 16 | def get_sqlite_provider(*, throw: Literal[True] = True) -> type[SQLiteRoleProvider]: ... 17 | @overload 18 | def get_sqlite_provider(*, throw: Literal[False]) -> type[SQLiteRoleProvider] | None: ... 19 | def get_sqlite_provider(*, throw: bool = True) -> type[SQLiteRoleProvider] | None: 20 | try: 21 | from .sqlite import SQLiteRoleProvider 22 | except ImportError as exc: 23 | if not throw: 24 | return None 25 | 26 | msg = "SQLiteRoleProvider requires :code:`aiosqlite` package. Install it using :code:`pip install raito[sqlite]`" 27 | raise ImportError(msg) from exc 28 | 29 | return SQLiteRoleProvider 30 | 31 | 32 | @overload 33 | def get_postgresql_provider(*, throw: Literal[True] = True) -> type[PostgreSQLRoleProvider]: ... 34 | @overload 35 | def get_postgresql_provider(*, throw: Literal[False]) -> type[PostgreSQLRoleProvider] | None: ... 36 | def get_postgresql_provider(*, throw: bool = True) -> type[PostgreSQLRoleProvider] | None: 37 | try: 38 | from .postgresql import PostgreSQLRoleProvider 39 | except ImportError as exc: 40 | if not throw: 41 | return None 42 | 43 | msg = "PostgreSQLRoleProvider requires :code:`asyncpg`, :code:`sqlalchemy` package. Install it using :code:`pip install raito[postgresql]`" 44 | raise ImportError(msg) from exc 45 | 46 | return PostgreSQLRoleProvider 47 | -------------------------------------------------------------------------------- /docs/source/quick_start.rst: -------------------------------------------------------------------------------- 1 | 🚀 Quick Start 2 | ========== 3 | 4 | Minimal setup to get your bot running with **Raito**: 5 | 6 | .. code-block:: python 7 | 8 | import asyncio 9 | 10 | from aiogram import Bot, Dispatcher 11 | from raito import Raito 12 | 13 | TOKEN: str = "YOUR_BOT_TOKEN" 14 | PRODUCTION: bool = False 15 | 16 | async def main() -> None: 17 | bot = Bot(token=TOKEN) 18 | dispatcher = Dispatcher() 19 | raito = Raito(dispatcher, "src/handlers", production=PRODUCTION) 20 | 21 | await raito.setup() 22 | await dispatcher.start_polling(bot) 23 | 24 | if __name__ == "__main__": 25 | asyncio.run(main()) 26 | 27 | What's happening here? 28 | ----------------------- 29 | 30 | - ``Raito(...)`` sets up handlers, locales, developers, managers, etc. 31 | - ``raito.setup()`` auto-loads routers from the `"src/handlers"` folder. 32 | - *You don't need to register routers manually.* 33 | - It starts handler hot-reload (watchdog) if production mode is disabled. 34 | 35 | ----------------------- 36 | 37 | .. tip:: 38 | Try adding a file with the following content, then modify the message text — 39 | the changes will be reflected instantly. 40 | 41 | .. code-block:: python 42 | 43 | from aiogram import Router 44 | from aiogram.types import Message 45 | from aiogram.filters import Command 46 | 47 | router = Router(name="ping") 48 | 49 | @router.message(Command("ping")) 50 | async def ping(message: Message): 51 | await message.answer("Pong! 🏓") 52 | 53 | 📦 Not installed yet? See :doc:`installation` 54 | 55 | ----- 56 | 57 | Also, Raito has a built-in commands. Send ``.rt help`` to your bot to see the list. 58 | 59 | .. image:: /_static/help-command.png 60 | :alt: Raito Help Command Example 61 | :align: left 62 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/sql/sqlite.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import and_, delete 2 | from sqlalchemy.dialects.sqlite import insert 3 | 4 | from .sqlalchemy import SQLAlchemyRoleProvider, roles_table 5 | 6 | __all__ = ("SQLiteRoleProvider",) 7 | 8 | 9 | class SQLiteRoleProvider(SQLAlchemyRoleProvider): 10 | """SQLite-based role provider. 11 | 12 | Required packages :code:`sqlalchemy[asyncio]`, :code:`aiosqlite` package installed (:code:`pip install raito[sqlite]`) 13 | """ 14 | 15 | async def set_role(self, bot_id: int, user_id: int, role_slug: str) -> None: 16 | """Set the role for a specific user. 17 | 18 | :param bot_id: The Telegram bot ID 19 | :param user_id: The Telegram user ID 20 | :param role_slug: The role slug to assign 21 | """ 22 | async with self.session_factory() as session: 23 | query = insert(roles_table).values( 24 | bot_id=bot_id, 25 | user_id=user_id, 26 | role=role_slug, 27 | ) 28 | query = query.on_conflict_do_update( 29 | index_elements=["bot_id", "user_id"], 30 | set_={"role": role_slug}, 31 | ) 32 | await session.execute(query) 33 | await session.commit() 34 | 35 | async def remove_role(self, bot_id: int, user_id: int) -> None: 36 | """Remove the role for a specific user. 37 | 38 | :param bot_id: The Telegram bot ID 39 | :param user_id: The Telegram user ID 40 | """ 41 | async with self.session_factory() as session: 42 | query = delete(roles_table).where( 43 | and_( 44 | roles_table.c.bot_id == bot_id, 45 | roles_table.c.user_id == user_id, 46 | ), 47 | ) 48 | await session.execute(query) 49 | await session.commit() 50 | -------------------------------------------------------------------------------- /examples/06-keyboards/handlers/leaderboard.py: -------------------------------------------------------------------------------- 1 | from dataclasses import dataclass 2 | from random import randint, sample 3 | 4 | from aiogram import F, Router 5 | from aiogram.types import Message 6 | from aiogram.utils.keyboard import InlineKeyboardBuilder 7 | 8 | from raito import rt 9 | 10 | router = Router(name="leaderboard") 11 | 12 | 13 | @dataclass 14 | class Player: 15 | number: int 16 | score: int 17 | 18 | 19 | @rt.keyboard.dynamic(adjust=False) 20 | def leaderboard_markup(builder: InlineKeyboardBuilder, players: list[Player]) -> None: 21 | adjust = [] 22 | 23 | for i, player in enumerate(players, start=1): 24 | match i: 25 | case 1: 26 | medal = "🥇 " 27 | case 2: 28 | medal = "🥈 " 29 | case 3: 30 | medal = "🥉 " 31 | case _: 32 | medal = "" 33 | 34 | text = f"{medal}#{player.number} — {player.score}" 35 | builder.button(text=text, callback_data=f"player:{player.number}") 36 | 37 | if i <= 3: 38 | adjust.append(1) 39 | elif i % 2 == 0: 40 | adjust.append(2) 41 | 42 | builder.button(text="◀️", callback_data="left") 43 | builder.button(text="↩️ Back", callback_data="back") 44 | builder.button(text="▶️", callback_data="right") 45 | adjust.append(3) 46 | 47 | builder.adjust(*adjust) 48 | 49 | 50 | def _get_players(total: int) -> list[Player]: 51 | numbers = sample(range(10, 1000), total) 52 | scores = sorted((randint(50, 300) * i for i in range(1, total + 1)), reverse=True) 53 | return [Player(number=num, score=score) for num, score in zip(numbers, scores)] 54 | 55 | 56 | @router.message(F.text == "🏆 Leaderboard") 57 | async def leaderboard(message: Message) -> None: 58 | players = _get_players(21) 59 | await message.answer("🏆 Leaderboard", reply_markup=leaderboard_markup(players)) 60 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/sql/postgresql.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import and_, delete 2 | from sqlalchemy.dialects.postgresql import insert 3 | 4 | from .sqlalchemy import SQLAlchemyRoleProvider, roles_table 5 | 6 | __all__ = ("PostgreSQLRoleProvider",) 7 | 8 | 9 | class PostgreSQLRoleProvider(SQLAlchemyRoleProvider): 10 | """PostgreSQL-based role provider. 11 | 12 | Required packages :code:`sqlalchemy[asyncio]`, :code:`asyncpg` package installed (:code:`pip install raito[postgresql]`) 13 | """ 14 | 15 | async def set_role(self, bot_id: int, user_id: int, role_slug: str) -> None: 16 | """Set the role for a specific user. 17 | 18 | :param bot_id: The Telegram bot ID 19 | :param user_id: The Telegram user ID 20 | :param role_slug: The role slug to assign 21 | """ 22 | async with self.session_factory() as session: 23 | query = insert(roles_table).values( 24 | bot_id=bot_id, 25 | user_id=user_id, 26 | role=role_slug, 27 | ) 28 | query = query.on_conflict_do_update( 29 | index_elements=["bot_id", "user_id"], 30 | set_={"role": role_slug}, 31 | ) 32 | await session.execute(query) 33 | await session.commit() 34 | 35 | async def remove_role(self, bot_id: int, user_id: int) -> None: 36 | """Remove the role for a specific user. 37 | 38 | :param bot_id: The Telegram bot ID 39 | :param user_id: The Telegram user ID 40 | """ 41 | async with self.session_factory() as session: 42 | query = delete(roles_table).where( 43 | and_( 44 | roles_table.c.bot_id == bot_id, 45 | roles_table.c.user_id == user_id, 46 | ), 47 | ) 48 | await session.execute(query) 49 | await session.commit() 50 | -------------------------------------------------------------------------------- /docs/source/plugins/conversations.rst: -------------------------------------------------------------------------------- 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 | .. warning:: 9 | 10 | Conversations DO NOT work with ``Dispatcher.events_isolation``, 11 | since ``SimpleEventIsolation`` operates with a ``asyncio.Lock`` on active handlers, 12 | and ``wait_for`` will never receive an update. 13 | 14 | 15 | -------- 16 | 17 | Example 18 | ------- 19 | 20 | .. code-block:: python 21 | 22 | from aiogram import F, Router, filters 23 | from aiogram.fsm.context import FSMContext 24 | from aiogram.types import Message 25 | 26 | from raito import Raito 27 | 28 | router = Router(name="mute") 29 | 30 | 31 | @router.message(filters.Command("mute")) 32 | async def mute(message: Message, raito: Raito, state: FSMContext) -> None: 33 | await message.answer("Enter username:") 34 | user = await raito.wait_for(state, F.text.regexp(r"@[\w]+")) 35 | 36 | await message.answer("Enter duration (in minutes):") 37 | duration = await raito.wait_for(state, F.text.isdigit()) 38 | 39 | while not duration.number or duration.number < 0: 40 | await message.answer("⚠️ Duration cannot be negative") 41 | duration = await duration.retry() 42 | 43 | await message.answer(f"✅ {user.text} will be muted for {duration.number} minutes") 44 | 45 | -------- 46 | 47 | How it works 48 | ------------ 49 | 50 | - Each ``wait_for`` call registers a pending conversation in Raito’s internal registry 51 | - A conversation entry stores: 52 | 53 | - A ``Future`` (to resume your handler later) 54 | - The filters to check incoming messages 55 | - When a message arrives: 56 | 57 | - Raito checks for an active conversation bound to that ``FSMContext.key`` 58 | - If filters match, the future is completed and returned to your handler 59 | -------------------------------------------------------------------------------- /examples/09-pagination/handlers/emoji_list.py: -------------------------------------------------------------------------------- 1 | from aiogram import Router 2 | from aiogram.filters.callback_data import CallbackData 3 | from aiogram.fsm.context import FSMContext 4 | from aiogram.types import CallbackQuery, InlineKeyboardButton, Message 5 | from aiogram.utils.keyboard import InlineKeyboardBuilder 6 | 7 | from raito import rt 8 | from raito.plugins.pagination import InlinePaginator 9 | 10 | router = Router(name="emoji_list") 11 | 12 | EMOJIS = [ 13 | "⚙️", 14 | "🔧", 15 | "🛠️", 16 | "🔒", 17 | "🗝️", 18 | "📣", 19 | "📩", 20 | "📍", 21 | "🧪", 22 | "🧬", 23 | "🌐", 24 | "🌍", 25 | "📌", 26 | "🎉", 27 | "🎊", 28 | "🎁", 29 | "🎈", 30 | "🎵", 31 | "🎤", 32 | ] 33 | 34 | 35 | class EmojiCallback(CallbackData, prefix="emoji_callback"): 36 | emoji: str 37 | 38 | 39 | @rt.on_pagination(router, "emoji_list") 40 | async def on_pagination( 41 | query: CallbackQuery, 42 | paginator: InlinePaginator, 43 | state: FSMContext, 44 | offset: int, 45 | limit: int, 46 | ) -> None: 47 | data = await state.get_data() 48 | numbers: list[int] = data.get("numbers", []) 49 | 50 | buttons = [] 51 | for number in numbers[offset : offset + limit]: 52 | emoji = EMOJIS[number % len(EMOJIS)] 53 | buttons.append( 54 | InlineKeyboardButton( 55 | text=emoji, 56 | callback_data=EmojiCallback(emoji=emoji).pack(), 57 | ) 58 | ) 59 | 60 | builder = InlineKeyboardBuilder() 61 | builder.button(text="[–] Remove", callback_data="remove") 62 | builder.button(text="[+] Add", callback_data="add") 63 | 64 | navigation = paginator.build_navigation() 65 | builder.attach(builder.from_markup(navigation)) 66 | 67 | await paginator.answer("Emoji list:", buttons=buttons, reply_markup=builder.as_markup()) 68 | 69 | 70 | @router.callback_query(EmojiCallback.filter()) 71 | async def send_emoji(query: CallbackQuery, callback_data: EmojiCallback): 72 | await query.answer(text=callback_data.emoji, show_alert=True) 73 | -------------------------------------------------------------------------------- /docs/source/index.rst: -------------------------------------------------------------------------------- 1 | 🔦 That's Raito! 2 | ============= 3 | 4 | *REPL, hot-reload, keyboards, pagination, and internal dev tools — all in one.* 5 | 6 | Features 7 | ~~~~~~~~ 8 | 9 | - :doc:`Hot Reload ` — automatic router loading and file watching for instant development cycles 10 | - :doc:`Role System ` — pre-configured roles (owner, support, tester, etc) and selector UI 11 | - :doc:`Pagination ` — easy pagination over text and media using inline buttons 12 | - **FSM Toolkit** — interactive confirmations, questionnaires, and mockable message flow 13 | - **CLI Generator** — ``$ raito init`` creates a ready-to-use bot template in seconds 14 | - :doc:`Keyboard Factory ` — static and dynamic generation 15 | - :doc:`Command Registration ` — automatic setup of bot commands with descriptions for each 16 | - :doc:`Album Support ` — groups media albums and passes them to handlers 17 | - :doc:`Rate Limiting ` — apply global or per-command throttling via decorators or middleware 18 | - **Database Storages** — optional JSON & SQL support 19 | - **REPL** — execute async Python in context (``_msg``, ``_user``, ``_raito``) 20 | - :doc:`Params Parser ` — extracts and validates command arguments 21 | - :doc:`Logging Formatter ` — beautiful, readable logs out of the box 22 | - **Metrics** — inspect memory usage, uptime, and caching stats 23 | 24 | 25 | ------------------- 26 | 27 | 28 | 🚀 Quick Start 29 | ~~~~~~~~~~~ 30 | 31 | .. code-block:: python 32 | 33 | import asyncio 34 | 35 | from aiogram import Bot, Dispatcher 36 | from raito import Raito 37 | 38 | async def main() -> None: 39 | bot = Bot(token="TOKEN") 40 | dispatcher = Dispatcher() 41 | raito = Raito(dispatcher, "src/handlers") 42 | 43 | await raito.setup() 44 | await dispatcher.start_polling(bot) 45 | 46 | if __name__ == "__main__": 47 | asyncio.run(main()) 48 | 49 | Contents 50 | -------- 51 | .. toctree:: 52 | :maxdepth: 1 53 | 54 | installation 55 | quick_start 56 | plugins/index 57 | utils/index 58 | -------------------------------------------------------------------------------- /raito/handlers/system/stats.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import time 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | import psutil 8 | from aiogram import Router, html 9 | 10 | from raito.plugins.commands import description, hidden 11 | from raito.plugins.roles.roles import DEVELOPER 12 | from raito.utils.filters import RaitoCommand 13 | 14 | if TYPE_CHECKING: 15 | from aiogram.types import Message 16 | 17 | router = Router(name="raito.system.stats") 18 | 19 | 20 | @dataclass 21 | class MemoryInformation: 22 | rss_mb: float 23 | vms_mb: float 24 | 25 | 26 | @dataclass 27 | class ProcessStats: 28 | uptime_sec: int 29 | memory: MemoryInformation 30 | cpu_percent: float 31 | 32 | 33 | def get_process_stats() -> ProcessStats: 34 | proc = psutil.Process() 35 | 36 | with proc.oneshot(): 37 | mem_info = proc.memory_info() 38 | cpu_percent = proc.cpu_percent(interval=0.1) 39 | create_time = proc.create_time() 40 | 41 | return ProcessStats( 42 | uptime_sec=int(time.time() - create_time), 43 | memory=MemoryInformation( 44 | rss_mb=mem_info.rss / 1024**2, 45 | vms_mb=mem_info.vms / 1024**2, 46 | ), 47 | cpu_percent=cpu_percent, 48 | ) 49 | 50 | 51 | def strf_seconds(seconds: int) -> str: 52 | days = seconds // 86400 53 | hours = (seconds % 86400) // 3600 54 | minutes = (seconds % 3600) // 60 55 | seconds = seconds % 60 56 | 57 | return ( 58 | (f"{days}d " if days else "") 59 | + (f"{hours}h " if hours else "") 60 | + (f"{minutes}m " if minutes else "") 61 | + (f"{seconds}s" if seconds else "") 62 | ) 63 | 64 | 65 | @router.message(RaitoCommand("stats"), DEVELOPER) 66 | @description("Show process stats") 67 | @hidden 68 | async def stats(message: Message) -> None: 69 | stats = get_process_stats() 70 | 71 | text = "\n".join( 72 | [ 73 | html.bold("Process stats"), 74 | "", 75 | f"• CPU: {stats.cpu_percent:.2f}%", 76 | f"• RAM: {stats.memory.rss_mb:.2f}mb", 77 | f"• Uptime: {strf_seconds(stats.uptime_sec)}", 78 | ] 79 | ) 80 | await message.answer(text=text, parse_mode="HTML") 81 | -------------------------------------------------------------------------------- /raito/plugins/commands/flags.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from aiogram.dispatcher.flags import Flag, FlagDecorator 7 | 8 | if TYPE_CHECKING: 9 | from aiogram.utils.i18n.lazy_proxy import LazyProxy # type: ignore 10 | 11 | __all__ = ("description", "hidden", "params") 12 | 13 | 14 | def description(description: str | LazyProxy) -> FlagDecorator: 15 | """Attach a description to the command handler for use in command registration (e.g., :code:`raito.register_commands`) 16 | 17 | The description will be shown in the Telegram command list (via :code:`set_my_commands`) 18 | Supports internationalization via `LazyProxy`. 19 | 20 | :param description: A string or LazyProxy representing the description. 21 | :return: A FlagDecorator to be applied to the handler. 22 | """ 23 | return FlagDecorator(Flag("raito__description", value=description)) 24 | 25 | 26 | def hidden(func: Callable) -> Callable[..., Any]: 27 | """Mark a command handler as hidden from the command list. 28 | 29 | Hidden handlers will not be included in Telegram's slash commands when calling :code:`raito.register_commands`. 30 | 31 | :param func: The command handler to mark as hidden. 32 | :return: The wrapped handler. 33 | """ 34 | return FlagDecorator(Flag("raito__hidden", value=True))(func) 35 | 36 | 37 | def params(**kwargs: type[str] | type[int] | type[bool] | type[float]) -> FlagDecorator: 38 | """Define expected parameters and their types for command parsing. 39 | 40 | This acts as a lightweight argument extractor and validator for commands. 41 | For example, :code:`@rt.params(user_id=int)` will extract :code:`user_id=1234` from a command like :code:`/ban 1234`. 42 | 43 | Example: 44 | 45 | .. code-block:: python 46 | 47 | @router.message(filters.Command("ban")) 48 | @rt.params(user_id=int) 49 | def ban(message: Message, user_id: int): 50 | ... 51 | 52 | :param kwargs: A mapping of parameter names to their expected types. 53 | :return: A FlagDecorator to be applied to the handler with param data. 54 | """ 55 | return FlagDecorator(Flag("raito__params", value=kwargs)) 56 | -------------------------------------------------------------------------------- /raito/plugins/album/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | from collections.abc import Awaitable, Callable 5 | from typing import TYPE_CHECKING, Any, TypeVar 6 | 7 | from aiogram.dispatcher.event.bases import REJECTED 8 | from aiogram.dispatcher.middlewares.base import BaseMiddleware 9 | from aiogram.types import Message 10 | from cachetools import TTLCache 11 | from typing_extensions import override 12 | 13 | if TYPE_CHECKING: 14 | from aiogram.types import TelegramObject 15 | 16 | R = TypeVar("R") 17 | 18 | 19 | __all__ = ("AlbumMiddleware",) 20 | 21 | 22 | class AlbumMiddleware(BaseMiddleware): 23 | """Middleware for album handling.""" 24 | 25 | def __init__( 26 | self, 27 | delay: float | int = 0.6, 28 | max_size: int = 10_000, 29 | ) -> None: 30 | """Initialize AlbumMiddleware. 31 | 32 | :param flag_name: flag name to filter 33 | :type flag_name: str 34 | """ 35 | self.delay = delay 36 | self._album_data = TTLCache[str, list[Message]](maxsize=max_size, ttl=delay * 5) 37 | 38 | @override 39 | async def __call__( 40 | self, 41 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[R]], 42 | event: TelegramObject, 43 | data: dict[str, Any], 44 | ) -> R | None: 45 | """Process message with album support. 46 | 47 | :param handler: Next handler in the middleware chain 48 | :param event: Telegram event (Message or CallbackQuery) 49 | :param data: Additional data passed through the middleware chain 50 | :return: Handler result 51 | """ 52 | if not isinstance(event, Message): 53 | return await handler(event, data) 54 | 55 | if not event.media_group_id: 56 | return await handler(event, data) 57 | 58 | if album_data := self._album_data.get(event.media_group_id): 59 | album_data.append(event) 60 | return REJECTED 61 | 62 | self._album_data[event.media_group_id] = [event] 63 | await sleep(self.delay) 64 | 65 | # after sending all media files: 66 | data["album"] = self._album_data.pop(event.media_group_id) 67 | 68 | return await handler(event, data) 69 | -------------------------------------------------------------------------------- /raito/plugins/conversations/waiter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Callable, Coroutine 4 | from dataclasses import dataclass 5 | from typing import TYPE_CHECKING 6 | 7 | from aiogram.dispatcher.event.handler import CallbackType 8 | from aiogram.fsm.context import FSMContext 9 | from aiogram.types import Message 10 | from typing_extensions import Any 11 | 12 | if TYPE_CHECKING: 13 | from raito import Raito 14 | 15 | __all__ = ("Waiter", "wait_for") 16 | 17 | 18 | @dataclass 19 | class Waiter: 20 | """Container for conversation result. 21 | 22 | :param text: Raw text of the message 23 | :param number: Parsed integer if ``text`` is a digit, otherwise ``None`` 24 | :param message: Original :class:`aiogram.types.Message` object 25 | :param retry: Callable coroutine for repeating the same wait 26 | """ 27 | 28 | text: str 29 | number: int | None 30 | message: Message 31 | retry: Callable[[], Coroutine[Any, Any, Waiter]] 32 | 33 | 34 | async def wait_for( 35 | raito: Raito, 36 | context: FSMContext, 37 | *filters: CallbackType, 38 | ) -> Waiter: 39 | """Wait for the next message from user that matches given filters. 40 | 41 | This function sets special state ``raito__conversation`` in FSM and 42 | suspends coroutine execution until user sends a message that passes 43 | all provided filters. Result is wrapped into :class:`Waiter`. 44 | 45 | :param raito: Current :class:`Raito` instance 46 | :param context: FSM context for current chat 47 | :param filters: Sequence of aiogram filters 48 | :return: Conversation result with text, parsed number and original message 49 | :raises RuntimeError: If handler object not found during filter execution 50 | :raises asyncio.CancelledError: If conversation was cancelled 51 | """ 52 | await context.set_state(raito.registry.STATE) 53 | message = await raito.registry.listen(context.key, *filters) 54 | 55 | async def retry() -> Waiter: 56 | return await wait_for(raito, context, *filters) 57 | 58 | text = message.text or message.caption or "" 59 | return Waiter( 60 | text=text, 61 | number=int(text) if text.isdigit() else None, 62 | message=message, 63 | retry=retry, 64 | ) 65 | -------------------------------------------------------------------------------- /raito/utils/errors.py: -------------------------------------------------------------------------------- 1 | from typing import TypeVar 2 | 3 | from aiogram.exceptions import TelegramBadRequest 4 | 5 | T = TypeVar("T") 6 | 7 | 8 | class SuppressNotModifiedError: 9 | """ 10 | Context manager that suppresses the ``TelegramBadRequest`` exception 11 | with the message ``Bad Request: message is not modified``. 12 | 13 | This is useful when editing a Telegram message and the new content 14 | is identical to the existing one, which would otherwise raise 15 | an error from the Telegram API. 16 | 17 | Example: 18 | 19 | .. code-block:: python 20 | 21 | from raito.utils.errors import SuppressNotModifiedError 22 | 23 | with SuppressNotModifiedError(): 24 | await message.edit_text("same text") 25 | 26 | :param ignore_message: The exact error message to match. 27 | If this message is found in the raised 28 | ``TelegramBadRequest``, the exception is suppressed. 29 | Defaults to ``"Bad Request: message is not modified"``. 30 | :type ignore_message: str 31 | """ 32 | 33 | def __init__(self, ignore_message: str = "Bad Request: message is not modified") -> None: 34 | """Initialize the context manager. 35 | 36 | :param ignore_message: Error message string to match against. 37 | """ 38 | self.ignore_message = ignore_message 39 | 40 | def __enter__(self: T) -> T: 41 | """Enter the runtime context related to this object. 42 | 43 | :return: The context manager instance itself. 44 | """ 45 | return self 46 | 47 | def __exit__( 48 | self, 49 | exc_type: type[BaseException] | None, 50 | exc_val: BaseException | None, 51 | _: object | None, 52 | ) -> bool: 53 | """Exit the runtime context and suppress the exception if it matches. 54 | 55 | :param exc_type: The exception type (if any). 56 | :param exc_val: The exception instance (if any). 57 | :param _: The traceback object (unused). 58 | :return: True if the exception should be suppressed, False otherwise. 59 | :rtype: bool 60 | """ 61 | return ( 62 | exc_type is TelegramBadRequest 63 | and exc_val is not None 64 | and self.ignore_message in str(exc_val) 65 | ) 66 | -------------------------------------------------------------------------------- /raito/utils/storages/sql/__init__.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Literal, overload 2 | 3 | __all__ = ( 4 | "get_postgresql_storage", 5 | "get_sqlite_storage", 6 | ) 7 | 8 | if TYPE_CHECKING: 9 | from aiogram.fsm.storage.redis import RedisStorage 10 | 11 | from .postgresql import PostgreSQLStorage 12 | from .sqlite import SQLiteStorage 13 | 14 | 15 | @overload 16 | def get_sqlite_storage(*, throw: Literal[True] = True) -> type["SQLiteStorage"]: ... 17 | @overload 18 | def get_sqlite_storage(*, throw: Literal[False]) -> type["SQLiteStorage"] | None: ... 19 | def get_sqlite_storage(*, throw: bool = True) -> type["SQLiteStorage"] | None: 20 | try: 21 | from .sqlite import SQLiteStorage 22 | except ImportError as exc: 23 | if not throw: 24 | return None 25 | 26 | msg = "SQLiteStorage requires :code:`aiosqlite` package. Install it using :code:`pip install raito[sqlite]`" 27 | raise ImportError(msg) from exc 28 | 29 | return SQLiteStorage 30 | 31 | 32 | @overload 33 | def get_postgresql_storage(*, throw: Literal[True] = True) -> type["PostgreSQLStorage"]: ... 34 | @overload 35 | def get_postgresql_storage(*, throw: Literal[False]) -> type["PostgreSQLStorage"] | None: ... 36 | def get_postgresql_storage(*, throw: bool = True) -> type["PostgreSQLStorage"] | None: 37 | try: 38 | from .postgresql import PostgreSQLStorage 39 | except ImportError as exc: 40 | if not throw: 41 | return None 42 | 43 | msg = "PostgreSQLStorage requires :code:`asyncpg`, :code:`sqlalchemy` package. Install it using :code:`pip install raito[postgresql]`" 44 | raise ImportError(msg) from exc 45 | 46 | return PostgreSQLStorage 47 | 48 | 49 | @overload 50 | def get_redis_storage(*, throw: Literal[True] = True) -> type["RedisStorage"]: ... 51 | @overload 52 | def get_redis_storage(*, throw: Literal[False]) -> type["RedisStorage"] | None: ... 53 | def get_redis_storage(*, throw: bool = True) -> type["RedisStorage"] | None: 54 | try: 55 | from aiogram.fsm.storage.redis import RedisStorage 56 | except ImportError as exc: 57 | if not throw: 58 | return None 59 | 60 | msg = "RedisStorage requires :code:`redis` package. Install it using :code:`pip install raito[redis]`" 61 | raise ImportError(msg) from exc 62 | 63 | return RedisStorage 64 | -------------------------------------------------------------------------------- /raito/plugins/conversations/middleware.py: -------------------------------------------------------------------------------- 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) 69 | -------------------------------------------------------------------------------- /raito/plugins/pagination/paginators/protocol.py: -------------------------------------------------------------------------------- 1 | from typing import TYPE_CHECKING, Protocol 2 | 3 | from raito.plugins.pagination.enums import PaginationMode 4 | 5 | if TYPE_CHECKING: 6 | from aiogram.types import InlineKeyboardMarkup 7 | 8 | __all__ = ("IPaginator",) 9 | 10 | 11 | class IPaginator(Protocol): 12 | """Protocol for paginator implementations. 13 | 14 | Defines the interface that all paginator classes must implement. 15 | """ 16 | 17 | @property 18 | def current_page(self) -> int: 19 | """Get current page number. 20 | 21 | :return: current page number 22 | :rtype: int 23 | """ 24 | ... 25 | 26 | @property 27 | def total_pages(self) -> int | None: 28 | """Get total pages count. 29 | 30 | :return: total pages or None 31 | :rtype: int | None 32 | """ 33 | ... 34 | 35 | @property 36 | def limit(self) -> int: 37 | """Get items per page limit. 38 | 39 | :return: items per page 40 | :rtype: int 41 | """ 42 | ... 43 | 44 | @property 45 | def mode(self) -> "PaginationMode": 46 | """Get pagination mode. 47 | 48 | :return: pagination mode 49 | :rtype: PaginationMode 50 | """ 51 | ... 52 | 53 | async def paginate(self) -> None: 54 | """Start pagination.""" 55 | ... 56 | 57 | def get_previous_page(self) -> int: 58 | """Get previous page number. 59 | 60 | :return: previous page number 61 | :rtype: int 62 | """ 63 | ... 64 | 65 | def get_next_page(self) -> int: 66 | """Get next page number. 67 | 68 | :return: next page number 69 | :rtype: int 70 | """ 71 | ... 72 | 73 | def build_navigation(self) -> "InlineKeyboardMarkup": 74 | """Build navigation keyboard. 75 | 76 | :return: navigation keyboard markup 77 | :rtype: InlineKeyboardMarkup 78 | """ 79 | ... 80 | 81 | @classmethod 82 | def calc_total_pages(cls, total_items: int, limit: int) -> int: 83 | """Calculate total pages from items count. 84 | 85 | :param total_items: total items count 86 | :type total_items: int 87 | :param limit: items per page 88 | :type limit: int 89 | :return: total pages count 90 | :rtype: int 91 | """ 92 | ... 93 | -------------------------------------------------------------------------------- /docs/source/plugins/hot_reload.rst: -------------------------------------------------------------------------------- 1 | 🔥 Hot Reload 2 | ============= 3 | 4 | Raito automatically reloads routers on file changes in development mode. 5 | This means you can edit handlers and see your updates **instantly — without restarting.** 6 | 7 | How it works 8 | ------------ 9 | 10 | - Hot reload is **enabled by default** when you set `production=False`: 11 | 12 | .. code-block:: python 13 | 14 | raito = Raito(dispatcher, routers_dir="src/handlers", production=False) 15 | 16 | - During ``raito.setup()``, Raito: 17 | 1. Scans your ``routers_dir`` for Python files 18 | 2. Skips files starting with ``_`` 19 | 3. Dynamically imports all routers (with ``router = Router(...)``) 20 | 4. Starts a `watchdog `_ using ``watchfiles.awatch`` 21 | 5. Tracks file changes and reloads the corresponding routers 22 | 23 | - No need to manually call ``include_router()`` or manage imports 24 | 25 | ---------- 26 | 27 | Example 28 | ~~~~~~~~~~ 29 | 30 | .. code-block:: python 31 | 32 | from aiogram import Router 33 | from aiogram.types import Message 34 | from aiogram.filters import Command 35 | 36 | router = Router(name="debug") 37 | 38 | @router.message(Command("debug")) 39 | async def debug_handler(message: Message): 40 | await message.answer("Hello, this is a live-reloading handler!") 41 | 42 | Edit the message and hit save — it will reload automatically. 43 | 44 | ---------- 45 | 46 | What happens on file change? 47 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 48 | 49 | - If a ``.py`` file is **modified or created**: 50 | - The corresponding router is **unloaded and loaded** 51 | - If a ``.py`` file is **deleted**: 52 | - The router is **unregistered** 53 | 54 | Each router is uniquely tracked by ``Router.name``. 55 | 56 | -------- 57 | 58 | Telegram Raito Commands 59 | ~~~~~~~~~~~~~~~~~~~~~~~ 60 | You can also manage routers **manually via Telegram chat**: 61 | 62 | - ``.rt routers`` — List all registered routers 63 | - ``.rt unload `` — Unload a router by name 64 | - ``.rt load `` — Load a router by name 65 | - ``.rt reload `` — Reload an existing router 66 | 67 | -------- 68 | 69 | Limitations 70 | ~~~~~~~~~~~ 71 | - Changes to **shared modules** (e.g. ``utils/``, ``models/``) do not trigger reloads 72 | - Reloads affect only files in ``routers_dir`` 73 | - If a router has side-effects at the top level (e.g., DB queries) — they may run twice 74 | -------------------------------------------------------------------------------- /raito/core/routers/parser.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import sys 4 | from importlib.util import module_from_spec, spec_from_file_location 5 | from pathlib import Path 6 | from typing import TYPE_CHECKING 7 | 8 | from aiogram import Router 9 | 10 | if TYPE_CHECKING: 11 | from raito.utils.types import StrOrPath 12 | 13 | 14 | __all__ = ("RouterParser",) 15 | 16 | 17 | class RouterParser: 18 | """Parses routers from Python files.""" 19 | 20 | @classmethod 21 | def extract_router(cls, file_path: StrOrPath) -> Router: 22 | """Extract router from a Python file. 23 | 24 | :param file_path: Path to the Python file 25 | :type file_path: StrOrPath 26 | :return: Extracted router instance 27 | :rtype: Router 28 | """ 29 | file_path = Path(file_path) 30 | module = cls._load_module(file_path) 31 | return cls._validate_router(module) 32 | 33 | @classmethod 34 | def _load_module(cls, file_path: StrOrPath) -> object: 35 | """Load module from file path. 36 | 37 | :param file_path: Path to the Python file to load 38 | :type file_path: StrOrPath 39 | :return: Loaded module object 40 | :rtype: object 41 | :raises ModuleNotFoundError: If module cannot be loaded from the file path 42 | """ 43 | spec = spec_from_file_location("dynamic_module", file_path) 44 | 45 | if spec is None or spec.loader is None: 46 | msg = f"Cannot load module from {file_path}" 47 | raise ModuleNotFoundError(msg) 48 | 49 | module = module_from_spec(spec) 50 | module.__name__ = spec.name 51 | module.__file__ = str(file_path) 52 | sys.modules[spec.name] = module 53 | spec.loader.exec_module(module) 54 | 55 | return module 56 | 57 | @classmethod 58 | def _validate_router(cls, module: object) -> Router: 59 | """Validate and return router from module. 60 | 61 | :param module: Module object to extract router from 62 | :type module: object 63 | :return: Validated router instance 64 | :rtype: Router 65 | :raises TypeError: If the module doesn't contain a valid Router instance 66 | """ 67 | router = getattr(module, "router", None) 68 | if not isinstance(router, Router): 69 | msg = f"Expected Router, got {type(router).__name__}" 70 | raise TypeError(msg) 71 | 72 | return router 73 | -------------------------------------------------------------------------------- /raito/plugins/lifespan/decorator.py: -------------------------------------------------------------------------------- 1 | from collections.abc import AsyncGenerator, Callable 2 | from contextlib import AbstractAsyncContextManager, asynccontextmanager, suppress 3 | from typing import TypeAlias 4 | 5 | from aiogram import Bot, Router 6 | 7 | from raito.utils.helpers.safe_partial import safe_partial 8 | 9 | __all__ = ("lifespan",) 10 | 11 | FuncType: TypeAlias = Callable[..., AsyncGenerator[None, None]] 12 | AsyncCtx: TypeAlias = AbstractAsyncContextManager[None] 13 | LifespanStacks: TypeAlias = dict[int, list[AsyncCtx]] 14 | 15 | _LIFESPAN_STACKS = "__lifespan_stacks__" 16 | 17 | 18 | def _get_stack(router: Router) -> LifespanStacks: 19 | stacks = getattr(router, _LIFESPAN_STACKS, None) 20 | if stacks is None: 21 | stacks = {} 22 | setattr(router, _LIFESPAN_STACKS, stacks) 23 | return stacks 24 | 25 | 26 | def lifespan(router: Router) -> Callable[[FuncType], FuncType]: 27 | """ 28 | Register a lifespan function for a given router, similar to FastAPI's lifespan handler. 29 | The function must be an async generator: it runs setup before `yield`, and cleanup after. 30 | """ 31 | 32 | def decorator(func: FuncType) -> FuncType: 33 | @asynccontextmanager 34 | async def context(**kwargs: dict[str, object]) -> AsyncGenerator[None, None]: 35 | gen = safe_partial(func, **kwargs)() 36 | await gen.__anext__() 37 | try: 38 | yield 39 | finally: 40 | with suppress(StopAsyncIteration): 41 | await gen.__anext__() 42 | 43 | async def on_startup(**kwargs: dict[str, object]) -> None: 44 | bot = kwargs.get("bot") 45 | assert isinstance(bot, Bot), "Missing or invalid 'bot' in lifespan context" 46 | 47 | ctx = context(**kwargs) 48 | await ctx.__aenter__() 49 | _get_stack(router).setdefault(bot.id, []).append(ctx) 50 | 51 | async def on_shutdown(**kwargs: dict[str, object]) -> None: 52 | bot = kwargs.get("bot") 53 | assert isinstance(bot, Bot), "Missing or invalid 'bot' in lifespan context" 54 | 55 | stack = _get_stack(router).get(bot.id, []) 56 | for ctx in reversed(stack): 57 | await ctx.__aexit__(None, None, None) 58 | stack.clear() 59 | 60 | router.startup.register(on_startup) 61 | router.shutdown.register(on_shutdown) 62 | 63 | return func 64 | 65 | return decorator 66 | -------------------------------------------------------------------------------- /raito/handlers/roles/revoke.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot, F, Router, html 6 | from aiogram.fsm.state import State, StatesGroup 7 | 8 | from raito.plugins.commands import description, hidden 9 | from raito.plugins.commands.registration import register_bot_commands 10 | from raito.plugins.roles.roles import ADMINISTRATOR, DEVELOPER, OWNER 11 | from raito.utils.filters import RaitoCommand 12 | 13 | if TYPE_CHECKING: 14 | from aiogram.fsm.context import FSMContext 15 | from aiogram.types import Message 16 | 17 | from raito.core.raito import Raito 18 | 19 | router = Router(name="raito.roles.revoke") 20 | 21 | 22 | class RevokeRoleGroup(StatesGroup): 23 | """State group for revoking roles.""" 24 | 25 | user_id = State() 26 | 27 | 28 | @router.message(RaitoCommand("revoke"), DEVELOPER | OWNER | ADMINISTRATOR) 29 | @description("Revokes a role from a user") 30 | @hidden 31 | async def revoke(message: Message, state: FSMContext) -> None: 32 | await message.answer("👤 Enter user ID:") 33 | await state.set_state(RevokeRoleGroup.user_id) 34 | 35 | 36 | @router.message( 37 | RevokeRoleGroup.user_id, 38 | F.text and F.text.isdigit(), 39 | DEVELOPER | OWNER | ADMINISTRATOR, 40 | ) 41 | async def revoke_role(message: Message, raito: Raito, state: FSMContext, bot: Bot) -> None: 42 | if not message.bot: 43 | await message.answer("🚫 Bot not found") 44 | return 45 | if not message.text or not message.text.isdigit(): 46 | await message.answer("🚫 Invalid user ID") 47 | return 48 | if not message.from_user: 49 | await message.answer("🚫 Initiator not found") 50 | return 51 | await state.set_state() 52 | 53 | role_slug = await raito.role_manager.get_role( 54 | message.bot.id, 55 | int(message.text), 56 | ) 57 | if not role_slug: 58 | await message.answer("⚠️ User does not have the role") 59 | return 60 | 61 | try: 62 | await raito.role_manager.revoke_role( 63 | message.bot.id, 64 | message.from_user.id, 65 | int(message.text), 66 | ) 67 | except PermissionError: 68 | await message.answer("🚫 Permission denied") 69 | return 70 | 71 | role = raito.role_manager.get_role_data(role_slug) 72 | await message.answer(f"🛑 User revoked from {html.bold(role.label)}", parse_mode="HTML") 73 | 74 | handlers = [] 75 | for loader in raito.router_manager.loaders.values(): 76 | handlers.extend(loader.router.message.handlers) 77 | 78 | await register_bot_commands(raito.role_manager, bot, handlers, raito.locales) 79 | -------------------------------------------------------------------------------- /raito/handlers/system/eval.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from html import escape 4 | from typing import TYPE_CHECKING, Any 5 | 6 | from aiogram import F, Router, html 7 | from aiogram.filters import CommandObject 8 | from aiogram.fsm.state import State, StatesGroup 9 | 10 | from raito.plugins.commands import description, hidden 11 | from raito.plugins.roles import DEVELOPER 12 | from raito.utils.filters import RaitoCommand 13 | from raito.utils.helpers.code_evaluator import CodeEvaluator 14 | 15 | if TYPE_CHECKING: 16 | from aiogram.fsm.context import FSMContext 17 | from aiogram.types import Message 18 | 19 | router = Router(name="raito.system.eval") 20 | code_evaluator = CodeEvaluator() 21 | 22 | 23 | class EvalGroup(StatesGroup): 24 | expression = State() 25 | 26 | 27 | async def _execute_code(message: Message, code: str, data: dict[str, Any]) -> None: 28 | data = {"_" + k: v for k, v in data.items()} 29 | data["_msg"] = message 30 | data["_user"] = message.from_user 31 | 32 | evaluation_data = await code_evaluator.evaluate(code, data) 33 | pre_blocks = [] 34 | 35 | if evaluation_data.stdout: 36 | pre_blocks.append(evaluation_data.stdout[:1000]) 37 | 38 | if evaluation_data.error: 39 | pre_blocks.append(evaluation_data.error[:3000]) 40 | elif evaluation_data.result is not None: 41 | pre_blocks.append(evaluation_data.result[:3000]) 42 | else: 43 | pre_blocks.append("no output") 44 | 45 | text = "\n\n".join([html.pre(escape(i)) for i in pre_blocks]) 46 | await message.answer(text=text, parse_mode="HTML") 47 | 48 | 49 | @router.message(RaitoCommand("eval", "py", "py3", "python", "exec"), DEVELOPER) 50 | @description("Execute Python script") 51 | @hidden 52 | async def eval_handler( 53 | message: Message, 54 | state: FSMContext, 55 | command: CommandObject, 56 | **data: Any, # noqa: ANN401 57 | ) -> None: 58 | if not command.args: 59 | await message.answer(text="📦 Enter Python expression:") 60 | await state.set_state(EvalGroup.expression) 61 | return 62 | 63 | data["message"] = message 64 | data["state"] = state 65 | data["command"] = command 66 | await _execute_code(message, command.args, data) 67 | 68 | 69 | @router.message(EvalGroup.expression, F.text, DEVELOPER) 70 | async def eval_process( 71 | message: Message, 72 | state: FSMContext, 73 | **data: Any, # noqa: ANN401 74 | ) -> None: 75 | await state.clear() 76 | 77 | if not message.text: 78 | await message.answer(text="⚠️ Expression cannot be empty") 79 | return 80 | 81 | data["message"] = message 82 | data["state"] = state 83 | await _execute_code(message, message.text, data) 84 | -------------------------------------------------------------------------------- /docs/source/plugins/pagination.rst: -------------------------------------------------------------------------------- 1 | 📖 Pagination 2 | ============================= 3 | 4 | Need to paginate a long list of items? 5 | 6 | Raito provides a built-in system for inline, text, and photo pagination — simple and fully customized. 7 | 8 | -------- 9 | 10 | Features 11 | -------- 12 | 13 | - Predefined paginator types (inline, text, photo) 14 | - Auto-generated navigation 15 | - Loop navigation (wrap around first/last) 16 | - Declarative handler with ``@rt.on_pagination(...)`` 17 | - Flexible API and pluggable paginator types 18 | 19 | --------- 20 | 21 | Quick Start 22 | ----------- 23 | 24 | 1. Invoking Pagination 25 | ~~~~~~~~~~~~~~~~~~~~~~ 26 | 27 | .. code-block:: python 28 | 29 | @router.message(filters.CommandStart()) 30 | async def start(message: Message, raito: Raito, bot: Bot) -> None: 31 | if not message.from_user: 32 | return 33 | 34 | await raito.paginate( 35 | "my_pagination_name", 36 | chat_id=message.chat.id, 37 | bot=bot, 38 | from_user=message.from_user, 39 | total_pages=10, 40 | limit=5, 41 | ) 42 | 43 | 2. Handling Pagination Events 44 | ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ 45 | 46 | To respond to page changes, use the ``@rt.on_pagination(...)`` decorator: 47 | 48 | .. code-block:: python 49 | 50 | @rt.on_pagination(router, "my_pagination_name") 51 | async def on_pagination( 52 | query: CallbackQuery, 53 | paginator: InlinePaginator, 54 | offset: int, 55 | limit: int, 56 | ): 57 | buttons = [InlineKeyboardButton(text=str(i), callback_data=f"button_{i}") for i in range(offset, offset + limit)] 58 | await paginator.answer("Button list:", buttons=buttons) 59 | 60 | --------- 61 | 62 | Navigation 63 | ---------- 64 | 65 | Each paginator generates a default navigation row. 66 | 67 | Raito merges this with your custom buttons automatically: 68 | 69 | - If ``buttons`` only — adds default navigation 70 | - If ``buttons`` + ``reply_markup`` — combines both 71 | - If ``reply_markup`` only — uses it directly 72 | 73 | To manually attach navigation: 74 | 75 | .. code-block:: python 76 | 77 | builder = InlineKeyboardBuilder() 78 | navigation = paginator.build_navigation() 79 | 80 | builder.attach(builder.from_markup(navigation)) 81 | builder.button(text="Back", callback_data="menu") 82 | 83 | --------- 84 | 85 | Under the Hood 86 | -------------- 87 | 88 | - All callbacks use this format: ``rt_p:mode:name:page:total:limit`` 89 | - `PaginatorMiddleware` parses data and injects: 90 | - ``paginator``, ``offset``, ``limit``, ``page`` 91 | - Everything is type-safe and based on ``IPaginator`` protocol 92 | - You can build custom paginators and plug them in 93 | -------------------------------------------------------------------------------- /raito/utils/storages/sql/sqlite.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Mapping 4 | from datetime import datetime, timezone 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from sqlalchemy import URL 8 | from sqlalchemy.dialects.sqlite import insert 9 | from sqlalchemy.ext.asyncio import create_async_engine 10 | from typing_extensions import override 11 | 12 | from .sqlalchemy import SQLAlchemyStorage, storage_table 13 | 14 | if TYPE_CHECKING: 15 | from aiogram.filters.state import StateType 16 | from aiogram.fsm.storage.base import StorageKey 17 | 18 | __all__ = ("SQLiteStorage",) 19 | 20 | 21 | class SQLiteStorage(SQLAlchemyStorage): 22 | """SQLite storage for FSM. 23 | 24 | Required packages :code:`sqlalchemy[asyncio]`, :code:`aiosqlite` package installed (:code:`pip install raito[sqlite]`) 25 | """ 26 | 27 | def __init__(self, url: str | URL) -> None: 28 | """Initialize SQLite storage. 29 | 30 | :param url: SQLite database URL 31 | :type url: str | URL 32 | """ 33 | self.url = url 34 | engine = create_async_engine(url=self.url) 35 | super().__init__(engine=engine) 36 | 37 | @override 38 | async def set_state(self, key: StorageKey, state: StateType | None = None) -> None: 39 | """Set state for specified key. 40 | 41 | :param key: Storage key 42 | :type key: StorageKey 43 | :param state: New state 44 | :type state: StateType | None 45 | """ 46 | str_key = self._build_key(key) 47 | async with self.session_factory() as session: 48 | query = insert(storage_table).values( 49 | key=str_key, 50 | state=state, 51 | data={}, 52 | ) 53 | query = query.on_conflict_do_update( 54 | index_elements=["key"], 55 | set_={"state": state, "updated_at": datetime.now(timezone.utc)}, 56 | ) 57 | await session.execute(query) 58 | await session.commit() 59 | 60 | @override 61 | async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: 62 | """Write data (replace). 63 | 64 | :param key: Storage key 65 | :type key: StorageKey 66 | :param data: New data 67 | :type data: Dict[str, Any] 68 | """ 69 | str_key = self._build_key(key) 70 | async with self.session_factory() as session: 71 | query = insert(storage_table).values( 72 | key=str_key, 73 | data=data, 74 | ) 75 | query = query.on_conflict_do_update( 76 | index_elements=["key"], 77 | set_={"data": data, "updated_at": datetime.now(timezone.utc)}, 78 | ) 79 | await session.execute(query) 80 | await session.commit() 81 | -------------------------------------------------------------------------------- /raito/utils/storages/sql/postgresql.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Mapping 4 | from datetime import datetime, timezone 5 | from typing import TYPE_CHECKING, Any 6 | 7 | from sqlalchemy import URL 8 | from sqlalchemy.dialects.postgresql import insert 9 | from sqlalchemy.ext.asyncio import create_async_engine 10 | from typing_extensions import override 11 | 12 | from .sqlalchemy import SQLAlchemyStorage, storage_table 13 | 14 | if TYPE_CHECKING: 15 | from aiogram.filters.state import StateType 16 | from aiogram.fsm.storage.base import StorageKey 17 | 18 | __all__ = ("PostgreSQLStorage",) 19 | 20 | 21 | class PostgreSQLStorage(SQLAlchemyStorage): 22 | """PostgreSQL storage for FSM. 23 | 24 | Required packages :code:`sqlalchemy[asyncio]`, :code:`asyncpg` package installed (:code:`pip install raito[postgresql]`) 25 | """ 26 | 27 | def __init__(self, url: str | URL) -> None: 28 | """Initialize SQLite storage. 29 | 30 | :param url: PostgreSQL database URL 31 | :type url: str | URL 32 | """ 33 | self.url = url 34 | engine = create_async_engine(url=self.url) 35 | super().__init__(engine=engine) 36 | 37 | @override 38 | async def set_state(self, key: StorageKey, state: StateType | None = None) -> None: 39 | """Set state for specified key. 40 | 41 | :param key: Storage key 42 | :type key: StorageKey 43 | :param state: New state 44 | :type state: StateType | None 45 | """ 46 | built_key = self._build_key(key) 47 | async with self.session_factory() as session: 48 | query = insert(storage_table).values( 49 | key=built_key, 50 | state=state, 51 | data={}, 52 | ) 53 | query = query.on_conflict_do_update( 54 | index_elements=["key"], 55 | set_={"state": state, "updated_at": datetime.now(tz=timezone.utc)}, 56 | ) 57 | await session.execute(query) 58 | await session.commit() 59 | 60 | @override 61 | async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: 62 | """Write data (replace). 63 | 64 | :param key: Storage key 65 | :type key: StorageKey 66 | :param data: New data 67 | :type data: Dict[str, Any] 68 | """ 69 | str_key = self._build_key(key) 70 | async with self.session_factory() as session: 71 | query = insert(storage_table).values( 72 | key=str_key, 73 | data=data, 74 | ) 75 | query = query.on_conflict_do_update( 76 | index_elements=["key"], 77 | set_={"data": data, "updated_at": datetime.now(tz=timezone.utc)}, 78 | ) 79 | await session.execute(query) 80 | await session.commit() 81 | -------------------------------------------------------------------------------- /docs/source/plugins/commands.rst: -------------------------------------------------------------------------------- 1 | ⚙️ Commands 2 | ======================== 3 | 4 | Raito adds power features to command handlers via flags, middleware, and auto-registration. 5 | 6 | - ``@rt.description(...)`` — adds descriptions for Telegram slash-command list 7 | - ``@rt.hidden`` — hides handlers from the command list 8 | - ``@rt.params(...)`` — extracts and validates command parameters 9 | - Automatic middleware for parameter parsing 10 | - Auto-registration via ``raito.register_commands(...)`` 11 | 12 | ----- 13 | 14 | Descriptions 15 | ~~~~~~~~~~~~ 16 | 17 | Use ``@rt.description()`` to attach a localized (or plain) description to a command. 18 | 19 | .. code-block:: python 20 | 21 | from aiogram import Router 22 | from aiogram.types import Message 23 | from aiogram.filters import Command 24 | from raito import rt 25 | 26 | router = Router(name="ban") 27 | 28 | @router.message(Command("ban")) 29 | @rt.description("Ban a user by ID") 30 | async def ban(message: Message): 31 | ... 32 | 33 | This will be used during command registration. 34 | 35 | ----- 36 | 37 | Hidden commands 38 | ~~~~~~~~~~~~~~~ 39 | 40 | Use ``@rt.hidden`` to exclude a handler from the command list. 41 | 42 | .. code-block:: python 43 | 44 | @router.message(Command("debug")) 45 | @rt.hidden 46 | async def debug(message: Message): 47 | ... 48 | 49 | ----- 50 | 51 | Parameter Parsing 52 | ~~~~~~~~~~~~~~~~~ 53 | 54 | Use ``@rt.params(...)`` to automatically parse parameters from ``/command arg1 arg2``. 55 | 56 | .. code-block:: python 57 | 58 | @router.message(Command("ban")) 59 | @rt.params(user_id=int) 60 | async def ban(message: Message, user_id: int): 61 | await message.answer(f"🔨 User {user_id} banned.") 62 | 63 | Supported types: ``str``, ``int``, ``bool``, ``float`` 64 | 65 | If a param is missing or invalid, Raito will: 66 | - Show an auto-generated help message (with ``description``) 67 | - Or trigger a custom error event via ``raito.command_parameters_error`` 68 | 69 | ----- 70 | 71 | Auto-Registration 72 | ~~~~~~~~~~~~~~~~~ 73 | 74 | Once you use the flags, just call: 75 | 76 | .. code-block:: python 77 | 78 | await raito.register_commands(bot) 79 | 80 | It will: 81 | 82 | - Collect all handlers 83 | - Use their flags (description, roles, hidden, etc.) 84 | - Register scoped commands for different roles and locales 85 | 86 | Raito will also: 87 | - Group commands by role 88 | - Assign different command lists to different users 89 | - Add role emojis to descriptions (e.g., ``[👑] Ban a user``) 90 | 91 | ----- 92 | 93 | Localization 94 | ~~~~~~~~~~~~ 95 | 96 | Descriptions support ``LazyProxy`` from ``aiogram.utils.i18n``. If you use i18n context: 97 | 98 | .. code-block:: python 99 | 100 | @rt.description(__("Ban a user")) 101 | def ... 102 | 103 | Raito will localize this during command registration for each locale. 104 | -------------------------------------------------------------------------------- /raito/handlers/management/list.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, NamedTuple 4 | 5 | from aiogram import Router, html 6 | 7 | from raito.plugins.commands import description, hidden 8 | from raito.plugins.roles import DEVELOPER 9 | from raito.utils.ascii import AsciiTree, TreeNode 10 | from raito.utils.configuration import RouterListStyle 11 | from raito.utils.const import ROOT_DIR 12 | from raito.utils.filters import RaitoCommand 13 | 14 | if TYPE_CHECKING: 15 | from aiogram.types import Message 16 | 17 | from raito.core.raito import Raito 18 | from raito.core.routers.loader import RouterLoader 19 | 20 | router = Router(name="raito.management.list") 21 | 22 | 23 | class Emojis(NamedTuple): 24 | """Emojis for router status.""" 25 | 26 | enabled: str 27 | restarting: str 28 | disabled: str 29 | not_found: str 30 | 31 | 32 | @router.message(RaitoCommand("routers"), DEVELOPER) 33 | @description("Lists all routers") 34 | @hidden 35 | async def list_routers(message: Message, raito: Raito) -> None: 36 | match raito.configuration.router_list_style: 37 | case RouterListStyle.CIRCLES: 38 | emojis = Emojis("🟢", "🟡", "🔴", "⚪") 39 | case RouterListStyle.DIAMONDS: 40 | emojis = Emojis("🔹", "🔸", "🔸", "🔸") 41 | case RouterListStyle.DIAMONDS_REVERSED: 42 | emojis = Emojis("🔸", "🔹", "🔹", "🔹") 43 | case _: 44 | emojis = Emojis("🟩", "🟨", "🟥", "⬜") 45 | 46 | def extract_loader_path(loader: RouterLoader) -> str: 47 | return ( 48 | loader.path.as_posix() 49 | .replace(ROOT_DIR.parent.as_posix(), "") 50 | .replace(".py", "") 51 | .strip("/") 52 | ) 53 | 54 | paths = { 55 | extract_loader_path(loader): loader for loader in raito.router_manager.loaders.values() 56 | } 57 | 58 | def get_status_icon(path: str) -> str: 59 | loader = paths.get(path) 60 | if loader and loader.is_restarting: 61 | return emojis.restarting 62 | if loader and loader.is_loaded: 63 | return emojis.enabled 64 | return emojis.disabled 65 | 66 | root = TreeNode("routers", is_folder=True) 67 | for path in paths: 68 | parts = path.split("/") 69 | current = root 70 | for i, part in enumerate(parts): 71 | full_path = "/".join(parts[: i + 1]) 72 | is_folder = i != len(parts) - 1 73 | icon = get_status_icon(full_path) if not is_folder else "" 74 | current = current.add_child(part, prefix=icon, is_folder=is_folder) 75 | 76 | tree = AsciiTree().render(root) 77 | text = ( 78 | html.bold("Here is your routers:") 79 | + "\n\n" 80 | + tree 81 | + "\n\n" 82 | + html.pre_language((f"{emojis[0]} — Enabled\n{emojis[2]} — Disabled\n"), "Specification") 83 | ) 84 | 85 | await message.answer(text, parse_mode="HTML") 86 | -------------------------------------------------------------------------------- /raito/utils/filters/command.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import re 4 | from collections.abc import Sequence 5 | from typing import Any 6 | 7 | from aiogram.filters import Command, CommandObject 8 | from aiogram.filters.command import CommandException, CommandPatternType 9 | from aiogram.utils.magic_filter import MagicFilter 10 | from typing_extensions import override 11 | 12 | PREFIX = ".rt " 13 | 14 | __all__ = ("RaitoCommand",) 15 | 16 | 17 | class RaitoCommand(Command): 18 | """A filter for Raito bot commands. 19 | 20 | This class filters messages that match the Raito command format: 21 | ".rt [arguments]" 22 | 23 | The filter matches commands exactly and optionally allows additional arguments. 24 | Commands are case-sensitive and must match the prefix ".rt" followed by 25 | one of the specified command strings. 26 | 27 | Example: 28 | 29 | .. code-block:: python 30 | 31 | @router.message(RaitoCommand("test")) 32 | async def test(message: Message): 33 | # Handles messages like: 34 | # ".rt test" 35 | # ".rt test foo bar 123" 36 | pass 37 | 38 | """ 39 | 40 | def __init__( 41 | self, 42 | *values: CommandPatternType, 43 | commands: Sequence[CommandPatternType] | CommandPatternType | None = None, 44 | ignore_case: bool = False, 45 | magic: MagicFilter | None = None, 46 | ) -> None: 47 | """Initialize the RaitoCommand filter. 48 | 49 | :param commands: One or more command strings to match 50 | :param ignore_case: Ignore case (Does not work with regexp, use flags instead) 51 | :param magic: Validate command object via Magic filter after all checks done 52 | :raises ValueError: If no commands are specified 53 | """ 54 | super().__init__( 55 | *values, 56 | commands=commands, 57 | ignore_case=ignore_case, 58 | ignore_mention=True, 59 | magic=magic, 60 | ) 61 | 62 | self.prefix = PREFIX 63 | pattern = ( 64 | rf"^{re.escape(self.prefix)} (?:{'|'.join(map(re.escape, self.commands))})(?: .+)?$" 65 | ) 66 | self._regex = re.compile(pattern) 67 | 68 | @override 69 | def extract_command(self, text: str) -> CommandObject: 70 | # First step: separate command with arguments 71 | # ".rt command arg1 arg2" -> ".rt", "command", ["arg1 arg2"] 72 | try: 73 | prefix, command, *args = text.split(maxsplit=2) 74 | except ValueError as exc: 75 | msg = "Not enough values to unpack" 76 | raise CommandException(msg) from exc 77 | 78 | return CommandObject(prefix=prefix + " ", command=command, args=args[0] if args else None) 79 | 80 | @override 81 | def update_handler_flags(self, flags: dict[str, Any]) -> None: 82 | super().update_handler_flags(flags) 83 | flags["raito__command"] = True 84 | -------------------------------------------------------------------------------- /raito/plugins/roles/roles.py: -------------------------------------------------------------------------------- 1 | from .constraint import RoleConstraint 2 | from .filter import RoleFilter 3 | 4 | __all__ = ( 5 | "ADMINISTRATOR", 6 | "AVAILABLE_ROLES", 7 | "AVAILABLE_ROLES_BY_SLUG", 8 | "DEVELOPER", 9 | "GUEST", 10 | "MANAGER", 11 | "MODERATOR", 12 | "OWNER", 13 | "SPONSOR", 14 | "SUPPORT", 15 | "TESTER", 16 | ) 17 | 18 | 19 | def _create_role(slug: str, name: str, description: str, emoji: str) -> RoleConstraint: 20 | return RoleConstraint( 21 | RoleFilter( 22 | slug=slug, 23 | name=name, 24 | description=description, 25 | emoji=emoji, 26 | ) 27 | ) 28 | 29 | 30 | DEVELOPER = _create_role( 31 | slug="developer", 32 | name="Developer", 33 | description="Has full access to all internal features, including debug tools and unsafe operations.", 34 | emoji="🖥️", 35 | ) 36 | 37 | OWNER = _create_role( 38 | slug="owner", 39 | name="Owner", 40 | description="Top-level administrator with permissions to manage administrators and global settings.", 41 | emoji="👑", 42 | ) 43 | 44 | ADMINISTRATOR = _create_role( 45 | slug="administrator", 46 | name="Administrator", 47 | description="Can manage users, moderate content, and configure most system settings.", 48 | emoji="💼", 49 | ) 50 | 51 | MODERATOR = _create_role( 52 | slug="moderator", 53 | name="Moderator", 54 | description="Can moderate user activity, issue warnings, and enforce rules within their scope.", 55 | emoji="🛡️", 56 | ) 57 | 58 | MANAGER = _create_role( 59 | slug="manager", 60 | name="Manager", 61 | description="Oversees non-technical operations like campaigns, tasks, or content planning.", 62 | emoji="📊", 63 | ) 64 | 65 | SPONSOR = _create_role( 66 | slug="sponsor", 67 | name="Sponsor", 68 | description="Supporter of the project. Usually does not have administrative privileges.", 69 | emoji="❤️", 70 | ) 71 | 72 | GUEST = _create_role( 73 | slug="guest", 74 | name="Guest", 75 | description="Has temporary access to specific internal features (e.g., analytics). Typically used for invited external users.", 76 | emoji="👤", 77 | ) 78 | 79 | SUPPORT = _create_role( 80 | slug="support", 81 | name="Support", 82 | description="Handles user support requests and assists with onboarding or issues.", 83 | emoji="💬", 84 | ) 85 | 86 | TESTER = _create_role( 87 | slug="tester", 88 | name="Tester", 89 | description="Helps test new features and provide feedback. May have access to experimental tools.", 90 | emoji="🧪", 91 | ) 92 | 93 | AVAILABLE_ROLES = [ 94 | i.filter.data 95 | for i in [ 96 | ADMINISTRATOR, 97 | DEVELOPER, 98 | GUEST, 99 | MANAGER, 100 | MODERATOR, 101 | OWNER, 102 | SPONSOR, 103 | SUPPORT, 104 | TESTER, 105 | ] 106 | ] 107 | AVAILABLE_ROLES_BY_SLUG = {role.slug: role for role in AVAILABLE_ROLES} 108 | -------------------------------------------------------------------------------- /raito/plugins/conversations/registry.py: -------------------------------------------------------------------------------- 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() 79 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/base.py: -------------------------------------------------------------------------------- 1 | from asyncio import Lock 2 | 3 | from aiogram.fsm.storage.base import BaseStorage, DefaultKeyBuilder, StorageKey 4 | 5 | from .protocol import IRoleProvider 6 | 7 | __all__ = ("BaseRoleProvider",) 8 | 9 | 10 | class BaseRoleProvider(IRoleProvider): 11 | """Base role provider class.""" 12 | 13 | def __init__(self, storage: BaseStorage) -> None: 14 | """Initialize BaseRoleProvider.""" 15 | self.storage = storage 16 | self.key_builder = DefaultKeyBuilder(with_destiny=True, with_bot_id=True) 17 | self._lock = Lock() 18 | 19 | def _build_key(self, *, bot_id: int) -> StorageKey: 20 | """Build a storage key for a specific bot. 21 | 22 | :param bot_id: The Telegram bot ID 23 | :return: The storage key 24 | """ 25 | return StorageKey( # applies to a single bot across all chats 26 | bot_id=bot_id, 27 | chat_id=0, 28 | user_id=0, 29 | destiny="roles", 30 | ) 31 | 32 | async def get_role(self, bot_id: int, user_id: int) -> str | None: 33 | """Get the role for a specific user. 34 | 35 | :param bot_id: The Telegram bot ID 36 | :param user_id: The Telegram user ID 37 | :return: The role slug or None if not found 38 | """ 39 | key = self._build_key(bot_id=bot_id) 40 | data: dict[str, str] = await self.storage.get_data(key) 41 | return data.get(str(user_id)) 42 | 43 | async def set_role(self, bot_id: int, user_id: int, role_slug: str) -> None: 44 | """Set the role for a specific user. 45 | 46 | :param bot_id: The Telegram bot ID 47 | :param user_id: The Telegram user ID 48 | :param role_slug: The role slug to assign 49 | """ 50 | key = self._build_key(bot_id=bot_id) 51 | async with self._lock: 52 | data: dict[str, str] = await self.storage.get_data(key) 53 | data[str(user_id)] = role_slug 54 | await self.storage.set_data(key, data) 55 | 56 | async def remove_role(self, bot_id: int, user_id: int) -> None: 57 | """Remove the role for a specific user. 58 | 59 | :param bot_id: The Telegram bot ID 60 | :param user_id: The Telegram user ID 61 | """ 62 | key = self._build_key(bot_id=bot_id) 63 | async with self._lock: 64 | data: dict[str, str] = await self.storage.get_data(key) 65 | data.pop(str(user_id), None) 66 | await self.storage.set_data(key, data) 67 | 68 | async def migrate(self) -> None: 69 | """Initialize the storage backend (create tables, etc.).""" 70 | return 71 | 72 | async def get_users(self, bot_id: int, role_slug: str) -> list[int]: 73 | """Get all users with a specific role. 74 | 75 | :param bot_id: The Telegram bot ID 76 | :param role_slug: The role slug to check for 77 | :return: A list of Telegram user IDs 78 | """ 79 | key = self._build_key(bot_id=bot_id) 80 | async with self._lock: 81 | data: dict[str, str] = await self.storage.get_data(key) 82 | return [int(user_id) for user_id, user_role in data.items() if user_role == role_slug] 83 | -------------------------------------------------------------------------------- /raito/plugins/roles/constraint.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from aiogram.filters import Filter, or_f 6 | from typing_extensions import override 7 | 8 | if TYPE_CHECKING: 9 | from .filter import RoleFilter 10 | 11 | __all__ = ( 12 | "RoleConstraint", 13 | "RoleGroupConstraint", 14 | ) 15 | 16 | 17 | class RoleConstraint(Filter): 18 | """Wrapper around RoleFilter that supports logical OR composition.""" 19 | 20 | def __init__(self, filter: RoleFilter) -> None: 21 | """Initialize RoleConstraint. 22 | 23 | :param filter: An instance of RoleFilter 24 | """ 25 | self.filter = filter 26 | 27 | def __or__(self, other: RoleConstraint | RoleGroupConstraint) -> RoleGroupConstraint: 28 | """Combine this marker with another using the `|` operator. 29 | 30 | :param other: Another RoleConstraint or RoleGroupConstraint 31 | :return: A combined RoleGroupConstraint 32 | """ 33 | if isinstance(other, RoleGroupConstraint): 34 | return RoleGroupConstraint(self, *other.filters) 35 | return RoleGroupConstraint(self, other) 36 | 37 | __ror__ = __or__ 38 | 39 | @override 40 | def update_handler_flags(self, flags: dict[str, Any]) -> None: 41 | """Attach role metadata to handler flags. 42 | 43 | This allows external tools to collect and display role-related constraints. 44 | """ 45 | self.filter.update_handler_flags(flags) 46 | 47 | @override 48 | async def __call__(self, *args: Any, **kwargs: Any) -> bool: 49 | """Delegate the call to the inner filter.""" 50 | return await self.filter(*args, **kwargs) 51 | 52 | 53 | class RoleGroupConstraint(Filter): 54 | """Logical group of multiple RoleConstraints combined via OR.""" 55 | 56 | def __init__(self, *filters: RoleConstraint) -> None: 57 | """Initialize RoleGroupConstraint. 58 | 59 | :param filters: One or more RoleConstraint instances 60 | """ 61 | self.filters = filters 62 | 63 | def __or__(self, other: RoleConstraint | RoleGroupConstraint) -> RoleGroupConstraint: 64 | """Extend the group with another marker or group. 65 | 66 | :param other: Another RoleConstraint or RoleGroupConstraint 67 | :return: New RoleGroupConstraint with all combined filters 68 | """ 69 | if isinstance(other, RoleGroupConstraint): 70 | return RoleGroupConstraint(*self.filters, *other.filters) 71 | return RoleGroupConstraint(*self.filters, other) 72 | 73 | __ror__ = __or__ 74 | 75 | @override 76 | def update_handler_flags(self, flags: dict[str, Any]) -> None: 77 | """Attach role metadata to handler flags. 78 | 79 | This allows external tools to collect and display role-related constraints. 80 | """ 81 | for filter in self.filters: 82 | filter.update_handler_flags(flags) 83 | 84 | @override 85 | async def __call__(self, *args: Any, **kwargs: Any) -> bool: 86 | """Convert the group into an `_OrFilter` filter.""" 87 | or_filter = or_f(*[f.filter for f in self.filters]) 88 | value = await or_filter(*args, **kwargs) 89 | return bool(value) 90 | -------------------------------------------------------------------------------- /raito/plugins/roles/filter.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING, Any 4 | 5 | from aiogram import Bot 6 | from aiogram.filters import Filter 7 | from aiogram.types import TelegramObject, User 8 | from typing_extensions import override 9 | 10 | from .data import RoleData 11 | 12 | if TYPE_CHECKING: 13 | from raito.core.raito import Raito 14 | 15 | __all__ = ("RoleFilter",) 16 | 17 | FLAG_NAME = "raito__roles" 18 | 19 | 20 | class RoleFilter(Filter): 21 | """Filter for checking user roles. 22 | 23 | This filter is used to verify whether the user associated with a Telegram event 24 | has a specific role assigned, such as "admin", "moderator", etc. 25 | 26 | It also attaches role metadata to the handler's flags for use in command registration 27 | and visualization logic. 28 | """ 29 | 30 | def __init__( 31 | self, 32 | slug: str, 33 | name: str, 34 | description: str, 35 | emoji: str, 36 | ) -> None: 37 | """Initialize the RoleFilter. 38 | 39 | :param slug: Unique identifier of the role (e.g., "developer") 40 | :param name: Display name of the role (e.g., "Administrator") 41 | :param description: Description of the role 42 | :param emoji: Emoji used to visually represent the role 43 | """ 44 | self.data = RoleData(slug=slug, name=name, description=description, emoji=emoji) 45 | 46 | @classmethod 47 | def from_data(cls, data: RoleData) -> RoleFilter: 48 | """Create a RoleFilter from a RoleData instance. 49 | 50 | :param data: RoleData instance containing role metadata 51 | :return: A new RoleFilter instance 52 | """ 53 | return RoleFilter( 54 | slug=data.slug, 55 | name=data.name, 56 | description=data.description, 57 | emoji=data.emoji, 58 | ) 59 | 60 | @override 61 | def update_handler_flags(self, flags: dict[str, Any]) -> None: 62 | """Attach role metadata to handler flags. 63 | 64 | This allows external tools to collect and display role-related constraints. 65 | """ 66 | roles = flags.setdefault("raito__roles", []) 67 | roles.append(self.data) 68 | 69 | @override 70 | async def __call__( 71 | self, 72 | event: TelegramObject, 73 | raito: Raito, 74 | bot: Bot, 75 | *args: Any, 76 | **kwargs: Any, 77 | ) -> bool: 78 | """Check if the user in the event has the required role. 79 | 80 | This method is automatically called by aiogram when the filter is used. 81 | It extracts the user from the event and checks the role manager. 82 | 83 | :param event: Telegram update object (e.g., Message, CallbackQuery) 84 | :param raito: Raito context object 85 | :return: Whether the user has the specified role 86 | :raises RuntimeError: If user could not be resolved from the event 87 | """ 88 | user = getattr(event, "from_user", None) 89 | if not isinstance(user, User): 90 | msg = "Cannot resolve user from TelegramObject" 91 | raise RuntimeError(msg) 92 | 93 | return await raito.role_manager.has_role(bot.id, user.id, self.data.slug) 94 | -------------------------------------------------------------------------------- /raito/plugins/keyboards/dynamic.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Callable 2 | from functools import wraps 3 | from typing import Concatenate, Literal, TypeAlias, cast, overload 4 | 5 | from aiogram.types import InlineKeyboardMarkup, ReplyKeyboardMarkup 6 | from aiogram.utils.keyboard import InlineKeyboardBuilder, ReplyKeyboardBuilder 7 | from typing_extensions import ParamSpec, TypeVar 8 | 9 | __all__ = ("dynamic_keyboard",) 10 | 11 | 12 | P = ParamSpec("P") 13 | BuilderT = TypeVar("BuilderT", InlineKeyboardBuilder, ReplyKeyboardBuilder) 14 | 15 | ButtonData: TypeAlias = str | tuple[str] | tuple[str, str] | list[str] 16 | KeyboardMarkupT: TypeAlias = InlineKeyboardMarkup | ReplyKeyboardMarkup 17 | 18 | InlineFn: TypeAlias = Callable[Concatenate[InlineKeyboardBuilder, P], object] 19 | ReplyFn: TypeAlias = Callable[Concatenate[ReplyKeyboardBuilder, P], object] 20 | 21 | InlineSyncFn: TypeAlias = Callable[P, InlineKeyboardMarkup] 22 | ReplySyncFn: TypeAlias = Callable[P, ReplyKeyboardMarkup] 23 | 24 | 25 | @overload 26 | def dynamic_keyboard( 27 | *sizes: int, 28 | repeat: bool = True, 29 | adjust: bool = True, 30 | inline: Literal[True] = True, 31 | **builder_kwargs: object, 32 | ) -> Callable[[InlineFn[P]], InlineSyncFn[P]]: ... 33 | @overload 34 | def dynamic_keyboard( 35 | *sizes: int, 36 | repeat: bool = True, 37 | adjust: bool = True, 38 | inline: Literal[False], 39 | **builder_kwargs: object, 40 | ) -> Callable[[ReplyFn[P]], ReplySyncFn[P]]: ... 41 | def dynamic_keyboard( # type: ignore[misc] 42 | *sizes: int, 43 | repeat: bool = True, 44 | adjust: bool = True, 45 | inline: bool = True, 46 | **builder_kwargs: object, 47 | ) -> Callable[[Callable[Concatenate[BuilderT, P], object]], Callable[P, KeyboardMarkupT]]: 48 | """Decorator to build inline or reply keyboards via builder style. 49 | 50 | Example: 51 | 52 | .. code-block:: python 53 | 54 | @keyboard(inline=True) 55 | def markup(builder: InlineKeyboardBuilder, name: str | None = None): 56 | if name is not None: 57 | builder.button(text=f"Hello, {name}", callback_data="hello") 58 | builder.button(text="Back", callback_data="back") 59 | 60 | :param sizes: Row sizes passed to `adjust(...)` 61 | :param repeat: Whether adjust sizes should repeat 62 | :param adjust: Auto-adjust layout if True 63 | :param inline: If True, builds InlineKeyboardMarkup 64 | :param builder_kwargs: Extra args passed to `as_markup()` 65 | :returns: A wrapped function returning KeyboardMarkup 66 | """ 67 | if not sizes: 68 | sizes = (1,) 69 | 70 | if not inline: 71 | builder_kwargs.setdefault("resize_keyboard", True) 72 | 73 | Builder = InlineKeyboardBuilder if inline else ReplyKeyboardBuilder 74 | 75 | def wrapper(fn: Callable[Concatenate[BuilderT, P], object]) -> Callable[P, KeyboardMarkupT]: 76 | @wraps(fn) 77 | def wrapped(*args: P.args, **kwargs: P.kwargs) -> KeyboardMarkupT: 78 | builder = Builder() 79 | fn(cast(BuilderT, builder), *args, **kwargs) 80 | if adjust: 81 | builder.adjust(*sizes, repeat=repeat) 82 | return builder.as_markup(**builder_kwargs) 83 | 84 | return wrapped 85 | 86 | return wrapper 87 | -------------------------------------------------------------------------------- /raito/utils/loggers.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import shutil 3 | from datetime import datetime 4 | from typing import Literal, cast 5 | 6 | from typing_extensions import override 7 | 8 | __all__ = ( 9 | "ColoredFormatter", 10 | "MuteLoggersFilter", 11 | "core", 12 | "log", 13 | "middlewares", 14 | "plugins", 15 | "roles", 16 | ) 17 | 18 | LEVEL = Literal["DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"] 19 | 20 | WHITE = "\033[37m" 21 | BRIGHT_BLACK = "\033[90m" 22 | RESET = "\033[0m" 23 | 24 | LEVEL_BACKGROUND_COLORS: dict[LEVEL, str] = { 25 | "DEBUG": "\033[42m", 26 | "INFO": "\033[104m", 27 | "WARNING": "\033[103m", 28 | "ERROR": "\033[101m", 29 | "CRITICAL": "\033[41m", 30 | } 31 | 32 | LEVEL_FOREGROUND_COLORS: dict[LEVEL, str] = { 33 | "DEBUG": "\033[32m", 34 | "INFO": RESET, 35 | "WARNING": RESET, 36 | "ERROR": RESET, 37 | "CRITICAL": "\033[31m", 38 | } 39 | 40 | 41 | class ColoredFormatter(logging.Formatter): 42 | @property 43 | def terminal_width(self) -> int: 44 | try: 45 | return shutil.get_terminal_size().columns 46 | except OSError: 47 | return 80 48 | 49 | @override 50 | def format(self, record: logging.LogRecord) -> str: 51 | levelname = cast(LEVEL, record.levelname) 52 | 53 | meta = self.get_meta(record) 54 | message = self.get_message(record, levelname) 55 | 56 | if not meta: 57 | return message 58 | return f"{meta} {message}" 59 | 60 | def get_meta(self, record: logging.LogRecord) -> str: 61 | dt = datetime.fromtimestamp(record.created) 62 | date = dt.strftime("%d.%m.%Y") 63 | time = dt.strftime("%H:%M:%S") 64 | now = date + " " + time 65 | 66 | if self.terminal_width >= 140: 67 | info = f"{BRIGHT_BLACK}{now}{RESET} {WHITE}{record.name}{RESET}" 68 | tabs = " " * (64 - len(info)) 69 | return info + tabs 70 | elif self.terminal_width >= 100: 71 | return f"{BRIGHT_BLACK}{now}{RESET}" 72 | elif self.terminal_width >= 70: 73 | return f"{BRIGHT_BLACK}{time}{RESET}" 74 | else: 75 | return "" 76 | 77 | def get_message(self, record: logging.LogRecord, levelname: LEVEL) -> str: 78 | background = LEVEL_BACKGROUND_COLORS.get(levelname, "") 79 | tag = f"{background} {levelname[0]} {RESET}" 80 | 81 | foreground = LEVEL_FOREGROUND_COLORS.get(levelname, "") 82 | message = f"{foreground}{record.getMessage()}{RESET}" 83 | return f"{tag} {message}" 84 | 85 | 86 | class MuteLoggersFilter(logging.Filter): 87 | def __init__(self, *names: str) -> None: 88 | self.names = set(names) 89 | super().__init__() 90 | 91 | def filter(self, record: logging.LogRecord) -> bool: 92 | return record.name not in self.names 93 | 94 | 95 | core = logging.getLogger("raito.core") 96 | routers = logging.getLogger("raito.core.routers") 97 | commands = logging.getLogger("raito.core.commands") 98 | 99 | middlewares = logging.getLogger("raito.middlewares") 100 | plugins = logging.getLogger("raito.plugins") 101 | roles = logging.getLogger("raito.plugins.roles") 102 | 103 | utils = logging.getLogger("raito.utils") 104 | storages = logging.getLogger("raito.utils.storages") 105 | 106 | log = logging.getLogger() 107 | -------------------------------------------------------------------------------- /raito/plugins/roles/providers/sql/sqlalchemy.py: -------------------------------------------------------------------------------- 1 | from sqlalchemy import ( 2 | BigInteger, 3 | Column, 4 | Index, 5 | Integer, 6 | MetaData, 7 | String, 8 | Table, 9 | and_, 10 | select, 11 | ) 12 | from sqlalchemy.ext.asyncio import ( 13 | AsyncSession, 14 | async_sessionmaker, 15 | ) 16 | 17 | from raito.plugins.roles.providers.protocol import IRoleProvider 18 | from raito.utils.storages.sql.sqlalchemy import SQLAlchemyStorage 19 | 20 | __all__ = ("SQLAlchemyRoleProvider",) 21 | 22 | metadata = MetaData() 23 | 24 | roles_table = Table( 25 | "raito__user_roles", 26 | metadata, 27 | Column("id", Integer, primary_key=True, autoincrement=True), 28 | Column("bot_id", BigInteger, nullable=False), 29 | Column("user_id", BigInteger, nullable=False), 30 | Column("role", String, nullable=False), 31 | Index("idx_bot_user", "bot_id", "user_id", unique=True), 32 | ) 33 | 34 | 35 | class SQLAlchemyRoleProvider(IRoleProvider): 36 | """Base SQLAlchemy role provider.""" 37 | 38 | def __init__( 39 | self, 40 | storage: SQLAlchemyStorage, 41 | session_factory: async_sessionmaker[AsyncSession] | None = None, 42 | ) -> None: 43 | """Initialize SQLAlchemyRoleProvider. 44 | 45 | :param engine: SQLAlchemy async engine 46 | :param session_factory: Optional session factory, defaults to None 47 | """ 48 | self.storage = storage 49 | self.engine = self.storage.engine 50 | self.session_factory = session_factory or async_sessionmaker( 51 | self.engine, 52 | class_=AsyncSession, 53 | expire_on_commit=False, 54 | ) 55 | 56 | async def get_role(self, bot_id: int, user_id: int) -> str | None: 57 | """Get the role for a specific user. 58 | 59 | :param bot_id: The Telegram bot ID 60 | :param user_id: The Telegram user ID 61 | :return: The role slug or None if not found 62 | """ 63 | async with self.session_factory() as session: 64 | query = select(roles_table.c.role).where( 65 | and_( 66 | roles_table.c.bot_id == bot_id, 67 | roles_table.c.user_id == user_id, 68 | ), 69 | ) 70 | result = await session.execute(query) 71 | return result.scalar_one_or_none() 72 | 73 | async def migrate(self) -> None: 74 | """Initialize the storage backend (create tables, etc.).""" 75 | async with self.engine.begin() as conn: 76 | await conn.run_sync(metadata.create_all) 77 | 78 | async def close(self) -> None: 79 | """Close the database connection.""" 80 | await self.engine.dispose() 81 | 82 | async def get_users(self, bot_id: int, role_slug: str) -> list[int]: 83 | """Get all users with a specific role. 84 | 85 | :param bot_id: The Telegram bot ID 86 | :param role_slug: The role slug to check for 87 | :return: A list of Telegram user IDs 88 | """ 89 | async with self.session_factory() as session: 90 | query = select(roles_table.c.user_id).where( 91 | and_( 92 | roles_table.c.bot_id == bot_id, 93 | roles_table.c.role == role_slug, 94 | ) 95 | ) 96 | result = await session.execute(query) 97 | return [row[0] for row in result.all()] 98 | -------------------------------------------------------------------------------- /raito/utils/helpers/code_evaluator.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import ast 4 | import sys 5 | import traceback 6 | from collections.abc import Awaitable, Callable, Generator 7 | from contextlib import contextmanager, redirect_stdout 8 | from dataclasses import dataclass 9 | from io import StringIO 10 | from typing import Any 11 | 12 | 13 | @dataclass 14 | class EvaluationData: 15 | """Stores the result of evaluated python code. 16 | 17 | :param stdout: Captured standard output (e.g. from ``print()``) 18 | :param result: The final returned result. 19 | :param error: Traceback string if an exception occurred. 20 | """ 21 | 22 | stdout: str | None = None 23 | result: str | None = None 24 | error: str | None = None 25 | 26 | 27 | class CodeEvaluator: 28 | """Async code evaluator with stdout capture and error handling.""" 29 | 30 | @contextmanager 31 | def _capture_output(self) -> Generator[StringIO, Any, None]: 32 | """Context manager that captures ``stdout`` into a buffer. 33 | 34 | :yield: A ``StringIO`` buffer with captured output. 35 | """ 36 | old_stdout = sys.stdout 37 | buf = StringIO() 38 | 39 | try: 40 | with redirect_stdout(buf): 41 | yield buf 42 | finally: 43 | sys.stdout = old_stdout 44 | 45 | def _wrap_code(self, code: str) -> ast.Module: 46 | """Wraps code into an async function, returning the last expression. 47 | 48 | :param code: python code to wrap. 49 | :return: A module with a single async def. 50 | """ 51 | module = ast.parse(code, mode="exec") 52 | body = module.body or [ast.Pass()] 53 | 54 | if isinstance(body[-1], ast.Expr): 55 | body[-1] = ast.Return(value=body[-1].value) 56 | 57 | func_def = ast.AsyncFunctionDef( 58 | name="__eval_func", 59 | args=ast.arguments( 60 | posonlyargs=[], 61 | args=[], 62 | defaults=[], 63 | kwonlyargs=[], 64 | kw_defaults=[], 65 | ), 66 | body=body, 67 | decorator_list=[], 68 | ) 69 | 70 | wrapped_module = ast.Module(body=[func_def], type_ignores=[]) 71 | ast.fix_missing_locations(wrapped_module) 72 | return wrapped_module 73 | 74 | async def evaluate(self, code: str, context: dict[str, Any]) -> EvaluationData: 75 | """Evaluates the given async python code with the provided context. 76 | 77 | :param code: Python code to execute. 78 | :param context: Variables available during execution. 79 | :return: Result of the evaluation as ``EvaluationData`` 80 | """ 81 | try: 82 | wrapped_module = self._wrap_code(code) 83 | compiled_code = compile(wrapped_module, "", "exec") 84 | 85 | exec_locals: dict[str, Any] = {} 86 | with self._capture_output() as output: 87 | exec(compiled_code, context, exec_locals) 88 | eval_func: Callable[[], Awaitable[Any]] = exec_locals["__eval_func"] 89 | result = await eval_func() 90 | 91 | return EvaluationData( 92 | stdout=output.getvalue(), 93 | result=str(result) if result is not None else None, 94 | ) 95 | except Exception: # noqa: BLE001 96 | return EvaluationData(error=traceback.format_exc()) 97 | -------------------------------------------------------------------------------- /raito/core/routers/loader.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from asyncio import sleep 4 | from pathlib import Path 5 | from typing import TYPE_CHECKING 6 | 7 | from .base_router import BaseRouter 8 | from .parser import RouterParser 9 | 10 | if TYPE_CHECKING: 11 | from aiogram import Dispatcher, Router 12 | 13 | from raito.utils.types import StrOrPath 14 | 15 | 16 | __all__ = ("RouterLoader",) 17 | 18 | 19 | class RouterLoader(BaseRouter, RouterParser): 20 | """A class for loading, unloading and reloading routers dynamically.""" 21 | 22 | def __init__( 23 | self, 24 | name: str, 25 | path: StrOrPath, 26 | dispatcher: Dispatcher, 27 | router: Router | None = None, 28 | ) -> None: 29 | """Initialize RouterLoader. 30 | 31 | :param name: Unique name of the router 32 | :type name: str 33 | :param path: Path to the router file 34 | :type path: StrOrPath 35 | :param dispatcher: Aiogram dispatcher 36 | :type dispatcher: Dispatcher 37 | :param router: Router instance, defaults to None 38 | :type router: Router | None, optional 39 | """ 40 | super().__init__(router) 41 | 42 | self.name = name 43 | self.path = Path(path) 44 | 45 | self._dispatcher = dispatcher 46 | 47 | self._router: Router | None = router 48 | self._parent_router: Router | None = None 49 | self._sub_routers: list[Router] = [] 50 | 51 | self._is_restarting: bool = False 52 | self._is_loaded: bool = False 53 | 54 | @property 55 | def is_loaded(self) -> bool: 56 | """Check whether the router is currently loaded. 57 | 58 | :return: True if the router has been loaded and registered into the dispatcher 59 | :rtype: bool 60 | """ 61 | return self._is_loaded 62 | 63 | @property 64 | def is_restarting(self) -> bool: 65 | """Check whether the router is currently being reloaded. 66 | 67 | :return: True if a reload operation is in progress 68 | :rtype: bool 69 | """ 70 | return self._is_restarting 71 | 72 | @property 73 | def router(self) -> Router: 74 | """Get or load the router instance. 75 | 76 | :return: The router instance 77 | :rtype: Router 78 | """ 79 | if self._router is None: 80 | self._router = self.extract_router(self.path) 81 | if not hasattr(self._router, "name"): 82 | self._router.name = self.name 83 | return self._router 84 | 85 | def load(self) -> None: 86 | """Load and register the router.""" 87 | if router := self.router: 88 | if self._parent_router: 89 | self._link_to_parent(self._parent_router) 90 | self._dispatcher.include_router(router) 91 | self._is_loaded = True 92 | 93 | def unload(self) -> None: 94 | """Unload and unregister the router.""" 95 | if self.router: 96 | self._unlink_from_parent() 97 | self._router = None 98 | self._is_loaded = False 99 | 100 | async def reload(self, timeout: float | None = None) -> None: 101 | """Reload the router with optional delay. 102 | 103 | :param timeout: Delay in seconds before reloading, defaults to None 104 | :type timeout: float | None, optional 105 | """ 106 | if not self._is_restarting: 107 | self._is_restarting = True 108 | self.unload() 109 | 110 | if timeout: 111 | await sleep(timeout) 112 | 113 | self.load() 114 | self._is_restarting = False 115 | -------------------------------------------------------------------------------- /raito/handlers/help.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Generator 4 | from typing import TYPE_CHECKING 5 | 6 | from aiogram import Dispatcher, Router, html 7 | from aiogram.dispatcher.event.handler import HandlerObject 8 | from aiogram.filters import Command, CommandObject 9 | from aiogram.types import CallbackQuery, LinkPreviewOptions 10 | 11 | from raito import Raito, rt 12 | from raito.plugins.commands import description, hidden 13 | from raito.plugins.pagination.enums import PaginationMode 14 | from raito.plugins.pagination.paginators.list import ListPaginator 15 | from raito.plugins.roles import DEVELOPER 16 | from raito.utils.filters import RaitoCommand 17 | from raito.utils.helpers.truncate import truncate 18 | 19 | if TYPE_CHECKING: 20 | from aiogram.types import Message 21 | 22 | router = Router(name="raito.help") 23 | 24 | 25 | def _iter_raito_commands( 26 | dispatcher: Dispatcher, 27 | ) -> Generator[tuple[list[Command], HandlerObject], None, None]: 28 | for router in dispatcher.chain_tail: 29 | for handler in router.message.handlers: 30 | commands: list[Command] | None = handler.flags.get("commands") 31 | if handler.flags.get("raito__command") and commands: 32 | yield commands, handler 33 | 34 | 35 | def _format_handler(command: Command, handler: HandlerObject) -> str: 36 | params: dict[str, type] = handler.flags.get("raito__params", {}) 37 | params_str = " ".join(f"[{name}]" for name in params) 38 | 39 | signature = f"{command.prefix}{command.commands[0]} {params_str}" 40 | description: str = truncate(handler.flags.get("raito__description", ""), max_length=96) 41 | 42 | return html.code(signature) + "\n" + html.blockquote(html.italic(description)) 43 | 44 | 45 | def _get_formatted_commands(dispatcher: Dispatcher) -> list[str]: 46 | return [ 47 | _format_handler(commands[0], handler) 48 | for commands, handler in _iter_raito_commands(dispatcher) 49 | if len(commands) > 0 50 | ] 51 | 52 | 53 | @router.message(RaitoCommand("help"), DEVELOPER) 54 | @description("Lists Raito commands") 55 | @hidden 56 | async def help_handler(message: Message, raito: Raito, command: CommandObject) -> None: 57 | if not message.bot or not message.from_user: 58 | return 59 | 60 | if command.args: 61 | for commands, handler in _iter_raito_commands(raito.dispatcher): 62 | if len(commands) <= 0: 63 | continue 64 | 65 | main_command = commands[0] 66 | if command.args in main_command.commands: 67 | await message.answer(_format_handler(main_command, handler), parse_mode="HTML") 68 | return 69 | 70 | await message.answer("⚠️ Command not found") 71 | return 72 | 73 | limit = 5 74 | raito_commands = _get_formatted_commands(raito.dispatcher) 75 | 76 | await raito.paginate( 77 | name="raito__commands", 78 | chat_id=message.chat.id, 79 | bot=message.bot, 80 | from_user=message.from_user, 81 | mode=PaginationMode.LIST, 82 | limit=limit, 83 | total_pages=ListPaginator.calc_total_pages(len(raito_commands), limit), 84 | ) 85 | 86 | 87 | @rt.on_pagination(router, "raito__commands", DEVELOPER) 88 | async def on_pagination( 89 | _: CallbackQuery, 90 | raito: Raito, 91 | paginator: ListPaginator, 92 | offset: int, 93 | limit: int, 94 | ) -> None: 95 | commands = _get_formatted_commands(raito.dispatcher) 96 | footer = html.italic(html.link("Powered by Raito", "https://github.com/Aidenable/Raito")) 97 | 98 | await paginator.answer( 99 | items=[*commands[offset : offset + limit], footer], 100 | separator="\n\n", 101 | parse_mode="HTML", 102 | link_preview_options=LinkPreviewOptions(is_disabled=True), 103 | ) 104 | -------------------------------------------------------------------------------- /pyproject.toml: -------------------------------------------------------------------------------- 1 | [build-system] 2 | requires = ["hatchling"] 3 | build-backend = "hatchling.build" 4 | 5 | [project] 6 | name = "raito" 7 | version = "1.3.7" 8 | description = "REPL, hot-reload, keyboards, pagination, and internal dev tools — all in one. That's Raito." 9 | authors = [{ name = "Aiden", email = "aidenthedev@gmail.com" }] 10 | license = "MIT" 11 | readme = "README.md" 12 | requires-python = ">=3.10" 13 | dependencies = [ 14 | "aiogram>=3.20.0", 15 | "cachetools>=6.0.0", 16 | "psutil>=7.0.0", 17 | "pydantic>=2.11.6", 18 | "watchfiles>=1.0.5", 19 | ] 20 | 21 | classifiers = [ 22 | "Development Status :: 5 - Production/Stable", 23 | "Environment :: Plugins", 24 | "Framework :: AsyncIO", 25 | "Intended Audience :: Developers", 26 | "License :: OSI Approved :: MIT License", 27 | "Operating System :: OS Independent", 28 | "Programming Language :: Python", 29 | "Programming Language :: Python :: 3", 30 | "Programming Language :: Python :: 3.10", 31 | "Programming Language :: Python :: 3.11", 32 | "Programming Language :: Python :: 3.12", 33 | "Topic :: Software Development :: Libraries", 34 | "Topic :: Software Development :: Testing", 35 | "Topic :: Software Development :: Debuggers", 36 | "Topic :: Software Development :: User Interfaces", 37 | "Topic :: Utilities", 38 | "Typing :: Typed", 39 | ] 40 | 41 | keywords = [ 42 | "aiogram", 43 | "telegram", 44 | "keyboard", 45 | "pagination", 46 | "repl", 47 | "hot-reload", 48 | "devtools", 49 | "bot", 50 | "aiogram3", 51 | "raito", 52 | ] 53 | 54 | [project.optional-dependencies] 55 | dev = [ 56 | "ruff", 57 | "mypy", 58 | "pytest", 59 | "ipdb", 60 | "pre-commit", 61 | "types-cachetools>=6.0.0.20250525", 62 | "python-dotenv", 63 | ] 64 | docs = ["sphinx", "furo", "sphinx-autodoc-typehints", "watchdog>=6.0.0"] 65 | redis = ["redis>=6.2.0"] 66 | postgresql = ["sqlalchemy[asyncio]>=2.0.0", "asyncpg>=0.29.0"] 67 | sqlite = ["sqlalchemy[asyncio]>=2.0.0", "aiosqlite>=0.20.0"] 68 | 69 | [project.urls] 70 | Repository = "https://github.com/Aidenable/raito" 71 | Documentation = "https://aidenable.github.io/raito" 72 | 73 | [tool.hatch.build] 74 | packages = ["raito"] 75 | 76 | [tool.ruff] 77 | line-length = 100 78 | target-version = "py310" 79 | 80 | [tool.ruff.lint] 81 | ignore = [ 82 | "E501", # line-too-long 83 | "D100", # undocumented-public-module 84 | "D104", # undocumented-public-package 85 | "D103", # undocumented-public-function 86 | "D203", # one-blank-line-before-class 87 | "D213", # multi-line-summary-second-line 88 | "PLC0415", # import-outside-top-level 89 | ] 90 | fixable = ["ALL"] 91 | extend-select = [ 92 | "E", # pycodestyle: basic style issues 93 | "F", # pyflakes: undefined variables, imports, unused code 94 | "B", # bugbear: likely bugs and design problems 95 | "C4", # flake8-comprehensions: improve list/set/dict comprehensions 96 | "SIM", # flake8-simplify: overly complex constructs 97 | "I", # isort: import sorting 98 | "UP", # pyupgrade: outdated syntax for your Python version 99 | "RUF", # ruff-specific: best practices from ruff itself 100 | "ANN", # flake8-annotations: type annotations 101 | "BLE", # flake8-blind-except: ignore base exceptions without an exception type 102 | ] 103 | 104 | [tool.ruff.lint.pylint] 105 | max-args = 10 106 | 107 | [tool.ruff.lint.per-file-ignores] 108 | "tests/*" = ["ALL"] 109 | "examples/*" = ["ALL"] 110 | "*/handlers/*.py" = ["INP001"] 111 | 112 | [tool.ruff.format] 113 | quote-style = "double" 114 | indent-style = "space" 115 | 116 | [tool.mypy] 117 | python_version = "3.10" 118 | ignore_missing_imports = true 119 | exclude = ["tests", "examples"] 120 | 121 | [tool.pytest.ini_options] 122 | addopts = "-ra" 123 | python_files = "tests/test_*.py" 124 | 125 | [dependency-groups] 126 | dev = ["types-cachetools>=6.0.0.20250525"] 127 | -------------------------------------------------------------------------------- /raito/plugins/commands/middleware.py: -------------------------------------------------------------------------------- 1 | from collections.abc import Awaitable, Callable 2 | from typing import TYPE_CHECKING, Any, TypeAlias, TypeVar 3 | 4 | from aiogram.dispatcher.event.bases import REJECTED 5 | from aiogram.dispatcher.event.handler import HandlerObject 6 | from aiogram.dispatcher.middlewares.base import BaseMiddleware 7 | from aiogram.filters.command import CommandObject 8 | from aiogram.types import Message, TelegramObject 9 | from typing_extensions import override 10 | 11 | from raito.utils.helpers.command_help import get_command_help 12 | 13 | if TYPE_CHECKING: 14 | from raito.core.raito import Raito 15 | 16 | DataT = TypeVar("DataT", bound=dict[str, Any]) 17 | R = TypeVar("R") 18 | ParamT: TypeAlias = type[int] | type[str] | type[bool] | type[float] 19 | 20 | 21 | __all__ = ("CommandMiddleware",) 22 | 23 | 24 | class CommandMiddleware(BaseMiddleware): 25 | """Middleware for command-related features. 26 | 27 | - Supports automatic parameter parsing from text based on the :code:`raito__params` flag. 28 | 29 | *Can be extended with additional logic in the future* 30 | """ 31 | 32 | def __init__(self) -> None: 33 | """Initialize CommandMiddleware.""" 34 | 35 | def _unpack_params( 36 | self, 37 | command: CommandObject, 38 | params: dict[str, ParamT], 39 | data: DataT, 40 | ) -> DataT: 41 | """Unpack command parameters into the metadata. 42 | 43 | :param handler_object: Handler object 44 | :param event: Telegram message 45 | :param data: Current metadata 46 | :return: Updated context with parsed parameters 47 | :raises ValueError, IndexError: If parameter is missing or invalid 48 | """ 49 | args = command.args.split() if command.args else [] 50 | for i, (key, value_type) in enumerate(params.items()): 51 | arg = args[i] 52 | if value_type is bool: 53 | bool_value: bool = arg.lower() in ("true", "yes", "on", "1", "ok", "+") 54 | data[key] = bool_value 55 | else: 56 | data[key] = value_type(arg) 57 | 58 | return data 59 | 60 | async def _send_help_message( 61 | self, 62 | handler_object: HandlerObject, 63 | command: CommandObject, 64 | params: dict[str, ParamT], 65 | event: Message, 66 | data: dict[str, Any], 67 | ) -> None: 68 | description = handler_object.flags.get("raito__description") 69 | raito: Raito | None = data.get("raito") 70 | 71 | if raito is not None and raito.command_parameters_error.handlers: 72 | target = {"handler": handler_object, "command": command} 73 | await raito.command_parameters_error.trigger(event, target=target) 74 | else: 75 | await event.reply( 76 | get_command_help(command, params, description=description), 77 | parse_mode="HTML", 78 | ) 79 | 80 | @override 81 | async def __call__( 82 | self, 83 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[R]], 84 | event: TelegramObject, 85 | data: dict[str, Any], 86 | ) -> R | None: 87 | """Process incoming events with command logic. 88 | 89 | :param handler: Next handler in the middleware chain 90 | :type handler: Callable 91 | :param event: Telegram event (Message or CallbackQuery) 92 | :type event: TelegramObject 93 | :param data: Additional data passed through the middleware chain 94 | :type data: dict[str, Any] 95 | :return: Handler result if not throttled, None if throttled 96 | """ 97 | if not isinstance(event, Message): 98 | return await handler(event, data) 99 | 100 | handler_object: HandlerObject | None = data.get("handler") 101 | if handler_object is None: 102 | return await handler(event, data) 103 | 104 | command: CommandObject | None = data.get("command") 105 | if command is None: 106 | return await handler(event, data) 107 | 108 | params: dict[str, ParamT] | None = handler_object.flags.get("raito__params") 109 | if params: 110 | try: 111 | data = self._unpack_params(command, params, data) 112 | except (ValueError, IndexError): 113 | await self._send_help_message(handler_object, command, params, event, data) 114 | return REJECTED 115 | 116 | return await handler(event, data) 117 | -------------------------------------------------------------------------------- /raito/handlers/roles/assign.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from typing import TYPE_CHECKING 4 | 5 | from aiogram import Bot, F, Router, html 6 | from aiogram.filters.callback_data import CallbackData 7 | from aiogram.fsm.state import State, StatesGroup 8 | from aiogram.types import Message 9 | from aiogram.utils.keyboard import InlineKeyboardBuilder 10 | 11 | from raito.plugins.commands import description 12 | from raito.plugins.commands.flags import hidden 13 | from raito.plugins.commands.registration import register_bot_commands 14 | from raito.plugins.keyboards import dynamic 15 | from raito.plugins.roles.data import RoleData 16 | from raito.plugins.roles.roles import ADMINISTRATOR, DEVELOPER, OWNER 17 | from raito.utils.filters import RaitoCommand 18 | 19 | if TYPE_CHECKING: 20 | from aiogram.fsm.context import FSMContext 21 | from aiogram.types import CallbackQuery 22 | 23 | from raito.core.raito import Raito 24 | 25 | router = Router(name="raito.roles.assign") 26 | 27 | 28 | class AssignRoleCallback(CallbackData, prefix="rt_assign_role"): # type: ignore[call-arg] 29 | """Callback data for assigning roles.""" 30 | 31 | role_slug: str 32 | 33 | 34 | class AssignRoleGroup(StatesGroup): 35 | """State group for assigning roles.""" 36 | 37 | user_id = State() 38 | 39 | 40 | @dynamic(2) 41 | def roles_list_markup(builder: InlineKeyboardBuilder, roles: list[RoleData]) -> None: 42 | for role in roles: 43 | builder.button( 44 | text=role.emoji + " " + role.name, 45 | callback_data=AssignRoleCallback(role_slug=role.slug), 46 | ) 47 | 48 | 49 | @router.message(RaitoCommand("roles", "assign"), DEVELOPER | OWNER | ADMINISTRATOR) 50 | @description("Assigns a role to a user") 51 | @hidden 52 | async def show_roles(message: Message, raito: Raito) -> None: 53 | await message.answer( 54 | "🎭 Select role to assign:", 55 | reply_markup=roles_list_markup(raito.role_manager.available_roles), 56 | ) 57 | 58 | 59 | @router.callback_query(AssignRoleCallback.filter(), DEVELOPER | OWNER | ADMINISTRATOR) 60 | async def store_role( 61 | query: CallbackQuery, 62 | state: FSMContext, 63 | callback_data: AssignRoleCallback, 64 | raito: Raito, 65 | ) -> None: 66 | if not query.bot: 67 | await query.answer("🚫 Bot not found", show_alert=True) 68 | return 69 | if not isinstance(query.message, Message): 70 | await query.answer("🚫 Invalid message", show_alert=True) 71 | return 72 | 73 | role = raito.role_manager.get_role_data(callback_data.role_slug) 74 | await state.update_data(rt_selected_role=role.slug) 75 | await state.set_state(AssignRoleGroup.user_id) 76 | 77 | chat_id = query.message.chat.id 78 | await query.bot.send_message( 79 | chat_id=chat_id, 80 | text=f"{html.bold(role.label)}\n\n{html.blockquote(role.description)}", 81 | parse_mode="HTML", 82 | ) 83 | await query.bot.send_message(chat_id=chat_id, text="👤 Enter user ID:") 84 | 85 | 86 | @router.message( 87 | AssignRoleGroup.user_id, 88 | F.text and F.text.isdigit(), 89 | DEVELOPER | OWNER | ADMINISTRATOR, 90 | ) 91 | async def assign_role(message: Message, raito: Raito, state: FSMContext, bot: Bot) -> None: 92 | data = await state.get_data() 93 | role_slug = data.get("rt_selected_role") 94 | if role_slug is None: 95 | await message.answer("🚫 Role not selected") 96 | return 97 | if not message.from_user: 98 | await message.answer("🚫 User not found") 99 | return 100 | if not message.text or not message.text.isdigit(): 101 | await message.answer("🚫 Invalid user ID") 102 | return 103 | if not message.bot: 104 | await message.answer("🚫 Bot instance not found") 105 | return 106 | 107 | await state.update_data(rt_selected_role=None) 108 | await state.set_state() 109 | 110 | role = raito.role_manager.get_role_data(role_slug) 111 | try: 112 | await raito.role_manager.assign_role( 113 | message.bot.id, 114 | message.from_user.id, 115 | int(message.text), 116 | role.slug, 117 | ) 118 | except PermissionError: 119 | await message.answer("🚫 Permission denied") 120 | return 121 | 122 | await message.answer(f"❇️ User assigned to {html.bold(role.label)}", parse_mode="HTML") 123 | 124 | handlers = [] 125 | for loader in raito.router_manager.loaders.values(): 126 | handlers.extend(loader.router.message.handlers) 127 | 128 | await register_bot_commands(raito.role_manager, bot, handlers, raito.locales) 129 | -------------------------------------------------------------------------------- /raito/utils/storages/json.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | import json 4 | from collections.abc import Mapping 5 | from pathlib import Path 6 | from typing import Any 7 | 8 | from aiogram.fsm.state import State 9 | from aiogram.fsm.storage.base import BaseStorage, StateType, StorageKey 10 | from typing_extensions import override 11 | 12 | from raito.utils import loggers 13 | 14 | __all__ = ("JSONStorage",) 15 | 16 | 17 | class JSONStorage(BaseStorage): 18 | """JSON-based FSM storage for development and testing. 19 | 20 | Stores FSM state and data in a local JSON file as a flat key-value mapping. 21 | """ 22 | 23 | def __init__(self, path: str | Path, *, key_separator: str = ":") -> None: 24 | """Initialize JSONStorage. 25 | 26 | :param path: Path to the JSON file to be used for persistent storage 27 | :param key_separator: Delimiter used when constructing keys 28 | """ 29 | self.path = Path(path) 30 | self.key_separator = key_separator 31 | 32 | self._data: dict[str, dict[str, Any]] = {} 33 | self._load() 34 | 35 | def _load(self) -> None: 36 | """Load JSON file contents into memory.""" 37 | if self.path.exists(): 38 | try: 39 | content = self.path.read_text(encoding="utf-8") 40 | self._data = json.loads(content) 41 | except json.JSONDecodeError as exc: 42 | loggers.storages.warning("JSON decode error: %s — file will be ignored", exc) 43 | self._data = {} 44 | except UnicodeDecodeError as exc: 45 | loggers.storages.warning("Invalid encoding in %s: %s", self.path, exc) 46 | self._data = {} 47 | except OSError as exc: 48 | loggers.storages.warning("Failed to read JSON storage file: %s", exc) 49 | self._data = {} 50 | 51 | def _save(self) -> None: 52 | """Write current memory state to JSON file.""" 53 | self.path.write_text( 54 | json.dumps(self._data, ensure_ascii=False, indent=2), 55 | encoding="utf-8", 56 | ) 57 | 58 | def _build_key(self, key: StorageKey) -> str: 59 | """Construct a unique key string from StorageKey. 60 | 61 | :param key: FSM storage key 62 | :return: String key 63 | """ 64 | parts = [str(key.bot_id), str(key.chat_id), str(key.user_id)] 65 | if key.thread_id: 66 | parts.append(str(key.thread_id)) 67 | if key.business_connection_id: 68 | parts.append(str(key.business_connection_id)) 69 | if key.destiny: 70 | parts.append(key.destiny) 71 | return self.key_separator.join(parts) 72 | 73 | @override 74 | async def get_state(self, key: StorageKey) -> str | None: 75 | """Retrieve the current state for a key. 76 | 77 | :param key: FSM storage key 78 | :return: Current state or None 79 | """ 80 | return self._data.get(self._build_key(key), {}).get("state") 81 | 82 | @override 83 | async def set_state(self, key: StorageKey, state: StateType | None = None) -> None: 84 | """Set a new state for the given key. 85 | 86 | :param key: FSM storage key 87 | :param state: New state to store 88 | """ 89 | if isinstance(state, State): 90 | state = state.state 91 | 92 | str_key = self._build_key(key) 93 | self._data.setdefault(str_key, {})["state"] = state 94 | self._save() 95 | 96 | @override 97 | async def get_data(self, key: StorageKey) -> dict[str, Any]: 98 | """Retrieve data dictionary for the key. 99 | 100 | :param key: FSM storage key 101 | :return: Stored data or empty dict 102 | """ 103 | return self._data.get(self._build_key(key), {}).get("data", {}) 104 | 105 | @override 106 | async def set_data(self, key: StorageKey, data: Mapping[str, Any]) -> None: 107 | """Set data dictionary for the key. 108 | 109 | :param key: FSM storage key 110 | :param data: Data to store 111 | """ 112 | str_key = self._build_key(key) 113 | self._data.setdefault(str_key, {})["data"] = data 114 | self._save() 115 | 116 | @override 117 | async def update_data(self, key: StorageKey, data: Mapping[str, Any]) -> dict[str, Any]: 118 | """Update the current data for the key. 119 | 120 | :param key: FSM storage key 121 | :param data: New data to merge with existing 122 | :return: Updated data 123 | """ 124 | current = await self.get_data(key) 125 | current.update(data) 126 | 127 | await self.set_data(key, current) 128 | return current 129 | 130 | async def clear(self) -> None: 131 | """Clear all states and data from storage.""" 132 | self._data.clear() 133 | self._save() 134 | 135 | @override 136 | async def close(self) -> None: 137 | """Close the storage (optional flush)""" 138 | self._save() 139 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | .DS_Store 6 | 7 | # C extensions 8 | *.so 9 | 10 | # Distribution / packaging 11 | .Python 12 | build/ 13 | develop-eggs/ 14 | dist/ 15 | downloads/ 16 | eggs/ 17 | .eggs/ 18 | lib/ 19 | lib64/ 20 | parts/ 21 | sdist/ 22 | var/ 23 | wheels/ 24 | share/python-wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | MANIFEST 29 | 30 | # PyInstaller 31 | # Usually these files are written by a python script from a template 32 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 33 | *.manifest 34 | *.spec 35 | 36 | # Installer logs 37 | pip-log.txt 38 | pip-delete-this-directory.txt 39 | 40 | # Unit test / coverage reports 41 | htmlcov/ 42 | .tox/ 43 | .nox/ 44 | .coverage 45 | .coverage.* 46 | .cache 47 | nosetests.xml 48 | coverage.xml 49 | *.cover 50 | *.py,cover 51 | .hypothesis/ 52 | .pytest_cache/ 53 | cover/ 54 | 55 | # Translations 56 | *.mo 57 | *.pot 58 | 59 | # Django stuff: 60 | *.log 61 | local_settings.py 62 | db.sqlite3 63 | db.sqlite3-journal 64 | 65 | # Flask stuff: 66 | instance/ 67 | .webassets-cache 68 | 69 | # Scrapy stuff: 70 | .scrapy 71 | 72 | # Sphinx documentation 73 | docs/_build/ 74 | 75 | # PyBuilder 76 | .pybuilder/ 77 | target/ 78 | 79 | # Jupyter Notebook 80 | .ipynb_checkpoints 81 | 82 | # IPython 83 | profile_default/ 84 | ipython_config.py 85 | 86 | # pyenv 87 | # For a library or package, you might want to ignore these files since the code is 88 | # intended to run in multiple environments; otherwise, check them in: 89 | # .python-version 90 | 91 | # pipenv 92 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 93 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 94 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 95 | # install all needed dependencies. 96 | #Pipfile.lock 97 | 98 | # UV 99 | # Similar to Pipfile.lock, it is generally recommended to include uv.lock in version control. 100 | # This is especially recommended for binary packages to ensure reproducibility, and is more 101 | # commonly ignored for libraries. 102 | uv.lock 103 | 104 | # poetry 105 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 106 | # This is especially recommended for binary packages to ensure reproducibility, and is more 107 | # commonly ignored for libraries. 108 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 109 | #poetry.lock 110 | 111 | # pdm 112 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 113 | #pdm.lock 114 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 115 | # in version control. 116 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 117 | .pdm.toml 118 | .pdm-python 119 | .pdm-build/ 120 | 121 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 122 | __pypackages__/ 123 | 124 | # Celery stuff 125 | celerybeat-schedule 126 | celerybeat.pid 127 | 128 | # SageMath parsed files 129 | *.sage.py 130 | 131 | # Environments 132 | .env 133 | .venv 134 | env/ 135 | venv/ 136 | ENV/ 137 | env.bak/ 138 | venv.bak/ 139 | 140 | # Spyder project settings 141 | .spyderproject 142 | .spyproject 143 | 144 | # Rope project settings 145 | .ropeproject 146 | 147 | # mkdocs documentation 148 | /site 149 | 150 | # mypy 151 | .mypy_cache/ 152 | .dmypy.json 153 | dmypy.json 154 | 155 | # Pyre type checker 156 | .pyre/ 157 | 158 | # pytype static type analyzer 159 | .pytype/ 160 | 161 | # Cython debug symbols 162 | cython_debug/ 163 | 164 | # PyCharm 165 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 166 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 167 | # and can be added to the global gitignore or merged into this file. For a more nuclear 168 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 169 | #.idea/ 170 | 171 | # Abstra 172 | # Abstra is an AI-powered process automation framework. 173 | # Ignore directories containing user credentials, local state, and settings. 174 | # Learn more at https://abstra.io/docs 175 | .abstra/ 176 | 177 | # Visual Studio Code 178 | # Visual Studio Code specific template is maintained in a separate VisualStudioCode.gitignore 179 | # that can be found at https://github.com/github/gitignore/blob/main/Global/VisualStudioCode.gitignore 180 | # and can be added to the global gitignore or merged into this file. However, if you prefer, 181 | # you could uncomment the following to ignore the enitre vscode folder 182 | # .vscode/ 183 | 184 | # Ruff stuff: 185 | .ruff_cache/ 186 | 187 | # PyPI configuration file 188 | .pypirc 189 | 190 | # Cursor 191 | # Cursor is an AI-powered code editor. `.cursorignore` specifies files/directories to 192 | # exclude from AI features like autocomplete and code analysis. Recommended for sensitive data 193 | # refer to https://docs.cursor.com/context/ignore-files 194 | .cursorignore 195 | .cursorindexingignore 196 | -------------------------------------------------------------------------------- /raito/plugins/throttling/middleware.py: -------------------------------------------------------------------------------- 1 | from __future__ import annotations 2 | 3 | from collections.abc import Awaitable, Callable 4 | from typing import TYPE_CHECKING, Any, Literal, TypeVar 5 | 6 | from aiogram.dispatcher.event.handler import HandlerObject 7 | from aiogram.dispatcher.middlewares.base import BaseMiddleware 8 | from aiogram.types import CallbackQuery, Message, Update 9 | from cachetools import TTLCache 10 | from typing_extensions import override 11 | 12 | if TYPE_CHECKING: 13 | from aiogram.types import TelegramObject 14 | 15 | R = TypeVar("R") 16 | 17 | __all__ = ( 18 | "THROTTLING_MODE", 19 | "ThrottlingMiddleware", 20 | ) 21 | 22 | THROTTLING_MODE = Literal["user", "chat", "bot"] 23 | 24 | 25 | class ThrottlingMiddleware(BaseMiddleware): 26 | """Middleware for global and per-handler throttling.""" 27 | 28 | def __init__( 29 | self, 30 | rate_limit: float = 0.5, 31 | mode: THROTTLING_MODE = "chat", 32 | max_size: int = 10_000, 33 | ) -> None: 34 | """Initialize the middleware. 35 | 36 | :param rate_limit: Global throttling timeout (seconds) 37 | :param mode: Throttling scope — 'user', 'chat', or 'bot' 38 | :param max_size: Maximum number of keys stored in cache 39 | """ 40 | self.rate_limit = rate_limit 41 | self.mode: THROTTLING_MODE = mode 42 | self.max_size = max_size 43 | 44 | self._global_cache: TTLCache[int, bool] = self._create_cache(ttl=self.rate_limit) 45 | self._local_cache: dict[int, TTLCache[int, bool]] = {} 46 | 47 | def _create_cache(self, ttl: float) -> TTLCache[int, bool]: 48 | return TTLCache(maxsize=self.max_size, ttl=ttl) 49 | 50 | def _get_key(self, event: TelegramObject, mode: THROTTLING_MODE) -> int | None: 51 | if isinstance(event, Message): 52 | if not event.from_user or not event.chat or not event.bot: 53 | return None 54 | match mode: 55 | case "user": 56 | return event.from_user.id 57 | case "chat": 58 | return event.chat.id 59 | case "bot": 60 | return event.bot.id 61 | elif isinstance(event, CallbackQuery): 62 | if not event.from_user or not event.message or not event.bot: 63 | return None 64 | match mode: 65 | case "user": 66 | return event.from_user.id 67 | case "chat": 68 | return event.message.chat.id 69 | case "bot": 70 | return event.bot.id 71 | return None 72 | 73 | async def _global_throttling( 74 | self, 75 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[R]], 76 | event: TelegramObject, 77 | data: dict[str, Any], 78 | ) -> R | None: 79 | global_key = self._get_key(event, self.mode) 80 | if global_key is None: 81 | return await handler(event, data) 82 | 83 | if global_key in self._global_cache: 84 | return None 85 | 86 | self._global_cache[global_key] = True 87 | return await handler(event, data) 88 | 89 | async def _local_throttling( 90 | self, 91 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[R]], 92 | event: TelegramObject, 93 | data: dict[str, Any], 94 | *, 95 | rate: float, 96 | mode: THROTTLING_MODE, 97 | handler_id: int, 98 | ) -> R | None: 99 | entity_id = self._get_key(event, mode) 100 | if entity_id is None: 101 | return await handler(event, data) 102 | 103 | cache = self._local_cache.setdefault(handler_id, self._create_cache(ttl=rate)) 104 | if entity_id in cache: 105 | return None 106 | 107 | cache[entity_id] = True 108 | return await handler(event, data) 109 | 110 | @override 111 | async def __call__( 112 | self, 113 | handler: Callable[[TelegramObject, dict[str, Any]], Awaitable[R]], 114 | event: TelegramObject, 115 | data: dict[str, Any], 116 | ) -> R | None: 117 | handler_object: HandlerObject | None = data.get("handler") 118 | if handler_object is None: 119 | return await handler(event, data) 120 | 121 | event_update: Update | None = data.get("event_update") 122 | if event_update is not None and event_update.update_id == 0: 123 | return await handler(event, data) 124 | 125 | limiter_data: dict[str, Any] = handler_object.flags.get("raito__limiter", {}) 126 | local_rate: float | None = limiter_data.get("rate_limit") 127 | local_mode: THROTTLING_MODE | None = limiter_data.get("mode") 128 | 129 | if local_rate is not None and local_mode is not None: 130 | handler_id = id(handler_object.callback) 131 | return await self._local_throttling( 132 | handler, 133 | event, 134 | data, 135 | rate=local_rate, 136 | mode=local_mode, 137 | handler_id=handler_id, 138 | ) 139 | else: 140 | return await self._global_throttling(handler, event, data) 141 | --------------------------------------------------------------------------------