vitrine
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=Trueforces 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)
| File | Size | Uploaded | |
|---|---|---|---|
| vitrine_tg-0.3.0.tar.gz | 50.9 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| 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}
|