Skip to content

Instantly share code, notes, and snippets.

@Rapptz
Created March 6, 2022 00:35
Show Gist options
  • Save Rapptz/c4324f17a80c94776832430007ad40e6 to your computer and use it in GitHub Desktop.
Save Rapptz/c4324f17a80c94776832430007ad40e6 to your computer and use it in GitHub Desktop.

On Resuming discord.py Development

It's been 6 months since I announced my departure from the ecosystem and a lot has happened during that time.

During the last 2 weeks, many contributors and I came together and saw the state of the Python Discord bot ecosystem was mostly on fire. After some contemplating, with the help of others, I came to the conclusion that development should resume. During these two weeks a lot of work was spent into catching up and implementing many things to the discord.py project and to eventually launch a v2.0 release. We're on a deadline but we got a lot of work done.

Why did you come back?

About three weeks ago, Discord announced that it would decommission API versions 6 and 7 on May 1st, 2022. While the current beta version, v2.0, is on version 9, the current stable version of discord.py, v1.7.3, is on version 7 of the API. This means that Discord's plan to decommission v6 and 7 will result in all bots on the stable version of discord.py ceasing to work as of May 1st, 2022. This wasn't something I envisioned happening so soon.

I attempted to discuss the impact of the decommission with Discord, and while they acknowledged my concerns, they gave me no guarantees that the decommission date would be postponed. discord.py remains the 2nd most popular library in the ecosystem. If the decommission goes ahead as planned, it will be a far more catastrophic blow to the ecosystem than the original privileged message intent requirement. In order to minimise damage of this change, I felt like the library needed to be updated.

Likewise, as time went on it became increasingly clear that the Python bot ecosystem has been too fragmented. I was hopeful that after half a year that there would be a clear alternative library that most users can transparently migrate to, but it seems that this never ended up materialising. In order to prevent more turbulence in the ecosystem, it made sense to try to remedy the current, unfortunate, situation.

Do you think Discord is improving now?

With the April 30th deadline approaching, I still don't particularly feel like Discord has figured out a sufficient plan for migrating the ecosystem as a whole, nor do I really feel like they're doing an exemplary job. In the last 6 months there haven't been that many things that have been added to Discord's API:

  • Member timeouts
    • Limited to 28 days, but overall a decent feature.
  • Role icons
    • Boost only
  • Server avatar and bio
    • Boost only
  • Slash command file attachments
    • This feature had issues initially, but I believe they're now fixed.
  • Ephemeral support for attachments.
    • This is good.
  • Component modals
    • Incredibly limited in their current form, only text components are allowed.
  • Autocomplete
    • This feature is pretty cool.
  • Alt text for attachments
  • Editing attachments on messages

That's basically it. There's two months to go until the deadline and major things such as permissions and the so-called "slate v2" are still missing though they're planned for testing at some point in the future.

Not to mention that people are still waiting for their message content intent applications after 1-3 months of waiting despite the so-called promised 5-day SLA. Everything remains a mess and pretending that it isn't doesn't seem conducive.

In terms of personal communication with Discord, I can't exactly say that it has improved since I dropped development, rather communication suffered. After development ceased I did not speak to any Discord developer and they didn't speak to me. The only form of contact came from me contacting them to talk about the decommission.

N.B.: "Permissions v2", "Slate v2", and Slash Command Localisation are features planned to be released at some point in the future.

New Features

These are the things we've been hard at work for the past two weeks.

Member Timeouts

There's a new timed_out_until attribute in Member. This works in Member.edit to time them out. There's also Member.is_timed_out to check if a member is timed out.

Role Icons

Equally simple. Role.icon and Role.display_icon. There's Role.edit support for display_icon if you want to change it.

Alt-text for Attachments

Also simple, just pass the description kwarg to File. It's also a property to read in Attachment.

v10 of the API

The library now supports the Intents.message_content flag and uses API v10 by default. We've found that this is surprisingly prohibitive since it requires users to have the message content intent before the deadline by April 30th, so it is recommended to enable Intents.message_content both in the bot and in the bot application page if it's necessary for your bot to function.

I feel this needs repeating due to importance, due to bumping API versions, you will need to enable the message content intent in both your code and the developer portal for your bot to function if it needs message content. This requirement is unfortunately being imposed by Discord and is out of my control.

Modals

Support for discord.ui.TextInput and discord.ui.Modal has been added. The syntax for this is similar to discord.ui.View with slight differences, since each component cannot have individual callbacks. A simple example:

import discord
from discord import ui

class Questionnaire(ui.Modal, title='Questionnaire Response'):
    name = ui.TextInput(label='Name')
    answer = ui.TextInput(label='Answer', style=discord.TextStyle.paragraph)

    async def on_submit(self, interaction: discord.Interaction):
        await interaction.response.send_message(f'Thanks for your response, {self.name}!', ephemeral=True)

In order to send a modal, Interaction.response.send_modal is used since it requires a special interaction response type. You cannot send a message and a modal at the same time. You might consider using an Interaction.followup.send if that's desirable.

Interaction Improvements

  • Interaction.client was added to get the client in case it's needed.
  • Interaction.response.defer now supports thinking=True or thinking=False in case you want the "Bot is thinking..." UI when deferring. This corresponds to InteractionType.deferred_channel_message.
  • Interaction.response.send_message now supports sending files. This also supports ephemeral files.
  • Add low-level Interaction.response.autocomplete helper for auto complete responses in application commands.

Slash Commands and Context Menu Commands

After some design work and contemplating, I implemented slash commands using a syntax that is a subset of the discord.ext.commands package. They reside in a new namespace, discord.app_commands and function pretty similarly. In order to start registering commands, you need a new type called a app_commands.CommandTree which takes a Client as its only argument when creating it.

import discord
from discord import app_commands

intents = discord.Intents.default()
intents.message_content = True

client = discord.Client(intents=intents)
tree = app_commands.CommandTree(client)

After setting up your tree, adding a command is mostly the same as the command extension:

@tree.command(guild=discord.Object(id=MY_GUILD_ID))
async def slash(interaction: discord.Interaction, number: int, string: str):
    await interaction.response.send_message(f'{number=} {string=}', ephemeral=True)

# Describing parameters...
@tree.command(guild=discord.Object(id=MY_GUILD_ID))
@app_commands.describe(attachment='The file to upload')
async def upload(interaction: discord.Interaction, attachment: discord.Attachment):
    await interaction.response.send_message(f'Thanks for uploading {attachment.filename}!', ephemeral=True)

By omitting the guild keyword argument it's added as a global command instead. If you don't want to use a decorator, then it's equivalent to the following code:

@app_commands.command()
async def slash(interaction: discord.Interaction, number: int, string: str):
    await interaction.response.send_message(f'{number=} {string=}', ephemeral=True)

# Can also specify a guild here, but this example chooses not to.
tree.add_command(slash)

Groups

Working with groups functions similarly to cogs which people are familiar with:

class Permissions(app_commands.Group):
    """Manage permissions of a member."""

    def get_permissions_embed(self, permissions: discord.Permissions) -> discord.Embed:
        embed = discord.Embed(title='Permissions', colour=discord.Colour.blurple())
        permissions = [
            (name.replace('_', ' ').title(), value)
            for name, value in permissions
        ]

        allowed = [name for name, value in permissions if value]
        denied = [name for name, value in permissions if not value]

        embed.add_field(name='Granted', value='\n'.join(allowed), inline=True)
        embed.add_field(name='Denied', value='\n'.join(denied), inline=True)
        return embed

    @app_commands.command()
    @app_commands.describe(target='The member or role to get permissions of')
    async def get(self, interaction: discord.Interaction, target: Union[discord.Member, discord.Role]):
        """Get permissions for a member or role"""
        
        if isinstance(target, discord.Member):
            assert target.resolved_permissions is not None
            embed = self.get_permissions_embed(target.resolved_permissions)
            embed.set_author(name=target.display_name, url=target.display_avatar)
        else:
            embed = self.get_permissions_embed(target.permissions)

        await interaction.response.send_message(embed=embed)

    @app_commands.command(name='in')
    @app_commands.describe(channel='The channel to get permissions in')
    @app_commands.describe(member='The member to get permissions of')
    async def _in(
        self, 
        interaction: discord.Interaction, 
        channel: Union[discord.TextChannel, discord.VoiceChannel],
        member: Optional[discord.Member] = None,
    ):
        """Get permissions for you or another member in a specific channel."""
        embed = self.get_permissions_embed(channel.permissions_for(member or interaction.user))
        await interaction.response.send_message(embed=embed)


# To add the Group to your tree...
tree.add_command(Permissions(), guild=discord.Object(id=MY_GUILD_ID))

Using nested groups (up to one layer) is also possible, do note that groups cannot have callbacks attached to them and be invoked due to a Discord limitation:

class Tag(app_commands.Group):
    """Fetch tags by their name"""

    stats = app_commands.Group(name='stats', description='Get tag statistics')

    @app_commands.command(name='get')
    @app_commands.describe(name='the tag name')
    async def tag_get(self, interaction: discord.Interaction, name: str):
        """Retrieve a tag by name"""
        await interaction.response.send_message(f'tag get {name}', ephemeral=True)

    @app_commands.command()
    @app_commands.describe(name='the tag name', content='the tag content')
    async def create(self, interaction: discord.Interaction, name: str, content: str):
        """Create a tag"""
        await interaction.response.send_message(f'tag create {name} {content}', ephemeral=True)

    @app_commands.command(name='list')
    @app_commands.describe(member='the member to get tags of')
    async def tag_list(self, interaction: discord.Interaction, member: discord.Member):
        """Get a user's list of tags"""
        await interaction.response.send_message(f'tag list {member}', ephemeral=True)

    @stats.command(name='server')
    async def stats_guild(self, interaction: discord.Interaction):
        """Gets the server's tag statistics"""
        await interaction.response.send_message(f'tag stats server', ephemeral=True)

    @stats.command(name='member')
    @app_commands.describe(member='the member to get stats of')
    async def stats_member(self, interaction: discord.Interaction, member: discord.Member):
        """Gets a member's tag statistics"""
        await interaction.response.send_message(f'tag stats member {member}', ephemeral=True)

tree.add_command(Tag())

Context Menus

Context menus are also straight forward, just annotate a function with either discord.Member or discord.Message:

@tree.context_menu(guild=discord.Object(id=MY_GUILD_ID))
async def bonk(interaction: discord.Interaction, member: discord.Member):
    await interaction.response.send_message('Bonk', ephemeral=True)

@tree.context_menu(name='Translate with Google', guild=discord.Object(id=MY_GUILD_ID))
async def translate(interaction: discord.Interaction, message: discord.Message):
    if not message.content:
        await interaction.response.send_message('No content!', ephemeral=True)
        return

    text = await google_translate(message.content)  # Exercise for the reader!
    await interaction.response.send_message(text, ephemeral=True)

Range

In order to restrict a number by a given range, we can use the app_commands.Range annotation:

@tree.command(guild=discord.Object(id=MY_GUILD_ID))
async def range(interaction: discord.Interaction, value: app_commands.Range[int, 1, 100]):
    await interaction.response.send_message(f'Your value is {value}', ephemeral=True)

Choices

Choices are also supported in three different flavours. The first is the simplest, via typing.Literal:

@app_commands.command()
@app_commands.describe(fruits='fruits to choose from')
async def fruit(interaction: discord.Interaction, fruits: Literal['apple', 'banana', 'cherry']):
    await interaction.response.send_message(f'Your favourite fruit is {fruits}.')

If you want to attach a name to the value, using an enum.Enum derived class is the next step up:

class Fruits(enum.Enum):
    apple = 1
    banana = 2
    cherry = 3

@app_commands.command()
@app_commands.describe(fruits='fruits to choose from')
async def fruit(interaction: discord.Interaction, fruits: Fruits):
    await interaction.response.send_message(f'Your favourite fruit is {fruits}.')

If you need more control over the actual list of choices, then there's the app_commands.choices decorator:

from discord.app_commands import Choice

@app_commands.command()
@app_commands.describe(fruits='fruits to choose from')
@app_commands.choices(fruits=[
    Choice(name='apple', value=1),
    Choice(name='banana', value=2),
    Choice(name='cherry', value=3),
])
async def fruit(interaction: discord.Interaction, fruits: Choice[int]):
    await interaction.response.send_message(f'Your favourite fruit is {fruits.name}.')

Note that you can also use bare int as an annotation here if you do not care about the name.

Autocomplete

The library also gained support for auto complete using two different decorator syntaxes:

@app_commands.command()
async def fruits(interaction: discord.Interaction, fruits: str):
    await interaction.response.send_message(f'Your favourite fruit seems to be {fruits}')

@fruits.autocomplete('fruits')
async def fruits_autocomplete(
    interaction: discord.Interaction,
    current: str,
    namespace: app_commands.Namespace
) -> List[app_commands.Choice[str]]:
    fruits = ['Banana', 'Pineapple', 'Apple', 'Watermelon', 'Melon', 'Cherry']
    return [
        app_commands.Choice(name=fruit, value=fruit)
        for fruit in fruits if current.lower() in fruit.lower()
    ]

Or, alternatively:

@app_commands.command()
@app_commands.autocomplete(fruits=fruits_autocomplete)
async def fruits(interaction: discord.Interaction, fruits: str):
    await interaction.response.send_message(f'Your favourite fruit seems to be {fruits}')

async def fruits_autocomplete(
    interaction: discord.Interaction,
    current: str,
    namespace: app_commands.Namespace
) -> List[app_commands.Choice[str]]:
    fruits = ['Banana', 'Pineapple', 'Apple', 'Watermelon', 'Melon', 'Cherry']
    return [
        app_commands.Choice(name=fruit, value=fruit)
        for fruit in fruits if current.lower() in fruit.lower()
    ]

Syncing

The library does not offer automatic syncing. The user is responsible for this. In order to sync our commands we can use

await tree.sync(guild=discord.Object(id=MY_GUILD_ID))

# Or, to sync global commands
await tree.sync()

Note that there is explicitly no way to sync every guild command since that would incur too many requests, likewise the library makes a conscious effort to not make HTTP requests in the background without being explicitly instructed to do so by the user.

Miscellaneous

There's a lot more to it, for example transformers (the equivalent of converters, for those familiar) and on_error handlers but this section is already too long.

Future Plans

There's still a lot that needs to be done, we've formed a working group to essentially make a guide to make discord.py more accessible and easier to learn. If you want to participate in these things, please feel free. It's a large effort that could use help.

These are the big things that are currently planned, though it is not known if they'll actually happen:

  • Working on a guide for discord.py that has more prose pages and easy to follow documentation.
  • Refactoring to allow usage of asyncio.run instead of holding loop objects hostage.
    • This allows discord.py to meet more modern asyncio design.
  • Refactoring events to take single parameter "event objects" rather than multi parameter.
    • This is more or less making every event a "raw event" with helper methods for the richer API.
  • Change View parameter order to be more consistent with the new app_commands namespace, i.e. interaction should always come first.

We're still on a tight deadline! Also, please note that the features explained might undergo changes as more feedback comes in.

Acknowledgements

I'd like to deeply thank everyone involved who helped in some form (in alphabetical order)

  • devon#4089
  • Eviee#0666
  • Gobot1234#2435
  • Imayhaveborkedit#6049
  • Jackenmen#6607
  • Josh#6734
  • Kaylynn#0001
  • Kowlin#2536
  • LostLuma#7931
  • Maya#9000
  • mniip#9046
  • NCPlayz#7941
  • Orangutan#9393
  • Palm__#0873
  • Predä#1001
  • SebbyLaw#2597
  • TrustyJAID#0001
  • Twentysix#5252
  • Umbra#0009
  • Vaskel#0001

This definitely could not have been done without your help and I greatly appreciate it :)

@Jonathing
Copy link

return of the king

@fschatbot
Copy link

The kings back in the game

@ActCelely
Copy link

Welcome back Danny!!!!!!!!!!!

@maksymov
Copy link

maksymov commented Feb 3, 2023

name 'List' is not defined
  File "/home/vlad/dev/afi/new_bot.py", line 99, in <module>
    ) -> List[app_commands.Choice[str]]:
NameError: name 'List' is not defined```

@PythonCoderAS
Copy link

name 'List' is not defined
  File "/home/vlad/dev/afi/new_bot.py", line 99, in <module>
    ) -> List[app_commands.Choice[str]]:
NameError: name 'List' is not defined```

This isn't discord.py support.

@rafaelurben
Copy link

name 'List' is not defined
  File "/home/vlad/dev/afi/new_bot.py", line 99, in <module>
    ) -> List[app_commands.Choice[str]]:
NameError: name 'List' is not defined```

@maksymov
from typing import List

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment