Skip to main content

Botty brings the elegance of FastAPI's dependency injection and type hints to Telegram bot development. Write clean, testable bot code with automatic dependency resolution, built-in message registry, and a developer-friendly API.

Project description

Botty

A FastAPI-inspired modern framework for building Telegram bots with Python

Botty is the elegance of FastAPI's dependency injection and type hints to Telegram bot development (based on python-telegram-bot). Write clean, code with automatic dependency resolution and a developer-friendly API.

from botty import Router, HandlerResponse, Context, Answer, Update, InjectableUser

router = Router()

@router.command("start")
async def start_handler(
    update: Update,
    context: Context,
    user_repo: UserRepository,  # Auto-injected!
    effective_user: InjectableUser # Auto-injected!
) -> HandlerResponse:
    user = user_repo.get_or_create(effective_user.id)
    yield Answer(text=f"Welcome back, {user.name}! ๐Ÿ‘‹")

๐ŸŽฏ Main Idea

Traditional Telegram bot frameworks require a lot of boilerplate and make it hard to:

  • Share code between handlers (no clean dependency injection)
  • Track and edit messages you've sent
  • Write testable code (everything is tightly coupled)
  • Get type hints and IDE support

Botty uses bringing FastAPI's best ideas to Telegram bots:

  • Dependency Injection - Repositories and services are automatically injected
  • Type Hints - Full type safety with IDE autocomplete
  • Async Generators - Handlers yield responses, making sending and editing messages trivial

Installation

pip install botty-framework

or

uv add botty-framework

๐ŸŒŸ Features

FastAPI-Style Dependency Injection

Botty automatically injects repositories and services just by typeโ€‘hinting them โ€“ no decorators, no Depends() boilerplate.

@router.command("profile")
async def show_profile(
    update: Update,
    context: Context,
    user_repo: UserRepository,      # Injected automatically
    settings_svc: SettingsService,   # Services too!
    effective_user: InjectableUser
) -> HandlerResponse:
    user = user_repo.get(effective_user.id)
    settings = settings_svc.get_user_settings(user.id)

    yield Answer(f"๐Ÿ‘ค {user.name}\nโš™๏ธ Theme: {settings.theme}")

What dependencies are generated?

  1. Any class that inherits from BaseRepository gets a requestโ€‘scoped instance with an open database session.
  2. Any class that inherits from BaseService is a singleton โ€“ shared across requests.
  3. Common objects like Update, Context, Session, InjectableUser, InjectableChat, InjectableMessage, and CallbackQuery are also injected automatically.

Custom dependencies with Depends

For cases where automatic injection isn't enough (e.g., you need to compute a value or fetch something conditionally), Botty provides FastAPIโ€‘style Depends.

from botty import Depends, Annotated

async def get_current_user(
    update: Update,
    user_repo: UserRepository   # โ† Nested dependencies work too!
) -> User:
    return user_repo.get(update.effective_user.id)

CurrentUser = Annotated[User, Depends(get_current_user)]

@router.command("profile")
async def profile_handler(
    update: Update,
    context: Context,
    current_user: CurrentUser   # โ† Injected via Depends
) -> HandlerResponse:
    yield Answer(f"Your profile: {current_user.name}")

Dependencies can be cached within the same request (default) or recomputed each time. They can also depend on other dependencies โ€“ the resolver handles the graph automatically.

Message Registry & Smart Editing

Track messages and edit them later by key, handler name, or automatically:

@router.command("countdown")
async def countdown_handler(
    update: Update,
    context: Context
) -> HandlerResponse:
    # Send message with a key
    yield Answer("Starting in 3...", message_key="countdown")

    await asyncio.sleep(1)

    # Edit by key
    yield EditAnswer("2...", message_key="countdown")

    await asyncio.sleep(1)
    yield EditAnswer("1...", message_key="countdown")

    await asyncio.sleep(1)
    yield EditAnswer("GO! ๐Ÿš€", message_key="countdown")

Clean Handler Syntax

Handlers are async generators that yield responses:

@router.command("weather")
async def weather_handler(
    update: Update,
    context: Context,
    weather_api: WeatherService
) -> HandlerResponse:
    city = " ".join(context.args)

    # Show loading state
    yield Answer("๐Ÿ” Fetching weather...", message_key="weather")

    # Get data
    data = await weather_api.get_current(city)

    # Update message
    yield EditAnswer(
        f"๐ŸŒค๏ธ {city}: {data.temp}ยฐC, {data.condition}",
        message_key="weather"
    )

Repository Pattern

Built-in support for the repository pattern with SQLModel:

from botty import BaseRepository
from sqlmodel import Session, select

class UserRepository(BaseRepository[User]): # Inheritance from BaseRepository allows to be injected
    model = User

    def get_by_telegram_id(self, telegram_id: int) -> User | None:
        statement = select(User).where(User.telegram_id == telegram_id)
        return self.session.exec(statement).first()

    def get_active_users(self) -> list[User]:
        statement = select(User).where(User.is_active == True)
        return list(self.session.exec(statement).all())

# Automatically injected with proper session management!
@router.command("stats")
async def stats_handler(
    update: Update,
    context: Context,
    user_repo: UserRepositoryDep
) -> HandlerResponse:
    active = user_repo.get_active_users()
    yield Answer(f"๐Ÿ“Š Active users: {len(active)}")

Type Safety & Validation

Handlers are validated at registration time with helpful error messages:

@router.command("test")
def wrong_handler(update: Update, context: Context):  # โŒ Forgot 'async'
    yield Answer("Hi")

# Error: Handler must be an async function (use 'async def')
# ๐Ÿ’ก Suggestion: Change 'def wrong_handler(...)' to 'async def wrong_handler(...)'

Built-in Database Support (optional)

SQLModel integration with automatic session management:

from botty import AppBuilder, SQLiteProvider
from sqlmodel import SQLModel, Field

# Define your models
class User(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    telegram_id: int = Field(unique=True)
    name: str
    created_at: datetime = Field(default_factory=lambda: datetime.now(datetime.UTC))

# Build app with database
app = (
    AppBuilder(token="YOUR_TOKEN")
    .database(SQLiteProvider("bot.db"))
    .build()
)

๐ŸŽจ Inspirations

FastAPI

Botty is heavily inspired by FastAPI's elegant dependency injection system:

  • Type hints for automatic injection
  • Clean decorator-based routing
  • Dependency resolution with Depends()

Django

Repository pattern and clean architecture:

  • Repository layer for data access
  • Service layer for business logic

๐Ÿ“š Complete Example

Project Structure

Botty auto-discovers handlers from project structure:

todo_bot/
โ”œโ”€โ”€ src/
โ”‚   โ”œโ”€โ”€ handlers/
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ start.py        # Start command handlers
โ”‚   โ”‚   โ”œโ”€โ”€ todos.py        # Todo-related handlers
โ”‚   โ”‚   โ””โ”€โ”€ settings.py     # Settings handlers
โ”‚   โ”œโ”€โ”€ repositories/
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ user_repository.py
โ”‚   โ”‚   โ””โ”€โ”€ todo_repository.py
โ”‚   โ”œโ”€โ”€ services/
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ””โ”€โ”€ notification_service.py
โ”‚   โ”œโ”€โ”€ models/
โ”‚   โ”‚   โ”œโ”€โ”€ __init__.py
โ”‚   โ”‚   โ”œโ”€โ”€ user.py
โ”‚   โ”‚   โ””โ”€โ”€ todo.py
โ”œโ”€โ”€ main.py                 # App entry point
โ””โ”€โ”€ pyproject.toml

Implementation

Here's a full todo bot showing all features:

from datetime import datetime
from telegram import InlineKeyboardButton, InlineKeyboardMarkup
from sqlmodel import SQLModel, Field, Session, select
from botty import (
    AppBuilder,
    Router,
    Context,
    HandlerResponse,
    BaseRepository,
    Answer,
    EditAnswer,
    SQLiteProvider,
    Update,
    Depends
)

# ============================================================================
# Models
# ============================================================================

class Todo(SQLModel, table=True):
    id: int | None = Field(default=None, primary_key=True)
    user_id: int
    task: str
    completed: bool = False
    created_at: datetime = Field(default_factory=lambda: datetime.now(datetime.UTC))


# ============================================================================
# Repositories
# ============================================================================

class TodoRepository(BaseRepository[Todo]):  # Inheritance from BaseRepository allows to be injected
    model = Todo

    def get_by_user(self, user_id: int) -> list[Todo]:
        """Get all todos for a user."""
        statement = select(Todo).where(Todo.user_id == user_id)
        return list(self.session.exec(statement).all())

    def get_pending(self, user_id: int) -> list[Todo]:
        """Get incomplete todos."""
        statement = (
            select(Todo)
            .where(Todo.user_id == user_id)
            .where(Todo.completed == False)
        )
        return list(self.session.exec(statement).all())

    def toggle_complete(self, todo_id: int) -> Todo | None:
        """Toggle completion status."""
        todo = self.get(todo_id)
        if todo:
            todo.completed = not todo.completed
            self.update(todo)
        return todo

# ============================================================================
# Handlers
# ============================================================================

router = Router()

@router.command("start")
async def start_handler(
    update: Update,
    context: Context
) -> HandlerResponse:
    """Welcome message."""
    yield Answer(
        "๐Ÿ‘‹ Welcome to Todo Bot!\n\n"
        "Commands:\n"
        "/add <task> - Add a new todo\n"
        "/list - Show all todos\n"
        "/pending - Show incomplete todos"
    )


@router.command("add")
async def add_todo_handler(
    update: Update,
    context: Context,
    todo_repo: TodoRepository,  # Auto-injected!
    effective_user: InjectableUser
) -> HandlerResponse:
    """Add a new todo."""
    if not context.args:
        yield Answer("โŒ Usage: /add <task description>")
        return

    task = " ".join(context.args)
    todo = Todo(
        user_id=effective_user.id,
        task=task
    )

    todo_repo.create(todo)
    yield Answer(f"โœ… Added: {task}")


@router.command("list")
async def list_todos_handler(
    update: Update,
    context: Context,
    todo_repo: TodoRepositoryDep,  # Auto-injected!
    effective_user: InjectableUser
) -> HandlerResponse:
    """List all todos."""
    todos = todo_repo.get_by_user(effective_user.id)

    if not todos:
        yield Answer("๐Ÿ“ You have no todos!\nUse /add to create one.")
        return

    # Build message with buttons
    text = "๐Ÿ“ Your todos:\n\n"
    buttons = []

    for i, todo in enumerate(todos, 1):
        status = "โœ…" if todo.completed else "โบ๏ธ"
        text += f"{i}. {status} {todo.task}\n"

        button_text = "โœ… Complete" if not todo.completed else "โ†ฉ๏ธ Undo"
        buttons.append([
            InlineKeyboardButton(
                f"{i}. {button_text}",
                callback_data=f"toggle_{todo.id}"
            )
        ])

    keyboard = InlineKeyboardMarkup(buttons)

    yield Answer(
        text=text,
        reply_markup=keyboard,
        message_key="todo_list"
    )


@router.command("pending")
async def pending_todos_handler(
    update: Update,
    context: Context,
    todo_repo: TodoRepositoryDep,  # Auto-injected!
    effective_user: InjectableUser
) -> HandlerResponse:
    """List incomplete todos."""
    todos = todo_repo.get_pending(effective_user.id)

    if not todos:
        yield Answer("๐ŸŽ‰ All done! No pending todos.")
        return

    text = "โบ๏ธ Pending todos:\n\n"
    for i, todo in enumerate(todos, 1):
        text += f"{i}. {todo.task}\n"

    yield Answer(text)


@router.callback_query(r"^toggle_(\d+)")
async def toggle_todo_handler(
    update: Update,
    context: Context,
    todo_repo: TodoRepositoryDep,  # Auto-injected!
    callback_query: CallbackQuery,
    effective_user: InjectableUser
) -> HandlerResponse:
    """Toggle todo completion."""
    await query.answer()

    # Extract todo ID from callback data
    todo_id = int(query.data.split("_")[1])

    # Toggle the todo
    todo = todo_repo.toggle_complete(todo_id)

    if not todo:
        yield EditAnswer("โŒ Todo not found")
        return

    # Refresh the list
    todos = todo_repo.get_by_user(effective_user.id)

    text = "๐Ÿ“ Your todos:\n\n"
    buttons = []

    for i, t in enumerate(todos, 1):
        status = "โœ…" if t.completed else "โบ๏ธ"
        text += f"{i}. {status} {t.task}\n"

        button_text = "โœ… Complete" if not t.completed else "โ†ฉ๏ธ Undo"
        buttons.append([
            InlineKeyboardButton(
                f"{i}. {button_text}",
                callback_data=f"toggle_{t.id}"
            )
        ])

    keyboard = InlineKeyboardMarkup(buttons)

    yield EditAnswer(
        text=text,
        reply_markup=keyboard,
        message_key="todo_list"
    )


# ============================================================================
# Application Setup
# ============================================================================

if __name__ == "__main__":
    app = (
        AppBuilder(token="YOUR_BOT_TOKEN_HERE")
        .database(SQLiteProvider("todos.db"))
        .build()
    )

    app.launch()

๐Ÿ” Comparison

vs. python-telegram-bot

python-telegram-bot:

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    # Manual session management
    session = Session(engine)
    try:
        user_repo = UserRepository(session)
        user_name = "unknown"
        if update.effective_user is not None:
            user = user_repo.get(update.effective_user.id)
            user_name = user.name
        if update.message is not None:
            await update.message.reply_text(f"Hello {user_name}")
    finally:
        session.close()

application.add_handler(CommandHandler("start", start))

Botty:

@router.command("start")
async def start_handler(
    update: Update,
    context: Context,
    user_repo: UserRepository,  # Auto-injected!
    effective_user: InjectableUser
) -> HandlerResponse:
    user = user_repo.get(effective_user.id)
    yield Answer(f"Hello {user.name}")
    # Session managed automatically

๐Ÿ“„ License

MIT License - see LICENSE file for details

Acknowledgments

๐Ÿ—บ๏ธ Roadmap

  • More database providers (PostgreSQL, MySQL, NoDatabase)
  • Conversation state management
  • Admin panel
  • CLI for scaffolding projects
  • Built-in middleware support
  • Metrics and monitoring
  • Plugin system

Botty - Because building bots should be as elegant as using them

Project details


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

botty_framework-0.0.2.tar.gz (34.4 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

botty_framework-0.0.2-py3-none-any.whl (51.8 kB view details)

Uploaded Python 3

File details

Details for the file botty_framework-0.0.2.tar.gz.

File metadata

  • Download URL: botty_framework-0.0.2.tar.gz
  • Upload date:
  • Size: 34.4 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for botty_framework-0.0.2.tar.gz
Algorithm Hash digest
SHA256 7a5af298fa9c258bec731816eed7269874929b0da40b01f2cbef41bb54bc794c
MD5 66192d6589bf65250283176e5d71c1b7
BLAKE2b-256 91169bbe389c08fdba4fbe0241a55b2e5481d548bd6aa5180387aef4dee41c4d

See more details on using hashes here.

File details

Details for the file botty_framework-0.0.2-py3-none-any.whl.

File metadata

  • Download URL: botty_framework-0.0.2-py3-none-any.whl
  • Upload date:
  • Size: 51.8 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.2 {"installer":{"name":"uv","version":"0.10.2","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

File hashes

Hashes for botty_framework-0.0.2-py3-none-any.whl
Algorithm Hash digest
SHA256 506316d86a9c12d71547018eec16daf197e3d404d2910f3c9897dcd540e43a85
MD5 9c051d86723f3558d917aa4f3f488de8
BLAKE2b-256 3179a39125989489d3455b64f8e611900a018834e5413745c8dcfa2de9004cf4

See more details on using hashes here.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page