Complete rebuild of the Discord bot with modern architecture including: - Modular API client with proper error handling - Clean separation of models, services, and commands - Comprehensive test coverage with pytest - Structured logging and configuration management - Organized command structure for scalability 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
369 lines
14 KiB
Python
369 lines
14 KiB
Python
"""
|
|
Discord Bot v2.0 - Main Entry Point
|
|
|
|
Modern discord.py bot with application commands and proper error handling.
|
|
"""
|
|
import asyncio
|
|
import hashlib
|
|
import json
|
|
import logging
|
|
import os
|
|
from logging.handlers import RotatingFileHandler
|
|
|
|
import discord
|
|
from discord.ext import commands
|
|
|
|
from config import get_config
|
|
from exceptions import BotException
|
|
from api.client import get_global_client, cleanup_global_client
|
|
|
|
|
|
def setup_logging():
|
|
"""Configure hybrid logging: human-readable console + structured JSON files."""
|
|
from utils.logging import JSONFormatter
|
|
|
|
# Create logs directory if it doesn't exist
|
|
os.makedirs('logs', exist_ok=True)
|
|
|
|
# Configure root logger
|
|
config = get_config()
|
|
logger = logging.getLogger('discord_bot_v2')
|
|
logger.setLevel(getattr(logging, config.log_level.upper()))
|
|
|
|
# Console handler - human readable for development
|
|
console_handler = logging.StreamHandler()
|
|
console_formatter = logging.Formatter(
|
|
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
|
)
|
|
console_handler.setFormatter(console_formatter)
|
|
logger.addHandler(console_handler)
|
|
|
|
# Traditional file handler - human readable with debug info
|
|
file_handler = RotatingFileHandler(
|
|
'logs/discord_bot_v2.log',
|
|
maxBytes=5 * 1024 * 1024, # 5MB
|
|
backupCount=5
|
|
)
|
|
file_formatter = logging.Formatter(
|
|
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
|
)
|
|
file_handler.setFormatter(file_formatter)
|
|
logger.addHandler(file_handler)
|
|
|
|
# JSON file handler - structured logging for analysis
|
|
json_handler = RotatingFileHandler(
|
|
'logs/discord_bot_v2.json',
|
|
maxBytes=5 * 1024 * 1024, # 5MB
|
|
backupCount=5
|
|
)
|
|
json_handler.setFormatter(JSONFormatter())
|
|
logger.addHandler(json_handler)
|
|
|
|
# Apply to all loggers (not just root)
|
|
root_logger = logging.getLogger()
|
|
root_logger.setLevel(getattr(logging, config.log_level.upper()))
|
|
|
|
# Add handlers to root logger so all child loggers inherit them
|
|
if not root_logger.handlers: # Avoid duplicate handlers
|
|
root_logger.addHandler(console_handler)
|
|
root_logger.addHandler(file_handler)
|
|
root_logger.addHandler(json_handler)
|
|
|
|
return logger
|
|
|
|
|
|
class SBABot(commands.Bot):
|
|
"""Custom bot class for SBA league management."""
|
|
|
|
def __init__(self):
|
|
# Configure intents
|
|
intents = discord.Intents.default()
|
|
intents.message_content = True # For legacy commands if needed
|
|
intents.members = True # For member management
|
|
|
|
super().__init__(
|
|
command_prefix='!', # Legacy prefix, primarily using slash commands
|
|
intents=intents,
|
|
description="SBA League Management Bot v2.0"
|
|
)
|
|
|
|
self.logger = logging.getLogger('discord_bot_v2')
|
|
|
|
async def setup_hook(self):
|
|
"""Called when the bot is starting up."""
|
|
self.logger.info("Setting up bot...")
|
|
|
|
# Load command packages
|
|
await self._load_command_packages()
|
|
|
|
# Smart command syncing: auto-sync in development if changes detected
|
|
config = get_config()
|
|
if config.is_development:
|
|
if await self._should_sync_commands():
|
|
self.logger.info("Development mode: changes detected, syncing commands...")
|
|
await self._sync_commands()
|
|
await self._save_command_hash()
|
|
else:
|
|
self.logger.info("Development mode: no command changes detected, skipping sync")
|
|
else:
|
|
self.logger.info("Production mode: commands loaded but not auto-synced")
|
|
self.logger.info("Use /sync command to manually sync when needed")
|
|
|
|
async def _load_command_packages(self):
|
|
"""Load all command packages with resilient error handling."""
|
|
from commands.players import setup_players
|
|
|
|
# Define command packages to load
|
|
command_packages = [
|
|
("players", setup_players),
|
|
# Future packages:
|
|
# ("teams", setup_teams),
|
|
# ("league", setup_league),
|
|
# ("admin", setup_admin),
|
|
]
|
|
|
|
total_successful = 0
|
|
total_failed = 0
|
|
|
|
for package_name, setup_func in command_packages:
|
|
try:
|
|
self.logger.info(f"Loading {package_name} commands...")
|
|
successful, failed, failed_modules = await setup_func(self)
|
|
total_successful += successful
|
|
total_failed += failed
|
|
|
|
if failed == 0:
|
|
self.logger.info(f"✅ {package_name} commands loaded successfully ({successful} cogs)")
|
|
else:
|
|
self.logger.warning(f"⚠️ {package_name} commands partially loaded: {successful} successful, {failed} failed")
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"❌ Failed to load {package_name} package: {e}", exc_info=True)
|
|
total_failed += 1
|
|
|
|
# Log overall summary
|
|
if total_failed == 0:
|
|
self.logger.info(f"🎉 All command packages loaded successfully ({total_successful} total cogs)")
|
|
else:
|
|
self.logger.warning(f"⚠️ Command loading completed with issues: {total_successful} successful, {total_failed} failed")
|
|
|
|
async def _should_sync_commands(self) -> bool:
|
|
"""Check if commands have changed since last sync."""
|
|
try:
|
|
# Create hash of current command tree
|
|
commands_data = []
|
|
for cmd in self.tree.get_commands():
|
|
# Include relevant command data for comparison
|
|
cmd_dict = {
|
|
'name': cmd.name,
|
|
'description': cmd.description,
|
|
'parameters': [
|
|
{
|
|
'name': param.name,
|
|
'description': param.description,
|
|
'required': param.required,
|
|
'type': str(param.type)
|
|
} for param in cmd.parameters
|
|
] if hasattr(cmd, 'parameters') else []
|
|
}
|
|
commands_data.append(cmd_dict)
|
|
|
|
# Sort for consistent hashing
|
|
commands_data.sort(key=lambda x: x['name'])
|
|
current_hash = hashlib.md5(
|
|
json.dumps(commands_data, sort_keys=True).encode()
|
|
).hexdigest()
|
|
|
|
# Compare with stored hash
|
|
hash_file = '.last_command_hash'
|
|
if os.path.exists(hash_file):
|
|
with open(hash_file, 'r') as f:
|
|
last_hash = f.read().strip()
|
|
return current_hash != last_hash
|
|
else:
|
|
# No previous hash = first run, should sync
|
|
return True
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error checking command hash: {e}")
|
|
# If we can't determine changes, err on the side of syncing
|
|
return True
|
|
|
|
async def _save_command_hash(self):
|
|
"""Save current command hash for future comparison."""
|
|
try:
|
|
# Create hash of current command tree (same logic as _should_sync_commands)
|
|
commands_data = []
|
|
for cmd in self.tree.get_commands():
|
|
cmd_dict = {
|
|
'name': cmd.name,
|
|
'description': cmd.description,
|
|
'parameters': [
|
|
{
|
|
'name': param.name,
|
|
'description': param.description,
|
|
'required': param.required,
|
|
'type': str(param.type)
|
|
} for param in cmd.parameters
|
|
] if hasattr(cmd, 'parameters') else []
|
|
}
|
|
commands_data.append(cmd_dict)
|
|
|
|
commands_data.sort(key=lambda x: x['name'])
|
|
current_hash = hashlib.md5(
|
|
json.dumps(commands_data, sort_keys=True).encode()
|
|
).hexdigest()
|
|
|
|
# Save hash to file
|
|
with open('.last_command_hash', 'w') as f:
|
|
f.write(current_hash)
|
|
|
|
except Exception as e:
|
|
self.logger.warning(f"Error saving command hash: {e}")
|
|
|
|
async def _sync_commands(self):
|
|
"""Internal method to sync commands."""
|
|
config = get_config()
|
|
if config.guild_id:
|
|
guild = discord.Object(id=config.guild_id)
|
|
self.tree.copy_global_to(guild=guild)
|
|
synced = await self.tree.sync(guild=guild)
|
|
self.logger.info(f"Synced {len(synced)} commands to guild {config.guild_id}")
|
|
else:
|
|
synced = await self.tree.sync()
|
|
self.logger.info(f"Synced {len(synced)} commands globally")
|
|
|
|
async def on_ready(self):
|
|
"""Called when the bot is ready."""
|
|
self.logger.info(f"Bot ready! Logged in as {self.user}")
|
|
self.logger.info(f"Connected to {len(self.guilds)} guilds")
|
|
|
|
# Set activity status
|
|
activity = discord.Activity(
|
|
type=discord.ActivityType.watching,
|
|
name="SBA League Management"
|
|
)
|
|
await self.change_presence(activity=activity)
|
|
|
|
async def on_error(self, event_method: str, /, *args, **kwargs):
|
|
"""Global error handler for events."""
|
|
self.logger.error(f"Error in event {event_method}", exc_info=True)
|
|
|
|
|
|
# Create global bot instance
|
|
bot = SBABot()
|
|
|
|
|
|
@bot.tree.command(name="health", description="Check bot and API health status")
|
|
async def health_command(interaction: discord.Interaction):
|
|
"""Health check command to verify bot and API connectivity."""
|
|
logger = logging.getLogger('discord_bot_v2')
|
|
|
|
try:
|
|
# Check API connectivity
|
|
api_status = "✅ Connected"
|
|
try:
|
|
client = await get_global_client()
|
|
# Test API with a simple request
|
|
result = await client.get('current')
|
|
if result:
|
|
api_status = "✅ Connected"
|
|
else:
|
|
api_status = "⚠️ API returned no data"
|
|
except Exception as e:
|
|
logger.error(f"API health check failed: {e}")
|
|
api_status = f"❌ Error: {str(e)}"
|
|
|
|
# Bot health info
|
|
bot_uptime = discord.utils.utcnow() - bot.user.created_at if bot.user else None
|
|
guild_count = len(bot.guilds)
|
|
|
|
# Create health status embed
|
|
embed = discord.Embed(
|
|
title="🏥 Bot Health Check",
|
|
color=discord.Color.green(),
|
|
timestamp=discord.utils.utcnow()
|
|
)
|
|
|
|
embed.add_field(name="Bot Status", value="✅ Online", inline=True)
|
|
embed.add_field(name="API Status", value=api_status, inline=True)
|
|
embed.add_field(name="Guilds", value=str(guild_count), inline=True)
|
|
embed.add_field(name="Latency", value=f"{bot.latency*1000:.1f}ms", inline=True)
|
|
|
|
if bot.user:
|
|
embed.set_footer(text=f"Bot: {bot.user.name}", icon_url=bot.user.display_avatar.url)
|
|
|
|
await interaction.response.send_message(embed=embed, ephemeral=True)
|
|
|
|
except Exception as e:
|
|
logger.error(f"Health check command error: {e}", exc_info=True)
|
|
await interaction.response.send_message(
|
|
f"❌ Health check failed: {str(e)}",
|
|
ephemeral=True
|
|
)
|
|
|
|
|
|
@bot.tree.error
|
|
async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError):
|
|
"""Global error handler for application commands."""
|
|
logger = logging.getLogger('discord_bot_v2')
|
|
|
|
# Handle specific error types
|
|
if isinstance(error, discord.app_commands.CommandOnCooldown):
|
|
await interaction.response.send_message(
|
|
f"⏰ Command on cooldown. Try again in {error.retry_after:.1f} seconds.",
|
|
ephemeral=True
|
|
)
|
|
elif isinstance(error, discord.app_commands.MissingPermissions):
|
|
await interaction.response.send_message(
|
|
"❌ You don't have permission to use this command.",
|
|
ephemeral=True
|
|
)
|
|
elif isinstance(error, discord.app_commands.CommandNotFound):
|
|
await interaction.response.send_message(
|
|
"❌ Command not found. Use `/help` to see available commands.",
|
|
ephemeral=True
|
|
)
|
|
elif isinstance(error, BotException):
|
|
# Our custom exceptions - show user-friendly message
|
|
await interaction.response.send_message(
|
|
f"❌ {str(error)}",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
# Unexpected errors - log and show generic message
|
|
logger.error(f"Unhandled command error: {error}", exc_info=True)
|
|
|
|
message = "❌ An unexpected error occurred. Please try again."
|
|
config = get_config()
|
|
if config.is_development:
|
|
message += f"\n\nDevelopment error: {str(error)}"
|
|
|
|
if interaction.response.is_done():
|
|
await interaction.followup.send(message, ephemeral=True)
|
|
else:
|
|
await interaction.response.send_message(message, ephemeral=True)
|
|
|
|
|
|
async def main():
|
|
"""Main entry point."""
|
|
logger = setup_logging()
|
|
|
|
config = get_config()
|
|
logger.info("Starting Discord Bot v2.0")
|
|
logger.info(f"Environment: {config.environment}")
|
|
logger.info(f"Guild ID: {config.guild_id}")
|
|
|
|
try:
|
|
await bot.start(config.bot_token)
|
|
except KeyboardInterrupt:
|
|
logger.info("Received keyboard interrupt, shutting down...")
|
|
except Exception as e:
|
|
logger.error(f"Fatal error: {e}", exc_info=True)
|
|
finally:
|
|
await cleanup_global_client()
|
|
await bot.close()
|
|
|
|
|
|
if __name__ == "__main__":
|
|
asyncio.run(main()) |