Skip to main content

A batteries-included framework on top of python-telegram-bot: typed callbacks, screens, DI, auth, conversations, and supervised workers.

Project description

vitrine

CI PyPI

A framework for building Telegram bots with python-telegram-bot that doesn't get in your way.

Handlers are just async functions. You get dependency injection, auth, robust message delivery, background workers, typed callbacks, rate limiting, and decent error handling -- the stuff every real bot needs. Everything from PTB stays accessible.

from vitrine import Bot, Button, CallbackData, Screen

bot = Bot(token="...")

class MenuCB(CallbackData, prefix="menu"):
    section: str

@bot.command("start", description="Open the menu")
async def start(update):
    return Screen(text="Welcome!", keyboard=[[Button("Shop", callback=MenuCB(section="shop"))]])

@bot.callback(MenuCB)
async def menu(data: MenuCB):          # decoded, validated payload injected
    return Screen(text=f"Section: {data.section}")

bot.run()

Install

uv add vitrine-tg
# or
pip install vitrine-tg

The distribution is named vitrine-tg; the import name is vitrine.

Local development

uv sync                                   # install deps (PTB 22, pydantic 2)
uv run pytest                             # run tests
BOT_TOKEN=... uv run python examples/small_bot.py
BOT_TOKEN=... uv run python examples/launcher_bot.py
BOT_TOKEN=... ADMIN_IDS=123 uv run python examples/shop/main.py

Core features

Dependency injection (vitrine.injection)

Handlers declare what they need by parameter name; the framework supplies it. Framework values (update, context, bot, data, state, event, delivery), your registered providers, command args, and middleware extras all live in one namespace:

bot.provide_value("orders", OrderService(...))     # constants / singletons

@bot.provide("session")                            # factories; may be async
async def session(db):                             # ...and depend on each other
    async with db.begin() as s:
        yield s                                    # cleanup after handler

@bot.callback(OrderCB)
async def view_order(data: OrderCB, user: User, orders: OrderService, session):
    ...

Dependencies are resolved once per handler call. Bad parameter names fail at startup, not production. Depends(fn) is available for explicit one-offs.

Identity & auth (vitrine.auth)

You define the principal type. The framework handles resolve-once-per-update, caching, injection, and guards:

auth = Auth(resolve_user, name="user",
            roles=lambda u: u.roles, is_banned=lambda u: u.banned)
bot = Bot(token, auth=auth)

@bot.command("refund", scope="admin")
@requires("support")          # or @admin_only
async def refund(user: User, order_id: int): ...

@bot.command("profile")
@requires_principal          # resolver returned None? -> "not registered" UX
async def profile(user: User): ...

Bans are enforced bot-wide. Guard failures turn into friendly error messages: a caller with no resolvable principal gets NotRegisteredError (point them at /start), one missing a role gets NotAuthorizedError. Any handler asking for user gets the same instance -- no re-fetching during an update.

Screens & delivery (vitrine.screens)

A Screen is a value object (text + keyboard + media + options) that doesn't need an Update -- unit-test your views by just calling them. Delivery sends it three ways: reply, edit, or proactively to any chat. It handles the annoying stuff:

  • text↔media transitions send the new message first, then delete the old one
  • all media types are detected (photo/video/animation/document/audio/voice/video-note/sticker)
  • uploads are cached by content hash and re-sent as file_id; rejected IDs trigger exactly one retry
  • "message is not modified" errors are silently skipped; fresh=True forces a new message anyway

Return a Screen from a handler and it renders automatically (edit for buttons, reply for commands). Or call screen.render(update, context) and delivery.send(chat_id, screen) manually.

Screens can also carry a persistent reply keyboard — the launcher pattern:

LAUNCHER = ReplyKeyboard([["🛍 Shop", "ℹ️ Help"]])          # persistent by default

@bot.command("start")
async def start():
    return Screen(text="Welcome!", reply_keyboard=LAUNCHER)  # set once, sticks around

@bot.reply_button("🛍 Shop")                                 # presses route like messages
async def shop():
    return shop_screen()                                     # jump here from anywhere

A message carries an inline or a reply keyboard, never both, and Telegram can't attach reply keyboards to edits — Delivery turns such edits into replaces automatically. Screen(reply_keyboard=REMOVE_REPLY_KEYBOARD) takes the keyboard away. See examples/launcher_bot.py.

Lifecycle & workers (vitrine.workers)

@bot.on_startup
async def warmup(delivery): ...

@bot.worker(every=30)
async def reconcile(orders, delivery):
    for o in await orders.confirmed():
        await delivery.send(o.chat_id, receipt_screen(o))

@bot.worker()
async def chain_watcher(feed): ...

Workers get DI, start after init, and shut down gracefully. Crashes restart automatically with exponential backoff -- no manual task supervision needed.

Features

Feature Module Details
Typed callbacks callbacks Pydantic models with a prefix. Stale/corrupt data returns "button expired" instead of crashing. Keyed encoding (keyed=True) uses query strings and tolerates schema changes; unpack() auto-detects either format so live buttons survive upgrades.
Reply keyboards screens ReplyKeyboard value object (persistent + resized by default), @bot.reply_button("label") routes presses through the full pipeline, ReplyButton(style=...) takes the same styles as an inline Button, REMOVE_REPLY_KEYBOARD clears it.
Markdown builder markdown Composable/nestable nodes, safe escaping for V1+V2, raw() escape hatch.
Routers routing @router.command/callback/message, sub-routers, router.raw() for plain PTB handlers.
Command args args Typed params from the signature; required/optional/Greedy; auto usage messages.
Pagination pagination Implement count()/fetch(offset, limit), use Paginator and nav_row() buttons.
Conversations conversations Dataclass state per run, string transitions, timeout, on_exit(reason) hooks. Entry commands land in /help; ANY_STATE mounts one step on every state; command="skip" is valid only inside its state; exclusive=True ends the caller's other runs. Full DI/middleware/principal interop.
Files/media media download() with timeout and cleanup; content-hash file_id cache shared with Screen rendering.
Rate limiting ratelimit @throttle(3, per=60), custom keys and custom behavior on limits.
Logging logging Key=value format, one line per update, audit() convention.
Errors errors @bot.on_error(Type) registry dispatched by MRO. Handlers can return a Screen to render into the current message.
Command discovery commands Auto /help filtered by caller's scopes. set_my_commands() per scope. hidden=True for internal handlers.
Middleware middleware async def mw(event, call_next) at bot or router scope. event.extras values become injectable.

Example: scaled mode

examples/shop/ is a full app: a storefront with a domain layer that never touches the bot layer (domain/), views as pure functions (domain -> Screen), services injected into handlers, a User principal for guards and menus, a purchase conversation, admin commands, and a reconciler worker that messages buyers. Only main.py knows all the layers.

The order flow in examples/shop/botapp/order_flow.py shows the conversation features together: two entry points (a Buy button and a discoverable /order command), a /skip command scoped to one state, a single Cancel handler mounted on ANY_STATE, and exclusive=True so a new order abandons the caller's half-finished one.

Escape hatches

Not a fork or parallel dispatcher. bot.build() returns the PTB Application for webhooks. router.raw(handler, group=...) registers plain PTB handlers. Screen.extra passes kwargs to PTB's send methods. Everything from PTB stays accessible.

Testing

Views are pure functions -- test them without a live bot. Screen.content()/markup() show what would be sent. Delivery accepts any object with send/edit methods (see tests/conftest.py for a mock). This repo has 127 tests that exercise dispatch and conversations the same way you can.

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

vitrine_tg-0.2.0.tar.gz (43.6 kB view details)

Uploaded Source

Built Distribution

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

vitrine_tg-0.2.0-py3-none-any.whl (53.6 kB view details)

Uploaded Python 3

File details

Details for the file vitrine_tg-0.2.0.tar.gz.

File metadata

  • Download URL: vitrine_tg-0.2.0.tar.gz
  • Upload date:
  • Size: 43.6 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","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 vitrine_tg-0.2.0.tar.gz
Algorithm Hash digest
SHA256 f40fe5f2f6d4b4e0a433fc0b1af8579c675a31d92fe7d02c86e215dcfb28a3d0
MD5 4fefb61f8c5415248f2c8bc83cdfb48c
BLAKE2b-256 7301f8e68f24891ca4c8ed6ca913e6c46cc29bf3cc1cc2540dd40eefd3297711

See more details on using hashes here.

File details

Details for the file vitrine_tg-0.2.0-py3-none-any.whl.

File metadata

  • Download URL: vitrine_tg-0.2.0-py3-none-any.whl
  • Upload date:
  • Size: 53.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: uv/0.11.32 {"installer":{"name":"uv","version":"0.11.32","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 vitrine_tg-0.2.0-py3-none-any.whl
Algorithm Hash digest
SHA256 ce766b1634f8d3e2f32fc9d1900fa4474c5f5669ebbb695ce59dc3567eaf79f2
MD5 0cbb5c68171f49567ee8a62bf2160a17
BLAKE2b-256 5e32fdea541b386b570847b4f50bb9e21002b660ef062bfa5851e8bf6b9c393c

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