This uses discord.py 2.x, where slash commands live in discord.app_commands. If you are on 1.7 or older, upgrade first; slash commands are not in the old versions.
A minimal bot with one slash command
import os
import discord
from discord.ext import commands
class MyBot(commands.Bot):
def __init__(self):
super().__init__(command_prefix="!", intents=discord.Intents.default())
async def setup_hook(self):
await self.tree.sync()
bot = MyBot()
@bot.tree.command(name="ping", description="Check the bot is alive")
async def ping(interaction: discord.Interaction):
await interaction.response.send_message("Pong")
bot.run(os.environ["BOT_TOKEN"])Three things matter here. bot.tree is where slash commands are registered. The description is required. And self.tree.sync() is what uploads the list to Discord.
Slash commands do not need the Message Content intent, so the default intents are enough.
Syncing, properly
Sync in setup_hook, not on_ready. on_ready can fire again whenever the bot reconnects, and syncing on every reconnect burns through a daily limit: Discord allows 200 command creations per day per server. setup_hook runs once per start.
Better still, sync only when commands change. A common pattern is an owner-only prefix command:
@bot.command()
@commands.is_owner()
async def sync(ctx):
synced = await bot.tree.sync()
await ctx.send(f"Synced {len(synced)} commands")That needs the Message Content intent for the !sync itself, or you can mention the bot instead of using a prefix.
Global or one server
tree.sync() with no arguments syncs globally, to every server the bot is in. While developing, syncing to a single test server is quicker to check:
TEST_GUILD = discord.Object(id=123456789012345678)
async def setup_hook(self):
self.tree.copy_global_to(guild=TEST_GUILD)
await self.tree.sync(guild=TEST_GUILD)When you go live, sync globally and, if you synced to a test server before, clear that copy so commands do not show twice there:
self.tree.clear_commands(guild=TEST_GUILD)
await self.tree.sync(guild=TEST_GUILD)Options
Parameters become options, and type hints decide what Discord shows the user:
@bot.tree.command(description="Roll some dice")
@discord.app_commands.describe(sides="How many sides", count="How many dice")
async def roll(interaction: discord.Interaction, sides: int = 6, count: int = 1):
import random
rolls = [random.randint(1, sides) for _ in range(count)]
await interaction.response.send_message(f"{rolls} = {sum(rolls)}")discord.Member, discord.TextChannel and discord.Role give the user a picker. A parameter with a default is optional.
For a fixed list, use app_commands.choices or a Literal["red", "blue"] type hint. For numbers within limits, app_commands.Range[int, 1, 100] makes Discord refuse anything outside them before your code runs.
Fixed choices
from discord import app_commands
@bot.tree.command(description="Pick a colour")
@app_commands.choices(colour=[
app_commands.Choice(name="Red", value="red"),
app_commands.Choice(name="Blue", value="blue"),
])
async def paint(interaction: discord.Interaction, colour: app_commands.Choice[str]):
await interaction.response.send_message(f"You picked {colour.name}")Up to 25 choices. The user can only pick one of them; colour.value is what your code uses.
Autocomplete
For a long or changing list, suggest as the user types instead:
FRUITS = ["apple", "apricot", "banana", "blackberry", "cherry"]
@bot.tree.command(description="Find a fruit")
async def fruit(interaction: discord.Interaction, name: str):
await interaction.response.send_message(f"You chose {name}")
@fruit.autocomplete("name")
async def fruit_autocomplete(interaction: discord.Interaction, current: str):
return [
app_commands.Choice(name=f, value=f)
for f in FRUITS if current.lower() in f
][:25]Unlike choices, autocomplete is only a suggestion: the user can still send any text, so check the value in the command.
Who can use a command
Two different things, and a moderation command wants both:
@bot.tree.command(description="Delete recent messages")
@app_commands.default_permissions(manage_messages=True)
@app_commands.checks.has_permissions(manage_messages=True)
@app_commands.guild_only()
async def purge(interaction: discord.Interaction, count: app_commands.Range[int, 1, 100]):
await interaction.response.defer(ephemeral=True)
deleted = await interaction.channel.purge(limit=count)
await interaction.followup.send(f"Deleted {len(deleted)} messages", ephemeral=True)default_permissionshides the command from members without that permission. Server admins can change it under Server Settings, Integrations, so it is a default, not a guarantee.checks.has_permissionsis checked by your bot every time it runs, and raisesMissingPermissionsif the member lacks it. This is the one to rely on.guild_onlykeeps it out of DMs, where there are no permissions to check.
Cooldowns
@bot.tree.command(description="Get a random number, once every 10 seconds")
@app_commands.checks.cooldown(1, 10.0, key=lambda i: i.user.id)
async def lucky(interaction: discord.Interaction):
await interaction.response.send_message("7")One use per 10 seconds per user; key=lambda i: i.guild_id would make it per server. Using it too soon raises CommandOnCooldown, handled in the error section below.
Groups: /config set, /config show
config = app_commands.Group(name="config", description="Change the bot's settings")
@config.command(name="set", description="Set a setting")
async def config_set(interaction: discord.Interaction, key: str, value: str):
await interaction.response.send_message(f"{key} = {value}", ephemeral=True)
@config.command(name="show", description="Show the settings")
async def config_show(interaction: discord.Interaction):
await interaction.response.send_message("...", ephemeral=True)
bot.tree.add_command(config)A group counts as one command against Discord's limit of 100 per bot, and keeps related commands together in the menu. In a cog, subclass commands.GroupCog to make the whole cog a group.
Right-click commands
Context menu commands appear when someone right-clicks a message or a member, under Apps:
@bot.tree.context_menu(name="Report message")
async def report(interaction: discord.Interaction, message: discord.Message):
await interaction.response.send_message(f"Reported {message.jump_url}", ephemeral=True)They take no options and have no description. Type the parameter as discord.Member for a member menu instead. They are synced with the rest of the tree.
The three second rule
Discord waits three seconds for your bot to respond to an interaction. Miss it and the user sees The application did not respond, even if your code finishes a moment later.
If a command does anything slow (an API call, a database query, generating an image), acknowledge it first and send the real answer afterwards:
@bot.tree.command(description="Look something up")
async def lookup(interaction: discord.Interaction, query: str):
await interaction.response.defer() # shows "thinking..."
result = await slow_search(query)
await interaction.followup.send(result)After deferring you have 15 minutes to send follow-ups. defer(ephemeral=True) makes the eventual reply visible only to the person who ran the command.
You can only use interaction.response once. A second send_message raises InteractionResponded; use followup.send for anything after the first.
Slash commands in a cog
import discord
from discord import app_commands
from discord.ext import commands
class Fun(commands.Cog):
def __init__(self, bot):
self.bot = bot
@app_commands.command(description="Say hello")
async def hello(self, interaction: discord.Interaction):
await interaction.response.send_message(f"Hello {interaction.user.mention}")
async def setup(bot):
await bot.add_cog(Fun(bot))Load the extension before you sync, or the sync uploads a list without the cog's commands:
async def setup_hook(self):
await self.load_extension("cogs.fun")
await self.tree.sync()Commands not showing up
- Never synced, or synced before the command was defined. Check the return value of
tree.sync(): it is the list of what Discord now has. - The bot was invited without
applications.commands. Older invite links only had thebotscope. Re-invite with both. - Your Discord client has an old list. Press Ctrl+R (Cmd+R on a Mac) to reload it.
- Synced to a guild, looking in another server. Guild syncs only exist in that guild.
- Hit the daily limit from syncing on every restart. You get a 429 error on sync. Wait, then move the sync out of the startup path.
- Two bots, or two copies of one. If a different application with the same command names is in the server, you may be running the other one.
Errors
Errors in slash commands do not go through on_command_error. Handle them on the tree:
import logging
import discord
from discord import app_commands
log = logging.getLogger(__name__)
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
if isinstance(error, app_commands.MissingPermissions):
msg = "You don't have permission to use that."
elif isinstance(error, app_commands.CommandOnCooldown):
msg = f"Slow down: try again in {error.retry_after:.0f} seconds."
else:
msg = "Something went wrong."
log.error("command failed", exc_info=error) # the full traceback, in your logs
if interaction.response.is_done():
await interaction.followup.send(msg, ephemeral=True)
else:
await interaction.response.send_message(msg, ephemeral=True)Log the unexpected ones rather than re-raising them: a raise before the reply means the user never gets one and just sees the command fail.
If commands register fine but do nothing, see bot online but not responding.
Did this page help?
Thanks for the feedback.