Search code examples
pythonerror-handlingdiscord

Access the context variable without passing it in from a command in a lower level function in a Discord bot?


I wanted to send an error message to the user without access to context from a command. The error message sent should reply to a command with a discord message. This is also within a function with no discord objects and mainly deals with data.

The way I had been doing this was by raising a CommandError

raise CommandError(message, logger)

and handling this by on_command_error

async def on_command_error(self, ctx, error):
...
    if isinstance(error, commands.CommandError):
        # Internal error called to kill a command call due to error
        try:
            message, logger = error.args
        except (TypeError, ValueError):
            message = error
            logger = self.logger
        else:
            logger = logger or self.logger
        logger.warning(message)
        await ctx.send(f"{message}")
        return

While this worked, it didn't if I also didn't want to kill the execution flow from the raise.

The question then becomes what do you do about it?

You can pass the context variable into every function/method that needs it. This is fine for higher-level methods/classes but for things that mostly do data manipulation, it doesn't need to be there and is just to call an error. Feels clunky.

Temporarily storing the context variable somewhere doesn't work for multi-user/guild situations. In the past, I have gotten around this by having a guild-specific error channel, but this also doesn't work as I am opening the bot to more users in more channels. Is there a better way?


Solution

  • My solution: To access the frame stack. Every function in the chain in discord.py is invoked with

    1. wrapper
    2. invoke
    3. your_function_here

    Even if not explictly called with invoke. This means you can scrape the context (called ctx) from the stack

    def _get_frame_context(self, logger=None):
        """Inspect the stack to find the context object from invoke
    
        Potential to increase speed as inspect.stack() is expensive
        """
        frame_stack = inspect.stack()
        for frame_info in frame_stack:
            if frame_info.function.lower() == "invoke":
                context = frame_info.frame.f_locals["ctx"]
                break
        else:
            logger = self.logger if not logger else logger
            raise CommandError("Could not find context object", logger)
        del frame_stack # to not leak frames
        return context
    
    async def send_error(self, message: str, logger: logging.Logger, context=None):
        """
        :param message: The error message
        :param logger: The logger from where the error occured
    
        Send a message to the invocation channel, without disrupting execution,
        assuming find context
        """
        if context is None:
            try:
                context = await self._get_frame_context()
            except CommandError:
                raise CommandError(f"Could not find context object:\n{message}", logger)
    
        await context.send(message)
        logger.warning(message)
    

    Note: inspect.stack() is slow and expensive so for larger calls, there are better options