Skip to content

Architecture

This page is the map you read before the code. It follows one Telegram update from the moment it lands on the webhook to the moment the bot replies, and names the object responsible at each hop. Once you can trace this path, most of the codebase reads in order.

The update flow

Every interaction, a tapped button, a typed command, a shared location, arrives as a single Telegram update and walks the same path:

Telegram
HTTP POST to your webhook
POST /telegram apps/bot/mitup_bot/web/telegram.py Checks the secret header, parses the JSON, and always answers 204.
enqueue and return; work happens out of band
app.update_queue python-telegram-bot An in-memory queue holding updates until the processor drains them.
PerUserUpdateProcessor apps/bot/mitup_bot/update_processor.py Drains the queue and serializes updates that share a (user, chat) key.
callback_with_metrics apps/bot/mitup_bot/handlers/registry.py Binds log context, times the handler, and routes faults to the error handler.
guards libs/telegram/mitup_bot/guards.py Validate and narrow the raw update into the exact type the handler needs.
@with_session libs/data/mitup_bot/db.py Opens one database session for the handler, read or write.
view libs/telegram/mitup_bot/views/ Builds the message text and inline keyboard for the screen to show.
context.api reply libs/telegram/mitup_bot/custom_context.py Sends the rendered view back to the user over the Bot API.
Telegram

Two properties are worth holding onto. The webhook answers 204 for every well-formed request, even one it cannot parse, so Telegram never retries a poison-pill update in a tight loop. And the endpoint hands off to the queue instead of processing inline, so the HTTP request returns in milliseconds while the handler runs on its own.

MitupRuntime, the composition root

apps/bot/mitup_bot/app.py holds MitupRuntime, the single place where the whole bot is wired together. Its constructor builds the config, configures logging, metrics, the database, and the timezone API, then assembles the PTB Application: the bot token, the custom MitupContext, the rate limiter, and the PerUserUpdateProcessor. HandlersRegistry.bind(app) registers every handler onto that application. run() picks polling or webhook mode from config and starts the server. Nothing else constructs these pieces, so if you want to know what depends on what, start here.

The webhook host

apps/bot/mitup_bot/web/ holds the FastAPI app factory and the POST /telegram route. The runtime serves it through uvicorn with workers=1. That single worker is not an oversight: the PTB Application owns in-memory state (conversation states, per-user data) that cannot be shared across processes, so exactly one worker may exist. The endpoint validates the Telegram secret header, parses the update, puts it on app.update_queue, and returns.

Concurrency in one paragraph

Because there is one worker, all concurrency lives inside that one event loop. PTB's own semaphore caps how many updates run at once through bot.concurrent_updates, which defaults to 1 and keeps processing observably sequential. PerUserUpdateProcessor adds a second guarantee: updates that share the same (user, chat) key always run one at a time, so a user's own actions never race each other even when the cap is raised above 1. In-process locks are enough precisely because there is only ever one worker holding them.

Going deeper on the web layer

This page stays at map altitude. For the FastAPI app factory, the lifespan that owns PTB's initialize/start/set_webhook/stop/shutdown sequence, secret-token validation, and the hard rules that keep the webhook from breaking silently, read the web-conventions skill.