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.)
This commit is contained in:
commit
43ca943cb8
7
.env.example
Normal file
7
.env.example
Normal file
@ -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=
|
||||||
58
.gitignore
vendored
Normal file
58
.gitignore
vendored
Normal file
@ -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
|
||||||
10
.omo/run-continuation/ses_fbe692663ffeFO1EMGYQXh2vR2.json
Normal file
10
.omo/run-continuation/ses_fbe692663ffeFO1EMGYQXh2vR2.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe692692ffewo9ABcTkxoW588.json
Normal file
10
.omo/run-continuation/ses_fbe692692ffewo9ABcTkxoW588.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe69269fffeVd44uKku7meXmD.json
Normal file
10
.omo/run-continuation/ses_fbe69269fffeVd44uKku7meXmD.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe6926a8ffepCAToNGqGkn3Fv.json
Normal file
10
.omo/run-continuation/ses_fbe6926a8ffepCAToNGqGkn3Fv.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json
Normal file
10
.omo/run-continuation/ses_fbe80c4f3ffeFzXnNq8LlwH4CA.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe80c509ffex2Up5zGuQXyCzB.json
Normal file
10
.omo/run-continuation/ses_fbe80c509ffex2Up5zGuQXyCzB.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe80c523ffdxYOuUd2d8STmgm.json
Normal file
10
.omo/run-continuation/ses_fbe80c523ffdxYOuUd2d8STmgm.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
.omo/run-continuation/ses_fbe831f22ffeX8pIpdL0ECVQDN.json
Normal file
10
.omo/run-continuation/ses_fbe831f22ffeX8pIpdL0ECVQDN.json
Normal file
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
63
AGENTS.md
Normal file
63
AGENTS.md
Normal file
@ -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.
|
||||||
114
README.md
Normal file
114
README.md
Normal file
@ -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 <name>` | Greet someone |
|
||||||
|
| `/emoji :name:` | Send a custom emoji |
|
||||||
|
| `/app-emoji <id>` | Fetch an app-owned emoji |
|
||||||
|
| `/list-emojis` | List all custom emojis (admin) |
|
||||||
|
| `/create-emoji <name> <file_path>` | Upload an emoji (admin) |
|
||||||
|
| `/delete-emoji <name>` | Delete an emoji (admin) |
|
||||||
|
|
||||||
|
### Text Commands
|
||||||
|
|
||||||
|
| Command | Description |
|
||||||
|
|---------|-------------|
|
||||||
|
| `!join <channel>` | Join a voice channel |
|
||||||
|
| `!play <url>` | Play a track |
|
||||||
|
| `!skip` | Skip current track |
|
||||||
|
| `!volume <level>` | 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
|
||||||
193
bot.py
Normal file
193
bot.py
Normal file
@ -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()
|
||||||
0
cogs/__init__.py
Normal file
0
cogs/__init__.py
Normal file
156
cogs/admin.py
Normal file
156
cogs/admin.py
Normal file
@ -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))
|
||||||
0
commands/__init__.py
Normal file
0
commands/__init__.py
Normal file
121
commands/admin.py
Normal file
121
commands/admin.py
Normal file
@ -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
|
||||||
37
commands/emoji.py
Normal file
37
commands/emoji.py
Normal file
@ -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
|
||||||
16
commands/hello.py
Normal file
16
commands/hello.py
Normal file
@ -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
|
||||||
30
commands/ping.py
Normal file
30
commands/ping.py
Normal file
@ -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
|
||||||
0
handlers/__init__.py
Normal file
0
handlers/__init__.py
Normal file
124
handlers/gif_emotes.py
Normal file
124
handlers/gif_emotes.py
Normal file
@ -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)
|
||||||
109
handlers/reactions.py
Normal file
109
handlers/reactions.py
Normal file
@ -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
|
||||||
296
handlers/voice.py
Normal file
296
handlers/voice.py
Normal file
@ -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))
|
||||||
132
main.py
Normal file
132
main.py
Normal file
@ -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()
|
||||||
10
requirements.txt
Normal file
10
requirements.txt
Normal file
@ -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
|
||||||
Loading…
x
Reference in New Issue
Block a user