· 5 min read

Building a Practical Telegram Bot Command Router in Python

A Telegram bot gets messy fast once commands, callbacks, and inline queries start competing for attention. This tutorial shows how to build a small but extensible router so your bot stays easy to extend, test, and reason about.

Telegram bots often start with a single if update.message.text == ... block and slowly turn into a pile of ad hoc handlers. That works for a prototype, but once you add /start, /help, callback buttons, inline queries, and admin-only actions, the code becomes hard to extend.

In this tutorial, we'll build a practical command router for a Telegram bot in Python. The goal is not to create a full framework. The goal is to create a small structure that makes real bot development easier:

  • clear command registration
  • support for message commands and callback queries
  • per-handler permission checks
  • easy testing
  • minimal boilerplate

We'll use python-telegram-bot because it is common and well documented, but the routing idea applies to any Telegram SDK.

Why a router helps

A router separates what happened from what to do.

Instead of writing logic like this:

if update.message and update.message.text == "/start":
    ...
elif update.message and update.message.text == "/stats":
    ...
elif update.callback_query and update.callback_query.data == "approve":
    ...

you register handlers once:

router.command("start")(start_handler)
router.command("stats", admin_only=True)(stats_handler)
router.callback("approve")(approve_handler)

That small change gives you a cleaner architecture and makes the bot easier to scale.

Project structure

A simple structure looks like this:

bot/
  app.py
  router.py
  handlers.py
  permissions.py
  • router.py contains routing logic
  • handlers.py contains Telegram feature handlers
  • permissions.py contains user-role checks
  • app.py wires everything together

Step 1: Define a small routing layer

Let's create a router that can dispatch commands and callback data.

# router.py
from dataclasses import dataclass
from typing import Callable, Awaitable, Any

Handler = Callable[..., Awaitable[Any]]

@dataclass
class Route:
    name: str
    handler: Handler
    admin_only: bool = False

class BotRouter:
    def __init__(self):
        self.command_routes: dict[str, Route] = {}
        self.callback_routes: dict[str, Route] = {}

    def command(self, name: str, admin_only: bool = False):
        def decorator(fn: Handler):
            self.command_routes[name] = Route(name=name, handler=fn, admin_only=admin_only)
            return fn
        return decorator

    def callback(self, name: str, admin_only: bool = False):
        def decorator(fn: Handler):
            self.callback_routes[name] = Route(name=name, handler=fn, admin_only=admin_only)
            return fn
        return decorator

This is intentionally small. It does not try to parse every Telegram update type. It just stores routes in dictionaries.

Step 2: Add a permission check

Telegram bots often need at least one role-based rule, for example:

  • only admins can approve posts
  • only the owner can shut down the bot
  • only members of a certain chat can use a feature

Let's add a helper for admin checks.

# permissions.py
from telegram import Update
from telegram.ext import ContextTypes

async def is_admin(update: Update, context: ContextTypes.DEFAULT_TYPE) -> bool:
    user = update.effective_user
    chat = update.effective_chat

    if not user or not chat:
        return False

    member = await context.bot.get_chat_member(chat.id, user.id)
    return member.status in {"administrator", "creator"}

This is chat-scoped. If your bot has global roles, you may instead load permissions from a database.

Step 3: Build a dispatcher function

Now we connect Telegram updates to our router.

# app.py
import logging
from telegram import Update
from telegram.ext import Application, CommandHandler, CallbackQueryHandler, ContextTypes

from router import BotRouter
from permissions import is_admin

logging.basicConfig(level=logging.INFO)
router = BotRouter()

async def dispatch_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    message = update.message
    if not message or not message.text:
        return

    command = message.text.split()[0][1:]  # /start -> start
    route = router.command_routes.get(command)

    if not route:
        return

    if route.admin_only and not await is_admin(update, context):
        await message.reply_text("You are not allowed to use this command.")
        return

    await route.handler(update, context)

async def dispatch_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    if not query or not query.data:
        return

    route = router.callback_routes.get(query.data)
    if not route:
        return

    if route.admin_only and not await is_admin(update, context):
        await query.answer("Not allowed", show_alert=True)
        return

    await route.handler(update, context)

A few practical notes:

  • We inspect update.message.text for commands.
  • We use query.data for callback buttons.
  • We keep the authorization check close to dispatching so handlers stay focused.

Step 4: Define handlers

Now write feature logic in separate functions.

# handlers.py
from telegram import Update
from telegram.ext import ContextTypes

async def start(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        "Welcome. Use /help to see available commands."
    )

async def help_command(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text(
        "/start - welcome message\n"
        "/help - show help\n"
        "/stats - admin-only stats"
    )

async def stats(update: Update, context: ContextTypes.DEFAULT_TYPE):
    await update.message.reply_text("Today: 42 requests processed.")

async def approve(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    await query.answer()
    await query.edit_message_text("Approved.")

These handlers do one thing each. They do not care how routing or permissions work.

Step 5: Register the routes

In your app startup code, register handlers with the router.

# app.py continued
from handlers import start, help_command, stats, approve

router.command("start")(start)
router.command("help")(help_command)
router.command("stats", admin_only=True)(stats)
router.callback("approve", admin_only=True)(approve)

That is the key benefit: the registration is explicit and readable.

Step 6: Wire it into python-telegram-bot

Add the dispatcher handlers to the Application.

# app.py continued

def main():
    app = Application.builder().token("YOUR_BOT_TOKEN").build()

    app.add_handler(CommandHandler("start", dispatch_command))
    app.add_handler(CommandHandler("help", dispatch_command))
    app.add_handler(CommandHandler("stats", dispatch_command))
    app.add_handler(CallbackQueryHandler(dispatch_callback))

    app.run_polling()

if __name__ == "__main__":
    main()

This works, but it has one obvious problem: you still have to manually add a CommandHandler for each command.

We can improve that.

Step 7: Auto-register Telegram handlers from the router

Since the router already knows the command names, we can generate handlers programmatically.

# app.py continued
from telegram.ext import CommandHandler

def build_application(token: str) -> Application:
    app = Application.builder().token(token).build()

    for command_name in router.command_routes:
        app.add_handler(CommandHandler(command_name, dispatch_command))

    app.add_handler(CallbackQueryHandler(dispatch_callback))
    return app

def main():
    app = build_application("YOUR_BOT_TOKEN")
    app.run_polling()

Now the registration is driven by the router itself.

Step 8: Pass arguments to commands

Most real Telegram commands need arguments.

For example:

  • /ban 123456 spam
  • /remind 15m take a break
  • /setlang en

You can parse arguments in the dispatcher or in the handler. For maintainability, I prefer parsing in the handler when the command has unique needs. For shared parsing, add a helper.

# helpers.py
from telegram import Update

def get_command_args(update: Update) -> list[str]:
    text = update.message.text if update.message else ""
    parts = text.split()
    return parts[1:]

Example use:

async def stats(update, context):
    args = get_command_args(update)
    if args:
        await update.message.reply_text(f"Unexpected arguments: {' '.join(args)}")
        return

    await update.message.reply_text("Today: 42 requests processed.")

For more advanced bots, consider a typed parser for each command.

Step 9: Make callback data more robust

Raw callback strings like approve are fine for small bots, but they get limiting fast.

A more scalable pattern is to encode structured callback data:

action:approve:post42

Then parse it in the dispatcher.

async def dispatch_callback(update: Update, context: ContextTypes.DEFAULT_TYPE):
    query = update.callback_query
    if not query or not query.data:
        return

    parts = query.data.split(":")
    key = parts[0]
    payload = parts[1:]

    route = router.callback_routes.get(key)
    if not route:
        return

    if route.admin_only and not await is_admin(update, context):
        await query.answer("Not allowed", show_alert=True)
        return

    await route.handler(update, context, payload)

Then the handler can accept the payload:

@router.callback("approve", admin_only=True)
async def approve(update, context, payload):
    query = update.callback_query
    post_id = payload[0] if payload else None

    if not post_id:
        await query.answer("Missing post id", show_alert=True)
        return

    await query.answer()
    await query.edit_message_text(f"Approved post {post_id}.")

This makes buttons much more useful in workflows like moderation queues, task tracking, and admin dashboards.

Step 10: Test the router without Telegram

A good sign that your architecture is healthy is that you can test most of it without hitting Telegram servers.

You can test route registration easily:

def test_route_registration():
    router = BotRouter()

    async def handler(update, context):
        pass

    router.command("start")(handler)

    assert "start" in router.command_routes
    assert router.command_routes["start"].handler == handler

You can also test dispatch behavior by mocking update and context.

For example, with pytest:

import pytest
from types import SimpleNamespace

@pytest.mark.asyncio
async def test_dispatch_command_calls_handler():
    called = False

    async def handler(update, context):
        nonlocal called
        called = True

    router.command("hello")(handler)

    fake_message = SimpleNamespace(text="/hello", reply_text=lambda x: None)
    update = SimpleNamespace(message=fake_message, effective_user=None, effective_chat=None)
    context = SimpleNamespace(bot=None)

    await dispatch_command(update, context)
    assert called

If you rely on business logic like approvals, reminders, or database updates, keep that logic outside Telegram-specific code so you can test it separately.

Practical improvements for real bots

Once the basic router works, consider these additions.

1. Separate business logic from Telegram I/O

Bad:

async def approve(update, context):
    await db.update_status(...)
    await query.edit_message_text(...)

Better:

async def approve(update, context):
    result = await approve_post(post_id)
    await query.edit_message_text(result.message)

The database or domain function should not know about Telegram update objects.

2. Add structured logging

Log route hits and failures so you can debug production bots.

import logging
logger = logging.getLogger(__name__)

logger.info("Dispatching command %s from user %s", command, update.effective_user.id)

3. Handle unknown commands gracefully

Unrecognized commands should not fail silently if your bot is user-facing.

if not route:
    await message.reply_text("Unknown command. Use /help.")
    return

4. Add a default callback handler

For stale buttons or deleted routes, inform the user.

if not route:
    await query.answer("This action is no longer available.", show_alert=True)
    return

5. Keep callback payloads versioned

If buttons live for a long time, old messages may contain outdated callback data. Use prefixes like:

v1:approve:post42

This lets you support future formats without breaking old messages.

When this pattern is a good fit

This small router is a good fit if your Telegram bot:

  • has a moderate number of commands
  • uses inline buttons for workflow actions
  • needs permission checks
  • benefits from clean separation of concerns
  • is likely to grow over time

If your bot is tiny, direct handlers may be enough. If your bot is large enough to need state machines or nested conversation flows, you may eventually move to a more advanced framework structure. Even then, a router-like approach is still useful for the non-conversational parts of the bot.

Final version of the pattern

If we compress the idea into one sentence:

Register commands and callbacks in a router, dispatch them through a single entry point, keep permissions near dispatching, and keep business logic out of Telegram-specific code.

That gives you a bot that is easier to extend without turning every new feature into a new pile of if statements.

Next steps

If you want to build on this, the most useful upgrades are:

  • command parameter parsing with validation
  • database-backed user roles
  • conversation state handling
  • retry logic for network calls
  • background jobs for reminders and scheduled actions

Those additions fit naturally into the router-based structure we built here.

For Telegram developers, the practical advantage is simple: fewer tangled handlers, cleaner permissions, and a bot that stays maintainable as the feature set grows.

← All posts