-
Notifications
You must be signed in to change notification settings - Fork 1
Dispatch and Extensions
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.
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: ...
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 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.
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.
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 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.
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.
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.