Skip to content
Build a Discord Bot with Python (Step by Step)

Build a Discord Bot with Python (Step by Step)

DodaTech Updated Jun 19, 2026 10 min read

Build a Discord bot with Python using discord.py featuring prefix commands, slash commands, event listeners, rich embeds, modular cog structure, permission handling, and keep-alive hosting tips.

What You’ll Build

You’ll build a Discord bot called “UtilityBot” that can greet users, moderate messages, fetch weather data, manage a simple ticket system, and respond to slash commands — all organized into cogs. This same bot architecture is used at DodaTech for internal Discord servers that manage Durga Antivirus Pro’s beta testing feedback and DodaZIP’s support channels.

Why Build a Discord Bot?

Discord bots are everywhere — modding, music, games, utilities, and community management. Building one teaches you event-driven programming, API integration, permission systems, asynchronous programming in Python, and how to structure a long-running application. Plus, you get to deploy something your friends can actually use on a real platform.

Prerequisites

  • Python 3.8+ installed
  • A Discord account and a server where you can add bots
  • Basic understanding of asynchronous Python

Step 1: Create a Discord Application

  1. Go to Discord Developer Portal
  2. Click New Application → name it “UtilityBot”
  3. Go to BotAdd Bot → confirm
  4. Under the Token section, click Reset Token → copy it (save this securely!)
  5. Enable these Privileged Gateway Intents: SERVER MEMBERS INTENT, MESSAGE CONTENT INTENT
  6. Go to OAuth2URL Generator → select bot and applications.commands scopes
  7. Select permissions: Send Messages, Read Message History, Use Slash Commands, Manage Messages
  8. Copy the generated URL, open it in your browser, select your server
mkdir discord-bot
cd discord-bot
python -m venv venv
source venv/bin/activate
pip install discord.py[voice] python-dotenv requests

Create a .env file (never commit this):

DISCORD_TOKEN=your_bot_token_here
WEATHER_API_KEY=optional_openweathermap_key

Step 2: Bot Entry Point

# bot.py
import discord
from discord.ext import commands
import os
from dotenv import load_dotenv

load_dotenv()

# Bot configuration
TOKEN = os.getenv("DISCORD_TOKEN")
intents = discord.Intents.default()
intents.message_content = True
intents.members = True

bot = commands.Bot(
    command_prefix="!",
    intents=intents,
    description="UtilityBot — your all-in-one Discord helper",
    activity=discord.Game(name="!help for commands"),
)

@bot.event
async def on_ready():
    print(f"✅ Logged in as {bot.user.name} (ID: {bot.user.id})")
    print(f"📡 Connected to {len(bot.guilds)} guilds")
    print(f"👥 Total members: {sum(g.member_count for g in bot.guilds)}")

    # Sync slash commands with Discord
    try:
        synced = await bot.tree.sync()
        print(f"✨ Synced {len(synced)} slash command(s)")
    except Exception as e:
        print(f"⚠️ Failed to sync commands: {e}")

@bot.event
async def on_message(message):
    # Don't respond to self
    if message.author == bot.user:
        return

    # Log messages in debug
    print(f"[{message.guild}] #{message.channel} {message.author}: {message.content[:80]}")

    await bot.process_commands(message)

# Load all cogs
async def load_cogs():
    for cog_file in ["cogs.basic", "cogs.moderation", "cogs.utility", "cogs.tickets"]:
        try:
            await bot.load_extension(cog_file)
            print(f"✅ Loaded cog: {cog_file}")
        except Exception as e:
            print(f"❌ Failed to load {cog_file}: {e}")

if __name__ == "__main__":
    import asyncio
    asyncio.run(load_cogs())
    bot.run(TOKEN)

Step 3: Basic Commands Cog

# cogs/basic.py
import discord
from discord.ext import commands
from discord import app_commands
import datetime

class BasicCog(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @commands.command(name="ping")
    async def ping(self, ctx):
        """Check the bot's latency."""
        latency = round(self.bot.latency * 1000)
        await ctx.send(f"🏓 Pong! {latency}ms")

    @commands.command(name="say")
    async def say(self, ctx, *, message: str):
        """Make the bot repeat your message."""
        await ctx.send(f"{ctx.author.mention} says: {message}")
        await ctx.message.delete()

    @commands.command(name="info")
    async def info(self, ctx):
        """Show bot information in an embed."""
        embed = discord.Embed(
            title="UtilityBot Info",
            description="A multi-purpose Discord bot built with discord.py",
            color=discord.Color.blue(),
            timestamp=datetime.datetime.utcnow()
        )
        embed.add_field(name="Developer", value="DodaTech", inline=True)
        embed.add_field(name="Library", value="discord.py 2.x", inline=True)
        embed.add_field(name="Servers", value=len(self.bot.guilds), inline=True)
        embed.add_field(name="Commands", value=len(self.bot.commands), inline=True)
        embed.set_footer(text=f"Requested by {ctx.author}", icon_url=ctx.author.display_avatar.url)
        await ctx.send(embed=embed)

    # Slash command equivalent
    @app_commands.command(name="ping", description="Check the bot's latency")
    async def slash_ping(self, interaction: discord.Interaction):
        latency = round(self.bot.latency * 1000)
        await interaction.response.send_message(f"🏓 Pong! {latency}ms")

    @app_commands.command(name="info", description="Show bot information")
    async def slash_info(self, interaction: discord.Interaction):
        embed = discord.Embed(
            title="UtilityBot Info",
            color=discord.Color.blue(),
            timestamp=datetime.datetime.utcnow()
        )
        embed.add_field(name="Library", value="discord.py 2.x")
        embed.add_field(name="Servers", value=len(self.bot.guilds))
        await interaction.response.send_message(embed=embed)

    @commands.Cog.listener()
    async def on_member_join(self, member):
        """Welcome new members."""
        channel = member.guild.system_channel
        if channel:
            embed = discord.Embed(
                title="Welcome!",
                description=f"Hey {member.mention}, welcome to **{member.guild.name}**!",
                color=discord.Color.green()
            )
            embed.set_thumbnail(url=member.display_avatar.url)
            embed.add_field(name="Member Count", value=member.guild.member_count)
            await channel.send(embed=embed)

    @commands.Cog.listener()
    async def on_member_remove(self, member):
        channel = member.guild.system_channel
        if channel:
            await channel.send(f"👋 {member.name} left the server.")

async def setup(bot):
    await bot.add_cog(BasicCog(bot))

Step 4: Moderation Cog

# cogs/moderation.py
import discord
from discord.ext import commands
from discord import app_commands

class ModerationCog(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @commands.command(name="kick")
    @commands.has_permissions(kick_members=True)
    async def kick(self, ctx, member: discord.Member, *, reason="No reason provided"):
        """Kick a member from the server."""
        if member == ctx.author:
            await ctx.send("You can't kick yourself!")
            return
        if member.top_role >= ctx.author.top_role:
            await ctx.send("You can't kick someone with equal or higher role!")
            return

        try:
            await member.kick(reason=reason)
            embed = discord.Embed(
                title="Member Kicked",
                description=f"{member.mention} was kicked",
                color=discord.Color.orange()
            )
            embed.add_field(name="Reason", value=reason)
            embed.add_field(name="Moderator", value=ctx.author.mention)
            await ctx.send(embed=embed)
        except discord.Forbidden:
            await ctx.send("I don't have permission to kick that member!")

    @commands.command(name="clear")
    @commands.has_permissions(manage_messages=True)
    async def clear(self, ctx, count: int):
        """Delete a number of messages (max 100)."""
        if count < 1 or count > 100:
            await ctx.send("Please specify a number between 1 and 100.")
            return
        deleted = await ctx.channel.purge(limit=count + 1)  # +1 for the command itself
        msg = await ctx.send(f"🗑 Deleted {len(deleted) - 1} messages.")
        await msg.delete(delay=3)

    @app_commands.command(name="clear", description="Delete messages (1-100)")
    @app_commands.default_permissions(manage_messages=True)
    async def slash_clear(self, interaction: discord.Interaction, count: int):
        if count < 1 or count > 100:
            await interaction.response.send_message("Use 1-100.", ephemeral=True)
            return
        deleted = await interaction.channel.purge(limit=count)
        await interaction.response.send_message(f"🗑 Deleted {len(deleted)} messages.", ephemeral=True)

    @commands.command(name="slowmode")
    @commands.has_permissions(manage_channels=True)
    async def slowmode(self, ctx, seconds: int = 5):
        """Set slowmode in the current channel."""
        if seconds < 0 or seconds > 21600:
            await ctx.send("Slowmode must be between 0 and 21600 seconds (6 hours).")
            return
        await ctx.channel.edit(slowmode_delay=seconds)
        if seconds == 0:
            await ctx.send("✅ Slowmode disabled.")
        else:
            await ctx.send(f"✅ Slowmode set to {seconds} seconds.")

    @commands.Cog.listener()
    async def on_message(self, message):
        """Auto-mod: delete messages with banned words."""
        if message.author.bot:
            return

        banned_words = ["badword1", "badword2"]  # Load from config file
        if any(word in message.content.lower() for word in banned_words):
            await message.delete()
            await message.channel.send(
                f"{message.author.mention} that word is not allowed!",
                delete_after=5
            )

    @kick.error
    async def kick_error(self, ctx, error):
        if isinstance(error, commands.MissingPermissions):
            await ctx.send("❌ You need the `Kick Members` permission to use this.")
        elif isinstance(error, commands.BadArgument):
            await ctx.send("❌ Could not find that member.")

async def setup(bot):
    await bot.add_cog(ModerationCog(bot))

Step 5: Utility Cog

# cogs/utility.py
import discord
from discord.ext import commands
from discord import app_commands
import requests
import os

class UtilityCog(commands.Cog):
    def __init__(self, bot):
        self.bot = bot
        self.weather_key = os.getenv("WEATHER_API_KEY")

    @commands.command(name="weather")
    async def weather(self, ctx, *, city: str):
        """Get current weather for a city."""
        if not self.weather_key:
            await ctx.send("Weather API key not configured.")
            return

        url = "https://api.openweathermap.org/data/2.5/weather"
        params = {"q": city, "appid": self.weather_key, "units": "metric"}

        try:
            res = requests.get(url, params=params, timeout=5)
            res.raise_for_status()
            data = res.json()

            embed = discord.Embed(
                title=f"Weather in {data['name']}, {data['sys']['country']}",
                color=discord.Color.blue()
            )
            embed.add_field(name="Temperature", value=f"{data['main']['temp']}°C", inline=True)
            embed.add_field(name="Feels Like", value=f"{data['main']['feels_like']}°C", inline=True)
            embed.add_field(name="Humidity", value=f"{data['main']['humidity']}%", inline=True)
            embed.add_field(name="Wind", value=f"{data['wind']['speed']} m/s", inline=True)
            embed.add_field(name="Conditions", value=data['weather'][0]['description'].capitalize(), inline=True)
            embed.set_thumbnail(url=f"https://openweathermap.org/img/wn/{data['weather'][0]['icon']}@2x.png")
            await ctx.send(embed=embed)
        except requests.exceptions.HTTPError:
            await ctx.send(f"❌ City '{city}' not found.")
        except requests.exceptions.RequestException:
            await ctx.send("❌ Could not fetch weather data.")

    @app_commands.command(name="weather", description="Get weather for a city")
    async def slash_weather(self, interaction: discord.Interaction, city: str):
        """Same functionality as a slash command."""
        if not self.weather_key:
            await interaction.response.send_message("Weather API not configured.", ephemeral=True)
            return
        # (Same logic as above, with interaction.response)
        url = "https://api.openweathermap.org/data/2.5/weather"
        params = {"q": city, "appid": self.weather_key, "units": "metric"}
        try:
            res = requests.get(url, params=params, timeout=5)
            res.raise_for_status()
            data = res.json()
            embed = discord.Embed(title=f"Weather in {data['name']}", color=discord.Color.blue())
            embed.add_field(name="Temp", value=f"{data['main']['temp']}°C")
            await interaction.response.send_message(embed=embed)
        except Exception:
            await interaction.response.send_message("City not found.", ephemeral=True)

    @commands.command(name="poll")
    async def poll(self, ctx, question: str, *options):
        """Create a poll with reactions. Usage: !poll "Question?" opt1 opt2 opt3"""
        if len(options) < 2:
            await ctx.send("Need at least 2 options.")
            return
        if len(options) > 10:
            await ctx.send("Maximum 10 options.")
            return

        emojis = ["1️⃣","2️⃣","3️⃣","4️⃣","5️⃣","6️⃣","7️⃣","8️⃣","9️⃣","🔟"]
        description = "\n".join(f"{emojis[i]} {opt}" for i, opt in enumerate(options))
        embed = discord.Embed(title=f"📊 {question}", description=description, color=discord.Color.blue())
        msg = await ctx.send(embed=embed)
        for i in range(len(options)):
            await msg.add_reaction(emojis[i])

async def setup(bot):
    await bot.add_cog(UtilityCog(bot))

Step 6: Tickets Cog (Mini Feature)

# cogs/tickets.py
import discord
from discord.ext import commands
from discord import app_commands

class TicketCog(commands.Cog):
    def __init__(self, bot):
        self.bot = bot

    @commands.command(name="ticket")
    @commands.has_permissions(manage_channels=True)
    async def ticket(self, ctx, *, reason: str = "No reason"):
        """Create a private support ticket channel."""
        guild = ctx.guild
        overwrites = {
            guild.default_role: discord.PermissionOverwrite(read_messages=False),
            ctx.author: discord.PermissionOverwrite(read_messages=True, send_messages=True),
            guild.me: discord.PermissionOverwrite(read_messages=True, send_messages=True),
        }
        channel_name = f"ticket-{ctx.author.name}".lower()
        channel = await guild.create_text_channel(channel_name, overwrites=overwrites)
        embed = discord.Embed(
            title="Ticket Created",
            description=f"Support ticket by {ctx.author.mention}",
            color=discord.Color.green()
        )
        embed.add_field(name="Reason", value=reason)
        embed.add_field(name="Channel", value=channel.mention)
        await ctx.send(embed=embed)
        await channel.send(f"{ctx.author.mention} your ticket has been created. Staff will be with you shortly.")

    @commands.command(name="close")
    async def close(self, ctx):
        """Close a ticket channel."""
        if "ticket-" not in ctx.channel.name:
            await ctx.send("This is not a ticket channel.")
            return
        await ctx.send("🔒 Closing ticket in 5 seconds...")
        await discord.utils.get(ctx.guild.text_channels, name=ctx.channel.name).delete(delay=5)

async def setup(bot):
    await bot.add_cog(TicketCog(bot))

Step 7: Run the Bot

mkdir cogs
# Place cog files in cogs/ directory
python bot.py

Expected output:

✅ Loaded cog: cogs.basic
✅ Loaded cog: cogs.moderation
✅ Loaded cog: cogs.utility
✅ Loaded cog: cogs.tickets
✅ Logged in as UtilityBot#1234 (ID: 123456789)
📡 Connected to 1 guilds
👥 Total members: 42
✨ Synced 4 slash command(s)

In Discord, type !ping → bot responds “🏓 Pong! 42ms”. Type /ping → same result. When a new member joins, the bot sends a welcome embed in the system channel.

Architecture


flowchart TD
    subgraph Discord Platform
        A[Discord API Gateway]
        B[Discord REST API]
    end

    subgraph Bot Process
        C[bot.py Entry Point]
        D[Cog: Basic]
        E[Cog: Moderation]
        F[Cog: Utility]
        G[Cog: Tickets]
        H[Event Loop]
    end

    subgraph External
        I[OpenWeatherMap API]
    end

    A <-->|WebSocket| H
    B <-->|REST + slash commands| H
    H -->|dispatch events| D
    H -->|dispatch events| E
    H -->|dispatch events| F
    H -->|dispatch events| G
    F -->|HTTP requests| I

Common Errors

1. “WebSocket closed with 1000” immediately after connecting Your bot token is invalid. Reset the token in the Discord Developer Portal and update your .env file. Also ensure the token is not wrapped in quotes in the .env file.

2. “Missing Access” when using slash commands You need to sync commands with bot.tree.sync(). Our on_ready event does this, but it takes a few seconds after startup. Slash commands also require the applications.commands scope in the OAuth2 URL — if you didn’t include it, regenerate the invite URL.

3. “Privileged Gateway Intents” error Discord requires explicitly enabling the MESSAGE_CONTENT and SERVER_MEMBERS intents in the Developer Portal under the Bot section. If they’re off, on_message won’t fire for message content and on_member_join won’t work.

4. Cogs not loading — “ModuleNotFoundError” The cogs/ directory must contain __init__.py (can be empty) and the import path in load_cogs must match. We use cogs.basic (not cogs.basic_cog). Alternatively, use os.listdir("cogs") to auto-discover and load all .py files.

Practice Questions

1. What’s the difference between prefix commands and slash commands? Prefix commands (!ping) require the Message Content intent and respond in the channel. Slash commands (/ping) appear in Discord’s autocomplete UI, support required/optional parameters natively, and work without special intents. Modern bots should implement both.

2. Why use cogs instead of putting all code in one file? Cogs allow modular organization — each cog handles one feature category. They support their own event listeners, error handlers, and can be loaded/unloaded at runtime without restarting the bot (bot.reload_extension("cogs.basic")).

3. How does the permission system work? @commands.has_permissions(kick_members=True) checks the command author’s permissions. The cog’s error handler catches MissingPermissions and sends a friendly message. Discord’s role hierarchy is checked manually: member.top_role >= ctx.author.top_role prevents privilege escalation.

4. Challenge: Add a music playback cog Use discord.FFmpegPCMAudio to play audio from YouTube URLs. Implement !play, !pause, !skip, !queue, and !nowplaying commands. Use yt-dlp to extract audio streams. Handle bot joining/leaving voice channels.

5. Challenge: Implement a leveling system Track message counts per user in a SQLite database. Award XP for messages. Create a !rank command showing level progress, and a !leaderboard command showing top 10 users. Announce level-ups in the system channel.

FAQ

How do I host the bot 24/7?
Use a Raspberry Pi at home, a free-tier cloud VM (Oracle Cloud, Google Cloud free tier), or a service like Railway, Fly.io, or Replit. The bot process must stay running. Add a keep-alive web server (Flask on a separate thread) for uptime monitoring.
How do I add buttons and modals?
Discord.py 2.x supports discord.ui.Button, discord.ui.Select, and discord.ui.Modal. Create persistent views with bot.add_view(MyView()) and attach them to messages. Buttons can replace prefix-based commands for interactive features.
Can the bot work across multiple servers?
Yes, by default. The bot joins multiple servers and responds in any server it’s in. Use ctx.guild.id to differentiate server-specific settings. Store per-guild configuration (prefix, welcome message, banned words) in a database.

Next Steps

  • Add a SQLite database for per-server settings
  • Explore Docker for containerized deployment
  • Learn about Redis for caching and rate limiting
  • Build the REST API tutorial to add a web dashboard for your bot

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro