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.

  1. Install

    One package from PyPI. Python 3.10 to 3.12, aiogram 3.

    pip install raito
  2. Mount it

    Two lines in your entry point, marked with + in the code. Polling and webhooks work the same way.

  3. Add handlers

    Every file in the folder with a Router is loaded. Names starting with _ are skipped. No include_router.

main.py
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())
src/handlers/start.py
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!")

The full tutorial

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.

Raito demobot

Tap a role. Buttons are callback buttons, so they wait for the bot's answer.

handlers/moderation.py
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

Set up roles

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.

Raito demobot

Text plus a column of your own buttons above the navigation. Good for picking one item from a long list.

handlers/inline.py
@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)

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.

Raito demobot
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.

handlers/mute.py
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")

Scenes or wait_for?

.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.

Command reference

Raito demobot

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.

Signed in as
Language
Raito demobot

handlers/admin.py
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)
Read the guide

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.

Raito demobot

Press a button to see what it sends.

keyboards.py
@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")
Read the guide

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.

stopped
  1. before yield
  2. yield · bot runs
  3. after yield
$ python -m bot
handlers/events/lifespan.py
@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!")
Read the guide

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.

FSM storage

  

main.py
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"),
)
Read the guide

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.

100 columns

main.py
raito.init_logging("aiogram.event")
Read the guide

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.

Docs

In English and in Russian, with 14 example bots in the repository.