Guide 29

Build a Discord bot in Python with discord.py and slash commands

discord.py 2.x has first-class support for slash commands through app_commands. This guide builds a bot around them, so you never need the privileged Message Content intent.

Guide4 min readUpdated

1. Create the application

  1. In the Discord Developer Portal, click New Application.
  2. On the Bot page, reset and copy the token. Keep it in an environment variable.
  3. Leave the privileged intents off. This bot will not need them; Discord gateway intents explained covers why.
  4. Invite the bot with the bot and applications.commands scopes and only the permissions it uses. The permissions calculator generates the URL.

Platform concepts are in the Discord bots guide.

2. Install discord.py

The latest release when this guide was written was discord.py 2.7.1; it requires Python 3.8 or newer, though a current Python release is the better choice.

python3 -m venv .venv && . .venv/bin/activate
pip install "discord.py==2.*"

3. Client with a command tree

# bot.py
import os
import discord
from discord import app_commands

TEST_GUILD = discord.Object(id=int(os.environ["TEST_GUILD_ID"]))

class Bot(discord.Client):
    def __init__(self):
        super().__init__(intents=discord.Intents.default())
        self.tree = app_commands.CommandTree(self)

    async def setup_hook(self):
        # During development: copy commands to one server for instant updates
        self.tree.copy_global_to(guild=TEST_GUILD)
        await self.tree.sync(guild=TEST_GUILD)

bot = Bot()

@bot.event
async def on_ready():
    print(f"Logged in as {bot.user} ({bot.user.id})")

@bot.tree.command(description="Check that the bot is alive")
async def ping(interaction: discord.Interaction):
    await interaction.response.send_message(f"Pong ({round(bot.latency * 1000)} ms)", ephemeral=True)

bot.run(os.environ["DISCORD_TOKEN"])

Intents.default() excludes the privileged intents, and slash commands arrive as interactions anyway. setup_hook runs once before the bot connects, which is the right place to sync.

Syncing in production. tree.sync() without a guild publishes global commands. It is rate limited, so do not call it on every start; sync when commands change, for example from an owner-only command or a deploy script.

4. Typed options and choices

discord.py turns type hints into command options, and Discord validates them before your code runs:

from typing import Literal

@bot.tree.command(description="Roll dice")
@app_commands.describe(sides="Number of sides", count="How many dice")
async def roll(interaction: discord.Interaction,
               sides: app_commands.Range[int, 2, 100] = 6,
               count: app_commands.Range[int, 1, 10] = 1):
    import random
    rolls = [random.randint(1, sides) for _ in range(count)]
    await interaction.response.send_message(f"{rolls} (total {sum(rolls)})")

@bot.tree.command(description="Set your notification preference")
async def notify(interaction: discord.Interaction, mode: Literal["all", "mentions", "none"]):
    await interaction.response.send_message(f"Notifications: {mode}", ephemeral=True)

Literal becomes a fixed list of choices; discord.Member, discord.TextChannel and discord.Role hints become user, channel and role pickers. Preview command JSON with our Discord command builder.

5. Slow work: defer

You have 3 seconds to respond. For anything slower, defer, then send a follow-up within 15 minutes:

@bot.tree.command(description="Summarize a web page")
async def summarize(interaction: discord.Interaction, url: str):
    await interaction.response.defer(thinking=True)
    text = await fetch_and_summarize(url)          # your slow function
    await interaction.followup.send(text[:2000])   # message content limit is 2000 characters

Long answers need splitting; the message splitter shows how.

6. Buttons with views

class Confirm(discord.ui.View):
    def __init__(self, author_id: int):
        super().__init__(timeout=60)
        self.author_id = author_id

    async def interaction_check(self, interaction: discord.Interaction) -> bool:
        return interaction.user.id == self.author_id     # only the caller may press

    @discord.ui.button(label="Confirm", style=discord.ButtonStyle.danger)
    async def confirm(self, interaction: discord.Interaction, button: discord.ui.Button):
        await interaction.response.edit_message(content="Done.", view=None)
        self.stop()

@bot.tree.command(description="Delete your saved data")
async def forget(interaction: discord.Interaction):
    await interaction.response.send_message("Are you sure?", view=Confirm(interaction.user.id), ephemeral=True)

Views are in memory; after a restart, old buttons stop working unless you register persistent views with a fixed custom_id and timeout=None.

7. Permissions and errors

Restrict commands declaratively:

@bot.tree.command(description="Delete recent messages")
@app_commands.default_permissions(manage_messages=True)
@app_commands.guild_only()
async def purge(interaction: discord.Interaction, amount: app_commands.Range[int, 1, 100]):
    await interaction.response.defer(ephemeral=True)
    deleted = await interaction.channel.purge(limit=amount)
    await interaction.followup.send(f"Deleted {len(deleted)} messages.", ephemeral=True)

default_permissions hides the command from members without that permission (server admins can override it), and the bot itself also needs Manage Messages in that channel.

Catch errors in one place:

@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError):
    msg = "Something went wrong."
    if isinstance(error, app_commands.CheckFailure):
        msg = "You cannot use this command here."
    if interaction.response.is_done():
        await interaction.followup.send(msg, ephemeral=True)
    else:
        await interaction.response.send_message(msg, ephemeral=True)
    raise error   # keep the traceback in the logs

If something still fails silently, work through Discord bot not responding.

8. Run it 24/7

A Discord bot holds a gateway connection open, so it needs an always-on process; serverless functions that sleep do not work for gateway bots. Options:

Handle SIGTERM so restarts do not leave two sessions running; see graceful shutdown for bots.

Sources

Related comparisons and tools

Frequently asked questions

Do I need the Message Content intent for discord.py slash commands?

No. Slash commands, buttons and modals arrive as interactions with their own data. Message Content is only needed to read ordinary message text.

Why are my commands not showing up?

They were not synced, or they were synced to a different server. Sync to a test guild while developing, sync globally when releasing, and invite the bot with the applications.commands scope.