Build Bale bots the aiogram way.
baleio is a modern, fully-async framework for Bale (بله) bots — inspired by aiogram. Routers, magic filters, FSM, dependency injection, keyboard builders and typed Pydantic v2 models. If you know aiogram 3.x, you already know baleio.
Routers & DI
Nested Routers, per-event observers, and by-name dependency injection.
Magic filters
Command, the magic F, and & | ~ combinators.
CallbackData
Type-safe, packed callback payloads with automatic length checks.
FSM
StatesGroup, State and pluggable storage for conversations.
Installation
baleio requires Python 3.9+ and installs a small, well-established stack:
aiohttp, pydantic v2 and magic-filter.
# from PyPI
pip install baleio
# or from source
git clone https://github.com/ehsndvr/baleio && cd baleio
pip install -e .Create a bot and grab a token from @botfather
in Bale — it looks like 123456789:XXXXXXXX.
Quick start
The snippet below is the official aiogram quickstart, ported to baleio almost
line-for-line. Only the import roots and the text formatter change — Bale renders Markdown, so
md replaces aiogram's html.
import asyncio
import logging
import sys
from os import getenv
from baleio import Bot, Dispatcher, md
from baleio.client.default import DefaultBotProperties
from baleio.enums import ParseMode
from baleio.filters import CommandStart
from baleio.types import Message
TOKEN = getenv("BOT_TOKEN") # get one from @botfather in Bale
dp = Dispatcher()
@dp.message(CommandStart())
async def command_start_handler(message: Message) -> None:
await message.answer(f"Hello, {md.bold(message.from_user.full_name)}!")
@dp.message()
async def echo_handler(message: Message) -> None:
try:
await message.send_copy(chat_id=message.chat.id)
except TypeError:
await message.answer("Nice try!")
async def main() -> None:
bot = Bot(token=TOKEN, default=DefaultBotProperties(parse_mode=ParseMode.MARKDOWN))
await dp.start_polling(bot)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, stream=sys.stdout)
asyncio.run(main())Bale vs. Telegram. Bale always renders Markdown and does
not accept a parse_mode request parameter. DefaultBotProperties(parse_mode=…)
is accepted for aiogram parity and safely ignored. Use md.bold / md.italic /
md.link for formatting.
Dispatcher & Router
The Dispatcher is the root router and the entry point for update processing.
Router groups related handlers so a large bot can be split into modules.
from baleio import Dispatcher, Router
from baleio.filters import Command
dp = Dispatcher()
admin = Router(name="admin")
dp.include_router(admin)
@dp.message(Command("start"))
async def start(message): ...
@admin.message(Command("ban"))
async def ban(message): ...Handlers run in registration order; the first whose filters all pass wins.
Dependency injection
Each handler receives the event first; further parameters are filled by name from the
context — bot, state, event_update, and anything a filter returned.
from baleio import Bot
from baleio.fsm import FSMContext
@dp.message(Command("me"))
async def me(message: Message, bot: Bot, state: FSMContext):
who = await bot.get_me() # bot & state injected automatically
await message.answer(f"I am {who.first_name}")Filters
A filter decides whether a handler should run. baleio accepts built-in filters, the magic
filter F, and plain callables — all interchangeable and composable with
& / | / ~.
from baleio.filters import Command, CommandStart, F
@dp.message(Command("help")) # /help
@dp.message(CommandStart()) # /start
@dp.message(F.text == "hello") # magic filter
@dp.message(F.text.startswith("/"))
@dp.message(F.from_user.id == 12345)
@dp.message(Command("go") & (F.from_user.id == 42)) # combinatorsCommand also injects a CommandObject with the parsed arguments:
from baleio.filters import Command, CommandObject
@dp.message(Command("say"))
async def say(message: Message, command: CommandObject):
await message.answer(command.args or "Say what?")CallbackData factory
Type-safe, structured payloads for inline buttons instead of hand-formatted strings — exactly
like aiogram. Fields are coerced back to their declared types on unpack, and Bale's
64-byte limit is enforced automatically.
from baleio import F
from baleio.filters import CallbackData
from baleio.types import CallbackQuery
from baleio.utils import InlineKeyboardBuilder
class Vote(CallbackData, prefix="vote"):
action: str
post_id: int
kb = (
InlineKeyboardBuilder()
.button("👍", callback_data=Vote(action="up", post_id=7).pack()) # "vote:up:7"
.button("👎", callback_data=Vote(action="down", post_id=7).pack())
.as_markup()
)
@dp.callback_query(Vote.filter(F.action == "up"))
async def upvote(query: CallbackQuery, callback_data: Vote):
await query.answer(f"Post {callback_data.post_id} liked")Finite State Machine
Drive multi-step conversations with an injected FSMContext. A State can be
used directly as a handler filter.
from baleio.fsm import State, StatesGroup, FSMContext
class Form(StatesGroup):
name = State()
age = State()
@dp.message(Command("start"))
async def start(message: Message, state: FSMContext):
await state.set_state(Form.name)
await message.answer("What's your name?")
@dp.message(Form.name) # a State used as a filter
async def got_name(message: Message, state: FSMContext):
await state.update_data(name=message.text)
await state.set_state(Form.age)
await message.answer("How old are you?")Keyboards
Builders for inline and reply keyboards, plus the raw markup types.
from baleio.utils import InlineKeyboardBuilder
from baleio import F
kb = (
InlineKeyboardBuilder()
.button("Yes", callback_data="yes")
.button("No", callback_data="no")
.adjust(2)
.as_markup()
)
await message.answer("Agree?", reply_markup=kb)
@dp.callback_query(F.data == "yes")
async def yes(cb):
await cb.answer("Great!", show_alert=True) # always answer to release the button
await cb.message.edit_text("You chose: Yes")Error handling
When a handler raises, baleio wraps the exception in an ErrorEvent and routes it to
@dp.errors() handlers. If none match, the exception is re-raised — logged in polling
so the bot stays alive.
from baleio.filters import ExceptionTypeFilter
from baleio.types import ErrorEvent
@dp.errors(ExceptionTypeFilter(ValueError))
async def on_value_error(event: ErrorEvent):
await event.update.message.answer("Something went wrong 😔")
@dp.errors() # any other unhandled error
async def on_any_error(event: ErrorEvent):
logging.exception("Unhandled", exc_info=event.exception)aiogram → baleio
Coming from aiogram? The mapping is almost one-to-one.
| aiogram | baleio |
|---|---|
from aiogram import Bot, Dispatcher, F | from baleio import Bot, Dispatcher, F |
aiogram.types.Message | baleio.types.Message |
aiogram.filters.Command | baleio.filters.Command |
aiogram.filters.callback_data.CallbackData | baleio.filters.CallbackData |
aiogram.fsm.state.StatesGroup | baleio.fsm.StatesGroup |
@dp.errors() + ErrorEvent | @dp.errors() + baleio.types.ErrorEvent |
aiogram.html.bold | baleio.md.bold (Markdown-only) |
Key differences. Bale has no parse_mode
parameter (always Markdown), its updates are limited to message,
edited_message, callback_query and pre_checkout_query, and
payments are wallet-only.