├── 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 |
--------------------------------------------------------------------------------