- Changed on_message handler to use bot._commands.get(content) instead of bot.get_command() - bot._commands is the internal command registry that stores commands by their exact name - This properly routes prefix commands to their handlers without relying on process_commands() - Fixes: commands were not being invoked because process_commands() doesn't route to prefix commands
272 lines
11 KiB
Python
272 lines
11 KiB
Python
"""Entry point for the Discord bot.
|
|
|
|
Usage:
|
|
python main.py
|
|
"""
|
|
|
|
import asyncio
|
|
import os
|
|
import sys
|
|
import logging
|
|
import logging.handlers
|
|
|
|
# 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()
|
|
|
|
# ─── Debug Mode ──────────────────────────────────────────────────────────────
|
|
|
|
DEBUG = os.getenv("APP_DEBUG", "0") == "1"
|
|
|
|
if DEBUG:
|
|
print("[DEBUG] Debug mode is ENABLED. Logs will be printed.")
|
|
else:
|
|
print("[DEBUG] Debug mode is DISABLED. Logs will NOT be printed.")
|
|
|
|
# ─── Logging Configuration ───────────────────────────────────────────────────
|
|
|
|
log_file = os.path.join(project_root, "bot.log")
|
|
|
|
# Create a rotating file handler (max 10 MB, keep 5 files)
|
|
file_handler = logging.handlers.RotatingFileHandler(
|
|
log_file,
|
|
maxBytes=10 * 1024 * 1024, # 10 MB
|
|
backupCount=5,
|
|
)
|
|
file_handler.setLevel(logging.DEBUG)
|
|
file_handler.setFormatter(logging.Formatter(
|
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
))
|
|
|
|
# Console handler (only when DEBUG is enabled)
|
|
console_handler = logging.StreamHandler(sys.stdout)
|
|
console_handler.setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
console_handler.setFormatter(logging.Formatter(
|
|
"%(asctime)s - %(name)s - %(levelname)s - %(message)s"
|
|
))
|
|
|
|
# Root logger
|
|
root_logger = logging.getLogger()
|
|
root_logger.addHandler(file_handler)
|
|
root_logger.addHandler(console_handler)
|
|
root_logger.setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
|
|
# Suppress noisy third-party logs
|
|
logging.getLogger("discord").setLevel(logging.DEBUG if DEBUG else logging.INFO)
|
|
logging.getLogger("discord.http").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
logging.getLogger("discord.gateway").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
logging.getLogger("discord.utils").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
logging.getLogger("discord.app_commands").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
logging.getLogger("discord.ext.commands").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
logging.getLogger("discord.errors").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
logging.getLogger("discord.client").setLevel(logging.DEBUG if DEBUG else logging.WARNING)
|
|
|
|
# ─── Logging Configuration ───────────────────────────────────────────────────
|
|
|
|
# Configure logging
|
|
logging.basicConfig(
|
|
level=logging.DEBUG if DEBUG else logging.WARNING,
|
|
format="%(asctime)s - %(name)s - %(levelname)s - %(message)s",
|
|
handlers=[file_handler, console_handler],
|
|
)
|
|
|
|
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."))
|
|
|
|
# ─── Prefix Commands ─────────────────────────────────────────────────────────
|
|
|
|
@bot.command(name="ping", description="Check bot latency.")
|
|
async def ping_command(ctx: commands.Context) -> None:
|
|
"""Check the bot's latency."""
|
|
latency = round(bot.latency * 1000)
|
|
await ctx.send(f"Pong! Latency: {latency}ms")
|
|
|
|
@bot.command(name="info", description="Show bot information.")
|
|
async def info_command(ctx: commands.Context) -> None:
|
|
"""Show bot information."""
|
|
await ctx.send(
|
|
f"**{ctx.guild.name if ctx.guild else 'DM'}**\n"
|
|
f"Bot: `{bot.user.name}` (ID: {bot.user.id})\n"
|
|
f"Latency: `{bot.latency:.3f}s`"
|
|
)
|
|
|
|
@bot.command(name="hello", description="Say hello to someone.")
|
|
async def hello_command(ctx: commands.Context, name: str = "World") -> None:
|
|
"""Say hello to someone."""
|
|
await ctx.send(f"Hello, {name}!")
|
|
|
|
@bot.command(name="list-commands", description="List all available commands.")
|
|
async def list_commands(ctx: commands.Context) -> None:
|
|
"""List all available commands."""
|
|
# Get all registered commands (both prefix and slash)
|
|
cmds = list(bot.commands.values())
|
|
cmd_list = []
|
|
for cmd in cmds:
|
|
cmd_list.append(f" `{cmd.name}` — {cmd.description}")
|
|
|
|
await ctx.send(
|
|
f"**{bot.user.name} — Command List**\n\n"
|
|
+ "\n".join(cmd_list)
|
|
)
|
|
|
|
# ─── 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."""
|
|
logger.debug(f"Command error for {ctx.author.name} ({ctx.author.id}): {error}")
|
|
if isinstance(error, commands.CommandNotFound):
|
|
await ctx.send("Command not found. Type `!list-commands` 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}.")
|
|
elif isinstance(error, commands.BadArgument):
|
|
await ctx.send(f"Bad argument: {error.argument}.")
|
|
elif isinstance(error, commands.CommandOnCooldown):
|
|
remaining = int(error.retry_after)
|
|
await ctx.send(
|
|
f"This command is on cooldown. Try again in {remaining} seconds."
|
|
)
|
|
else:
|
|
await ctx.send(f"An error occurred: {error}")
|
|
|
|
|
|
@bot.event
|
|
async def on_message(message: discord.Message) -> None:
|
|
"""Handle text messages sent in channels (prefix commands)."""
|
|
# Ignore bot's own messages
|
|
if message.author == bot.user:
|
|
return
|
|
|
|
# Ignore messages in DMs
|
|
if message.guild is None:
|
|
return
|
|
|
|
# Check if the message starts with the command prefix (exactly one !)
|
|
if message.content.startswith("!."):
|
|
# Strip ONLY the first '!' character
|
|
content = message.content[1:]
|
|
|
|
# Find the matching command by name from the bot's internal command registry
|
|
command = bot._commands.get(content)
|
|
|
|
if command is not None:
|
|
logger.debug(f"Found command: {command.name}")
|
|
# Create a minimal context for the command
|
|
ctx = commands.Context(bot=bot, channel=message.channel, message=message, guild=message.guild)
|
|
# Call the command directly (bypassing prefix parsing)
|
|
await command.invoke(ctx)
|
|
else:
|
|
logger.debug(f"No command found for: {content}")
|
|
await message.channel.send("Command not found. Type `!list-commands` for a list of commands.")
|
|
else:
|
|
# Message doesn't start with '!', check for normal prefix commands
|
|
if message.content.startswith("!"):
|
|
content = message.content[1:]
|
|
command = bot._commands.get(content)
|
|
|
|
if command is not None:
|
|
logger.debug(f"Found command: {command.name}")
|
|
ctx = commands.Context(bot=bot, channel=message.channel, message=message, guild=message.guild)
|
|
await command.invoke(ctx)
|
|
else:
|
|
logger.debug(f"No command found for: {content}")
|
|
await message.channel.send("Command not found. Type `!list-commands` for a list of commands.")
|
|
|
|
|
|
@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()
|