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.

install
source · Clone the upstream repo
git clone https://github.com/jaydeland/Tony
Claude Code · Install into ~/.claude/skills/
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"
manifest: .claude/skills/discord/skill.md
safety · automated scan (medium risk)
This is a pattern-based risk scan, not a security review. Our crawler flagged:
  • pip install
  • references .env files
Always read a skill's source content before installing. Patterns alone don't mean the skill is malicious — but they warrant attention.
source content

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.

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:

  1. Go to discord.com/developers/applications
  2. Click New Application → name it
  3. Under Bot → click Reset Token to get your bot token
  4. Enable Message Content Intent (under Bot → Privileged Gateway Intents) for reading message content
  5. Enable Server Members Intent for member management
  6. Under OAuth2 → URL Generator:
    • Select scopes:
      bot
      ,
      applications.commands
    • Select permissions:
      Send Messages
      ,
      Manage Messages
      ,
      Embed Links
      , etc.
  7. 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:

ComponentUse Case
ui.Button
Single-click actions
ui.Select
Dropdown menus
ui.TextInput
Modal form fields
ui.ChannelSelect
Select channels
ui.RoleSelect
Select roles
ui.UserSelect
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

ApproachUse CaseLibrary
Gateway (WebSocket)Real-time bot interactionsdiscord.py (Client/Bot)
HTTP API (REST)One-time actions, webhooksdiscord.HTTPClient or requests
Webhook (outbound)External services posting to DiscordJust 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:

DurationWhen to Use
60 minutesActive 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

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

IssueSolution
Bot doesn't respond to slash commandsEnsure
message_content
intent is enabled in Developer Portal
Commands not syncingCall
await tree.sync()
after bot is ready
"Application not found" errorBot token may be wrong or revoked
Buttons/selects not workingView must stay alive (don't set
view=None
immediately)
Rate limitedCheck you're not making too many API calls; use
discord.HTTPClient