loading...

Notes on making Discord bots

andreheringer profile image André Heringer ・4 min read

Recently I've been working on a Discord bot(and by recently I mean the past 8 months, you know, usual nights and weekends side project stuff). But since quarantine time started in Brazil I've found myself with a lot more spare time.
Although I'm taking my sweet time when it comes to defining a deadline and working into finishing it I think the progress I've made so far is considerable and worth sharing.

Pivoting for mental health sake

When I started working in SmolDM back in 2018 I wanted to build a virtual Dungeon Master for the Tormenta RPG world, that means the bot should be able to:

  • Understand the player attributes and stats (that includes: abilities, talents, equips, etc)
  • Run a Combat simulator
  • Manage a Bestiary
  • Perform Characters status management
  • Enable player actions

Needless to say the project's scope quickly run out of control, and two years later it does not have a single feature described above. So for the sake of my mental health (and ego, because I really want to finish this thing) I've decided to pivot SmolDM's goals to executing a smaller and simpler set of routines. Here is what's up.

Text adventures and one-shot RPG

Once we reduce the scope of the project I had a reachable goal, a one-shot RPG bot, and can wrap my head around how to achieve it. To put it simply:

  • I need a command handler, meaning a easier way to interpret users messages
  • A way to load the RPG "scenes"
  • Probably need a way to "walk" through said scenes
  • And a place to save the adventure data (the player, where they are, what they have?)

In this post I want to talk specifically about the command handler.

Dive in

When writing code that I know won't be "user faced" I'm inclined to start by defining how I want the API to look like, since I will be the primary user of this code in the future. I ask myself: What API would make my life easy and be explicit enough that I can read this in 6 months and know what's going on?

I've been doing Python for almost 3 years now and one thing that I know for sure is "The Flask API won" meaning that over the years many web frameworks appeared in the Python space, but most of them copy the Flask API with route decorators. And that sexy API kind of fits really nicely in my use case, although I've also developed a desire for a more functional style for it, given that it alters state at a not so known level. But I came up with this:

    bot = DiscordClient()
    bot.add_command(roll, "!roll d<num>")

Sweet! Here the "num" is a variable defined the same way Flask defines url variables, the add_command method receives a function and a pattern which upon detection will trigger the function's execution. This way I'll know what is/isn't happening when I revisit this code in a couple months. Nice! Now let's build a module to parse the string and build a pattern:

    def build_command_pattern(command: str) -> re.Pattern:
        """Build regex pattern based on SmolDM command rules.

        Args:
            command: The command string used for registration

        Returns:
            The regular expression generated.

        """
        # swap every expression with the format <variable> to ?Pvariable
        # this way the .match method from re can be called
        logger.info(f"Building new command pattern for command: {command}")
        command_regex = re.sub(r"(<\w+>)", r"(?P\1.+)", command)
        return re.compile(f"^{command_regex}$")

This function receives a string and parses it into a Python Regular Expression, swapping the places of the variables found. Now let's store that somewhere so we can read the commands later and call the corresponding function.

def add_command(
    command_list: List[Tuple[re.Pattern, callable]],
    func: callable,
    command_str: str
) -> List[Tuple[re.Pattern, callable]]:
    """Add a function and the command pattern to the command list.

    Args:
        func: Function it will be called
        command_str: command string that specifies the pattern

    """
    command_pattern = build_command_pattern(command_str)
    command_list.append((command_pattern, func))
    return command_list

Nice! This will create a command list for us, now for a way to access a command given an message string:

def get_command_match(
    command_list: List[Tuple[re.Match, callable]],
    message: discord.Message
) -> Optional[Tuple[dict, callable]]:
    """Find an registered command that matches pattern on message.

    Args:
        message: message object from the Discord API

    Returns:
        optinal tuple: A tuple containing the match dictionary and the
                        function reference

    """
    command = message.content  # get the text in message

    match_list = map(lambda match: (match[0].match(command), match[1]), command_list)

    for command_patter, command_func in match_list:
        if command_patter:
            return command_patter.groupdict(), command_func

    return None

Cool, cool, co-co-co-cool. Now let's put that in to discord's python client:

import smolDM.commands as cmd

class DiscordClient(discord.Client):
    """Discord Client class.

    This class inhered from the discord client wrapper,
    should abstract the connection with the discord api.
    """

    def __init__(self):
        self._commands = []

    def add_command(self, func: callable, command_str: str):
        """Add a command to the bot command list.

        Args:
            func: function called on command
            command_str: command pattern string

        """
        cmd.add_command(self._commands, func, command_str)
        return self

    @staticmethod
    async def on_ready() -> None:
        """Re-implementation from parent class.

        Event triggered whenever the client is ready for
        interaction. Parent class requires it to be static.
        """
        logger.info("Logged in ----")

    async def on_message(self, message: discord.Message) -> None:
        """Event triggered whenever the client receives a new message.

        Re-implementation from parent class.

        Args:
            message: Discord.py message object

        """
        logger.info("Listening to messages...")
        pattern_match = cmd.get_command_match(self._commands, message)
        logger.info("Searching command match for new message")
        if not pattern_match:
            return None
        kwargs, func = patern_match
        response = await func(self, message, **kwargs)
        channel = message.channel
        await channel.send(response)

I may or may not come back and talk about the rest of this project and how it came to be, in any case here is the Github repo with the code for the final version of the bot. Feedbacks are more then welcome.
See ya :)

Posted on by:

andreheringer profile

André Heringer

@andreheringer

Computer Science Student at UFMG, Zen practitioner, fascinated by Distributed Cloud Computing and Open Source. He/Him

Discussion

markdown guide