- Discord Bot Development Setup
- Basic Bot Structure
- discord.Bot vs. commands.Bot, and the Extension-Loading Trap
- First Run: What Actually Shows Up in the Terminal
- Slash Commands
- Slash Commands Do Not Show Up in the List
- The 3-Second Rule: defer and the Interaction Token
- Button Interactions
- Modal Forms
- Select Menus
- Error Handling
- Rate Limits: What Happens When a 429 Arrives
- Moderation Commands
- Deployment
- Failure Cases: From Symptom to Cause
- When Not to Use a Discord Bot
- References
- Quiz
Discord Bot Development Setup
This guide targets Pycord v2.8.1 (released 2026-07-25, Python 3.10 or later and below 3.15). There is a reason for pinning the version. The Python Discord library world splits into two branches, discord.py and Pycord, and both import as discord, so code you find through a search gives no outward sign of which one it is. Yet the two differ at two decisive points: extension loading and callback argument order. We walk through each of them below.
Discord Developer Portal Configuration
- Create a New Application at the Discord Developer Portal
- Copy the Token from the Bot tab (never expose this publicly!)
- Generate a bot invite URL from the OAuth2 tab:
- Scopes:
bot,applications.commands - Permissions: Select the required permissions
- Scopes:
Leaving applications.commands out of Scopes is extremely common. Check only bot and the bot still joins the server and appears online, but it has no permission to register slash commands.
Where You Turn On the Privileged Intents
Under the Bot tab, the Privileged Gateway Intents section has three switches: SERVER MEMBERS, PRESENCE, and MESSAGE CONTENT. They correspond to Intents.members, Intents.presences, and Intents.message_content in code.
The Pycord docs attach a warning here: even if you enabled an intent in the portal, you still have to enable it in code. The two switches are independent and their symptoms differ. Enable it only in code and the bot cannot even log in, raising an exception. Enable it only in the portal and there is no exception at all, just events that never arrive. The latter is far harder to find.
Once you pass 10,000 unique users, privileged intents go through Discord review. If you plan to make the bot public, design it without message_content from the start.
Project Setup
# Create a virtual environment
python -m venv venv
source venv/bin/activate
# Install Pycord
pip install py-cord python-dotenv aiohttp
# Project structure
# my-discord-bot/
# ├── bot.py # Main bot file
# ├── cogs/
# │ ├── __init__.py
# │ ├── general.py # General commands
# │ ├── moderation.py # Moderation commands
# │ └── fun.py # Fun commands
# ├── utils/
# │ └── helpers.py
# ├── .env
# └── requirements.txt
.env File
DISCORD_TOKEN=your_bot_token_here
GUILD_IDS=123456789012345678
Basic Bot Structure
# bot.py
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv
load_dotenv()
# Intents configuration
intents = discord.Intents.default()
intents.message_content = True
intents.members = True
bot = discord.Bot(intents=intents)
@bot.event
async def on_ready():
print(f"✅ {bot.user} logged in!")
print(f"📊 Connected to {len(bot.guilds)} servers")
await bot.change_presence(
activity=discord.Activity(
type=discord.ActivityType.watching,
name="Watching the server 👀"
)
)
# Load Cogs
for filename in os.listdir("./cogs"):
if filename.endswith(".py") and not filename.startswith("_"):
bot.load_extension(f"cogs.{filename[:-3]}")
bot.run(os.getenv("DISCORD_TOKEN"))
Look at just two lines in the code above. bot = discord.Bot(intents=intents) is a declaration that you will only use slash commands, and the missing await in front of bot.load_extension(...) is not a typo. Both are the subject of the next section.
discord.Bot vs. commands.Bot, and the Extension-Loading Trap
Which Bot Class to Choose
The documentation opens its description of commands.Bot like this: the class is a subclass of discord.Bot, so anything you can do with discord.Bot you can also do with this bot. What it adds on top is GroupMixin, that is, prefix command support. So the criterion is simple. Slash commands only means discord.Bot; prefix commands like !ping as well means commands.Bot.
There is a hidden cost. Prefix commands have to read message bodies, which requires the MESSAGE CONTENT privileged intent. For a new bot, start from discord.Bot. Because of the subclass relationship you can swap it out later and the existing slash commands keep working.
load_extension Without await
This is the trap that holds people up most often. Extension loading in Pycord is a synchronous function.
# Pycord v2.8.1 — the signature as published in the docs
def load_extension(self, name, *, package=None, recursive=False, store=False)
Not async def, just plain def. The Cog entry point is a synchronous function too.
# At the bottom of cogs/general.py — the Pycord way
def setup(bot):
bot.add_cog(General(bot))
discord.py 2.x requires exactly the opposite, await bot.load_extension(...) and async def setup(bot). Most Stack Overflow answers are written against discord.py, so copying one over silently puts you out of step.
The symptom is vague, which makes it worse. Pycord calls setup synchronously, and calling an async def setup makes Python return a coroutine object without running the body. That means bot.add_cog(...) never executes. No exception, the bot logs in perfectly fine, and only the commands are missing from the list. Add await instead and you get a TypeError for awaiting a value that is not a coroutine.
First Run: What Actually Shows Up in the Terminal
The screen you get the first time you press python bot.py falls into roughly four cases.
When It Succeeds
✅ MyBot#1234 logged in!
📊 Connected to 1 servers
The print calls inside on_ready come out as written. If you got this far, the token and the gateway connection are fine. Note, though, that nothing guarantees on_ready is called only once per process. It is called again on reconnect, so anything that must happen exactly once, such as a migration or a startup announcement, needs a separate flag to guard it.
When the Token Is Wrong
Authentication is rejected, an exception is raised at the login step, and the process exits. Check the token string, the .env path, whether load_dotenv() was called, and the quotes and whitespace around the token, in that order. Resetting the token in the portal invalidates the previous one immediately.
When the Intents Are Not Turned On
You get discord.PrivilegedIntentsRequired. Exactly as the docs describe it, this is the exception raised when the gateway requests privileged intents that have not yet been checked on the developer page. It carries a shard_id attribute so you can tell which shard it came from. The bot.py above turns on both message_content and members, so both have to be turned on in the portal too.
When the Bot Starts but message.content Is Empty
This is the one people chase the longest, because no exception is raised. Without the MESSAGE CONTENT intent, Discord sends down the field that carries what the user typed as an empty value. No error, no warning, just an empty string.
Four exceptions make it more confusing still. Messages the bot itself sent, DMs with the bot, messages that mention the bot, and the message targeted by a message context menu command all arrive with content even without the intent. So testing in a DM works, and mentioning the bot works. You have to test with an ordinary message sent in a server channel without a mention before the problem shows itself.
Slash Commands
# cogs/general.py
import discord
from discord.ext import commands
from discord import option
import aiohttp
from datetime import datetime
class General(commands.Cog):
def __init__(self, bot):
self.bot = bot
@discord.slash_command(name="ping", description="Check the bot's response time")
async def ping(self, ctx: discord.ApplicationContext):
latency = round(self.bot.latency * 1000)
embed = discord.Embed(
title="🏓 Pong!",
description=f"Latency: **{latency}ms**",
color=discord.Color.green() if latency < 100 else discord.Color.red()
)
await ctx.respond(embed=embed)
@discord.slash_command(name="userinfo", description="Display user information")
@option("user", description="User to view info for", type=discord.Member, required=False)
async def userinfo(self, ctx: discord.ApplicationContext, user: discord.Member = None):
user = user or ctx.author
embed = discord.Embed(
title=f"👤 {user.display_name}",
color=user.color
)
embed.set_thumbnail(url=user.display_avatar.url)
embed.add_field(name="ID", value=user.id, inline=True)
embed.add_field(name="Joined", value=user.joined_at.strftime("%Y-%m-%d"), inline=True)
embed.add_field(name="Account Created", value=user.created_at.strftime("%Y-%m-%d"), inline=True)
embed.add_field(
name="Roles",
value=", ".join([r.mention for r in user.roles[1:]]) or "None",
inline=False
)
await ctx.respond(embed=embed)
@discord.slash_command(name="weather", description="Get weather information")
@option("city", description="City name", type=str, required=True)
async def weather(self, ctx: discord.ApplicationContext, city: str):
await ctx.defer() # Show response delay
async with aiohttp.ClientSession() as session:
url = f"https://wttr.in/{city}?format=j1"
async with session.get(url) as resp:
if resp.status != 200:
await ctx.followup.send("❌ City not found.")
return
data = await resp.json()
current = data["current_condition"][0]
embed = discord.Embed(
title=f"🌤 Weather in {city}",
color=discord.Color.blue()
)
embed.add_field(name="🌡 Temperature", value=f"{current['temp_C']}°C", inline=True)
embed.add_field(name="💧 Humidity", value=f"{current['humidity']}%", inline=True)
embed.add_field(name="💨 Wind", value=f"{current['windspeedKmph']} km/h", inline=True)
embed.add_field(name="Condition", value=current["weatherDesc"][0]["value"], inline=False)
await ctx.followup.send(embed=embed)
def setup(bot):
bot.add_cog(General(bot))
The option Decorator and the Option Type
There are two ways to define a parameter: use a Python type annotation, or use the @discord.option decorator. The docs describe the latter as a decorator you can use instead of using Option as a type hint.
This is where the documentation itself trips you up. The guide page example writes @discord.option("first", type=...) with type=, while the API reference signature is option(name, input_type=None, **kwargs), where the parameter is named input_type. Examples in both spellings circulate side by side, so when in doubt use input_type= or stick to type annotations alone.
When you use discord.Option directly the signature is Option(input_type=str, /, description=None, **kwargs). The input_type before the slash is positional-only, so it cannot be passed as a keyword. To separate the UI option name from the Python parameter name, use parameter_name.
from discord import option and from discord import slash_command are valid too. They are re-exported through discord/commands/__init__.py, so @discord.slash_command(...) and @slash_command(...) are the same thing. Different examples use different spellings and it looks like two different APIs, so settle on one. As the command count grows, group them two levels deep with discord.SlashCommandGroup(name, description=None, guild_ids=None, parent=None, cooldown=None, max_concurrency=None, **kwargs).
Slash Commands Do Not Show Up in the List
The bot is online, but pressing slash shows no commands. There are about four causes, and the order you check them in matters.
1. applications.commands Is Missing from the Invite URL
Overwhelmingly the most common. Check only bot in Scopes and the bot joins the server and appears online, but it has no permission at all to register slash commands. Re-invite it to the same server with a URL containing both scopes. A bot that is already in the server can be re-invited, and its existing configuration is kept.
2. It Has Not Synced Yet
In Pycord, Bot.auto_sync_commands defaults to True and calls Bot.sync_commands at discord.on_connect. Usually you never have to think about it, but here is the signature for the times you call it manually.
async def sync_commands(
self,
commands=None,
method: Literal["individual", "bulk", "auto"] = "bulk",
force=False,
guild_ids=None,
register_guild_commands=True,
check_guilds=[],
delete_existing=True,
)
It is a coroutine, so this one does need await. That is the opposite of load_extension above, which is easy to mix up. delete_existing=True is the default, so a command you delete from your code disappears from Discord too.
3. You Mixed Up Guild Commands and Global Commands
The Discord docs put it this way: guild commands update instantly, so use guild commands for quick testing and global commands once you are ready to go public. Global commands carry an internal version check, and running one with a stale definition makes Discord reject that command and trigger a reload.
During development, debug_guilds is the most convenient option. The docs describe it as the IDs of the guilds to use for testing commands, and note that the bot will not create any global commands if debug guild IDs are passed. That second half is the important part. Ship with this still enabled and no command is visible anywhere outside that server. It is the classic cause of the "works locally, breaks in production" symptom.
4. You Used Up the Daily Registration Limit
Discord has a global rate limit of 200 application command creations per guild per day. You would not normally come near it, but a bot stuck in a crash-restart loop that runs a full sync every time can reach that number.
The 3-Second Rule: defer and the Interaction Token
There are two numbers you have to memorize. To quote the Discord docs directly, you must send the initial response within 3 seconds of receiving the event, and missing the 3-second deadline invalidates the token. And the interaction token stays valid for 15 minutes. The 3 seconds is the deadline for the initial response; miss it and there is nowhere to send the result even when it finally arrives. The 15 minutes is the window in which you can send follow-up messages or edit the response after sending the initial response on time.
So for work that will not finish inside 3 seconds, claim your slot with defer first and send a follow-up message once the work is done. The /weather command above is exactly this pattern. An external HTTP call can exceed 3 seconds at any time, so it calls await ctx.defer() first and sends the result with ctx.followup.send(...) when it arrives. /ping, by contrast, only computes a latency figure, so it responds immediately.
The signature of defer is this.
async def defer(self, *, ephemeral: bool = False, invisible: bool = True)
invisible is what makes the practical difference. In terms of Discord response types, one is DEFERRED_CHANNEL_MESSAGE_WITH_SOURCE (type 5), which the docs describe as showing a loading state to the user. The other is DEFERRED_UPDATE_MESSAGE (type 6), where no loading state is shown. When you want to handle a button click quietly in the background, the latter is the right one.
Why respond Just Works
There is a reason most Pycord examples end at a single ctx.respond. respond calls self.response.send_message(...) when self.response.is_done() is false, and self.followup.send(...) when a response has already been sent. It catches the InteractionResponded exception as well. In other words, it decides for you whether this is the initial response or a follow-up.
That is why ctx.respond still works after defer. Call interaction.response.send_message directly twice, on the other hand, and you get this error.
Interaction was already issued a response. Try using {type}.send_followup() instead.
From the second call onward you have to use the follow-up path.
Button Interactions
# cogs/fun.py
import discord
from discord.ext import commands
import random
class RockPaperScissorsView(discord.ui.View):
def __init__(self):
super().__init__(timeout=30)
@discord.ui.button(label="✊ Rock", style=discord.ButtonStyle.primary, custom_id="rock")
async def rock(self, button: discord.ui.Button, interaction: discord.Interaction):
await self.play(interaction, "rock")
@discord.ui.button(label="✋ Paper", style=discord.ButtonStyle.success, custom_id="paper")
async def paper(self, button: discord.ui.Button, interaction: discord.Interaction):
await self.play(interaction, "paper")
@discord.ui.button(label="✌️ Scissors", style=discord.ButtonStyle.danger, custom_id="scissors")
async def scissors(self, button: discord.ui.Button, interaction: discord.Interaction):
await self.play(interaction, "scissors")
async def play(self, interaction: discord.Interaction, user_choice: str):
choices = {"rock": "✊", "paper": "✋", "scissors": "✌️"}
bot_choice = random.choice(list(choices.keys()))
if user_choice == bot_choice:
result = "🤝 Draw!"
color = discord.Color.yellow()
elif (user_choice == "rock" and bot_choice == "scissors") or \
(user_choice == "paper" and bot_choice == "rock") or \
(user_choice == "scissors" and bot_choice == "paper"):
result = "🎉 You Win!"
color = discord.Color.green()
else:
result = "😢 You Lose!"
color = discord.Color.red()
embed = discord.Embed(title=result, color=color)
embed.add_field(name="You", value=choices[user_choice], inline=True)
embed.add_field(name="Bot", value=choices[bot_choice], inline=True)
# Disable buttons
for child in self.children:
child.disabled = True
await interaction.response.edit_message(embed=embed, view=self)
class Fun(commands.Cog):
def __init__(self, bot):
self.bot = bot
@discord.slash_command(name="rps", description="Rock Paper Scissors game!")
async def rps(self, ctx: discord.ApplicationContext):
embed = discord.Embed(
title="✊✋✌️ Rock Paper Scissors!",
description="Click a button to make your choice!",
color=discord.Color.blue()
)
await ctx.respond(embed=embed, view=RockPaperScissorsView())
def setup(bot):
bot.add_cog(Fun(bot))
Watch the Callback Argument Order
Pycord's button callback signature is (self, button, interaction), so the component comes first. To quote the docs directly, the decorated function must have three parameters: self representing the discord.ui.View, the discord.ui.Button that was pressed, and the discord.Interaction you receive. Select follows the same order, (self, select, interaction). Only the modal has no component argument, (self, interaction).
Reverse the order and Python says nothing at all. You have merely written the type hints the wrong way round. At runtime the variable named interaction holds the button object when you call await interaction.response.send_message(...), and since a button has no such attribute you get an AttributeError. This is the classic shape of a failure on the very first click after porting an example from another library.
View timeout and Persistent Views
The timeout default on discord.ui.View is 180.0 seconds. Leave it unspecified and the View expires after three minutes, calling the on_timeout coroutine. The docs describe it as being called when the View's timeout has elapsed without being explicitly stopped. The rock-paper-scissors View above sets timeout=30, so it ends if nobody presses a button within 30 seconds.
The problem is buttons that have to stay alive, like a role-selection panel. There are two conditions. In the words of the docs, the timeout must be set to None and every child of the View must have a custom_id attribute.
Meeting only the second and forgetting the first happens often. The symptom looks like this: right after deployment the buttons work fine, but once you restart the bot the buttons on older messages are dead. The bot process was holding the View object in memory and lost it on restart. A persistent View has to be re-registered at startup, and for that its timeout must be None.
Conversely, for a throwaway View like a game, it is better not to fix a custom_id. If several users run the same command at once, you end up with multiple buttons carrying the same custom_id floating around the channel.
discord.ButtonStyle has five members, primary, secondary, success, danger, and link, with aliases attached. blurple is primary, grey and gray are secondary, green is success, red is danger, and url is the same value as link. That is why the spelling differs from example to example.
Modal Forms
class FeedbackModal(discord.ui.Modal):
def __init__(self):
super().__init__(title="📋 Submit Feedback")
self.add_item(discord.ui.InputText(
label="Title",
placeholder="Enter the feedback title",
style=discord.InputTextStyle.short,
required=True,
max_length=100
))
self.add_item(discord.ui.InputText(
label="Content",
placeholder="Enter detailed content",
style=discord.InputTextStyle.long,
required=True,
max_length=2000
))
self.add_item(discord.ui.InputText(
label="Rating (1-5)",
placeholder="1",
style=discord.InputTextStyle.short,
required=False,
max_length=1
))
async def callback(self, interaction: discord.Interaction):
title = self.children[0].value
content = self.children[1].value
rating = self.children[2].value or "Not provided"
embed = discord.Embed(
title="📋 New Feedback",
color=discord.Color.blue()
)
embed.add_field(name="Title", value=title, inline=False)
embed.add_field(name="Content", value=content, inline=False)
embed.add_field(name="Rating", value=f"{'⭐' * int(rating)}" if rating.isdigit() else rating)
embed.set_footer(text=f"Author: {interaction.user.display_name}")
# Send to feedback channel
feedback_channel = interaction.guild.get_channel(FEEDBACK_CHANNEL_ID)
if feedback_channel:
await feedback_channel.send(embed=embed)
await interaction.response.send_message(
"✅ Feedback submitted! Thank you.", ephemeral=True
)
# Open modal via slash command
@discord.slash_command(name="feedback", description="Submit feedback")
async def feedback(ctx: discord.ApplicationContext):
await ctx.send_modal(FeedbackModal())
discord.InputTextStyle has aliases too. Looking at the enum definition, short and singleline are both 1, and paragraph, multiline, and long are all 2. The long and paragraph in the example above are the same value.
Where You Open a Modal, and the 3-Second Rule
You open a modal from a slash command with await ctx.send_modal(modal), or from a button callback with await interaction.response.send_modal(...). The important constraint is that a modal has to be the initial response. You cannot call defer first and then open a modal, because the initial response slot is already spent.
So a design that runs a heavy lookup first and then shows a modal prefilled with the result does not hold up if the lookup takes longer than 3 seconds. Turn the order around. Show the modal first to collect the input, then defer after submission and do the heavy work.
FEEDBACK_CHANNEL_ID in the example above is an undefined constant. You need to read it from an environment variable and convert it to an integer. Pulling values out by self.children index is risky too. Add an input field or reorder them and the indexes silently shift.
The full constructor arguments of discord.ui.Modal and discord.ui.InputText can differ between versions. For arguments beyond label and style, check the docs for the version you are using.
Select Menus
class RoleSelectView(discord.ui.View):
@discord.ui.select(
placeholder="Select roles (up to 3)",
min_values=1,
max_values=3,
options=[
discord.SelectOption(label="Developer", emoji="💻", value="developer"),
discord.SelectOption(label="Designer", emoji="🎨", value="designer"),
discord.SelectOption(label="Planner", emoji="📊", value="planner"),
discord.SelectOption(label="Marketer", emoji="📢", value="marketer"),
discord.SelectOption(label="Data Analyst", emoji="📈", value="analyst"),
]
)
async def select_callback(self, select: discord.ui.Select, interaction: discord.Interaction):
selected = ", ".join(select.values)
await interaction.response.send_message(
f"✅ Selected roles: {selected}", ephemeral=True
)
The Select callback takes the same order as a button, (self, select, interaction). select.values is a list, so it arrives as a list even when max_values is 1. Forget to pull the element out and the brackets get printed on screen as-is.
The example above only reports back what was selected. Actually assigning the role needs two more conditions. The bot must have the manage roles permission, and the bot's highest role must sit above the role being assigned in the server's role list. That second condition is why an HTTP 403-class error is common even after granting every permission.
Error Handling
# Add global error handler to bot.py
@bot.event
async def on_application_command_error(ctx: discord.ApplicationContext, error):
if isinstance(error, commands.MissingPermissions):
await ctx.respond("❌ Insufficient permissions.", ephemeral=True)
elif isinstance(error, commands.CommandOnCooldown):
await ctx.respond(
f"⏳ On cooldown. Please try again in {error.retry_after:.1f} seconds.",
ephemeral=True
)
elif isinstance(error, commands.MemberNotFound):
await ctx.respond("❌ User not found.", ephemeral=True)
else:
# Logging
import traceback
traceback.print_exception(type(error), error, error.__traceback__)
await ctx.respond("❌ An error occurred.", ephemeral=True)
on_application_command_error only receives exceptions raised inside slash command callbacks. Exceptions from button, Select, and modal callbacks never arrive here. Views have their own error handling hook, so a bot heavy on interactions needs one set up separately.
Reporting an error with ctx.respond takes care as well. If the 3 seconds have already passed and the token is invalidated, you cannot send anything. The user is left with nothing but the failure, and the trace survives only in the logs. That is why an error handler must always log first and attempt the notification second. It is why the code above calls traceback.print_exception first. Flip the order and a failed notification takes the original exception down with it.
Rate Limits: What Happens When a 429 Arrives
The Discord API returns HTTP 429 when you go over a limit. The response carries X-RateLimit-Limit, Retry-After, and X-RateLimit-Reset-After headers. The global limit the docs state is this: every bot can send up to 50 API requests per second.
On top of that there is a separate invalid request limit. Exceed 10,000 invalid requests in 10 minutes and you are blocked. An invalid request means a 401, 403, or 429 response. A bot with an expired token stuck in a retry loop reaches this limit.
What Pycord Does for You
Most of the time you do not have to handle this yourself. Pycord's HTTPClient wraps each request in a for tries in range(5): loop, and on a 429 it reads the retry wait time from the response body, sleeps that long, and tries again. It also distinguishes a global limit from a per-bucket one using the global flag in the response.
In other words, short overruns are absorbed as delay. What you see in your code is not an error but a command getting slower. When a bulk delete or bulk send command is unusually slow, this is usually where it comes from. Nothing shows up in the error log, so without knowing this behavior the cause is hard to find.
When You Have to Catch It Yourself
If five retries are not enough, the exception propagates. There is a common mistake here. discord.RateLimited does not exist in Pycord v2.8.1. discord.py has it, so examples using that name circulate, but importing it from Pycord fails.
In Pycord, catch discord.HTTPException and check whether .status is 429. That exception has .status, .code, .text, and .response attributes. .status is the HTTP status code and .code is Discord's own error code.
Past 2500 Guilds
In the words of the docs, each shard can support up to 2500 guilds, and an app in 2500 or more guilds must enable sharding. Which guild goes to which shard follows a fixed formula.
shard_id = (guild_id >> 22) % num_shards
Get the shard count wrong and the gateway closes the connection with close code 4010 Invalid Shard. In Pycord, use discord.AutoShardedBot or commands.AutoShardedBot. Most bots never face this, but if you are planning a public bot, assume from the beginning that the process eventually splits into several. Any design that keeps state in process memory has to be rewritten entirely at that point.
Moderation Commands
# cogs/moderation.py
class Moderation(commands.Cog):
def __init__(self, bot):
self.bot = bot
@discord.slash_command(name="clear", description="Delete messages")
@commands.has_permissions(manage_messages=True)
@option("amount", description="Number of messages to delete", type=int, min_value=1, max_value=100)
async def clear(self, ctx: discord.ApplicationContext, amount: int):
deleted = await ctx.channel.purge(limit=amount)
await ctx.respond(f"🗑️ {len(deleted)} messages deleted", ephemeral=True)
@discord.slash_command(name="slowmode", description="Set slow mode")
@commands.has_permissions(manage_channels=True)
@option("seconds", description="Seconds (0=disable)", type=int, min_value=0, max_value=21600)
async def slowmode(self, ctx: discord.ApplicationContext, seconds: int):
await ctx.channel.edit(slowmode_delay=seconds)
if seconds == 0:
await ctx.respond("✅ Slow mode has been disabled.")
else:
await ctx.respond(f"✅ Slow mode set to {seconds} seconds")
def setup(bot):
bot.add_cog(Moderation(bot))
The purge Trap
ctx.channel.purge(limit=amount) looks convenient but comes with a constraint. Discord's bulk delete endpoint cannot delete old messages. Pass a large number and only the recent ones are deleted while the rest silently remain. Check the Discord API docs for the exact age cutoff in days.
The response order is a problem too. The code above runs purge first and then calls ctx.respond, and if the deletion takes a while it can exceed 3 seconds. Then the messages are deleted but the user sees a failure. Calling await ctx.defer(ephemeral=True) first and sending a follow-up message is the safer shape.
What the Permission Check Actually Checks
@commands.has_permissions(manage_messages=True) looks at the permissions of the user who ran the command. It does not look at the bot's own permissions. If the user has the permission and the bot does not, the check passes and the actual delete step returns a 403. That falls through to the final else rather than the MissingPermissions branch, so the user sees a generic error message instead of a permission notice.
Pair it with default_member_permissions on the slash command decorator and Discord's UI can hide the command entirely from users who lack the permission. It is only a UI filter, though, so it does not replace the permission check in code. Keep both.
Deployment
systemd Service
# /etc/systemd/system/discord-bot.service
[Unit]
Description=Discord Bot
After=network.target
[Service]
Type=simple
User=bot
WorkingDirectory=/opt/discord-bot
ExecStart=/opt/discord-bot/venv/bin/python bot.py
Restart=always
RestartSec=10
EnvironmentFile=/opt/discord-bot/.env
[Install]
WantedBy=multi-user.target
sudo systemctl enable discord-bot
sudo systemctl start discord-bot
sudo journalctl -u discord-bot -f
There is a reason Restart=always and RestartSec=10 are attached. The gateway connection is a stateful WebSocket, so the process can exit when the network drops or Discord closes the connection.
What to watch out for is the restart loop. If the token is wrong or an intent is off, the bot dies the moment it starts and comes back 10 seconds later. Leave that running overnight and you reach the invalid request limit. Watch the first few minutes after a deploy with journalctl -u discord-bot -f. One login message followed by silence is normal; the same log repeating every 10 seconds is a loop.
WorkingDirectory matters too. The bot.py above walks ./cogs as a relative path, so a different working directory means it finds no Cogs at all. When something that worked locally loses its commands once it runs as a service, this is another candidate. .env holds the token in plain text, so keep it readable by its owner only, and if you commit it by accident, do not stop at deleting the file, reset the token in the portal.
Failure Cases: From Symptom to Cause
Here are the traps covered so far, re-sorted so that you start from the symptom. When the bot misbehaves, work down this list from the top and most of the time you are done here.
- The bot does not start at all — check the token string, the
.envpath and whereload_dotenv()is called, and then whether this isdiscord.PrivilegedIntentsRequired, in that order. If it is the last one, it is the Privileged Gateway Intents switches in the portal. - The bot is online but slash commands are invisible — the
applications.commandsscope in the invite URL, thedebug_guildssetting, and whether the Cog actually loaded, in that order. The third is the easiest to miss. If you wrote the entry point asasync def setup(bot), Pycord skips that Cog without raising anything. - The commands are visible but fail when pressed — most likely you went past 3 seconds. If the callback touches an external API or a database, put
await ctx.defer()at the very top. This is especially likely when the logs show no exception at all. - message.content is always an empty string — the MESSAGE CONTENT intent. Testing in a DM or with a mention looks fine, so test with an unmentioned message in a server channel.
- Old buttons die after a restart — the persistent View conditions.
timeout=Noneand acustom_idon every child are needed together, and the View has to be re-registered at startup. - The first button click raises AttributeError — the callback argument order. In Pycord the component comes first.
- A command suddenly got slow — rate limit delay. Pycord received a 429, waited, and retried, so it never shows up as an error.
- It works locally but not once deployed — check
debug_guilds,WorkingDirectory, and intents, in that order.
When Not to Use a Discord Bot
A Discord bot is a process that permanently maintains a stateful WebSocket connection. There are cases where that shape clearly does not fit.
Use a Webhook When You Only Need to Send Notifications
If dropping one message into a channel is the whole job, you do not need a bot. Create a webhook URL in the channel settings, POST to it once, and you are done. No process, no token lifetime management, no restart policy, no intent review. CI results, deployment notices, and monitoring alerts all fall here. Build it as a bot and you take on one always-on process and one gateway connection as running cost, and when that process dies the notifications die with it. A webhook has no process to die.
If You Only Need Slash Commands, You Can Skip the Gateway
Discord also supports receiving interactions over HTTP. Register a public endpoint and Discord sends requests there, so the bot can run as a serverless function with no gateway connection at all. In exchange you give up the conveniences of a library built around gateway events. Message reception, member joins and leaves, and presence cannot arrive at all. Either way the 3-second rule applies just the same, and with serverless the cold start eats into those 3 seconds.
It Is a Poor Fit for Work That Needs Delivery Guarantees
Messages that arrive while the bot is restarting are gone. The gateway does resend some events on reconnect, but you must not trust it as a queue. If an event absolutely has to be processed, let the bot handle only the intake and hand the actual processing to a separate queue and worker.
Running long jobs inside the bot is dangerous too. A single event loop handles every interaction, so one command that holds on too long eats into another user's 3-second budget. For the same reason, state must not live in process memory. View objects, games in progress, and per-user settings all vanish on restart. The persistent View problem seen earlier is a small instance of this principle.
It Can Be Overkill for Business Workflows
Taking in a form, running it through approval steps, and keeping a record is something you can build with modals and buttons. But you have to implement the audit log, permission delegation, and data retention yourself. There is a single criterion: are people already living in Discord? If your community lives there, a bot is a powerful option. If it is a channel people drop into once a day, you can build it and nobody will use it.
References
- Pycord official documentation — the relationship between
discord.Botandcommands.Bot, theload_extensionsignature,slash_commandandoptionarguments, theViewtimeout default and the persistent View conditions,sync_commandsanddebug_guilds(checked 2026-08-16) - Pycord Intents guide — the list of privileged intents and the warning that they must be turned on in both the portal and the code (checked 2026-08-16)
- Discord Docs: Receiving and Responding to Interactions — the 3-second deadline, the 15-minute token lifetime, deferred response types 5 and 6 (checked 2026-08-16)
- Discord Docs: Gateway — events per intent, the 2500-guild-per-shard limit, the shard calculation (checked 2026-08-16)
- Discord Docs: Rate Limits — 50 requests per second, 429 response headers, the invalid request limit (checked 2026-08-16)
The code and signatures in this article target Pycord v2.8.1 (released 2026-07-25). For any API not covered here, check the docs for the version you are using.
📝 Review Quiz (6 Questions)
Q1. What are Discord Bot Intents?
Intents are settings that specify the types of events the bot will receive. Privileged Intents (message_content, members) require separate activation in the Developer Portal.
Q2. When do you use ctx.defer() in slash commands?
When the response takes more than 3 seconds. After defer(), you send the actual response using ctx.followup.send().
Q3. What does ephemeral=True mean?
It makes the message visible only to the user who executed the command. Other users cannot see it.
Q4. What are the advantages of Cogs?
They allow you to separate commands into modules for better organization and enable dynamic loading/unloading. This is beneficial for code structure and maintainability.
Q5. What does the View's timeout parameter control?
The time in seconds until buttons/select menus are deactivated. Setting it to None means no timeout.
Q6. What is the difference between a Modal and a regular message?
A Modal displays an input form to the user for collecting structured data. Regular messages only exchange text.
Quiz
Q1: What is the main topic covered in "Complete Guide to Discord Bot Development: Slash
Commands, Buttons, and Modals with Pycord"?
A complete hands-on guide to developing a Discord Bot with Pycord. Covers slash commands, button interactions, modal forms, embed messages, and Cog-based architecture at a production level.
Q2: What are the key steps for Discord Bot Development Setup?
Discord Developer Portal Configuration Create a New Application at the Discord Developer Portal
Copy the Token from the Bot tab (never expose this publicly!) Generate a bot invite URL from the
OAuth2 tab: Scopes: bot, applications.commands Permissions: Select the required permiss...
Q3: Explain the core concept of Deployment.
systemd Service Q1. What are Discord Bot Intents? Intents are settings that specify the types of
events the bot will receive. Privileged Intents (message_content, members) require separate
activation in the Developer Portal. Q2. When do you use ctx.defer() in slash commands?