Tony discord
Build Discord bots using discord.py for moderation, notifications, alerts, and custom workflows. Covers slash commands, events, message components, webhooks, and Discord Developer Portal setup.
git clone https://github.com/jaydeland/Tony
T=$(mktemp -d) && git clone --depth=1 https://github.com/jaydeland/Tony "$T" && mkdir -p ~/.claude/skills && cp -r "$T/.claude/skills/discord" ~/.claude/skills/jaydeland-tony-discord && rm -rf "$T"
.claude/skills/discord/skill.md- pip install
- references .env files
Discord Bot Skill
Overview
Discord is a real-time communication platform featuring voice, video, and text channels. Bots extend Discord with automated capabilities — moderation, notifications, alerts, and custom workflows.
This skill covers the discord.py library, which is Python's most popular Discord bot framework.
- Repository: discord.py
- Documentation: discordpy.readthedocs.io
- Discord Developer Portal: discord.com/developers/applications
- Python: 3.8+
- Latest Version: 2.0+ (rewrite with slash commands as primary)
Installation
pip install discord.py
For voice support:
pip install "discord.py[voice]"
Discord Developer Portal Setup
Before writing any code, you must create a bot at the Discord Developer Portal:
- Go to discord.com/developers/applications
- Click New Application → name it
- Under Bot → click Reset Token to get your bot token
- Enable Message Content Intent (under Bot → Privileged Gateway Intents) for reading message content
- Enable Server Members Intent for member management
- Under OAuth2 → URL Generator:
- Select scopes:
,botapplications.commands - Select permissions:
,Send Messages
,Manage Messages
, etc.Embed Links
- Select scopes:
- Use the generated URL to invite the bot to your server
⚠️ Never expose your bot token. Store it in environment variables.
import os import discord BOT_TOKEN = os.environ["DISCORD_BOT_TOKEN"]
Core Concepts
Intents
Intents declare what events your bot wants to receive. Without them, events are filtered out.
intents = discord.Intents.default() intents.message_content = True # Required to read message text intents.members = True # Required for member events intents.guilds = True # Required for guild/server events client = discord.Client(intents=intents)
Guilds vs Channels
Discord's hierarchy:
Guild (Server) ├── Categories (folders) │ ├── Text Channels │ ├── Voice Channels │ └── Stage Channels └── Roles & Members
Client Setup
Basic Bot Client
import discord from discord import Intents intents = Intents.default() intents.message_content = True client = discord.Client(intents=intents) @client.event async def on_ready(): print(f"Bot logged in as {client.user}") client.run("YOUR_BOT_TOKEN")
Bot with Commands (ext.commands)
The commands extension provides prefix commands alongside slash commands.
import discord from discord.ext import commands intents = Intents.default() intents.message_content = True bot = commands.Bot(command_prefix="!", intents=intents) @bot.command() async def ping(ctx): """Respond with bot latency.""" await ctx.send(f"Pong! {round(bot.latency * 1000)}ms") @bot.command() async def hello(ctx): """Greet the user.""" await ctx.send(f"Hello {ctx.author.mention}! 👋") bot.run("YOUR_BOT_TOKEN")
Using a Config File (Recommended)
Create a
config.py or .env file — never commit tokens.
# config.py import os from dataclasses import dataclass @dataclass class DiscordConfig: token: str guild_id: int log_channel_id: int @classmethod def from_env(cls): return cls( token=os.environ["DISCORD_BOT_TOKEN"], guild_id=int(os.environ.get("DISCORD_GUILD_ID", 0)), log_channel_id=int(os.environ.get("DISCORD_LOG_CHANNEL_ID", 0)), ) config = DiscordConfig.from_env()
Slash Commands
Slash commands are Discord's native command interface. They appear when users type
/.
Global vs Guild Commands
- Global: Available in all servers the bot is in
- Guild: Available only in specific servers (instant registration, faster testing)
import discord from discord import app_commands intents = Intents.default() client = discord.Client(intents=intents) tree = app_commands.CommandTree(client) # Global slash command @tree.command(name="ping", description="Check bot latency") async def ping(interaction: discord.Interaction): await interaction.response.send_message(f"Pong! {round(client.latency * 1000)}ms") # Guild slash command (instant, for testing) GUILD_ID = discord.Object(id=123456789) @tree.command(name="test", description="Test command", guild=GUILD_ID) async def test(interaction: discord.Interaction): await interaction.response.send_message("Testing!") # Sync commands (called once to register them) @client.event async def on_ready(): await tree.sync() # Global commands # await tree.sync(guild=GUILD_ID) # Guild-specific print(f"Logged in as {client.user}")
Slash Command Options
@tree.command(name="report", description="Report an issue") @app_commands.describe( title="Brief title for the issue", description="Detailed description", severity="How critical is this?", ) @app_commands.choices(severity=[ app_commands.Choice(name="Low", value=1), app_commands.Choice(name="Medium", value=2), app_commands.Choice(name="High", value=3), app_commands.Choice(name="Critical", value=4), ]) async def report( interaction: discord.Interaction, title: str, description: str, severity: app_commands.Choice[int], ): embed = discord.Embed(title=title, description=description) embed.add_field(name="Severity", value=severity.name) await interaction.response.send_message(embed=embed)
Subcommands and Groups
@tree.group(name="moderation", description="Moderation commands") async def moderation(interaction: discord.Interaction): pass # Group itself does nothing @moderation.command(name="kick", description="Kick a user") @app_commands.describe(user="User to kick", reason="Reason") async def kick(interaction: discord.Interaction, user: discord.Member, reason: str = "No reason"): await user.kick(reason=reason) await interaction.response.send_message(f"Kicked {user.mention}")
@Mentions and Roles
Sending Mentionable Messages
# Mention a specific user await channel.send(f"Hello {user.mention}!") # Mention a role await channel.send(f"Attention {role.mention}!") # Mention everyone in a role for member in role.members: await channel.send(f"{member.mention}")
Role-Based Access
MOD_ROLE_ID = 123456789 @tree.command(name="cleanup", description="Delete messages in channel") @app_commands.check(lambda interaction: any( role.id == MOD_ROLE_ID for role in interaction.user.roles )) async def cleanup(interaction: discord.Interaction, count: int = 10): if count > 100: count = 100 deleted = await interaction.channel.purge(limit=count) await interaction.response.send_message(f"Deleted {len(deleted)} messages")
Dynamic Role Assignment
@tree.command(name="assign", description="Assign yourself a role") @app_commands.describe(role="Role to assign") async def assign(interaction: discord.Interaction, role: discord.Role): # Prevent assigning admin roles if role.permissions.administrator: await interaction.response.send_message("Cannot assign admin roles!", ephemeral=True) return await interaction.user.add_roles(role) await interaction.response.send_message(f"Assigned {role.mention} to you!", ephemeral=True) @tree.command(name="unassign", description="Remove a role from yourself") @app_commands.describe(role="Role to remove") async def unassign(interaction: discord.Interaction, role: discord.Role): await interaction.user.remove_roles(role) await interaction.response.send_message(f"Removed {role.mention} from you!", ephemeral=True)
Text Channels and Threads
Reading and Sending Messages
@tree.command(name="status", description="Check project status") async def status(interaction: discord.Interaction): channel = interaction.guild.get_channel(123456789) # status channel messages = [] async for message in channel.history(limit=10): messages.append(f"[{message.created_at}] {message.author}: {message.content}") await interaction.response.send_message( "\n".join(messages) or "No messages", ephemeral=True # Only visible to the user )
Thread Operations
# Create a thread @tree.command(name="thread", description="Create a discussion thread") @app_commands.describe(topic="Thread topic") async def create_thread(interaction: discord.Interaction, topic: str): thread = await interaction.channel.create_thread( name=topic, auto_archive_duration=60, # Minutes: 60, 1440, 4320, 10080 ) await thread.send(f"Thread created by {interaction.user.mention}") await interaction.response.send_message(f"Created thread: {thread.mention}") # Archive/Close a thread @tree.command(name="close", description="Archive this thread") async def close_thread(interaction: discord.Interaction): if isinstance(interaction.channel, discord.Thread): await interaction.channel.archive() await interaction.response.send_message("Thread archived!") else: await interaction.response.send_message("This isn't a thread!", ephemeral=True)
Embed Messages
Embeds are rich message blocks commonly used for status updates, logs, and formatted data.
@tree.command(name="info", description="Show project info") async def project_info(interaction: discord.Interaction): embed = discord.Embed( title="Project Dashboard", description="Current status of all systems", color=discord.Color.green(), url="https://github.com/your-repo", ) embed.set_author( name="Project Bot", icon_url="https://example.com/icon.png", ) embed.add_field(name="Uptime", value="99.9%", inline=True) embed.add_field(name="Active Users", value="1,234", inline=True) embed.add_field(name="Issues Open", value="23", inline=False) embed.set_footer(text="Last updated: just now") await interaction.response.send_message(embed=embed)
Buttons, Select Menus, and Modals
Button Interactions
from discord import ui class ConfirmView(ui.View): def __init__(self): super().__init__(timeout=60) # Times out after 60 seconds self.value = None @ui.button(label="Confirm", style=discord.ButtonStyle.green) async def confirm(self, interaction: discord.Interaction, button: ui.Button): self.value = True self.stop() # Stops the view @ui.button(label="Cancel", style=discord.ButtonStyle.red) async def cancel(self, interaction: discord.Interaction, button: ui.Button): self.value = False self.stop() @tree.command(name="deploy", description="Trigger a deployment") async def deploy(interaction: discord.Interaction): view = ConfirmView() await interaction.response.send_message( "Are you sure you want to deploy?", view=view, ) # Wait for user interaction await view.wait() if view.value: await interaction.edit_original_response( content="Deploying...", view=None, # Remove buttons ) else: await interaction.edit_original_response( content="Deployment cancelled.", view=None, )
Select Menu (Dropdown)
class RoleSelect(ui.View): @ui.select( placeholder="Choose a role...", options=[ discord.SelectOption(label="Developer", emoji="💻", description="Get developer role"), discord.SelectOption(label="Designer", emoji="🎨", description="Get designer role"), discord.SelectOption(label="Manager", emoji="📊", description="Get manager role"), ], ) async def select_callback(self, interaction: discord.Interaction, select: ui.Select): # Get the selected role role_name = select.values[0] role = discord.utils.get(interaction.guild.roles, name=role_name) if role: await interaction.user.add_roles(role) await interaction.response.send_message( f"Added {role.mention} to you!", ephemeral=True, ) else: await interaction.response.send_message( "Role not found!", ephemeral=True, ) @tree.command(name="getrole", description="Select a role") async def getrole(interaction: discord.Interaction): view = RoleSelect() await interaction.response.send_message( "Select a role to assign:", view=view, )
Modal Interactions
from discord import ui import discord class BugReportModal(ui.Modal, title="Bug Report"): title_input = ui.TextInput( label="Bug Title", placeholder="Brief description", max_length=100, ) description_input = ui.TextInput( label="Steps to Reproduce", placeholder="How to reproduce this bug...", style=discord.TextStyle.paragraph, max_length=1000, ) severity_input = ui.TextInput( label="Severity (1-5)", placeholder="1 = minor, 5 = critical", max_length=1, ) async def on_submit(self, interaction: discord.Interaction): # Log to your system print(f"Bug: {self.title_input.value}") print(f"Steps: {self.description_input.value}") print(f"Severity: {self.severity_input.value}") await interaction.response.send_message( "Bug report submitted! Thank you.", ephemeral=True, ) @tree.command(name="bug", description="Report a bug") async def bug_report(interaction: discord.Interaction): modal = BugReportModal() await interaction.response.send_modal(modal)
Webhooks
Webhooks allow external services (CI/CD, monitoring) to send messages without a bot user.
Creating a Webhook
# Via Discord UI: Channel Settings → Integrations → Webhooks # Or programmatically: webhook = await channel.create_webhook(name="CI Bot") print(f"Webhook URL: {webhook.url}") # Store this URL securely!
Sending Webhook Messages
import aiohttp WEBHOOK_URL = "https://discord.com/api/webhooks/..." async def send_webhook(content: str, embed: dict = None): payload = {"content": content} if embed: payload["embeds"] = [embed] async with aiohttp.ClientSession() as session: await session.post(WEBHOOK_URL, json=payload) # Example: CI deployment notification await send_webhook( content=f"✅ Deployment complete by <@!{user_id}>", embed={ "title": "Production Deploy", "description": "v1.2.3 deployed successfully", "color": 5763719, # Green }, )
GitHub-Style Webhook Embed
def github_embed( title: str, description: str, color: int, fields: list[tuple[str, str, bool]] = None, ) -> dict: """Build a GitHub-style embed for webhook notifications.""" embed = { "title": title, "description": description, "color": color, "footer": {"text": "GitHub Integration"}, "timestamp": datetime.utcnow().isoformat(), } if fields: embed["fields"] = [ {"name": name, "value": value, "inline": inline} for name, value, inline in fields ] return embed
Message Components Reference
Discord supports rich message components beyond text:
| Component | Use Case |
|---|---|
| Single-click actions |
| Dropdown menus |
| Modal form fields |
| Select channels |
| Select roles |
| Select users |
Complete Bot Example
import discord from discord import app_commands from discord.ext import commands from datetime import datetime intents = discord.Intents.default() intents.message_content = True intents.members = True bot = commands.Bot(command_prefix="/", intents=intents) tree = app_commands.CommandTree(bot) # ─── Slash Commands ───────────────────────────────────────────────────────── @tree.command(name="hello", description="Greet the user") async def hello(interaction: discord.Interaction): await interaction.response.send_message( f"Hello {interaction.user.mention}! 👋", ephemeral=True, # Only visible to user ) @tree.command(name="server-info", description="Show server information") async def server_info(interaction: discord.Interaction): guild = interaction.guild embed = discord.Embed( title=guild.name, color=discord.Color.blue(), ) embed.add_field(name="Members", value=guild.member_count) embed.add_field(name="Channels", value=len(guild.channels)) embed.add_field(name="Roles", value=len(guild.roles)) embed.set_thumbnail(url=guild.icon.url if guild.icon else None) await interaction.response.send_message(embed=embed) # ─── Message Commands (prefix-based) ───────────────────────────────────────── @bot.command() async def ping(ctx: commands.Context): """Legacy prefix command - still works alongside slash commands.""" await ctx.send(f"Latency: {round(bot.latency * 1000)}ms") # ─── Events ────────────────────────────────────────────────────────────────── @bot.event async def on_member_join(member: discord.Member): """Welcome new members.""" channel = member.guild.system_channel if channel: embed = discord.Embed( title=f"Welcome {member.name}!", description=f"{member.mention} joined the server.", color=discord.Color.green(), ) embed.set_thumbnail(url=member.display_avatar.url) await channel.send(embed=embed) @bot.event async def on_message(message: discord.Message): """Handle message events (for prefix commands).""" if message.author.bot: return # Ignore bot messages # Process prefix commands await bot.process_commands(message) # ─── Startup ───────────────────────────────────────────────────────────────── @bot.event async def on_ready(): print(f"Logged in as {bot.user}") print(f"Bot ID: {bot.user.id}") await tree.sync() # Register all slash commands print("Slash commands synced!") bot.run("YOUR_BOT_TOKEN")
REST API vs Gateway
| Approach | Use Case | Library |
|---|---|---|
| Gateway (WebSocket) | Real-time bot interactions | discord.py (Client/Bot) |
| HTTP API (REST) | One-time actions, webhooks | discord.HTTPClient or requests |
| Webhook (outbound) | External services posting to Discord | Just HTTP POST |
Most bot interactions use the Gateway via discord.py. The REST API is for operations like:
# Direct API call example await bot.http.get_channel(channel_id) await bot.http.send_message(channel_id, content="Hello via API!")
Rate Limiting
Discord has rate limits on API calls:
- Global: ~50-100 requests per second across all endpoints
- Per-route: Varies (e.g., sending messages: 5 per 5 seconds per channel)
- Webhook: 30 per minute per webhook
discord.py handles rate limiting automatically. For manual HTTP calls:
import asyncio import aiohttp async def send_with_retry(url, payload, max_retries=3): for attempt in range(max_retries): try: async with aiohttp.ClientSession() as session: async with session.post(url, json=payload) as resp: if resp.status == 429: # Rate limited retry_after = await resp.json() await asyncio.sleep(retry_after.get("retry_after", 1)) continue return await resp.json() except Exception as e: print(f"Attempt {attempt + 1} failed: {e}") raise Exception("Max retries exceeded")
Channel Types Reference
discord.ChannelType.text # Text channel discord.ChannelType.voice # Voice channel discord.ChannelType.stage_voice # Stage channel discord.ChannelType.news # Announcement channel discord.ChannelType.forum # Forum channel (organized threads) discord.ChannelType.news_thread # Thread in news channel discord.ChannelType.public_thread # Public thread discord.ChannelType.private_thread # Private thread
Role Hierarchy
Discord roles have a hierarchy. Higher roles can manage lower roles:
# Check if user can perform action based on role position def has_higher_role(user: discord.Member, target: discord.Member) -> bool: return user.top_role > target.top_role @tree.command(name="ban", description="Ban a user") @app_commands.describe(user="User to ban", reason="Reason") async def ban(interaction: discord.Interaction, user: discord.Member, reason: str = "No reason"): if not has_higher_role(interaction.user, user): await interaction.response.send_message( "You cannot ban someone with a higher or equal role!", ephemeral=True, ) return await interaction.guild.ban(user, reason=reason) await interaction.response.send_message(f"Banned {user.mention}")
Thread Auto-Archive
Threads automatically archive after inactivity:
| Duration | When to Use |
|---|---|
| 60 minutes | Active discussions |
| 1440 minutes (1 day) | Standard threads |
| 4320 minutes (3 days) | Longer discussions |
| 10080 minutes (1 week) | Slow-moving threads |
# When creating a thread thread = await channel.create_thread( name="Discussion", auto_archive_duration=1440, # 1 day )
Resources
- discord.py Documentation
- Discord Developer Portal
- Discord API Documentation
- Discord.js (JavaScript alternative)
- PyNaCl (voice encryption)
Environment Variables Template
Create a
.env file for your Discord configuration:
# .env (add to .gitignore!) DISCORD_BOT_TOKEN=your-bot-token-here DISCORD_GUILD_ID=123456789012345678 DISCORD_LOG_CHANNEL_ID=123456789012345678 DISCORD_ADMIN_ROLE_ID=123456789012345678
Common Issues
| Issue | Solution |
|---|---|
| Bot doesn't respond to slash commands | Ensure intent is enabled in Developer Portal |
| Commands not syncing | Call after bot is ready |
| "Application not found" error | Bot token may be wrong or revoked |
| Buttons/selects not working | View must stay alive (don't set immediately) |
| Rate limited | Check you're not making too many API calls; use |