ESN Docs

Docs › Discord bots › Course

Part 3: Real commands, embeds, buttons and cogs

3 min readUpdated Nov 3, 2026

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.

Your first Discord bot (Python course)
  1. Part 1: Create the bot account
  2. Part 2: Hello world with a /ping command
  3. Part 3: Real commands, embeds, buttons and cogs (you are here)
  4. Part 4: Save data with SQLite
  5. 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.py

Create 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] = 6 becomes a number option that Discord checks for you (2 to 100, default 6). @app_commands.describe sets the hint users see.
  • Optional member: member: discord.Member | None = None lets users pick someone, or leave it empty.
  • Embeds: discord.Embed is 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.View holds 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.py

You 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.

Want your bot online 24/7? Part 5 shows how to run it on ESN Hosting, with bot hosting from $0.50/month. Read Part 5 or see bot plans.

Still stuck? Use the live chat on the panel or ask in the ESN Systems Discord. Last updated November 3, 2026.