– One syntactic sugar pill to rule them all.
When building any system that interacts with users you always need to check that they are who they are claiming to be and whether they are allowed to do what they are trying to do. In other words, you need to authenticate and authorize users.
Chatbots are no exception. Strictly speaking, with chatbots you do not need to authenticate users yourself – the platform does it for you. However, on each request you still have to load the user model and authorize it.
Below I will show how I approached authorization and preprocessing requests in my Telegram bot for shopping and to-do lists listOK (project page to avoid code duplication and make it more explicit. This approach is not limited to Telegram or chatbots in general. It can be applied in any request-centered projects, which in our API date and age are the majority.
The code below is close to what I use with some simplifications and omissions for brevity.
To communicate with Telegram bot-API I use the python-telegram-bot library. It processes each message or other type of communication via separate handlers: functions that receive
update details and overall bot
In listOK each user has lists and each list consists of items. This means that all handlers for creating, reading, updating, deleting lists and items should be able to do similar things: load required models from the database and authorize the user.
At the very beginning of the project I tried a direct approach – loading user models and authorizing them in the request handlers themselves:
def command_my_lists( update: Update, context: CallbackContext ) -> None: user = find_user_by_telegram_id( db.session, update.effective_user.id ) if user is None: return # Display user's lists
However, very soon it became unviable: there are dozens of handlers, and repeating the same code everywhere was not DRY at all. What if I wanted to change the logic or react to an unauthorized user request?
Here Python decorators came in very handy. They allow adding behavior and pre-processing to any function without modifying it:
def load_user_or_fail(fn): """Decorator: loads User model and passes it to the function or stops the request.""" def wrapper(*args, **kwargs): # Expects that Update object is always the first arg update: Update = args user = find_user_by_telegram_id( db.session, update.effective_user.id ) # Ignore requests from unknown users if user is None: return return fn(*args, **kwargs, user=user) return wrapper
Now I can add user loading to any handler with only one line and additional argument in the handler.
@load_user_or_fail def command_my_lists( update: Update, context: CallbackContext, user: User ) -> None: # Display user's lists
I use this decorator for all handlers except for the
/start command, since new users need it to register. It handles the user registration/loading itself.
The same is true for loading other models. For instance, all handlers that work with lists need to load the respective models. It also can be moved to a decorator to reduce the boilerplate code:
def load_list_or_fail(source): def load_list_or_fail_outer_wrapper(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): # Expects the update and context objects # to be first arguments update: Update = args context: CallbackContext = args list_id = None if source == "callback_data": list_id = int(re.findall( r"[0-9]+", update.callback_query.data )) if source == "user_data": list_id = context.user_data.get( "active_list_id", None ) items_list = find_list_by_id(db.session, list_id) # Ignore request for non-existing list if items_list is None: logging.getLogger().error( "Could not load list." ) return return fn(*args, **kwargs, items_list=items_list) return wrapper return load_list_or_fail_outer_wrapper
This is a bit more involved than loading a user. A
list_id of the list to load can arrive from either of two sources: a callback data (string returned after a user presses an inline keyboard button) or stored in user context (required for multi-step communications). To accommodate this I added a
source argument to the decorator.
It adds complexity: I need to provide in advance what source to use. I thought about making source detection automatic but decided against it. First, sometimes both sources can be legitimately present. Second, this would have introduced unnecessary at that moment 'magic' and reduced code readability.
Decorators help with authorization as well, although their implementation will vary greatly from project to project. For example, here is what I did to check permissions for updating a list name:
from enum import Enum, auto class Permissions(Enum): """Permissions constants""" CREATE = auto() READ = auto() UPDATE = auto() DELETE = auto() def permission_required(permission): """Decorator for checking permissions""" def outer_wrapper(fn): @functools.wraps(fn) def wrapper(*args, **kwargs): is_authorized = False keys = kwargs.keys() if "items_list" in keys: target = kwargs["items_list"] elif "list_item" in keys: target = kwargs["list_item"] is_authorized = target.check_user_permission( kwargs["user"], permission ) if not is_authorized: return return fn(*args, **kwargs) return wrapper return outer_wrapper @load_user_or_fail @load_list_or_fail(source="user_data") @permission_required(Permissions.UPDATE) def message_rename_list( update: Update, context: CallbackContext, user: User, items_list: ItemsList ) -> int: # Update list name
Here I chained three decorators: two that we saw before and a new one:
@load_user_or_failloads the user model and passes it as a keyword argument.
@load_list_or_fail(source="user_data")loads a list model based on
user_datacontext and passes it further as a keyword argument as well.
items_listkeyword argument is present, and if it is, calls list's method
check_user_permissionwhich checks if the loaded user has the permission required.
If instead of a list we loaded a list item, the
permission_required decorator would have detected it and called the item's
load_list_or_fail, here I opted to automatically detect the target. It does not introduce any magic: by controlling the decorator I am calling before checking the permission I control the target.
@permission_requiredwill fail if
list_item. This is by design: it means that I forgot a loading decorator and requires immediate attention. If it happens in production, the bot will send me a message with all the exception details.
These decorators made the code much more concise and DRY, saving me a lot of time. What I especially like about this approach is how modular and explicit it is.