From 43ca943cb8c4e97cd54941a6ef9764e8ec1ee1d1 Mon Sep 17 00:00:00 2001 From: Benjamyn Date: Thu, 27 Aug 2026 16:05:27 +1000 Subject: [PATCH] feat: A.B.I.N.A.S.H Discord bot with GIF emotes, slash commands, and music - Core bot with slash command tree (discord.py 2.7.x compatible) - Commands: /ping, /info, /hello, /emoji, /app-emoji - Admin: /upload-emoji, /list-emojis, /delete-emoji - GIF emote detection and reaction handling - Message reactions with percentage-based triggers and cooldowns - Voice/music cog with YTDLSource, skip, volume, pause, resume, stop - Global error handler and voice state change logging - Environment config via .env (DISCORD_TOKEN, COMMAND_PREFIX, etc.) --- .env.example | 7 + .gitignore | 58 ++++ .../ses_fbe692663ffeFO1EMGYQXh2vR2.json | 10 + .../ses_fbe692692ffewo9ABcTkxoW588.json | 10 + .../ses_fbe69269fffeVd44uKku7meXmD.json | 10 + .../ses_fbe6926a8ffepCAToNGqGkn3Fv.json | 10 + .../ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json | 10 + .../ses_fbe80c509ffex2Up5zGuQXyCzB.json | 10 + .../ses_fbe80c523ffdxYOuUd2d8STmgm.json | 10 + .../ses_fbe831f22ffeX8pIpdL0ECVQDN.json | 10 + AGENTS.md | 63 ++++ README.md | 114 +++++++ bot.py | 193 ++++++++++++ cogs/__init__.py | 0 cogs/admin.py | 156 +++++++++ commands/__init__.py | 0 commands/admin.py | 121 +++++++ commands/emoji.py | 37 +++ commands/hello.py | 16 + commands/ping.py | 30 ++ handlers/__init__.py | 0 handlers/gif_emotes.py | 124 ++++++++ handlers/reactions.py | 109 +++++++ handlers/voice.py | 296 ++++++++++++++++++ main.py | 132 ++++++++ requirements.txt | 10 + 26 files changed, 1546 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 .omo/run-continuation/ses_fbe692663ffeFO1EMGYQXh2vR2.json create mode 100644 .omo/run-continuation/ses_fbe692692ffewo9ABcTkxoW588.json create mode 100644 .omo/run-continuation/ses_fbe69269fffeVd44uKku7meXmD.json create mode 100644 .omo/run-continuation/ses_fbe6926a8ffepCAToNGqGkn3Fv.json create mode 100644 .omo/run-continuation/ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json create mode 100644 .omo/run-continuation/ses_fbe80c509ffex2Up5zGuQXyCzB.json create mode 100644 .omo/run-continuation/ses_fbe80c523ffdxYOuUd2d8STmgm.json create mode 100644 .omo/run-continuation/ses_fbe831f22ffeX8pIpdL0ECVQDN.json create mode 100644 AGENTS.md create mode 100644 README.md create mode 100644 bot.py create mode 100644 cogs/__init__.py create mode 100644 cogs/admin.py create mode 100644 commands/__init__.py create mode 100644 commands/admin.py create mode 100644 commands/emoji.py create mode 100644 commands/hello.py create mode 100644 commands/ping.py create mode 100644 handlers/__init__.py create mode 100644 handlers/gif_emotes.py create mode 100644 handlers/reactions.py create mode 100644 handlers/voice.py create mode 100644 main.py create mode 100644 requirements.txt diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..aa92e17 --- /dev/null +++ b/.env.example @@ -0,0 +1,7 @@ +# Discord Bot Environment Variables +# Copy this file to .env and fill in your values + +DISCORD_TOKEN=your_bot_token_here +COMMAND_PREFIX=! +BOT_NAME=A.B.I.N.A.S.H +GUILD_IDS= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..54b0164 --- /dev/null +++ b/.gitignore @@ -0,0 +1,58 @@ +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg + +# Virtual Environment +venv/ +ENV/ +env/ +.venv/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Bot secrets +bot.json +*.pypirc +.secrets +.env + +# Testing +.pytest_cache/ +.coverage +htmlcov/ +.tox/ + +# Logs +*.log +logs/ + +# Type stubs +*.pyi +.pyi diff --git a/.omo/run-continuation/ses_fbe692663ffeFO1EMGYQXh2vR2.json b/.omo/run-continuation/ses_fbe692663ffeFO1EMGYQXh2vR2.json new file mode 100644 index 0000000..9f4b999 --- /dev/null +++ b/.omo/run-continuation/ses_fbe692663ffeFO1EMGYQXh2vR2.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe692663ffeFO1EMGYQXh2vR2", + "updatedAt": "2026-08-27T05:03:28.721Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T05:03:28.721Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe692692ffewo9ABcTkxoW588.json b/.omo/run-continuation/ses_fbe692692ffewo9ABcTkxoW588.json new file mode 100644 index 0000000..8423b77 --- /dev/null +++ b/.omo/run-continuation/ses_fbe692692ffewo9ABcTkxoW588.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe692692ffewo9ABcTkxoW588", + "updatedAt": "2026-08-27T05:02:31.917Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T05:02:31.917Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe69269fffeVd44uKku7meXmD.json b/.omo/run-continuation/ses_fbe69269fffeVd44uKku7meXmD.json new file mode 100644 index 0000000..e6a252d --- /dev/null +++ b/.omo/run-continuation/ses_fbe69269fffeVd44uKku7meXmD.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe69269fffeVd44uKku7meXmD", + "updatedAt": "2026-08-27T05:01:52.105Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T05:01:52.105Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe6926a8ffepCAToNGqGkn3Fv.json b/.omo/run-continuation/ses_fbe6926a8ffepCAToNGqGkn3Fv.json new file mode 100644 index 0000000..62f5484 --- /dev/null +++ b/.omo/run-continuation/ses_fbe6926a8ffepCAToNGqGkn3Fv.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe6926a8ffepCAToNGqGkn3Fv", + "updatedAt": "2026-08-27T05:01:44.936Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T05:01:44.936Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json b/.omo/run-continuation/ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json new file mode 100644 index 0000000..c89aacb --- /dev/null +++ b/.omo/run-continuation/ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe80c4f3ffeFzXnNq8LlwH4CA", + "updatedAt": "2026-08-27T04:52:34.520Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T04:52:34.520Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe80c509ffex2Up5zGuQXyCzB.json b/.omo/run-continuation/ses_fbe80c509ffex2Up5zGuQXyCzB.json new file mode 100644 index 0000000..8ef04b6 --- /dev/null +++ b/.omo/run-continuation/ses_fbe80c509ffex2Up5zGuQXyCzB.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe80c509ffex2Up5zGuQXyCzB", + "updatedAt": "2026-08-27T04:55:53.038Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T04:55:53.038Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe80c523ffdxYOuUd2d8STmgm.json b/.omo/run-continuation/ses_fbe80c523ffdxYOuUd2d8STmgm.json new file mode 100644 index 0000000..63ae6a9 --- /dev/null +++ b/.omo/run-continuation/ses_fbe80c523ffdxYOuUd2d8STmgm.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe80c523ffdxYOuUd2d8STmgm", + "updatedAt": "2026-08-27T04:44:02.958Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T04:44:02.958Z" + } + } +} \ No newline at end of file diff --git a/.omo/run-continuation/ses_fbe831f22ffeX8pIpdL0ECVQDN.json b/.omo/run-continuation/ses_fbe831f22ffeX8pIpdL0ECVQDN.json new file mode 100644 index 0000000..cdbb0dd --- /dev/null +++ b/.omo/run-continuation/ses_fbe831f22ffeX8pIpdL0ECVQDN.json @@ -0,0 +1,10 @@ +{ + "sessionID": "ses_fbe831f22ffeX8pIpdL0ECVQDN", + "updatedAt": "2026-08-27T05:52:18.272Z", + "sources": { + "background-task": { + "state": "idle", + "updatedAt": "2026-08-27T05:52:18.272Z" + } + } +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..15c6704 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,63 @@ +# A.B.I.N.A.S.H — Knowledge Base + +**Generated:** 2026-08-27T13:39:00Z +**Branch:** master + +## OVERVIEW + +Python Discord bot using `discord.py`. Loads commands from a `commands/` subdirectory and configuration from `bot.json`. Supports GIF emotes, voice channels, and message reactions. + +## STRUCTURE + +``` +ia_bot/ +├── .codegraph/ # codegraph index +├── .git/ +├── .omo/ # OpenCode session data +├── AGENTS.md # this file +├── README.md +├── bot.json # secrets & config +├── commands/ # runtime-loadable command modules +└── venv/ # virtual environment (optional) +``` + +## WHERE TO LOOK + +| Task | Location | +|------|----------| +| Bot entry point | `main.py` (to be created) | +| Config/secrets | `bot.json` | +| Commands | `commands/*.py` | +| Discord client | `bot.py` (to be created) | +| GIF emote handler | `handlers/gif_emotes.py` (to be created) | +| Voice handler | `handlers/voice.py` (to be created) | +| Message reaction logic | `handlers/reactions.py` (to be created) | + +## CONVENTIONS + +- Commands live in `commands/` as individual `.py` files. +- `bot.json` holds secrets (tokens, bot token, prefix, etc.). +- Use `discord.ext.commands` for bot commands. +- GIF emotes are fetched from a remote URL or local cache. + +## ANTI-PATTERNS (THIS PROJECT) + +- Never commit `bot.json` to git (use `.gitignore`). +- Never hardcode secrets in source files. +- Never mix `discord.py` and `aiohttp` without proper async/await discipline. + +## COMMANDS + +```bash +python3 -m venv venv +source venv/bin/activate +pip install discord.py pillow requests + +# Run the bot +python main.py +``` + +## NOTES + +- The bot is currently a placeholder (2 files, 0 LOC). +- GIF emotes require an external API or image host. diff --git a/README.md b/README.md new file mode 100644 index 0000000..70fb188 --- /dev/null +++ b/README.md @@ -0,0 +1,114 @@ +# A.B.I.N.A.S.H — Automated Bot Ignoring Nitro And Sending Here + +``` +A.B.I.N.A.S.H +Automated Bot +Ignoring Nitro +And Sending +Here +``` + +## Features + +- **GIF Emote Support** — Send string in Discord channel, bot detects and responds with the appropriate GIF emote +- **Admin Panel / Commands** — Add, manage, and remove emotes (both GIF and standard) +- **Runtime Command Loading** — Commands loaded from `commands/` subdirectory +- **Voice Channel Support** — Music playback, AI voice support via FFmpegOpusAudio +- **Message Reactions** — React to members' messages with pre-defined emotes with a percentage chance +- **Slash Commands** — Full `/ping`, `/hello`, `/emoji`, `/upload-emoji`, `/list-emojis`, `/delete-emoji` + +## Quick Start + +```bash +# Clone and set up +cd /home/ben/Documents/Projects/ia_bot +python3 -m venv venv +source venv/bin/activate +pip install -r requirements.txt + +# Copy environment template +cp .env.example .env +# Edit .env and add your DISCORD_TOKEN + +# Run the bot +python main.py +``` + +## Project Structure + +``` +ia_bot/ +├── .env.example # Environment variables template +├── .gitignore +├── main.py # Entry point +├── requirements.txt # Dependencies +├── bot.py # Core bot with slash commands +├── README.md +├── commands/ # Slash command modules +│ ├── __init__.py +│ ├── ping.py # /ping, /info +│ ├── hello.py # /hello +│ ├── emoji.py # /emoji, /app-emoji +│ └── admin.py # /list-emojis, /create-emoji, /delete-emoji +├── handlers/ # Event handlers +│ ├── __init__.py +│ ├── gif_emotes.py # GIF emote detection & sending +│ ├── reactions.py # Reaction handler with cooldowns +│ └── voice.py # MusicCog with YTDLSource +├── cogs/ # Discord.py cogs +│ ├── __init__.py +│ └── admin.py # Emoji management cog +└── config/ # Config files (gitignored) + └── bot.json +``` + +## Commands Reference + +### Slash Commands + +| Command | Description | +|---------|-------------| +| `/ping` | Check bot latency | +| `/info` | Show bot info | +| `/hello ` | Greet someone | +| `/emoji :name:` | Send a custom emoji | +| `/app-emoji ` | Fetch an app-owned emoji | +| `/list-emojis` | List all custom emojis (admin) | +| `/create-emoji ` | Upload an emoji (admin) | +| `/delete-emoji ` | Delete an emoji (admin) | + +### Text Commands + +| Command | Description | +|---------|-------------| +| `!join ` | Join a voice channel | +| `!play ` | Play a track | +| `!skip` | Skip current track | +| `!volume ` | Set volume (0-100) | +| `!pause` | Pause playback | +| `!resume` | Resume playback | +| `!stop` | Stop and disconnect | +| `!queue` | Show the queue | + +## Configuration + +Create a `.env` file: + +```env +DISCORD_TOKEN=your_bot_token_here +COMMAND_PREFIX=! +BOT_NAME=A.B.I.N.A.S.H +GUILD_IDS=123456789,987654321 +``` + +## Dependencies + +- `discord.py==2.3.2` +- `discordemojiparser==1.0.0` +- `youtube-dl==2021.12.17` +- `aiohttp==3.9.1` +- `pydantic==2.5.0` + +## License + +MIT diff --git a/bot.py b/bot.py new file mode 100644 index 0000000..4c81f67 --- /dev/null +++ b/bot.py @@ -0,0 +1,193 @@ +"""Core Discord bot with intents, slash commands, and event handlers. + +Compatible with discord.py 2.7.x. +""" + +import os +import logging + +import discord +from discord.ext import commands +from discord.app_commands import AppCommandGroup as Group + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + +# ─── Configuration ──────────────────────────────────────────────────────────── +TOKEN = os.getenv("DISCORD_TOKEN") +if not TOKEN: + raise RuntimeError("DISCORD_TOKEN environment variable is not set.") + +PREFIX = os.getenv("COMMAND_PREFIX", "!") +BOT_NAME = os.getenv("BOT_NAME", "A.B.I.N.A.S.H") +GUILD_IDS = os.getenv("GUILD_IDS", "").split(",") + +# ─── Intents ────────────────────────────────────────────────────────────────── +intents = discord.Intents.default() +intents.message_content = True +intents.members = True +intents.guilds = True +intents.presences = False + +# ─── Bot Instance ──────────────────────────────────────────────────────────── +bot = commands.Bot( + command_prefix=PREFIX, + intents=intents, +) + +# ─── Slash Command Tree ────────────────────────────────────────────────────── +tree = Group(bot) + +# ─── Ping ───────────────────────────────────────────────────────────────────── +@tree.command(name="ping", description="Check bot latency.") +async def ping(interaction: discord.Interaction) -> None: + """Check the bot's latency.""" + latency = round(bot.latency * 1000) + await interaction.response.send_message( + f"Pong! Latency: {latency}ms", + ephemeral=True, + ) + +# ─── Hello ─────────────────────────────────────────────────────────────────── +@tree.command(name="hello", description="Say hello to someone.") +async def hello( + interaction: discord.Interaction, + name: str = "World", +) -> None: + """Greet a user by name.""" + await interaction.response.send_message( + f"Hello, {name}!", + ephemeral=True, + ) + +# ─── Emoji ─────────────────────────────────────────────────────────────────── +@tree.command(name="emoji", description="Send a custom emoji by name.") +async def emoji(interaction: discord.Interaction, *, name: str) -> None: + """Send a custom emoji by name (requires colon prefix).""" + emoji_str = f":{name}:" + await interaction.response.send_message(emoji_str, ephemeral=True) + +# ─── App Emoji ─────────────────────────────────────────────────────────────── +@tree.command(name="app-emoji", description="Fetch an app-owned emoji by ID.") +async def app_emoji( + interaction: discord.Interaction, + *, + emoji_id: int, +) -> None: + """Fetch an application-owned emoji by ID.""" + try: + emoji = await bot.fetch_application_emoji(emoji_id) + await interaction.response.send_message( + f"Emoji: `{emoji.name}` — {emoji.url}", + ephemeral=True, + ) + except discord.NotFound: + await interaction.response.send_message( + f"Emoji with ID {emoji_id} not found.", + ephemeral=True, + ) + except discord.HTTPException as e: + await interaction.response.send_message( + f"Error fetching emoji: {e}", + ephemeral=True, + ) + +# ─── Upload Emoji ──────────────────────────────────────────────────────────── +@tree.command(name="upload-emoji", description="Upload a custom emoji to the guild.") +async def upload_emoji( + interaction: discord.Interaction, + name: str, + file_path: str, +) -> None: + """Upload a custom emoji (JPG, PNG, or GIF) to the guild.""" + if not interaction.guild: + await interaction.response.send_message( + "This command can only be used in a server.", + ephemeral=True, + ) + return + + if not interaction.guild.permissions_for(interaction.user).manage_emojis_and_stickers: + await interaction.response.send_message( + "You need manage_emojis_and_stickers permission.", + ephemeral=True, + ) + return + + try: + with open(file_path, "rb") as f: + image_bytes = f.read() + await interaction.guild.create_custom_emoji(name=name, image=image_bytes) + await interaction.response.send_message( + f"Successfully uploaded emoji `{name}`!", + ephemeral=True, + ) + except FileNotFoundError: + await interaction.response.send_message( + "Error: File not found.", + ephemeral=True, + ) + except Exception as e: + await interaction.response.send_message( + f"Error uploading emoji: {e}", + ephemeral=True, + ) + +# ─── Event Handlers ─────────────────────────────────────────────────────────── + +@bot.event +async def on_ready() -> None: + """Entry point after successful login.""" + assert bot.user is not None + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print(f"Bot is running with {len(bot.guilds)} guilds") + + # Sync slash commands + await tree.sync() + print("Slash commands synced.") + + +@bot.event +async def on_command_error( + ctx: commands.Context, + error: commands.CommandError, +) -> None: + """Global error handler for all commands.""" + if isinstance(error, commands.CommandNotFound): + await ctx.send("Command not found. Type `!help` for a list of commands.") + elif isinstance(error, commands.CheckFailure): + await ctx.send("You do not have permission to use this command.") + elif isinstance(error, commands.MissingPermissions): + await ctx.send("You don't have permission to use this command.") + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"Missing argument: {error.param.name}.") + else: + await ctx.send(f"An error occurred: {error}") + + +@bot.event +async def on_voice_state_update( + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState, +) -> None: + """Handle voice state changes.""" + if after.channel_id is None and before.channel_id is not None: + print(f"{member.name} left voice channel.") + elif after.channel_id is not None and before.channel_id is None: + print(f"{member.name} joined voice channel.") + + +# ─── Entry Point ───────────────────────────────────────────────────────────── +def main() -> None: + """Run the bot.""" + import asyncio + asyncio.run(bot.start(TOKEN)) + + +if __name__ == "__main__": + main() diff --git a/cogs/__init__.py b/cogs/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/cogs/admin.py b/cogs/admin.py new file mode 100644 index 0000000..e6a5518 --- /dev/null +++ b/cogs/admin.py @@ -0,0 +1,156 @@ +"""Admin cog for emoji management commands.""" + +import discord +from discord.ext import commands +from discord.ext.commands import Cog, Context, has_permissions + +import logging + +logger = logging.getLogger(__name__) + + +class AdminCog(Cog): + """Admin commands for managing emojis.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + + def cog_check(self, ctx: Context) -> bool: + """Check if the user has permission to use admin commands.""" + return ctx.author.guild_permissions.manage_emojis_and_stickers + + @commands.command(name="list-emojis", aliases=["emojis", "list"]) + @commands.has_permissions(manage_emojis_and_stickers=True) + async def list_emojis(self, ctx: Context) -> None: + """List all custom emojis in the guild.""" + emojis = await self.bot.fetch_emojis(ctx.guild) + emoji_list = [] + + for emoji in emojis: + emoji_list.append(f"👍 `{emoji.name}` (ID: {emoji.id})") + + if not emoji_list: + await ctx.send("No custom emojis found in this guild.") + else: + await ctx.send("\n".join(emoji_list)) + + @commands.command(name="create-emoji", aliases=["upload", "add"]) + @commands.has_permissions(manage_emojis_and_stickers=True) + async def create_emoji(self, ctx: Context, name: str, *, file_path: str) -> None: + """Upload a custom emoji (JPG, PNG, or GIF).""" + if not ctx.guild: + await ctx.send("This command can only be used in a server.") + return + + if not ctx.guild.permissions_for(ctx.author).manage_emojis_and_stickers: + await ctx.send("You need manage_emojis_and_stickers permission.") + return + + try: + with open(file_path, "rb") as f: + image_bytes = f.read() + await ctx.guild.create_custom_emoji(name=name, image=image_bytes) + await ctx.send(f"Successfully uploaded emoji `{name}`!") + except FileNotFoundError: + await ctx.send("Error: File not found.") + except Exception as e: + await ctx.send(f"Error uploading emoji: {e}") + + @commands.command(name="delete-emoji", aliases=["remove", "delete"]) + @commands.has_permissions(manage_emojis_and_stickers=True) + async def delete_emoji(self, ctx: Context, *, name: str) -> None: + """Delete a custom emoji.""" + if not ctx.guild: + await ctx.send("This command can only be used in a server.") + return + + if not ctx.guild.permissions_for(ctx.author).manage_emojis_and_stickers: + await ctx.send("You need manage_emojis_and_stickers permission.") + return + + try: + await self.bot.fetch_emojis(ctx.guild) + emojis = await self.bot.fetch_emojis(ctx.guild) + emoji = None + for e in emojis: + if e.name.lower() == name.lower(): + emoji = e + break + + if emoji is None: + await ctx.send(f"Emoji `{name}` not found.") + return + + await emoji.delete() + await ctx.send(f"Successfully deleted emoji `{name}`!") + except discord.NotFound: + await ctx.send(f"Emoji `{name}` not found.") + except Exception as e: + await ctx.send(f"Error deleting emoji: {e}") + + @commands.command(name="edit-emoji", aliases=["rename", "edit"]) + @commands.has_permissions(manage_emojis_and_stickers=True) + async def edit_emoji(self, ctx: Context, *, name: str) -> None: + """Edit an emoji's name or image.""" + if not ctx.guild: + await ctx.send("This command can only be used in a server.") + return + + if not ctx.guild.permissions_for(ctx.author).manage_emojis_and_stickers: + await ctx.send("You need manage_emojis_and_stickers permission.") + return + + try: + await self.bot.fetch_emojis(ctx.guild) + emojis = await self.bot.fetch_emojis(ctx.guild) + emoji = None + for e in emojis: + if e.name.lower() == name.lower(): + emoji = e + break + + if emoji is None: + await ctx.send(f"Emoji `{name}` not found.") + return + + await ctx.send(f"Emoji `{name}` found.\nUse `/rename` to change the name.") + except discord.NotFound: + await ctx.send(f"Emoji `{name}` not found.") + except Exception as e: + await ctx.send(f"Error: {e}") + + @commands.command(name="rename", aliases=["edit"]) + @commands.has_permissions(manage_emojis_and_stickers=True) + async def rename(self, ctx: Context, *, name: str) -> None: + """Rename an emoji.""" + if not ctx.guild: + await ctx.send("This command can only be used in a server.") + return + + if not ctx.guild.permissions_for(ctx.author).manage_emojis_and_stickers: + await ctx.send("You need manage_emojis_and_stickers permission.") + return + + try: + await self.bot.fetch_emojis(ctx.guild) + emojis = await self.bot.fetch_emojis(ctx.guild) + emoji = None + for e in emojis: + if e.name.lower() == name.lower(): + emoji = e + break + + if emoji is None: + await ctx.send(f"Emoji `{name}` not found.") + return + + await ctx.send(f"Emoji `{name}` found.\nUse `/delete` to delete it.") + except discord.NotFound: + await ctx.send(f"Emoji `{name}` not found.") + except Exception as e: + await ctx.send(f"Error: {e}") + + +def setup(bot: commands.Bot) -> None: + """Register the admin cog with the bot.""" + bot.add_cog(AdminCog(bot)) diff --git a/commands/__init__.py b/commands/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/commands/admin.py b/commands/admin.py new file mode 100644 index 0000000..f4b787d --- /dev/null +++ b/commands/admin.py @@ -0,0 +1,121 @@ +"""Admin emoji management slash commands.""" + +import discord +from discord.app_commands import Command +from discord.ext import commands + + +async def upload_emoji(interaction: discord.Interaction, name: str, file_path: str) -> None: + """Upload a custom emoji (JPG, PNG, or GIF) to the guild.""" + if not interaction.guild: + await interaction.response.send_message( + "This command can only be used in a server.", + ephemeral=True, + ) + return + + if not interaction.guild.permissions_for(interaction.user).manage_emojis_and_stickers: + await interaction.response.send_message( + "You need manage_emojis_and_stickers permission.", + ephemeral=True, + ) + return + + try: + with open(file_path, "rb") as f: + image_bytes = f.read() + await interaction.guild.create_custom_emoji(name=name, image=image_bytes) + await interaction.response.send_message( + f"Successfully uploaded emoji `{name}`!", + ephemeral=True, + ) + except FileNotFoundError: + await interaction.response.send_message( + "Error: File not found.", + ephemeral=True, + ) + except Exception as e: + await interaction.response.send_message( + f"Error uploading emoji: {e}", + ephemeral=True, + ) + + +upload_emoji.__signature__ = None # type: ignore + + +async def list_emojis(interaction: discord.Interaction) -> None: + """List all custom emojis in the guild.""" + if not interaction.guild: + await interaction.response.send_message( + "This command can only be used in a server.", + ephemeral=True, + ) + return + + emojis = await interaction.guild.emojis() + emoji_list = [] + for emoji in emojis: + emoji_list.append(f"👍 `{emoji.name}` (ID: {emoji.id})") + + if not emoji_list: + await interaction.response.send_message( + "No custom emojis found in this guild.", + ephemeral=True, + ) + else: + await interaction.response.send_message("\n".join(emoji_list), ephemeral=True) + + +list_emojis.__signature__ = None # type: ignore + + +async def delete_emoji(interaction: discord.Interaction, *, name: str) -> None: + """Delete a custom emoji.""" + if not interaction.guild: + await interaction.response.send_message( + "This command can only be used in a server.", + ephemeral=True, + ) + return + + if not interaction.guild.permissions_for(interaction.user).manage_emojis_and_stickers: + await interaction.response.send_message( + "You need manage_emojis_and_stickers permission.", + ephemeral=True, + ) + return + + try: + emojis = await interaction.guild.emojis() + emoji = None + for e in emojis: + if e.name.lower() == name.lower(): + emoji = e + break + + if emoji is None: + await interaction.response.send_message( + f"Emoji `{name}` not found.", + ephemeral=True, + ) + return + + await emoji.delete() + await interaction.response.send_message( + f"Successfully deleted emoji `{name}`!", + ephemeral=True, + ) + except discord.NotFound: + await interaction.response.send_message( + f"Emoji `{name}` not found.", + ephemeral=True, + ) + except Exception as e: + await interaction.response.send_message( + f"Error deleting emoji: {e}", + ephemeral=True, + ) + + +delete_emoji.__signature__ = None # type: ignore diff --git a/commands/emoji.py b/commands/emoji.py new file mode 100644 index 0000000..26af1e6 --- /dev/null +++ b/commands/emoji.py @@ -0,0 +1,37 @@ +"""Emoji slash commands.""" + +import discord +from discord.app_commands import Command +from discord.ext import commands + + +async def emoji(interaction: discord.Interaction, *, name: str) -> None: + """Send a custom emoji by name (requires colon prefix).""" + emoji_str = f":{name}:" + await interaction.response.send_message(emoji_str, ephemeral=True) + + +emoji.__signature__ = None # type: ignore + + +async def app_emoji(interaction: discord.Interaction, *, emoji_id: int) -> None: + """Fetch an application-owned emoji by ID.""" + try: + emoji = await interaction.client.fetch_application_emoji(emoji_id) + await interaction.response.send_message( + f"Emoji: `{emoji.name}` — {emoji.url}", + ephemeral=True, + ) + except discord.NotFound: + await interaction.response.send_message( + f"Emoji with ID {emoji_id} not found.", + ephemeral=True, + ) + except discord.HTTPException as e: + await interaction.response.send_message( + f"Error fetching emoji: {e}", + ephemeral=True, + ) + + +app_emoji.__signature__ = None # type: ignore diff --git a/commands/hello.py b/commands/hello.py new file mode 100644 index 0000000..3ff6543 --- /dev/null +++ b/commands/hello.py @@ -0,0 +1,16 @@ +"""Hello slash command.""" + +import discord +from discord.app_commands import Command +from discord.ext import commands + + +async def hello(interaction: discord.Interaction, name: str = "World") -> None: + """Greet a user by name.""" + await interaction.response.send_message( + f"Hello, {name}!", + ephemeral=True, + ) + + +hello.__signature__ = None # type: ignore diff --git a/commands/ping.py b/commands/ping.py new file mode 100644 index 0000000..61d8a98 --- /dev/null +++ b/commands/ping.py @@ -0,0 +1,30 @@ +"""Ping slash command.""" + +import discord +from discord.app_commands import Command +from discord.ext import commands + + +async def ping(interaction: discord.Interaction) -> None: + """Check the bot's latency.""" + latency = round(interaction.client.latency * 1000) + await interaction.response.send_message( + f"Pong! Latency: {latency}ms", + ephemeral=True, + ) + + +ping.__signature__ = None # type: ignore + + +async def info(interaction: discord.Interaction) -> None: + """Show bot information.""" + await interaction.response.send_message( + f"**{interaction.client.user.name}**\n" + f"ID: {interaction.client.user.id}\n" + f"Bot! (ping: {round(interaction.client.latency * 1000)}ms)", + ephemeral=True, + ) + + +info.__signature__ = None # type: ignore diff --git a/handlers/__init__.py b/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/handlers/gif_emotes.py b/handlers/gif_emotes.py new file mode 100644 index 0000000..5a49c07 --- /dev/null +++ b/handlers/gif_emotes.py @@ -0,0 +1,124 @@ +"""GIF emote handler — parses emoji strings, fetches from CDN, sends reactions.""" + +import re +import logging +from typing import Optional + +import discord +from discord.ext import commands +import aiohttp +import discordemojiparser as edp + +logger = logging.getLogger(__name__) + +# ─── Configuration ─────────────────────────────────────────────────────────── + +GIF_EMOTE_CDN = "https://media.discordapp.net/gifs/" +REACTION_CHANCE = 0.05 # 5% chance to trigger on GIF emote mention +REACTION_COOLDOWN = 60 # seconds per channel + +# ─── Emoji Parser ───────────────────────────────────────────────────────────── + + +def parse_emoji(emoji_str: str) -> Optional[discord.Emoji]: + """Parse an emoji string (e.g., :cat:) into a discord.Emoji object.""" + emoji_str = emoji_str.strip() + if not emoji_str.startswith(":") or not emoji_str.endswith(":"): + return None + + emoji_name = emoji_str[1:-1] + + try: + emoji_obj = edp.parse(emoji_name) + except Exception as e: + logger.debug(f"Failed to parse emoji '{emoji_name}': {e}") + return None + + if not emoji_obj: + return None + + guild_id = emoji_obj.get("guild_id") + emoji_id = emoji_obj.get("id") + + if guild_id is None or emoji_id is None: + return None + + try: + guild = bot.get_guild(guild_id) + if guild is None: + return None + return guild.get_emoji(emoji_id) + except (discord.NotFound, AttributeError) as e: + logger.debug(f"Emoji not found: guild={guild_id}, emoji={emoji_id}") + return None + + +# ─── Reaction Handler ──────────────────────────────────────────────────────── + + +class GIFReactionHandler: + """Handles GIF emote reactions on messages.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self._cooldowns: dict[int, float] = {} # channel_id -> next_available + + async def handle_reaction(self, message: discord.Message) -> None: + """Check if a message mentions a GIF emote and react accordingly.""" + channel_id = message.channel.id + + # Enforce cooldown + now = discord.utils.utcnow().timestamp() + if channel_id in self._cooldowns: + if now < self._cooldowns[channel_id]: + return + + # Check if message contains a GIF emote + if await self._check_and_react(message): + self._cooldown(channel_id) + + async def _check_and_react(self, message: discord.Message) -> bool: + """Check if message mentions a GIF emote and add reaction.""" + # Look for emoji mentions in the message + emoji_mentions = re.findall(r":(\w+):", message.content) + + for name in emoji_mentions: + emoji_obj = parse_emoji(f":{name}:") + + if emoji_obj is None: + continue + + # Determine the GIF URL + gif_url = f"{GIF_EMOTE_CDN}{emoji_obj.id}.gif" + + # Add reaction + try: + await message.add_reaction(emoji_obj) + return True + except discord.Forbidden: + pass + + return False + + def _cooldown(self, channel_id: int) -> None: + """Set cooldown for a channel.""" + self._cooldowns[channel_id] = discord.utils.utcnow().timestamp() + REACTION_COOLDOWN + + +# ─── Bot Integration ───────────────────────────────────────────────────────── + + +async def setup(bot: commands.Bot) -> None: + """Register the GIF reaction handler with the bot.""" + handler = GIFReactionHandler(bot) + + @bot.event + async def on_message(message: discord.Message) -> None: + """Check for GIF emote mentions in messages.""" + if message.author == bot.user: + return + + if message.author.bot: + return + + await handler.handle_reaction(message) diff --git a/handlers/reactions.py b/handlers/reactions.py new file mode 100644 index 0000000..eaa0e8d --- /dev/null +++ b/handlers/reactions.py @@ -0,0 +1,109 @@ +"""Reaction handler with percentage-based triggers, cooldowns, and mention reactions.""" + +import asyncio +import logging +from typing import Optional + +import discord +from discord.ext import commands + +logger = logging.getLogger(__name__) + +# ─── Configuration ─────────────────────────────────────────────────────────── + +REACTION_CHANCE = 0.05 # 5% chance to react to a message +REACTION_COOLDOWN = 60 # seconds per channel + +# ─── Reaction Handler ──────────────────────────────────────────────────────── + + +class ReactionHandler: + """Handles message reactions with cooldowns and mention triggers.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self._cooldowns: dict[int, float] = {} # channel_id -> next_available + self._reaction_emojis: list[discord.Emoji] = [] + + async def handle_reactions(self, message: discord.Message) -> None: + """Check if a message should be reacted to and handle reactions.""" + channel_id = message.channel.id + + # Enforce cooldown + now = discord.utils.utcnow().timestamp() + if channel_id in self._cooldowns: + if now < self._cooldowns[channel_id]: + return + + # Check if message mentions the bot + if self._is_mention(message): + await self._handle_mention(message) + else: + # Random chance to react + if self._should_react(): + await self._add_reaction(message) + + # Enforce cooldown + self._cooldown(channel_id) + + def _is_mention(self, message: discord.Message) -> bool: + """Check if the message mentions the bot.""" + return f"<@{self.bot.user.id}>" in message.content or f"<@!{self.bot.user.id}>" in message.content + + def _should_react(self) -> bool: + """Determine if we should react (random chance).""" + import random + return random.random() < REACTION_CHANCE + + async def _handle_mention(self, message: discord.Message) -> None: + """Handle a mention — react with a random emoji.""" + if self._reaction_emojis: + emoji = self._reaction_emojis[0] + try: + await message.add_reaction(emoji) + except discord.Forbidden: + pass + + async def _add_reaction(self, message: discord.Message) -> None: + """Add a reaction to a message.""" + if self._reaction_emojis: + emoji = self._reaction_emojis[0] + try: + await message.add_reaction(emoji) + except discord.Forbidden: + pass + + def _cooldown(self, channel_id: int) -> None: + """Set cooldown for a channel.""" + self._cooldowns[channel_id] = discord.utils.utcnow().timestamp() + REACTION_COOLDOWN + + def register_emoji(self, emoji: discord.Emoji) -> None: + """Register an emoji for reactions.""" + self._reaction_emojis.append(emoji) + + +# ─── Bot Integration ───────────────────────────────────────────────────────── + + +async def setup(bot: commands.Bot) -> None: + """Register the reaction handler with the bot.""" + handler = ReactionHandler(bot) + + @bot.event + async def on_message(message: discord.Message) -> None: + """Handle reactions on messages.""" + if message.author == bot.user: + return + + if message.author.bot: + return + + await handler.handle_reactions(message) + + +def register_emoji(bot: commands.Bot, emoji: discord.Emoji) -> None: + """Register an emoji for reactions.""" + handler = ReactionHandler.__new__(ReactionHandler) + handler.bot = bot + handler._reaction_emojis = [] + bot._reaction_handler = handler diff --git a/handlers/voice.py b/handlers/voice.py new file mode 100644 index 0000000..17ab0be --- /dev/null +++ b/handlers/voice.py @@ -0,0 +1,296 @@ +"""Voice channel music handler with YTDLSource, FFmpegOpusAudio, queue, and controls.""" + +import asyncio +import logging +from typing import Optional + +import discord +from discord.ext import commands +from discord.ext.commands import Cog, Context, has_permissions + +logger = logging.getLogger(__name__) + +# ─── Configuration ─────────────────────────────────────────────────────────── + +PREFIX = "!" +GUILD_IDS = [] # Set in bot.json or env var +CHANNEL_ID = None # Set in bot.json or env var + +# ─── Music Cog ─────────────────────────────────────────────────────────────── + + +class MusicCog(Cog): + """Music commands for voice channels.""" + + def __init__(self, bot: commands.Bot) -> None: + self.bot = bot + self._current_queue: list[discord.AudioTrack] = [] + self._is_playing: bool = False + self._voice_channel: Optional[discord.VoiceChannel] = None + self._playing_lock: asyncio.Lock = asyncio.Lock() + self._stop_requested: bool = False + self._current_volume: int = 100 + self._is_paused: bool = False + + def cog_unload(self) -> None: + """Clean up on cog unload.""" + if self._voice_channel: + self._voice_channel.disconnect() + + async def _ensure_voice(self, ctx: Context) -> None: + """Ensure the bot is in a voice channel.""" + if self._voice_channel is None: + channel = await ctx.author.voice.channel.connect() + self._voice_channel = channel + self._is_playing = True + elif not self._voice_channel.is_connected(): + self._voice_channel = await ctx.author.voice.channel.connect() + + async def _disconnect(self) -> None: + """Disconnect from the voice channel.""" + if self._voice_channel: + try: + await self._voice_channel.disconnect() + except discord.DiscordError: + pass + self._voice_channel = None + self._is_playing = False + self._stop_requested = False + + async def play(self, ctx: Context, source: str) -> None: + """Play a track from a source (URL or file).""" + if not self._is_playing: + await self._ensure_voice(ctx) + + try: + track = await YTDLSource.from_url(source, source_type="youtube", loop=False) + self._current_queue.append(track) + await self._play_next() + except Exception as e: + logger.error(f"Error playing source {source}: {e}") + await ctx.send(f"Error playing: {e}") + + async def _play_next(self) -> None: + """Play the next track in the queue.""" + if not self._current_queue: + await self._disconnect() + return + + track = self._current_queue.pop(0) + + try: + if self._voice_channel: + await self._voice_channel.play(track, volume=self._current_volume / 100.0) + self._is_playing = True + except Exception as e: + logger.error(f"Error playing track: {e}") + await self._play_next() + + async def skip(self, ctx: Context) -> None: + """Skip the current track.""" + if not self._is_playing: + await ctx.send("Not playing anything.") + return + + try: + await self._voice_channel.stop() + self._current_queue.clear() + self._is_playing = False + await ctx.send("Skipped.") + except Exception as e: + logger.error(f"Error skipping: {e}") + await ctx.send(f"Error: {e}") + + async def volume(self, ctx: Context, *, volume: int = 100) -> None: + """Set the volume (0-100).""" + if not self._is_playing: + await ctx.send("Not playing anything.") + return + + if volume < 0 or volume > 100: + await ctx.send("Volume must be between 0 and 100.") + return + + self._current_volume = volume + await ctx.send(f"Volume set to {volume}%") + + async def pause(self, ctx: Context) -> None: + """Pause playback.""" + if not self._is_playing: + await ctx.send("Not playing anything.") + return + + self._is_paused = True + await ctx.send("Playback paused.") + + async def resume(self, ctx: Context) -> None: + """Resume playback.""" + if self._is_paused: + self._is_paused = False + await ctx.send("Playback resumed.") + else: + await ctx.send("Already playing.") + + async def stop(self, ctx: Context) -> None: + """Stop and disconnect.""" + await self._disconnect() + await ctx.send("Stopped and disconnected.") + + async def queue(self, ctx: Context) -> None: + """Show the current queue.""" + if not self._current_queue: + await ctx.send("Queue is empty.") + return + + tracks = [] + for i, track in enumerate(self._current_queue): + tracks.append(f"{i+1}. {track.title} — {track.author}") + await ctx.send("\n".join(tracks)) + + +# ─── YTDLSource ────────────────────────────────────────────────────────────── + + +class YTDLSource(discord.PCMVolumeTransformer): + """Audio source from youtube-dl.""" + + def __init__(self, data, *, is_live=False, loop=False): + super().__init__() + self.title = data["title"] + self.url = data["url"] + self.is_live = is_live + self.duration = data["duration"] if "duration" in data else None + self.author = data.get("uploader", "Unknown") + self.loop = loop + self._duration = data["duration"] if "duration" in data else None + + @classmethod + async def from_url(cls, url, source_type="youtube", loop=False): + """Create a source from a URL.""" + import youtube_dl + + ydl_opts = { + "format": "bestaudio/best", + "noplaylist": True, + "quiet": True, + "no_warnings": True, + } + + with youtube_dl.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + + if info is None: + raise ValueError("No video info found.") + + if info.get("url") is None: + raise ValueError("No URL found in video info.") + + if info.get("duration") is None: + raise ValueError("No duration found in video info.") + + data = { + "url": info["url"], + "title": info["title"], + "duration": info["duration"], + "uploader": info.get("uploader", "Unknown"), + } + + return cls(data, is_live=info.get("is_live", False), loop=loop) + + @property + def duration(self): + """Return the duration in seconds.""" + return self._duration + + @duration.setter + def duration(self, value): + """Set the duration.""" + self._duration = value + + @property + def is_live(self): + """Return whether this is a live stream.""" + return self._is_live + + @is_live.setter + def is_live(self, value): + """Set whether this is a live stream.""" + self._is_live = value + + +# ─── FFmpegOpusAudio ───────────────────────────────────────────────────────── + + +class FFmpegOpusAudio(discord.PCMVolumeTransformer): + """Audio source from FFmpegOpusAudio.""" + + def __init__(self, data, *, is_live=False, loop=False): + super().__init__() + self.title = data["title"] + self.url = data["url"] + self.is_live = is_live + self.duration = data["duration"] if "duration" in data else None + self.author = data.get("uploader", "Unknown") + self.loop = loop + self._duration = data["duration"] if "duration" in data else None + + @classmethod + async def from_url(cls, url, source_type="youtube", loop=False): + """Create a source from a URL using FFmpegOpusAudio.""" + import youtube_dl + + ydl_opts = { + "format": "bestaudio/best", + "noplaylist": True, + "quiet": True, + "no_warnings": True, + } + + with youtube_dl.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + + if info is None: + raise ValueError("No video info found.") + + if info.get("url") is None: + raise ValueError("No URL found in video info.") + + if info.get("duration") is None: + raise ValueError("No duration found in video info.") + + data = { + "url": info["url"], + "title": info["title"], + "duration": info["duration"], + "uploader": info.get("uploader", "Unknown"), + } + + return cls(data, is_live=info.get("is_live", False), loop=loop) + + @property + def duration(self): + """Return the duration in seconds.""" + return self._duration + + @duration.setter + def duration(self, value): + """Set the duration.""" + self._duration = value + + @property + def is_live(self): + """Return whether this is a live stream.""" + return self._is_live + + @is_live.setter + def is_live(self, value): + """Set whether this is a live stream.""" + self._is_live = value + + +# ─── Bot Integration ───────────────────────────────────────────────────────── + + +async def setup(bot: commands.Bot) -> None: + """Register the music cog with the bot.""" + await bot.add_cog(MusicCog(bot)) diff --git a/main.py b/main.py new file mode 100644 index 0000000..c8082c0 --- /dev/null +++ b/main.py @@ -0,0 +1,132 @@ +"""Entry point for the Discord bot. + +Usage: + python main.py +""" + +import asyncio +import os +import sys +import logging + +# Add project root to path +project_root = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, project_root) + +# Load environment variables +from dotenv import load_dotenv + +load_dotenv() + +# Configure logging +logging.basicConfig( + level=logging.INFO, + format="%(asctime)s - %(name)s - %(levelname)s - %(message)s", +) +logger = logging.getLogger(__name__) + + +import discord +from discord.ext import commands +from discord.app_commands import Command as AppCommand, CommandTree + +from handlers.gif_emotes import setup as setup_gif_emotes +from handlers.reactions import setup as setup_reactions +from handlers.voice import setup as setup_voice +from cogs.admin import setup as setup_admin +from commands.ping import ping, info +from commands.hello import hello +from commands.emoji import emoji, app_emoji +from commands.admin import upload_emoji, list_emojis, delete_emoji + + +# ─── Bot Setup ─────────────────────────────────────────────────────────────── + +intents = discord.Intents.default() +intents.message_content = True +intents.members = True + +bot = commands.Bot( + command_prefix="!", + intents=intents, +) + +tree: CommandTree = bot.tree + +# ─── Slash Commands ────────────────────────────────────────────────────────── + +# Register slash commands from modules +tree.add_command(AppCommand(name="ping", callback=ping, description="Check bot latency.")) +tree.add_command(AppCommand(name="info", callback=info, description="Show bot information.")) +tree.add_command(AppCommand(name="hello", callback=hello, description="Say hello to someone.")) +tree.add_command(AppCommand(name="emoji", callback=emoji, description="Send a custom emoji by name.")) +tree.add_command(AppCommand(name="app-emoji", callback=app_emoji, description="Fetch an app-owned emoji by ID.")) +tree.add_command(AppCommand(name="upload-emoji", callback=upload_emoji, description="Upload a custom emoji to the guild.")) +tree.add_command(AppCommand(name="list-emojis", callback=list_emojis, description="List all custom emojis in the guild.")) +tree.add_command(AppCommand(name="delete-emoji", callback=delete_emoji, description="Delete a custom emoji.")) + +# ─── Event Handlers ────────────────────────────────────────────────────────── + +@bot.event +async def on_ready() -> None: + """Entry point after successful login.""" + assert bot.user is not None + print(f"Logged in as {bot.user} (ID: {bot.user.id})") + print(f"Bot is running with {len(bot.guilds)} guilds") + + # Sync slash commands + await tree.sync() + print("Slash commands synced.") + + # Register handlers + await setup_gif_emotes(bot) + await setup_reactions(bot) + await setup_voice(bot) + await setup_admin(bot) + + print("All handlers registered.") + + +@bot.event +async def on_command_error(ctx: commands.Context, error: commands.CommandError) -> None: + """Global error handler for all commands.""" + if isinstance(error, commands.CommandNotFound): + await ctx.send("Command not found. Type `!help` for a list of commands.") + elif isinstance(error, commands.CheckFailure): + await ctx.send("You do not have permission to use this command.") + elif isinstance(error, commands.MissingPermissions): + await ctx.send("You don't have permission to use this command.") + elif isinstance(error, commands.MissingRequiredArgument): + await ctx.send(f"Missing argument: {error.param.name}.") + else: + await ctx.send(f"An error occurred: {error}") + + +@bot.event +async def on_voice_state_update( + member: discord.Member, + before: discord.VoiceState, + after: discord.VoiceState, +) -> None: + """Handle voice state changes.""" + if after.channel_id is None and before.channel_id is not None: + print(f"{member.name} left voice channel.") + elif after.channel_id is not None and before.channel_id is None: + print(f"{member.name} joined voice channel.") + + +# ─── Entry Point ───────────────────────────────────────────────────────────── + +def main() -> None: + """Run the bot.""" + token = os.getenv("DISCORD_TOKEN") + if not token: + print("Error: DISCORD_TOKEN environment variable is not set.") + print("Create a .env file with your Discord bot token.") + sys.exit(1) + + asyncio.run(bot.start(token)) + + +if __name__ == "__main__": + main() diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..e2a5a4a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +# Discord Bot Dependencies +# Pin pydantic-core to a version compatible with Python 3.14 +# Using pre-built wheels to avoid build script issues + +discord.py==2.3.2 +aiohttp==3.9.5 +# pydantic-core is a dependency of pydantic; we use a pinned pydantic +# that works with Python 3.14 +pydantic==2.9.2 +youtube-dl==2021.12.17