Skip to main content

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.

Injection is by name, so an annotation on an injected parameter is documentation rather than a contract — but when it provably disagrees with what the provider hands over (count: int against a provider returning str), you get a warning at startup on the vitrine.build logger instead of an AttributeError deep inside the handler. Only clear-cut cases are reported: protocols, generics, unions and unannotated factories are left alone, so a duck-typed stand-in keeps working. Bot(strict_types=True) turns the warning into a build failure.

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. Handlers ignore edited messages unless they pass edits=True.
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, and /help <command> for one command's usage, docstring, arguments and requirements. set_my_commands() per scope, with the menus of chats that have left a scope deleted rather than left stale; bot.sync_commands() republishes without a restart. 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 158 tests that exercise dispatch and conversations the same way you can.

Metadata

Release files for vitrine-tg 0.3.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for vitrine-tg 0.3.0
File Size Uploaded
vitrine_tg-0.3.0.tar.gz 50.9 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for vitrine-tg 0.3.0
File Interpreter ABI Platform
vitrine_tg-0.3.0-py3-none-any.whl Python 3 none any Details

Total release size: 112.1 kB

Release files / vitrine_tg-0.3.0.tar.gz

Download URL vitrine_tg-0.3.0.tar.gz
Size 50.9 kB
Tags Source
SHA-256 checksum
How to use checksums
66c5fc7814c550fbde797c21fc3f35c60813eacf99fee982209f7449f2438060
BLAKE2b-256 checksum
How to use checksums
8f50eabc0a0c8b8969cd25b61656cfec846b3bf53c84eaaecc91fad30ad12fe9
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.0 {"installer":{"name":"uv","version":"0.12.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}

Release files / vitrine_tg-0.3.0-py3-none-any.whl

Download URL vitrine_tg-0.3.0-py3-none-any.whl
Size 61.2 kB
Tags Python 3
SHA-256 checksum
How to use checksums
235565333fdbdb50da961709970f03f103a03de3ce69f0d769086a866821372e
BLAKE2b-256 checksum
How to use checksums
9e2cf12fc36ca314f7bc84246eb76e9e2eeab9021c94a3b66588f721379796f6
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.12.0 {"installer":{"name":"uv","version":"0.12.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}

Release history Release notifications | RSS feed

0.4.0

2 release files

This release

0.3.0 This release

2 release files

0.2.0

2 release files

0.1.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page