Docs › Discord bots › Course
Part 3: Real commands, embeds, buttons and cogs
Part 3 of the Python Discord bot course: slash command options, an embed, a button with discord.ui.View, cooldowns, an error handler and splitting commands into cogs.
- Part 1: Create the bot account
- Part 2: Hello world with a /ping command
- Part 3: Real commands, embeds, buttons and cogs (you are here)
- Part 4: Save data with SQLite
- Part 5: Keep it online 24/7 on ESN
One file with every command gets messy fast. In this part you add three real commands and move them into a cog, discord.py's way of grouping commands into separate files.
1. The new folder layout #
my-bot/
├── .env
├── bot.py
└── cogs/
└── fun.pyCreate the cogs folder. Python finds cogs/fun.py as cogs.fun.
2. Write cogs/fun.py #
import random
import discord
from discord import app_commands
from discord.ext import commands
class CounterView(discord.ui.View):
def __init__(self) -> None:
super().__init__(timeout=120) # buttons stop working after 2 minutes
self.count = 0
@discord.ui.button(label="Clicked 0 times", style=discord.ButtonStyle.primary)
async def counter(self, interaction: discord.Interaction, button: discord.ui.Button) -> None:
self.count += 1
button.label = f"Clicked {self.count} times"
await interaction.response.edit_message(view=self)
class Fun(commands.Cog):
def __init__(self, bot: commands.Bot) -> None:
self.bot = bot
@app_commands.command(name="roll", description="Roll a dice")
@app_commands.describe(sides="How many sides the dice has (2 to 100)")
@app_commands.checks.cooldown(1, 3.0)
async def roll(self, interaction: discord.Interaction, sides: app_commands.Range[int, 2, 100] = 6) -> None:
result = random.randint(1, sides)
await interaction.response.send_message(f"\N{GAME DIE} You rolled **{result}** (d{sides})")
@app_commands.command(name="userinfo", description="Show info about a member")
@app_commands.describe(member="Who to look up (leave empty for yourself)")
async def userinfo(self, interaction: discord.Interaction, member: discord.Member | None = None) -> None:
member = member or interaction.user
embed = discord.Embed(title=member.display_name, color=discord.Color.blurple())
embed.set_thumbnail(url=member.display_avatar.url)
embed.add_field(name="Username", value=str(member))
embed.add_field(name="Account created", value=discord.utils.format_dt(member.created_at, "R"))
if isinstance(member, discord.Member) and member.joined_at:
embed.add_field(name="Joined this server", value=discord.utils.format_dt(member.joined_at, "R"))
await interaction.response.send_message(embed=embed)
@app_commands.command(name="clicker", description="A button that counts clicks")
async def clicker(self, interaction: discord.Interaction) -> None:
await interaction.response.send_message("Press the button:", view=CounterView())
async def setup(bot: commands.Bot) -> None:
await bot.add_cog(Fun(bot))- Options:
sides: app_commands.Range[int, 2, 100] = 6becomes a number option that Discord checks for you (2 to 100, default 6).@app_commands.describesets the hint users see. - Optional member:
member: discord.Member | None = Nonelets users pick someone, or leave it empty. - Embeds:
discord.Embedis the coloured card.discord.utils.format_dt(..., "R")shows dates like "3 years ago" in each user's own time zone. - Buttons: a
discord.ui.Viewholds buttons. Pressing one runs the decorated method, which edits the message with the new label. After the 120-second timeout the button stops responding. - Cooldown:
@app_commands.checks.cooldown(1, 3.0)allows each user one /roll every 3 seconds. setup: discord.py calls this function when it loads the file.
3. Update bot.py: load the cog and handle errors #
Replace bot.py with this. The /ping command can move into a cog too; try it as practice.
import os
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
load_dotenv()
TOKEN = os.getenv("DISCORD_TOKEN")
EXTENSIONS = ["cogs.fun"]
class MyBot(commands.Bot):
def __init__(self) -> None:
super().__init__(command_prefix=commands.when_mentioned, intents=discord.Intents.default())
async def setup_hook(self) -> None:
for ext in EXTENSIONS:
await self.load_extension(ext)
synced = await self.tree.sync()
print(f"Loaded {len(EXTENSIONS)} cog(s), synced {len(synced)} slash command(s)")
bot = MyBot()
@bot.event
async def on_ready() -> None:
print(f"Logged in as {bot.user} (ID: {bot.user.id})")
@bot.tree.error
async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError) -> None:
if isinstance(error, app_commands.CommandOnCooldown):
message = f"Slow down! Try again in {error.retry_after:.0f} seconds."
elif isinstance(error, app_commands.MissingPermissions):
message = "You don't have permission to use that command."
else:
message = "Something went wrong. The error was logged."
name = interaction.command.name if interaction.command else "unknown"
print(f"Error in /{name}: {error!r}")
# You can only respond to an interaction once; after that, use followup.
if interaction.response.is_done():
await interaction.followup.send(message, ephemeral=True)
else:
await interaction.response.send_message(message, ephemeral=True)
if not TOKEN:
raise SystemExit("DISCORD_TOKEN is missing. Put it in your .env file.")
bot.run(TOKEN)The error handler catches problems in any slash command and tells the user privately (ephemeral=True) instead of leaving them with "The application did not respond". Real errors still print in your console, so you can fix them.
4. Run and test #
python bot.pyYou should see Loaded 1 cog(s), synced 3 slash command(s). Try /roll sides:20, /userinfo, and /clicker. Use /roll twice quickly to see the cooldown message.
Adding more cogs #
Create another file in cogs/ with a commands.Cog class and a setup function, then add its name to EXTENSIONS. That's exactly what Part 4 does with a points system.
FAQ #
I get "This interaction failed" on the button.
The view timed out (120 seconds here) or the bot restarted, which forgets old views. Run /clicker again. For buttons that survive restarts, see the persistent views section in fix This interaction failed.
My new command doesn't show up.
Make sure the cog's name is in EXTENSIONS, restart the bot, check the console says it synced, and press Ctrl+R in Discord.
Can I still use prefix commands like !hello?
Yes, commands.Bot supports them, but they need the Message Content intent turned on in the portal and in your code. Slash commands don't.
Still stuck? Use the live chat on the panel or ask in the ESN Systems Discord. Last updated November 3, 2026.