Skip to content

Dispatch and Extensions

Bluenix edited this page Apr 5, 2022 · 1 revision

This page will review the APIs of other libraries including (but not limited to) the following:

The point is to figure out the perfect API for event listeners and extensions necessary for making gateway bots with Wumpy.

Similar to the already existing slash commands system all event listeners will be registered with decorators. This is what all of these libraries have in common and what has come to be a very pythonic way to register callbacks.

Event object or members of it

First up for discussion is in what format the data should come in for the user.

There are two main ways this has been achieved:

  • Special event objects

    @bot.listen()
    async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
        ...
  • Passing the members as arguments

    @bot.event
    async def on_message(self, message: discord.Message) -> None:
        ...

Comparison

Both discord.py and hata give the individual members of the DISPATCH gateway event as arguments, but this has introduced problems (primarily in discord.py) for the libraries.

In discord.py you will find what is called "raw events". This is because not all gateway events give complete objects so when the cache is missing the real events are not dispatched.

@bot.event
async def on_raw_message_delete(payload: discord.RawMessageDeleteEvent) -> None:
    ...

This simply goes to show how inexpressive this system is in respect to the data received from the gateway. As well as leaving little room for any potential extra details that may want to be sent.

There is of course another consideration that needs to be made and that is how extendible this way of structuring the data is for the user.

Event objects lie in the hands of the user, this means that it is very easy for the user to make another subclass of an event and annotate their code using that so that the library calls it.

# Hypothetical Event class
class SpecialEvent(Event):
    ...  # This can perhaps lookup in a database

@bot.listen()
async def on_my_event(event: SpecialEvent) -> None:
    ...

On the other hand the way discord.py and hata structure their system means that custom event behavior needs to be injected into the library.

All in all, event objects give more expressiveness for the library and leads to more extensibility for the user. This is the strategy that Wumpy will take.

Decorator naming

Decorator naming is not the most interesting aspect of this document, but it is an important aspect of the public API.

This is because a decision has to be made and then uphold consistently across the library to be the least confusing for the user.

Discord.py uses event, listener and listen for different purposes. This is confusing and can easily be mixed up:

# `event` is used for discord.Client
bot = discord.Client(...)
@bot.event
async def on_message(message: discord.Message) -> None:
    ....

# `listen` is an improvement over discord.Client
bot = discord.ext.commands.Bot(...)
@bot.listen()
async def on_message(message: discord.Message) -> None:
    ...

# `listener` is used for cogs
class MyCog(discord.ext.commands.Cog):
    @discord.ext.commands.Cog.listener()
    async def on_message(self, message: discord.Message) -> None:
        ...

Both Disco and Hikari use listen to register event listener:

# Disco plugin example
class SimplePlugin(disco.bot.Plugin):
    @disco.bot.Plugin.listen('MesageCreate')
    def on_message_create(self, event):
        ...

# Hikari quick example
@bot.listen()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
    ...

On the other hand, Hata uses events for the name...

@bot.events
async def message_create(client, message):
    ...

Odd one out is a low-level library called Corded which uses on similar to NodeJS's EventEmitter interface:

@bot.on("message_create")
async def on_message_create(event: GatewayEvent) -> None:
    ...

This isn't considered as pythonic as the other proposals and doesn't make much sense without the first argument:

# Hikari example but with the name changed
@bot.on()
async def on_message(event: hikari.GuildMessageCreateEvent) -> None:
    ...

The naming of the decorator should reflect what this decorates. Both group and command decorate groups respectively commands, as such the naming of this decorator should clearly show that it decorates an event listener.

Both event and events are inferior to listen as well as listener because the function is not an event, it is a listener of an event.

Out of listen and listener the noun should be preferred to follow command and group. This is the naming that will be used for Wumpy.

Splitting into multiple files

There's a reason that most libraries provide some variant of an "extension feature" that allows the bot to be split into multiple dynamically loaded files. It is because Discord imposes limits on how often a bot can connect.

For bigger bots this means that rolling out an update can take extremely long time because a shard needs to disconnect, download the update, and then reconnect which hits this limit.

The way that most libraries have solved this is with dynamically loaded files that can be completely unloaded without disconnecting from the gateway.

The interaction server doesn't have this issue because restarting it to apply updates does not have any limits.

Discord.py and disco

The system that by far the most should be experienced with is discord.py's version which is split into two systems.

First are cogs which allow commands and listeners to be registered as methods on a special subclass. Cogs are then paired with extensions which allow you to load a separate file.

Example seen below from the documentation:

class Greetings(commands.Cog):
    def __init__(self, bot):
        self.bot = bot
        self._last_member = None

    @commands.Cog.listener()
    async def on_member_join(self, member):
        channel = member.guild.system_channel
        if channel is not None:
            await channel.send('Welcome {0.mention}.'.format(member))

    @commands.command()
    async def hello(self, ctx, *, member: discord.Member = None):
        """Says hello"""
        member = member or ctx.author
        if self._last_member is None or self._last_member.id != member.id:
            await ctx.send('Hello {0.name}~'.format(member))
        else:
            await ctx.send('Hello {0.name}... This feels familiar.'.format(member))
        self._last_member = member

def setup(bot):
    bot.add_cog(Greetings(bot))

Disco has its own variant of this called Plugins. Which can be seen as a mixture of discord.py's cogs and extension system.

Here is an example from Disco's repository:

class BasicPlugin(Plugin):
    @Plugin.command('ban', '<user:snowflake> <reason:str...>')
    def on_ban(self, event, user, reason):
        event.guild.create_ban(user, reason=reason + u'\U0001F4BF')

    @Plugin.command('ping')
    def on_ping_command(self, event):
        # Generally all the functionality you need to interact with is contained
        #  within the event object passed to command and event handlers.
        event.msg.reply('Pong!')

Hata

Hata extensions are quite different, and totally not black magic...

Here's an example from the documentation:

from hata import Client

# Annotating client will help your IDE with linting/inspection (it won't not derp out).
Sakuya: Client


@Sakuya.commands(aliases='*')
async def multiply(first:int, second:int):
    """Multiplies the two numbers."""
    return first*second

What happens is that Hata injects special variables you define using EXTENSION_LOADER.add_default_variables(Sakuya=Sakuya) with importlib.

Basically as if they were defined! This makes passing variables into extensions really easy.

Pros and cons

Let's start exploring the class-based approach with discord.py and Disco.

Having a file with only one class can be seen as extraneous because the structure is already divided because of the file. The only benefit with having such a class would be that you can easily create multiple instances, yet this isn't even used...

Therefor classes only cause more confusion for less experienced users. Such users may forget to add a self argument, mess something up with inheritance or accidentally overriding something they shouldn't.

This doesn't even touch on how disco and discord.py make this work. The extra burden of inject the class instance and hacky solutions with special attributes and class variables isn't worth it.

If instead a shallow copy of the API of a client would be presented where the listeners and commands get loaded once the extension is this means you no longer have a way to passing variables into the extension file.

The proposal

Much inspired by how the ASGI specification is very general extensions will only consist of a function that takes the client and returns a function to call when being unloaded.

This is specified using a string in the format of path.to.file:function.

Wumpy will provide a callable Extension class that mimics the API of the client and be a simple implementation of this system.

This function or Extension will take the client and a dictionary of the values passed. If the user wants access to this data on initialization a function can be used like this:

from wumpy import Extension
from wumpy.interactions import CommandInteraction
from wumpy.gateway import Client

ext = Extension()

@ext.command()
async def hello(interaction: CommandInteraction) -> None:
    """Wave hello!"""
    await interaction.respond('Hi!', ephemeral=True)

def setup(client: Client, data: Dict[str, Any]):
    print(data)
    return ext.load(client)

This is an expressive enough system with the benefits of both systems of discord.py, disco and hata.