· 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.pycontains routing logichandlers.pycontains Telegram feature handlerspermissions.pycontains user-role checksapp.pywires 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.textfor commands. - We use
query.datafor 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.