Skip to content

Adding a handler

A handler is an async function that runs when a matching Telegram update arrives: a /command, a button press, a text reply, an inline query. Adding one is the most common first contribution, so this page walks the whole path from an empty file to a passing test.

The code lives under apps/bot/mitup_bot/handlers/, one package per feature. A package like main_menu/ is a good one to read alongside this page. It holds an enums.py, one module per screen, and an __init__.py that wires them together.

Two rules that never bend

  • No hardcoded user-facing text. Every string a user sees resolves through a MessageBase member so it can be translated. Not even error alerts are exceptions.
  • Never hardcode the language. Pass lang=user.lang (or meeting.lang) to every .get() call. A literal lang="en" is a bug the reviewer will catch.

Define a HandlerId

Every handler is identified by a HandlerId enum member. Each feature package owns a subclass in its enums.py. The members are plain auto() values; the base class in libs/core/mitup_bot/handler_id.py derives the string value and the metrics dimension for you.

from enum import auto

from mitup_bot.handler_id import HandlerId


class RemindersHandlerId(HandlerId):
    SHOW_REMINDERS_CALLBACK = auto()

Name callback members with a _CALLBACK suffix so the intent reads at a glance, and add a new subclass rather than borrowing another feature's enum.

Register the callback

Decorate the function with the matching HandlersRegistry method. The registry in registry.py has one per update type:

Update Method
/command register_command
Button press register_callback_query
Text or media message register_message
Inline query register_inline_handler
Inline result picked register_chosen_inline_result
Bot blocked / unblocked register_chat_member
Multi-step flow register_conversation_handler

The function name follows the registration type: callback_query_<action> for a button, command_<name> for a command, <action>_message_handler for a message. The callback_query_ prefix is required, not a style preference.

@HandlersRegistry.register_callback_query(
    RemindersHandlerId.SHOW_REMINDERS_CALLBACK,
    callback_data=cb.SHOW_REMINDERS,
)

Button actions carry their own CallbackData instance, defined once in libs/telegram/mitup_bot/utils/callbacks.py and reused at every call site.

Wrap database access

If the handler touches the database, add @with_session from mitup_bot/db.py directly under the registration decorator. It injects an AsyncSession as the first positional argument.

@HandlersRegistry.register_callback_query(
    RemindersHandlerId.SHOW_REMINDERS_CALLBACK,
    callback_data=cb.SHOW_REMINDERS,
)
@with_session
async def callback_query_show_reminders(
    session: AsyncSession, update: Update, context: TMitupContext
) -> None:
    ...

A handler that mutates state and then fans out over Telegram (it takes the per-meeting row lock, refreshes meeting messages, or notifies participants) uses @with_session(write=True) instead. The database skill covers when and why.

Validate the input with guards

Handlers never trust the raw update. The guards module turns a fuzzy Update into the concrete objects you need, raising a clean error when something is missing. Fetch the user early, before branching on anything else, so user.lang is in scope everywhere below.

callback_data = guards.valid_callback_data(
    cb.SHOW_REMINDERS.parse(context.match), RemindersHandlerId.SHOW_REMINDERS_CALLBACK
)
user = await guards.current_user(update, session)

Build the view and the text

The reply is a view, not a raw message. Check the factory catalogue in views/factory.py before building one by hand; something like confirmation_view or main_menu_view often already fits. Every factory takes a RenderContext as its first argument; build it in the handler with guards.render_context(user, update, context). Button labels and the description come from MessageBase subclasses in libs/telegram/mitup_bot/utils/messages.py, rendered with .get(lang=user.lang).

view = MitupView(
    description=ReminderMessages.LIST.get(lang=user.lang),
    keyboard=[
        [ButtonConfig(text=ButtonMessages.MAIN_MENU.get(lang=user.lang), callback_data=cb.MAIN_MENU)],
    ],
)

If your reply needs a string that does not exist yet, add it to the right MessageBase subclass, run uv run mb locales sync, and hand the new keys to the translator. The user-facing-text skill owns the wording and the MessageBase API.

Wire it into the package

A handler only runs once its module is imported, because the import is what executes the decorator that registers it. Import the new module from the package __init__.py, and import that package from apps/bot/mitup_bot/handlers/__init__.py.

Order matters in two places:

  • A conversation handler looks up its entry points and states at registration time, so those handlers must be imported before the module that composes them.
  • Global catch-all handlers are imported last, so the specific handlers claim their updates first.

When two modules need each other's enums, pull the shared enum into a standalone file rather than importing across handler modules. Cross-importing handler logic is not allowed; shared helpers go in a utils.py inside the same package.

Add a test

Every handler gets a test at tests/handlers/<package>/test_<module>.py. If the handler calls any guards.* function, also register its context in tests/handlers/test_failure_modes.py; that matrix exercises each guard's failure path and turns red if a new guard call is left out. Run the ones you touched as you go:

uv run mb test tests/handlers/reminders

Before opening a merge request, run the full gate:

uv run mb validate

Where to go deeper

This page is the map. Two skills hold the detail:

  • handler-conventions for the full rules on registration, conversation state machines, filters, callback-data formats, and the add/remove checklists.
  • new-handler for scaffolding a fresh package with the files already in the right shape.