Discord Bot Builder
Scaffold, ship, and maintain Discord bots with the right library, the right intents, the right hosting, and the right observability — without the usual footguns (token leaks, missing intents, rate-limit storms, raid vulnerabilities). Covers discord.py, discord.js, and Serenity (Rust). Targets community ops teams, server admins automating moderation, and hobbyist creators building feature bots.
Usage
Invoke this skill when you need a Discord bot built, extended, or rescued.
Basic invocation:
Build me a Discord ticket bot with a /ticket command that opens a private thread Add reaction roles to my discord.js bot — react with an emoji, get a role Migrate my discord.py 1.7 bot to 2.x with slash commands My bot is getting rate-limited, help me add bucketing and retry
With context:
Here's my current bot.py, add a moderation log channel for bans/kicks/timeouts I need a Rust bot with Serenity that handles 500 guilds and tracks message counts My bot worked locally but Railway deploy crashes — read the logs and fix it Pick a host: I want sub-$10/mo, 99% uptime, easy log access
The agent decides on architecture, scaffolds the project, writes commands and handlers, wires persistence, and gives you a deploy plan.
How It Works
Step 1: Bot Type Decision
Before any code, the agent decides what kind of bot this is. The decision drives intents, hosting, and library choice.
| Bot Type | Description | Intents Needed | Library Sweet Spot |
|---|---|---|---|
| Slash-command only | All interactions go through /commands. No message reading. | Default (no privileged) | Any library; cheapest hosting |
| Message-listener | Reacts to plain messages (auto-mod, leveling, keyword triggers) | MESSAGE_CONTENT (privileged) | discord.py / discord.js |
| Hybrid | Slash commands + selective message handling | MESSAGE_CONTENT only if needed | discord.py / discord.js |
| Voice bot | Music, TTS, recording | GUILD_VOICE_STATES + MESSAGE_CONTENT | discord.js + lavalink, or Serenity |
| Mod / audit | Bans, mutes, audit logs | GUILDS, GUILD_MEMBERS (privileged), GUILD_MODERATION | discord.py |
| Welcome / leave | Onboarding, role-on-join | GUILD_MEMBERS (privileged) | Any |
| Presence-aware | Status-based features (rare, expensive) | GUILD_PRESENCES (privileged, hard to verify) | Avoid unless essential |
Decision flow:
Does the bot need to read message content?
NO -> Slash-command-only. Skip MESSAGE_CONTENT intent. Easy verification at 75 guilds.
YES -> Will it scale past 100 guilds?
NO -> MESSAGE_CONTENT works without verification (under 100 guilds).
YES -> Apply for verification + MESSAGE_CONTENT intent approval (Discord may reject).
Consider a slash-command rewrite to avoid the privileged intent entirely.
Step 2: Project Scaffolding
The agent generates a project skeleton matched to the chosen library.
discord.py (Python 3.11+, discord.py 2.4+):
# bot.py
import os
import logging
import discord
from discord.ext import commands
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s")
intents = discord.Intents.default()
intents.message_content = False # Flip to True only if you need it
class MyBot(commands.Bot):
def __init__(self):
super().__init__(command_prefix="!", intents=intents)
async def setup_hook(self):
await self.load_extension("cogs.tickets")
await self.load_extension("cogs.moderation")
await self.tree.sync() # Sync slash commands globally (~1hr propagation)
async def on_ready(self):
logging.info(f"Logged in as {self.user} ({self.user.id})")
bot = MyBot()
bot.run(os.environ["DISCORD_TOKEN"])
project/
bot.py
cogs/
tickets.py
moderation.py
db/
schema.sql
.env # DISCORD_TOKEN=... (gitignored)
requirements.txt # discord.py>=2.4, python-dotenv, aiosqlite
Dockerfile
README.md
discord.js (Node 20+, discord.js v14+):
// index.js
import 'dotenv/config';
import { Client, GatewayIntentBits, Collection, Events } from 'discord.js';
import { readdirSync } from 'node:fs';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { dirname, join } from 'node:path';
const client = new Client({
intents: [GatewayIntentBits.Guilds, GatewayIntentBits.GuildMembers],
});
client.commands = new Collection();
const __dirname = dirname(fileURLToPath(import.meta.url));
for (const file of readdirSync(join(__dirname, 'commands'))) {
const mod = await import(pathToFileURL(join(__dirname, 'commands', file)).href);
client.commands.set(mod.data.name, mod);
}
client.on(Events.InteractionCreate, async (i) => {
if (!i.isChatInputCommand()) return;
const cmd = client.commands.get(i.commandName);
if (!cmd) return;
try { await cmd.execute(i); }
catch (e) { console.error(e); await i.reply({ content: 'Error.', ephemeral: true }); }
});
client.login(process.env.DISCORD_TOKEN);
project/
index.js
commands/
ticket.js
ban.js
deploy-commands.js # Registers slash commands via REST
package.json # type: module, discord.js@14
.env
Serenity (Rust, async, Tokio):
// src/main.rs
use serenity::all::{Client, Context, EventHandler, GatewayIntents, Interaction, Ready};
use serenity::async_trait;
use std::env;
struct Handler;
#[async_trait]
impl EventHandler for Handler {
async fn ready(&self, _ctx: Context, ready: Ready) {
println!("Logged in as {}", ready.user.name);
}
async fn interaction_create(&self, ctx: Context, interaction: Interaction) {
if let Interaction::Command(cmd) = interaction {
match cmd.data.name.as_str() {
"ping" => commands::ping::run(&ctx, &cmd).await,
_ => {}
}
}
}
}
#[tokio::main]
async fn main() {
let token = env::var("DISCORD_TOKEN").expect("DISCORD_TOKEN");
let intents = GatewayIntents::GUILDS;
let mut client = Client::builder(&token, intents).event_handler(Handler).await.unwrap();
if let Err(e) = client.start().await { eprintln!("{e}"); }
}
project/
Cargo.toml # serenity, tokio, sqlx, tracing
src/
main.rs
commands/
ping.rs
ticket.rs
Step 3: Command Registration
discord.py — slash command with subcommand group:
from discord import app_commands
import discord
class Tickets(commands.GroupCog, name="ticket"):
@app_commands.command(name="open", description="Open a support ticket")
@app_commands.describe(reason="Why you need help")
async def open(self, interaction: discord.Interaction, reason: str):
await interaction.response.send_message(f"Ticket opened: {reason}", ephemeral=True)
@app_commands.command(name="close", description="Close your ticket")
@app_commands.default_permissions(manage_threads=True) # Permission gate
async def close(self, interaction: discord.Interaction):
await interaction.response.send_message("Closed.", ephemeral=True)
async def setup(bot): await bot.add_cog(Tickets(bot))
Permission patterns:
@app_commands.default_permissions(...)— server-side default, admins can override per-role in Server Settings -> Integrations@app_commands.guild_only()— block DMs- Manual check via
interaction.user.guild_permissions.ban_membersfor runtime decisions
Sync strategy:
bot.tree.sync()global — propagates over ~1 hour, use for productionbot.tree.sync(guild=discord.Object(id=GUILD_ID))— instant, use for development
Step 4: Interaction Patterns
| Component | Use Case | Lifetime |
|---|---|---|
| Buttons | Confirm/cancel, paginate, role assignment | 15 min interaction token, but custom_id persists indefinitely |
| Select menus | Multi-choice (string, user, role, channel, mentionable) | Same |
| Modals | Forms (max 5 input fields, can chain on submit) | Triggered from command or button only |
| Autocomplete | Live search inside slash command args | 3-second response window |
Modal example (discord.py):
class TicketModal(discord.ui.Modal, title="Open a ticket"):
subject = discord.ui.TextInput(label="Subject", max_length=80)
details = discord.ui.TextInput(label="Details", style=discord.TextStyle.paragraph, max_length=1500)
async def on_submit(self, interaction: discord.Interaction):
thread = await interaction.channel.create_thread(
name=f"ticket-{interaction.user.name}",
type=discord.ChannelType.private_thread,
)
await thread.send(f"{interaction.user.mention} **{self.subject}**\n{self.details}")
await interaction.response.send_message(f"Opened {thread.mention}", ephemeral=True)
Persistent custom_id pattern (survives bot restarts):
class RoleButton(discord.ui.View):
def __init__(self): super().__init__(timeout=None) # timeout=None = persistent
@discord.ui.button(label="Get role", custom_id="rolebtn:gamer")
async def get_role(self, interaction, button): ...
# In setup_hook:
bot.add_view(RoleButton()) # Re-register on startup so the custom_id keeps working
Step 5: Persistent State
Pick by access pattern, not by hype.
| Store | Use For | When to Pick |
|---|---|---|
| SQLite + aiosqlite | Single-process bot, < 50 guilds, < 1M rows | Default for hobbyist bots; zero ops |
| Postgres | Multi-process, sharded, multi-server, 50+ guilds | Production bots with growth |
| Redis | Hot ratelimits, command cooldowns, presence cache, leaderboards | Always paired with primary store, never alone |
| JSON file | Static config (welcome message, role IDs) | Read-mostly; reload on SIGHUP |
| In-memory dict | Per-runtime state (active tickets, music queue) | Volatile by design; never put user data here |
Sharding hint: if you cross 2,500 guilds Discord requires sharding. Each shard is a separate gateway connection. Postgres + Redis is the only sane backing store from that point.
# aiosqlite skeleton (discord.py cog)
import aiosqlite
class Tickets(commands.Cog):
async def cog_load(self):
self.db = await aiosqlite.connect("tickets.db")
await self.db.execute("""CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY, user_id INTEGER, thread_id INTEGER,
subject TEXT, opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
await self.db.commit()
Step 6: Gateway Intents
Intents tell Discord which events to send you. Wrong intents = silent failures.
| Intent | Privileged? | Cost |
|---|---|---|
GUILDS | No | Required for almost everything |
GUILD_MESSAGES | No | You receive message events but NO content |
MESSAGE_CONTENT | YES | Required to read message.content. Verification needed at 100 guilds. |
GUILD_MEMBERS | YES | Member join/leave/update events. Verification at 100 guilds. |
GUILD_PRESENCES | YES | Online/offline status. Hardest to get approved. Expensive event volume. |
GUILD_VOICE_STATES | No | Voice channel join/leave events |
DIRECT_MESSAGES | No | DM events |
GUILD_MODERATION | No | Ban/unban/audit-log events |
Privacy implications:
MESSAGE_CONTENTreads every message in every channel the bot can see — Discord scrutinizes this. Justify it in your verification application or don't ask.GUILD_PRESENCESat scale = millions of presence updates per minute. Most bots that ask for it don't actually need it (usemember.activityonly when needed).- Default-off everything privileged; flip on what's load-bearing.
Step 7: Rate Limit Handling
Discord rate-limits per-route, per-major-parameter (channel/guild/webhook), and globally (50 req/sec per bot).
Library defaults (good starting point):
- discord.py auto-handles 429s with
X-RateLimit-Reset-After - discord.js v14 has a built-in REST queue with retries
- Serenity's
Httpdoes the same
When to add custom logic:
# Bulk operations: add jitter and bucketing
import asyncio, random
async def mass_dm(users, message):
for user in users:
try:
await user.send(message)
except discord.Forbidden:
pass
except discord.HTTPException as e:
if e.status == 429:
await asyncio.sleep(e.response.headers.get("Retry-After", 5))
await asyncio.sleep(1.0 + random.random() * 0.5) # 1-1.5s jitter
Rules:
- Never
asyncio.gather100 API calls — bucket them - Add jitter on bulk loops to avoid synchronized retry storms
- Cache reads where possible (use
bot.get_*beforebot.fetch_*) - Webhook routes have their own bucket — useful for high-volume logging
Step 8: Hosting Choice Tree
| Host | Cost | Uptime | Logs | Best For |
|---|---|---|---|---|
| VPS (Hetzner/DO) | $4-6/mo | 99.9% | Full systemd journals | Long-term, multi-bot, custom infra |
| Railway | $5/mo + usage | 99.9% | Web UI, last 7 days | Quick deploys, hobby projects |
| Fly.io | Free tier viable, $2-5/mo realistic | 99.9% | fly logs, Grafana | Global edge, multi-region |
| Replit (Reserved VM) | $7/mo | 99% | Web UI | Beginners, prototyping |
| Pterodactyl panel | Whatever VPS hosts it | Depends | Built-in | Game-server-style bot management |
| Heroku | $7/mo+ | 99.9% | heroku logs | Avoid — pricier than alternatives, no free tier |
| Lambda / Cloud Run | Cheap | High | CloudWatch | Slash-command-only via interactions endpoint URL |
Decision tree:
Bot uses gateway (most do)?
YES -> Long-running process. Pick VPS, Railway, Fly, or Replit.
< 100 guilds, want easy: Railway or Replit
Want cheapest stable: Hetzner CX11 ($4/mo)
Multi-region / many shards: Fly.io
NO (slash-only via HTTP endpoint) -> Cloud Run / Lambda / Cloudflare Workers.
E200HA-class hosting note: a 4GB-RAM small bot can run on a $4 Hetzner box; for 500+ guilds plan 1GB RAM per 1000 guilds plus Postgres.
Step 9: Logging + Observability
Minimum viable:
# Structured JSON logs to stdout, then ship to wherever (Loki, Datadog, plain file)
import logging, json, sys
class JsonFormatter(logging.Formatter):
def format(self, r):
return json.dumps({
"ts": self.formatTime(r), "level": r.levelname,
"msg": r.getMessage(), "logger": r.name,
**(getattr(r, "extra", {}) or {})
})
handler = logging.StreamHandler(sys.stdout)
handler.setFormatter(JsonFormatter())
logging.getLogger().addHandler(handler)
Sentry for unhandled errors:
import sentry_sdk
sentry_sdk.init(dsn=os.environ["SENTRY_DSN"], traces_sample_rate=0.1)
# discord.py raises in command handlers — Sentry SDK auto-captures via global exception hook
Error notifications to a Discord channel (poor man's alerting, free):
@bot.event
async def on_command_error(ctx, error):
log_channel = bot.get_channel(LOG_CHANNEL_ID)
await log_channel.send(f"```{type(error).__name__}: {error}```")
raise error # Re-raise for Sentry
What to log:
- Every command invocation:
user_id,guild_id,command,latency_ms - Every gateway disconnect/resume
- Every 4xx/5xx from REST API
- Token rotation events
- Rate-limit hits (count per route)
Step 10: Testing Strategy
| Layer | Tool | What It Covers |
|---|---|---|
| Unit | pytest / vitest | Pure logic (parsing, business rules), no Discord client |
| Mock client | dpytest (discord.py) / mock discord.js REST | Command handlers without hitting Discord |
| Sandbox guild | A throwaway server with the bot | Real interactions, real permissions |
| Fixture replay | Save raw gateway events as JSON, replay through handler | Reproduce production bugs |
| Smoke test in CI | Run bot for 30s against a private guild, run a /health command, exit 0 | Catch import/auth/intent breakage before deploy |
Sandbox guild setup:
- Create
bot-devserver, bot has Admin - Use guild-scoped command sync for instant updates:
await tree.sync(guild=GUILD) - Keep production token and dev token in separate
.env.dev/.env.prod
Step 11: Common Features
| Feature | Approach | Library Hints |
|---|---|---|
| Reaction roles | Persistent message + on_raw_reaction_add listener, or button-based (preferred — no MESSAGE_CONTENT intent) | discord.py View with persistent custom_id |
| Ticket system | Slash command -> modal -> private thread + DB row (see worked example below) | discord.py Thread API |
| Mod log | Listen on on_member_ban, on_member_remove, audit log lookup, post to channel | Requires GUILD_MODERATION |
| Music | Lavalink (Java daemon) + wavelink (py) / lavacord (js) — never decode in-process | discord.js + lavalink is the sweet spot |
| Leveling | XP per message (with cooldown), Postgres aggregate, optional rank card via Pillow/canvas | Needs MESSAGE_CONTENT for content-aware XP, otherwise just count messages |
| Captcha / verify | Button -> modal with image captcha (or hcaptcha-on-website-with-OAuth flow) | Avoid math-question captchas, bots solve them trivially |
| Welcome | on_member_join + embed in welcome channel | Needs GUILD_MEMBERS privileged |
| Auto-mod | Discord native AutoMod API for keyword/spam/mention rules | Native AutoMod beats custom in 90% of cases |
Step 12: Security Pitfalls
| Risk | Prevention |
|---|---|
| Token leak in git | .env in .gitignore, scan with git-secrets, rotate immediately if pushed |
| Token in logs | Never log full request.headers. discord.py masks tokens; custom HTTP code may not. |
| Token in error reports | Sentry: before_send hook to scrub Authorization headers |
| Command spoofing | All slash commands are signed by Discord — trust interaction.user, never interaction.data user fields |
| Privilege escalation | Check interaction.user.guild_permissions server-side; client-side default_permissions is a UX hint, not a gate |
| Raid prevention | Rate-limit on_member_join, auto-kick accounts < 1 day old during a raid, use Discord's native raid mode |
| DM-based attacks | Never trust DM content from unverified users; sanitize before posting to a server channel |
| SQL injection in commands | Use parameterized queries always, even for "internal" admin commands |
| Prompt injection (AI bots) | Strip Discord markdown from user input before sending to LLM; never put user input in system prompts |
| Webhook URL leaks | Webhook URLs are bearer credentials — rotate if leaked, use them server-side only |
Worked Example: discord.py Ticket Bot
A minimal but real ticket bot. /ticket opens a modal, modal submission creates a private thread tagged to the user, support staff are auto-added.
# bot.py
import os, logging, aiosqlite
import discord
from discord import app_commands
from discord.ext import commands
from dotenv import load_dotenv
load_dotenv()
logging.basicConfig(level=logging.INFO)
SUPPORT_ROLE_ID = int(os.environ["SUPPORT_ROLE_ID"])
intents = discord.Intents.default()
bot = commands.Bot(command_prefix="!", intents=intents)
class TicketModal(discord.ui.Modal, title="Open a support ticket"):
subject = discord.ui.TextInput(label="Subject", max_length=80, required=True)
details = discord.ui.TextInput(
label="Details", style=discord.TextStyle.paragraph, max_length=1500, required=True,
)
async def on_submit(self, interaction: discord.Interaction):
guild = interaction.guild
thread = await interaction.channel.create_thread(
name=f"ticket-{interaction.user.name}-{self.subject.value[:30]}",
type=discord.ChannelType.private_thread,
invitable=False,
)
await thread.add_user(interaction.user)
support_role = guild.get_role(SUPPORT_ROLE_ID)
if support_role:
for m in support_role.members:
try: await thread.add_user(m)
except discord.HTTPException: pass
embed = discord.Embed(title=self.subject.value, description=self.details.value, color=0x5865F2)
embed.set_author(name=str(interaction.user), icon_url=interaction.user.display_avatar.url)
await thread.send(embed=embed, view=CloseButton())
async with aiosqlite.connect("tickets.db") as db:
await db.execute(
"INSERT INTO tickets (user_id, thread_id, subject) VALUES (?, ?, ?)",
(interaction.user.id, thread.id, self.subject.value),
)
await db.commit()
await interaction.response.send_message(
f"Ticket opened: {thread.mention}", ephemeral=True,
)
class CloseButton(discord.ui.View):
def __init__(self): super().__init__(timeout=None)
@discord.ui.button(label="Close ticket", style=discord.ButtonStyle.danger, custom_id="ticket:close")
async def close(self, interaction: discord.Interaction, button: discord.ui.Button):
if not isinstance(interaction.channel, discord.Thread):
return await interaction.response.send_message("Not a ticket.", ephemeral=True)
await interaction.response.send_message("Closing in 5s...", ephemeral=False)
await interaction.channel.edit(archived=True, locked=True)
@bot.tree.command(name="ticket", description="Open a support ticket")
async def ticket(interaction: discord.Interaction):
await interaction.response.send_modal(TicketModal())
@bot.event
async def setup_hook():
async with aiosqlite.connect("tickets.db") as db:
await db.execute("""CREATE TABLE IF NOT EXISTS tickets (
id INTEGER PRIMARY KEY, user_id INTEGER, thread_id INTEGER,
subject TEXT, opened_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
)""")
await db.commit()
bot.add_view(CloseButton()) # Persist button across restarts
await bot.tree.sync()
bot.run(os.environ["DISCORD_TOKEN"])
What this demonstrates:
- Slash command -> modal -> thread creation in one flow
- Persistent view (
bot.add_view) so the Close button survives restarts - Private thread with explicit invite list (no
MESSAGE_CONTENTneeded) - SQLite persistence via
aiosqlite - Ephemeral response (only the user sees the confirmation)
- Embed with author attribution
- Defensive HTTP error handling on bulk add
To run:
pip install discord.py aiosqlite python-dotenv
echo 'DISCORD_TOKEN=...' > .env
echo 'SUPPORT_ROLE_ID=123456789' >> .env
python bot.py
Output
The agent produces:
- Bot type decision with intent recommendations and verification implications
- Project skeleton in your chosen library (discord.py / discord.js / Serenity)
- Command and handler code matched to your features
- Persistence layer with schema and access pattern
- Hosting recommendation with concrete cost and deploy steps
- Observability setup: structured logs, Sentry init, error-to-channel hook
- Test scaffold: unit tests, mock client setup, sandbox-guild deploy script
- Security checklist: token handling, permission gates, raid mitigations
- Migration path if rewriting from an old library version
Common Scenarios
"Build me a bot that does X"
The agent picks the library based on your requirements (Python for ML/data, JS for ecosystem breadth, Rust for scale), scaffolds the project, writes the feature, and gives you a deploy plan.
"My bot is rate-limited, getting 429s in production"
The agent inspects your loop patterns, adds bucketing + jitter, recommends Redis-backed cooldowns if needed, and configures library-level retry settings.
"I need to migrate from discord.py 1.7 to 2.x" (or v13 -> v14 discord.js)
The agent maps deprecated APIs to new ones (intents, slash commands, Client.run -> async commands.Bot, Embed builder changes), updates handler signatures, and provides a working migration patch.
"My bot keeps crashing on Railway / Fly / Replit"
The agent reads the deployment logs, identifies the cause (missing intent, missing env var, wrong Python version, missing FFmpeg for voice, stale token), and applies the fix.
"Build me a slash-command-only bot for Cloud Run"
The agent uses Discord's HTTP interactions endpoint URL pattern (PING/PONG verification + Ed25519 signature check), avoiding the gateway entirely. Stateless, scales to zero.
Tips for Best Results
- Tell the agent your target guild count — it changes library, hosting, and persistence choice
- Mention if you need privileged intents — verification adds 1-2 weeks of lead time
- Share existing code if migrating; the agent diffs old patterns against new APIs
- For voice bots, specify whether you want music streaming (lavalink) or recording/TTS (different stacks)
- Provide your deploy target up front — Railway and Fly have different
Procfile/fly.tomlpatterns - If you have a moderation use case, ask whether Discord's native AutoMod covers it before building custom
When NOT to use
Don't build a bot when an existing one already does the job well. Pay-as-you-go bots beat months of unpaid maintenance.
| If you need... | Use this instead of building |
|---|---|
| Generic moderation (warns, mutes, automod) | MEE6, Dyno, Carl-bot |
| Reaction roles | Carl-bot, YAGPDB |
| Music (Spotify/YouTube) | Hydra, Jockie, or any free music bot — building one means lavalink ops |
| Leveling / XP | MEE6, Arcane, Tatsu |
| Tickets | Ticket Tool, TicketsBot |
| Anti-raid / verification | Wick, Beemo, Discord native verification |
| Polls | Discord native /poll (built-in) |
| Logging / audit | Logger, GearBot |
Build your own only when:
- The feature is novel and no existing bot does it
- You need tight integration with your own backend (your game, your SaaS, your API)
- You're deeply customizing UX and the off-the-shelf bot fights you
- You're learning bot development and the goal is the journey, not the result
- The off-the-shelf bot has a paywall on the exact feature you need and your time is worth less than $5/mo
If MEE6 or Dyno already does 90% of what you want, configure them and ship. A working bot you didn't write beats a half-finished bot you did.