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.
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.
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.
These are the things we've been hard at work for the past two weeks.
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.
Equally simple. Role.icon
and Role.display_icon
. There's Role.edit
support for display_icon
if you want to change it.
Also simple, just pass the description
kwarg to File
. It's also a property to read in Attachment
.
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.
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.client
was added to get the client in case it's needed.Interaction.response.defer
now supportsthinking=True
orthinking=False
in case you want the "Bot is thinking..." UI when deferring. This corresponds toInteractionType.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.
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)
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 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)
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 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.
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()
]
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.
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.
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.
- This allows discord.py to meet more modern
- 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 newapp_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.
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 :)
What a great story