Docs / Overview
GitHub
Python · Async · Bale bot API

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.

pip install baleio Python 3.9+ Pydantic v2 aiohttp MIT license
/

Routers & DI

Nested Routers, per-event observers, and by-name dependency injection.

F

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.

Getting started

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.

Getting started

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())
i

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.

Guide

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}")
Guide

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))   # combinators

Command 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?")
Guide

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")
Guide

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?")
Guide

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")
Guide

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)
Reference

aiogram → baleio

Coming from aiogram? The mapping is almost one-to-one.

aiogrambaleio
from aiogram import Bot, Dispatcher, Ffrom baleio import Bot, Dispatcher, F
aiogram.types.Messagebaleio.types.Message
aiogram.filters.Commandbaleio.filters.Command
aiogram.filters.callback_data.CallbackDatabaleio.filters.CallbackData
aiogram.fsm.state.StatesGroupbaleio.fsm.StatesGroup
@dp.errors() + ErrorEvent@dp.errors() + baleio.types.ErrorEvent
aiogram.html.boldbaleio.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.