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

router = Router()

@router.command("start")
async def start_handler(
    update: Update,
    context: Context,
    user_repo: UserRepository  # Auto-injected!
) -> HandlerResponse:
    user = user_repo.get_or_create(update.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

async def get_repository(update: Update, context: Context, session: Session): # โ† Session handled automatically
    return UserRepository(session)

async def get_settings(update: Update, context: Context): # Database session NOT created here
    return SettingsService()

UserRepositoryDep = Annotated[UserRepository, Depends(get_repository)]
SettingsServiceDep = Annotated[SettingsService, Depends(get_settings)]

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

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

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]):
    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())


UserRepositoryDep = Annotated[UserRepository, Depends(get_repository)]

# 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

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]):
    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

def get_todo_repo(update: Update, context: Context, session: Session):
    return TodoRepository(session)

TodoRepositoryDep = Annotated[TodoRepository, Depends(get_todo_repo)]

# ============================================================================
# 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: TodoRepositoryDep  # Auto-injected!
) -> HandlerResponse:
    """Add a new todo."""
    if not context.args:
        yield answer("โŒ Usage: /add <task description>")
        return

    task = " ".join(context.args)
    todo = Todo(
        user_id=update.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!
) -> HandlerResponse:
    """List all todos."""
    todos = todo_repo.get_by_user(update.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!
) -> HandlerResponse:
    """List incomplete todos."""
    todos = todo_repo.get_pending(update.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!
) -> HandlerResponse:
    """Toggle todo completion."""
    query = update.callback_query
    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 edit_answer("โŒ Todo not found")
        return

    # Refresh the list
    todos = todo_repo.get_by_user(update.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 edit_answer(
        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 = user_repo.get(update.effective_user.id)
        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: UserRepositoryDep  # Auto-injected!
) -> HandlerResponse:
    user = user_repo.get(update.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)
  • 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.1.tar.gz (16.3 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.1-py3-none-any.whl (22.2 kB view details)

Uploaded Python 3

File details

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

File metadata

  • Download URL: botty_framework-0.0.1.tar.gz
  • Upload date:
  • Size: 16.3 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.0 {"installer":{"name":"uv","version":"0.10.0","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.1.tar.gz
Algorithm Hash digest
SHA256 5d6dd4583d9acfc74440c3bc539c100cbc1444b8b45a077e922f425bcc342d9b
MD5 bce2083c4ae0b34be4483c5ea71ddbff
BLAKE2b-256 d79a785e6b723e4bd5c1f5565891a23881c12ae0aac47cd1ffc9133261c87f50

See more details on using hashes here.

File details

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

File metadata

  • Download URL: botty_framework-0.0.1-py3-none-any.whl
  • Upload date:
  • Size: 22.2 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.10.0 {"installer":{"name":"uv","version":"0.10.0","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.1-py3-none-any.whl
Algorithm Hash digest
SHA256 51420b9e4970d6254febbf6d04ed298173cb72495a85a9aa093cac82210e9bc1
MD5 c82655ddc95f55fb05ed41396e109d22
BLAKE2b-256 3558964a505d6cd6991d3597a4bbebd09c9091ec852427650dfe2df2f1476433

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