Build a Discord Bot with Python (Step by Step)
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
- Go to Discord Developer Portal
- Click New Application → name it “UtilityBot”
- Go to Bot → Add Bot → confirm
- Under the Token section, click Reset Token → copy it (save this securely!)
- Enable these Privileged Gateway Intents:
SERVER MEMBERS INTENT,MESSAGE CONTENT INTENT - Go to OAuth2 → URL Generator → select
botandapplications.commandsscopes - Select permissions:
Send Messages,Read Message History,Use Slash Commands,Manage Messages - 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 requestsCreate a .env file (never commit this):
DISCORD_TOKEN=your_bot_token_here
WEATHER_API_KEY=optional_openweathermap_keyStep 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.pyExpected 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
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