En-rich plugin for aiogram
Not a new framework. It adds hot-reload, roles, pagination, FSM managers, lifespan, commands, keyboards and raito commands. Your dispatcher and handlers stay plain aiogram, so you can take one part and skip the rest.
pip install raito
Setup takes two calls.
Create Raito with your dispatcher and a folder, then call setup(). Handlers stay
ordinary aiogram routers, with no base classes and no custom dispatcher.
-
Install
One package from PyPI. Python 3.10 to 3.12, aiogram 3.
pip install raito -
Mount it
Two lines in your entry point, marked with + in the code. Polling and webhooks work the same way.
-
Add handlers
Every file in the folder with a
Routeris loaded. Names starting with_are skipped. Noinclude_router.
import asyncio
from aiogram import Bot, Dispatcher
from raito import Raito
async def main() -> None:
bot = Bot(token="TOKEN")
dispatcher = Dispatcher()
raito = Raito(dispatcher, "src/handlers")
await raito.setup()
await dispatcher.start_polling(bot)
if __name__ == "__main__":
asyncio.run(main())
from aiogram import Router, filters, types
router = Router(name="start")
@router.message(filters.CommandStart())
async def start(message: types.Message) -> None:
await message.answer("Hello!")
Hot-reload
Start with production=False and Raito watches the handlers folder. Save a file, and
the next update runs the new code. The process never restarts.
Animated demo: the reply text in start.py is edited and saved. Raito logs “File changed: src/handlers/start.py. Reloading...”, and the bot answers the next /start with the new text.
A changed file is reloaded, a new file is loaded, a deleted one is unloaded. Modules are re-imported rather than patched, and each router returns to its place in the dispatcher. Module-level code runs again on every reload, so keep heavy setup in a lifespan. How it works
Roles
Nine roles for giving your team access inside the bot. Each is an ordinary aiogram filter,
combined with | and stored wherever your FSM storage is.
Tap a role. Buttons are callback buttons, so they wait for the bot's answer.
from raito.rt import ADMINISTRATOR, MODERATOR, OWNER
@router.message(
filters.Command("ban"),
OWNER | ADMINISTRATOR | MODERATOR,
)
async def ban(message: types.Message) -> None: ...
Flat on purpose
One role per user and no hierarchy. OWNER doesn't quietly include
ADMINISTRATOR, so every role that may run a command is written where the
command is. Custom roles are supported.
- Storage
- Memory, JSON, Redis, SQLite, PostgreSQL
- In chat
.rt roles.rt revoke.rt staff
Pagination
Five paginators for five kinds of content. Each keeps its page inside the button's callback data, so nothing is stored on the server and a button sent last week still turns the page.
Text plus a column of your own buttons above the navigation. Good for picking one item from a long list.
@rt.on_pagination(router, "orders")
async def orders(query: CallbackQuery, paginator: InlinePaginator, offset: int, limit: int) -> None:
rows = await db.fetch_orders(offset=offset, limit=limit)
buttons = [
InlineKeyboardButton(text=row.title, callback_data=f"order:{row.id}")
for row in rows
]
await paginator.answer("Your orders:", buttons=buttons)
One block of text per page. Terms, help pages, long announcements.
@rt.on_pagination(router, "terms")
async def terms(query: CallbackQuery, paginator: TextPaginator, offset: int, limit: int) -> None:
await paginator.answer(text=TERMS[offset])
A list of strings joined into one message. Leaderboards, logs, search results.
@rt.on_pagination(router, "top")
async def top(query: CallbackQuery, paginator: ListPaginator, offset: int, limit: int) -> None:
players = await db.top_players(offset=offset, limit=limit)
await paginator.answer(
items=[f"{offset + i + 1}. {p.name} — {p.points} pts" for i, p in enumerate(players)],
)
A photo with a caption; the media is swapped in place as you page.
@rt.on_pagination(router, "gallery")
async def gallery(query: CallbackQuery, paginator: PhotoPaginator, offset: int, limit: int) -> None:
photo = PHOTOS[offset]
await paginator.answer(photo=photo.file_id, caption=photo.title)
Telegram's structured rich messages: headings, lists, tables and code. Needs aiogram 3.30 or newer.
@rt.on_pagination(router, "rich_docs")
async def on_rich_pagination(query: CallbackQuery, paginator: RichPaginator, page: int) -> None:
await paginator.answer(rich_message=InputRichMessage(blocks=PAGES[page]))
Loop navigation, a page counter and your own buttons come with every type. Why stateless pagination
FSM managers
Three ways to run a multi-step dialog. The chat looks almost the same in all three. What changes is your code, and what stays busy while the user is typing.
- Handler
- idle
- Database session
- in the pool
- Collected so far
- nothing yet
The built-in way. One handler per state, data in a plain dict. It works, but every step is wired by hand and a typo in a key is silent.
class Mute(StatesGroup):
username = State()
duration = State()
@router.message(filters.Command("mute"))
async def start(message: Message, state: FSMContext) -> None:
await state.set_state(Mute.username)
await message.answer("Enter username:")
@router.message(Mute.username, F.text.startswith("@"))
async def username(message: Message, state: FSMContext) -> None:
await state.update_data(username=message.text)
await state.set_state(Mute.duration)
await message.answer("Enter duration in minutes:")
@router.message(Mute.duration, F.text.isdigit())
async def duration(message: Message, state: FSMContext) -> None:
data = await state.get_data()
await state.clear()
await message.answer(f"✅ {data['username']} will be muted for {message.text} minutes")
The whole dialog reads top to bottom in one handler. The handler stays paused while it waits, so anything it holds, like a database session, stays open too.
@router.message(filters.Command("mute"))
async def mute(message: Message, raito: Raito, state: FSMContext) -> None:
await message.answer("Enter username:")
user = await raito.wait_for(state, F.text.regexp(r"@[\w]+"))
await message.answer("Enter duration in minutes:")
duration = await raito.wait_for(state, F.text.isdigit())
await message.answer(f"✅ {user.text} will be muted for {duration.number} minutes")
Each step is an ordinary handler that finishes, so request-scoped dependencies are released between messages. The draft is a typed pydantic model, and a misspelled field fails at once.
class MuteData(SceneData):
username: str | None = None
duration: int | None = None
mute = router.scene(MuteStates, data=MuteData)
@mute.on_message.enter(filters.Command("mute"))
async def start(message: Message, scene: Scene[MuteData]) -> None:
await message.answer("Enter username:")
await scene.next()
@mute.on_message(MuteStates.username, F.text)
async def username(message: Message, scene: Scene[MuteData]) -> None:
if not message.text.startswith("@"):
await message.answer("⚠️ Enter an @username")
return await scene.retry()
scene.data.username = message.text
await message.answer("Enter duration in minutes:")
await scene.next()
.rt dev tools
Built-in commands that start with .rt. They stay out of the slash menu and answer
only to the right roles, so you can check what's loaded and switch routers from your phone.
| Command | What it does |
|---|---|
| Routers | |
.rt help
|
Every built-in command, paginated |
.rt routers
|
The router tree with load status |
.rt load <name>
|
Load a router, for example one with autoload=False |
.rt unload <name>
|
Unload a router |
.rt reload <name>
|
Reload a router |
| Roles | |
.rt roles
|
Pick a role, then send the user ID |
.rt assign
|
The same as .rt roles |
.rt revoke
|
Take a role away from a user |
.rt staff
|
Everyone with a role, as a tree |
| System | |
.rt stats
|
Process CPU, memory and uptime |
.rt eval
|
Run Python in the bot's context |
.rt py
|
The same as .rt eval |
.rt py3
|
The same as .rt eval |
.rt python
|
The same as .rt eval |
.rt exec
|
The same as .rt eval |
.rt bash
|
Run a shell command on the host |
.rt sh
|
The same as .rt bash |
eval and bash are off by default. They run unsandboxed code, so they do
nothing until you start the bot with enable_dangerous_commands=True, and even
then only developers can use them.
More examples
The rest of the library in five groups. Pick a feature on the side and try it. The code under each demo is adapted from the example bots.
Commands
What users can type, who sees it, and how often.
A slash menu per role and language
Describe a command once. register_commands puts public commands in everyone's menu, role-gated ones only in the menus of people who hold the role, in every locale you pass.
from aiogram.utils.i18n import lazy_gettext as __
@router.message(filters.Command("stats"), DEVELOPER | OWNER)
@rt.description(__("Bot statistics")) # translated per locale
async def stats(message: types.Message) -> None: ...
@router.message(filters.Command("ping"))
@rt.hidden # works, stays out of the menu
async def ping(message: types.Message) -> None: ...
raito = Raito(dispatcher, "src/handlers", locales=["en", "ru"])
await raito.register_commands(bot)
Typed command arguments
Declare what a command takes and receive the values as handler arguments. When the input doesn't parse, Raito replies with the signature and an example.
@router.message(filters.Command("add"))
@rt.description("Add two numbers")
@rt.params(a=int, b=int)
async def add(message: types.Message, a: int, b: int) -> None:
await message.answer(str(a + b))
Rate limits
A global cooldown, a per-handler one, or both; the handler's limit wins. Modes: user (one person across chats), chat (shared by the chat) and bot (one global cooldown).
raito.add_throttling(0.5, mode="chat") # every handler
@router.message(filters.Command("export"))
@rt.limiter(5, mode="user") # overrides the global limit
async def export(message: types.Message) -> None:
await message.answer("📦 Exporting your data, please wait...")
Messages
Keyboards and albums, the two things every bot ends up needing.
Keyboards
Two ways to make a keyboard: list the buttons, or add them one by one. A button whose value looks like a link becomes a link button on its own.
Press a button to see what it sends.
@rt.keyboard.static(inline=True)
def menu():
return [
("📄 Terms of Service", "tos"),
[("ℹ️ About", "about"), ("⚙️ Website", "https://example.com")],
]
@rt.keyboard.dynamic(1, 2, adjust=True, inline=False)
def start_menu(builder: ReplyKeyboardBuilder, app_url: str):
builder.button(text="📱 Open App", web_app=WebAppInfo(url=app_url))
builder.button(text="💬 Support")
Albums in one call
Telegram delivers a media group as separate updates. Raito collects them and calls your handler once, with the whole group.
@router.message(F.media_group_id)
async def media_group(message: types.Message, album: list[types.Message] = []) -> None:
await message.answer(f"Got {len(album)} files")
Structure
How routers load, start, stop and decide who gets in.
Startup and shutdown
FastAPI-style lifespan per router: code before yield runs on start, code after it on stop.
- before yield
- yield · bot runs
- after yield
@router.lifespan()
async def lifespan(bot: Bot):
user = await bot.get_me()
rt.debug("🚀 Bot [%s] is starting...", user.full_name)
yield
rt.debug("👋🏻 Bye!")
Router order and manual routers
Routers with a higher priority are tried first, so auth can turn banned users away before anything else runs. A router with autoload=False stays off until you switch it on from the chat.
- authpriority=100on
- startpriority=0on
- debugautoload=Falseoff
from raito import Router
router = Router(name="auth", priority=100) # tried before every other router
@router.message(F.from_user.id.in_(BANNED))
async def deny(message: types.Message) -> None:
await message.answer("Access denied.")
# handlers/debug.py
router = Router(name="debug", autoload=False) # enable with: .rt load debug
@router.message(filters.Command("debug"))
async def debug(message: types.Message) -> None:
await message.answer("Debug router is active.")
Your own roles
Define a role with a slug, name, description and emoji, and combine it with the built-in ones. Add it to a RoleManager subclass so the .rt roles picker offers it.
Pick a role for Alex from the picker, then send /approve. The custom role sits in the picker like any other.
from raito.plugins.roles.constraint import RoleConstraint
from raito.plugins.roles.filter import RoleFilter
REVIEWER = RoleConstraint(
RoleFilter(
slug="reviewer",
name="Reviewer",
description="Approves posts before they go out",
emoji="📝",
)
)
@router.message(filters.Command("approve"), REVIEWER | ADMINISTRATOR)
async def approve(message: types.Message) -> None: ...
Deployment
Where state lives, how updates arrive, what you can tune.
Storages
Raito keeps roles wherever your FSM storage is. Pick one, give out a role, restart the bot, and see what is left. All of them work in plain aiogram too.
from raito.utils.storages.json import JSONStorage
from raito.utils.storages.sql import get_sqlite_storage
SQLiteStorage = get_sqlite_storage()
dispatcher = Dispatcher(storage=JSONStorage("fsm.json"))
raito = Raito(
dispatcher,
"src/handlers",
storage=SQLiteStorage("sqlite+aiosqlite:///raito.db"),
)
Webhooks
Everything hangs off the dispatcher, so the transport doesn't matter. Call setup() on startup and serve updates the aiogram way.
Handlers, roles and .rt tools don't change. Only the entry point does.
raito = Raito(dispatcher, "src/handlers")
async def on_startup(_: web.Application) -> None:
await raito.setup()
app = web.Application()
app.on_startup.append(on_startup)
SimpleRequestHandler(dispatcher=dispatcher, bot=bot, secret_token=SECRET).register(app, path="/webhook")
setup_application(app, dispatcher, bot=bot)
web.run_app(app, port=8080)
Configuration
Change the pagination controls and counter, or the markers .rt routers uses, without touching handlers.
Reliability
Telegram errors and the logs you read when things go wrong.
Readable logs
A coloured formatter that adapts to the terminal width. Pass logger names to mute the noisy ones.
raito.init_logging("aiogram.event")
Retries and redraws
Retry a call on flood control or network errors, honouring retry_after. Stop “message is not modified” from crashing handlers that redraw a screen.
await rt.retry(bot.send_message, chat_id, "Your report is ready")
with rt.SuppressNotModifiedError():
await message.edit_text(text, reply_markup=markup)
Before you ship
- How it attaches
- Through aiogram's own routers, filters, flags and middleware. The Dispatcher isn't replaced or wrapped, and polling and webhooks behave the same.
- Tests
- 1,909 of them, in 15,451 lines, for 7,412 lines of library. CI runs them on Python 3.10, 3.11 and 3.12 for every pull request.
- Types
- mypy checks all 99 library modules and all 72 test files. ruff requires annotations everywhere.
- Who maintains it
- One person. There is no bug bounty, and the security policy says so plainly: use it in production at your own risk.