From 838198648ef364bd5369c7b2f81b591b1c8ccdd4 Mon Sep 17 00:00:00 2001 From: Jared Vititoe Date: Tue, 10 Feb 2026 20:11:22 -0500 Subject: [PATCH] Refactor bot with 17 issue fixes: logging, caching, error handling, security - Add RotatingFileHandler for log rotation (fixes #21) - Add MetricsCollector for command/error tracking (fixes #20) - Add discord.py cooldown decorator on /ask (fixes #19) - Add ConfigValidator for startup config checks (fixes #17) - Add /status health check command for admins (fixes #16) - Add autocomplete for /minecraft usernames (fixes #15) - Add AuditLogger with batched queue + periodic flush (fixes #14) - Add MediaManager with lazy-load cache (fixes #13) - Add UsernameCache with TTL for Mojang API (fixes #12) - Extract magic numbers to named constants (fixes #11) - DRY kill/punch/hug/revive into send_interaction_media (fixes #10) - Add handle_command_errors decorator for consistent error handling (fixes #9) - Add sanitize_input() for /ask question input (fixes #8) - Move Ollama URL to OLLAMA_URL env var (fixes #7) - Only log commands, not all message content (fixes #6) - Wrap RCON in async executor with timeout (fixes #5) - Reuse shared aiohttp.ClientSession on CustomBot (fixes #4) - Validate RCON/Pelican/Ollama config on startup (fixes #3) - Replace ask_cooldowns dict with CooldownManager + auto-cleanup (fixes #2) Note: #18 (MariaDB) deferred - requires database infrastructure setup. Co-Authored-By: Claude Opus 4.6 --- bot.py | 1410 +++++++++++++++++++++++++++++++++----------------------- 1 file changed, 823 insertions(+), 587 deletions(-) diff --git a/bot.py b/bot.py index 6ef2f79..c156118 100644 --- a/bot.py +++ b/bot.py @@ -4,29 +4,49 @@ import random import asyncio import json import logging -import aiohttp +from logging.handlers import RotatingFileHandler +from collections import Counter from datetime import datetime, timedelta from datetime import time as datetime_time from pathlib import Path +from functools import wraps, partial from dotenv import load_dotenv from discord import app_commands from discord.ext import commands, tasks from discord.utils import get from itertools import cycle from mcrcon import MCRcon +import aiohttp # Create logs directory if it doesn't exist Path("logs").mkdir(exist_ok=True) -logging.basicConfig( - level=logging.INFO, - format='%(asctime)s - %(levelname)s - %(message)s', - handlers=[ - logging.FileHandler(f'logs/discord_{datetime.now().strftime("%Y-%m-%d")}.log'), - logging.StreamHandler() - ] -) -logger = logging.getLogger('discord_bot') +# --- Issue #21: Improve Logging Configuration --- +def setup_logging(): + """Configure logging with rotation""" + logger = logging.getLogger('discord_bot') + logger.setLevel(logging.INFO) + + # Rotating file handler (10MB max, keep 5 backups) + file_handler = RotatingFileHandler( + 'logs/discord.log', + maxBytes=10*1024*1024, + backupCount=5 + ) + file_handler.setFormatter( + logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + ) + + stream_handler = logging.StreamHandler() + stream_handler.setFormatter( + logging.Formatter('%(asctime)s - %(levelname)s - %(message)s') + ) + + logger.addHandler(file_handler) + logger.addHandler(stream_handler) + return logger + +logger = setup_logging() load_dotenv() @@ -43,9 +63,24 @@ REACTION_MESSAGE_ID = int(os.getenv('REACTION_MESSAGE_ID', '744047519696420914') MINECRAFT_RCON_HOST = os.getenv('MINECRAFT_RCON_HOST', '10.10.10.67') MINECRAFT_RCON_PASSWORD = os.getenv('MINECRAFT_RCON_PASSWORD', '') PELICAN_URL = os.getenv('PELICAN_URL', 'http://10.10.10.67') -PELICAN_API_KEY = os.getenv('PELICAN_API_KEY', '') # Client API key +PELICAN_API_KEY = os.getenv('PELICAN_API_KEY', '') HYTALE_SERVER_UUID = os.getenv('HYTALE_SERVER_UUID', '7a656836-c3f3-491e-ac55-66affe435e72') -STATUS_UPDATE_INTERVAL = int(os.getenv('STATUS_UPDATE_INTERVAL', '300')) # 5 minutes default +STATUS_UPDATE_INTERVAL = int(os.getenv('STATUS_UPDATE_INTERVAL', '300')) + +# --- Issue #7: Hardcoded URLs - Use env vars for internal services --- +OLLAMA_URL = os.getenv('OLLAMA_URL', 'http://10.10.10.157:11434') + +# --- Issue #11: Magic Numbers - Extract constants --- +MIN_USERNAME_LENGTH = 3 +MAX_USERNAME_LENGTH = 16 +MAX_INPUT_LENGTH = 500 +MAX_DICE_SIDES = 100 +MAX_DICE_COUNT = 20 +COOLDOWN_MINUTES = 2 +RCON_TIMEOUT = 5.0 +USERNAME_CACHE_TTL_MINUTES = 60 +AUDIT_BATCH_SIZE = 5 +AUDIT_FLUSH_INTERVAL = 30 # Emoji to role mapping for reaction roles EMOJI_ROLE_MAP = { @@ -73,6 +108,277 @@ EMOJI_ROLE_MAP = { } +# --- Issue #17: Configuration Validator --- +class ConfigValidator: + REQUIRED = ['DISCORD_TOKEN'] + OPTIONAL = { + 'MINECRAFT_RCON_PASSWORD': 'Minecraft commands', + 'PELICAN_API_KEY': 'Pelican integration', + 'OLLAMA_URL': '/ask command' + } + + @classmethod + def validate(cls): + """Validate configuration on startup""" + errors = [] + warnings = [] + + for var in cls.REQUIRED: + if not os.getenv(var): + errors.append(f"Missing required: {var}") + + for var, feature in cls.OPTIONAL.items(): + if not os.getenv(var): + warnings.append(f"Missing {var} - {feature} may use defaults") + + return errors, warnings + + +# --- Issue #2: Memory Leak in Cooldown System --- +class CooldownManager: + def __init__(self, minutes: int): + self.cooldowns = {} + self.duration = timedelta(minutes=minutes) + + def check(self, user_id: int) -> tuple: + """Returns (can_use, remaining_minutes)""" + current_time = datetime.now() + if user_id in self.cooldowns: + time_diff = current_time - self.cooldowns[user_id] + if time_diff < self.duration: + remaining = self.duration.total_seconds() - time_diff.total_seconds() + return False, remaining / 60 + return True, 0 + + def update(self, user_id: int): + self.cooldowns[user_id] = datetime.now() + # Cleanup old entries (>24 hours) + cutoff = datetime.now() - timedelta(hours=24) + self.cooldowns = {k: v for k, v in self.cooldowns.items() if v > cutoff} + + + +# --- Issue #12: Cache Username Validations --- +class UsernameCache: + def __init__(self, ttl_minutes: int = 60): + self.cache = {} + self.ttl = timedelta(minutes=ttl_minutes) + + async def is_valid(self, username: str, session: aiohttp.ClientSession) -> bool: + if username in self.cache: + is_valid, timestamp = self.cache[username] + if datetime.now() - timestamp < self.ttl: + return is_valid + + url = f'https://api.mojang.com/users/profiles/minecraft/{username}' + try: + async with session.get(url) as response: + is_valid = response.status == 200 + self.cache[username] = (is_valid, datetime.now()) + return is_valid + except Exception as e: + logger.error(f"Error validating username: {e}") + return False + + +username_cache = UsernameCache(ttl_minutes=USERNAME_CACHE_TTL_MINUTES) + + +# --- Issue #13: Lazy Load Media Files --- +class MediaManager: + def __init__(self): + self._cache = {} + + def get_random(self, category: str) -> str | None: + """Get random media file from category""" + if category not in self._cache: + patterns = [f"media/{category}.gif", f"media/{category}1.gif", + f"media/{category}2.gif", f"media/{category}3.gif"] + self._cache[category] = [p for p in patterns if Path(p).exists()] + + files = self._cache[category] + return random.choice(files) if files else None + + +media_manager = MediaManager() + + +# --- Issue #14: Batch Audit Logs --- +class AuditLogger: + def __init__(self, channel_id: int, batch_size: int = AUDIT_BATCH_SIZE, + flush_interval: int = AUDIT_FLUSH_INTERVAL): + self.channel_id = channel_id + self.batch_size = batch_size + self.flush_interval = flush_interval + self.queue = [] + self.last_flush = datetime.now() + + async def log(self, message: str, color: int = 0x980000): + self.queue.append((message, color, datetime.now())) + if len(self.queue) >= self.batch_size or \ + (datetime.now() - self.last_flush).seconds >= self.flush_interval: + await self.flush() + + async def flush(self): + if not self.queue: + return + + channel = client.get_channel(self.channel_id) + if not channel: + return + + embed = discord.Embed(color=0x980000, timestamp=datetime.now()) + for msg, _, timestamp in self.queue[:10]: + embed.add_field(name=timestamp.strftime("%H:%M:%S"), value=msg[:1024], inline=False) + + try: + await channel.send(embed=embed) + except Exception as e: + logger.error(f"Failed to send audit log: {e}", exc_info=True) + + self.queue.clear() + self.last_flush = datetime.now() + + +audit_logger = AuditLogger(AUDIT_CHANNEL_ID) + + +# --- Issue #20: Metrics Collection --- +class MetricsCollector: + def __init__(self): + self.command_counts = Counter() + self.error_counts = Counter() + self.start_time = datetime.now() + + def record_command(self, command_name: str): + self.command_counts[command_name] += 1 + + def record_error(self, command_name: str): + self.error_counts[command_name] += 1 + + def get_stats(self) -> dict: + uptime = datetime.now() - self.start_time + return { + "uptime_seconds": uptime.total_seconds(), + "commands_executed": sum(self.command_counts.values()), + "top_commands": self.command_counts.most_common(5), + "error_count": sum(self.error_counts.values()) + } + + +metrics = MetricsCollector() + + +# --- Issue #8: Input Sanitization --- +def sanitize_input(text: str, max_length: int = MAX_INPUT_LENGTH) -> str: + """Sanitize user input""" + text = text.strip()[:max_length] + text = ''.join(char for char in text if char.isprintable()) + return text + + +# --- Issue #9: Inconsistent Error Handling --- +def handle_command_errors(func): + @wraps(func) + async def wrapper(interaction: discord.Interaction, *args, **kwargs): + try: + metrics.record_command(func.__name__) + return await func(interaction, *args, **kwargs) + except Exception as e: + metrics.record_error(func.__name__) + logger.error(f"Error in {func.__name__}: {e}", exc_info=True) + error_msg = "An unexpected error occurred. Please try again later." + if not interaction.response.is_done(): + await interaction.response.send_message(error_msg, ephemeral=True) + else: + await interaction.followup.send(error_msg, ephemeral=True) + return wrapper + + +# --- Issue #5: Race Condition in RCON --- +def execute_rcon(host: str, password: str, command: str): + """Execute RCON command synchronously (for use with executor)""" + with MCRcon(host, password, timeout=3) as mcr: + return mcr.command(command) + + +async def rcon_command(host: str, password: str, command: str, timeout: float = RCON_TIMEOUT): + """Execute RCON command with timeout in executor""" + try: + loop = asyncio.get_event_loop() + func = partial(execute_rcon, host, password, command) + return await asyncio.wait_for(loop.run_in_executor(None, func), timeout=timeout) + except asyncio.TimeoutError: + raise Exception("RCON timeout - server may be offline") + except Exception as e: + raise Exception(f"RCON error: {str(e)}") + + +# --- Issue #10: Duplicate Code for Media Files --- +async def send_interaction_media( + interaction: discord.Interaction, + member: discord.Member, + action: str, + media_prefix: str +): + """Generic function for interaction commands""" + messages = { + 'kill': f"You killed {member}", + 'punch': f"You punched {member}", + 'hug': f"{member} has been squeezed tightly!", + 'revive': f"{member} has been brought back to life" + } + message = messages[action] + + media_file = media_manager.get_random(media_prefix) + if media_file: + await interaction.response.send_message( + message, file=discord.File(media_file) + ) + else: + await interaction.response.send_message(message) + + +# --- Issue #15: Track whitelisted usernames for autocomplete --- +whitelisted_usernames = set() +WHITELIST_FILE = Path("data/whitelisted_usernames.json") + + +def load_whitelisted_usernames(): + """Load previously whitelisted usernames from file""" + global whitelisted_usernames + try: + if WHITELIST_FILE.exists(): + with open(WHITELIST_FILE) as f: + whitelisted_usernames = set(json.load(f)) + except Exception as e: + logger.error(f"Error loading whitelisted usernames: {e}") + + +def save_whitelisted_username(username: str): + """Save a whitelisted username to file""" + whitelisted_usernames.add(username) + try: + WHITELIST_FILE.parent.mkdir(parents=True, exist_ok=True) + with open(WHITELIST_FILE, 'w') as f: + json.dump(list(whitelisted_usernames), f) + except Exception as e: + logger.error(f"Error saving whitelisted username: {e}") + + +async def minecraft_username_autocomplete( + interaction: discord.Interaction, + current: str +) -> list: + """Suggest previously whitelisted usernames""" + return [ + app_commands.Choice(name=username, value=username) + for username in sorted(whitelisted_usernames) + if current.lower() in username.lower() + ][:25] + + +# --- Issue #4: Unclosed aiohttp Sessions --- class CustomBot(commands.Bot): def __init__(self): intents = discord.Intents.all() @@ -87,12 +393,31 @@ class CustomBot(commands.Bot): "Ranked Minesweeper" ]) self.remove_command("help") + self.http_session: aiohttp.ClientSession = None async def setup_hook(self): + self.http_session = aiohttp.ClientSession() + + # --- Issue #3: Validate configs on startup --- + if not MINECRAFT_RCON_PASSWORD: + logger.warning("MINECRAFT_RCON_PASSWORD not set - /minecraft command will fail") + if not PELICAN_API_KEY: + logger.warning("PELICAN_API_KEY not set - Pelican features disabled") + if not OLLAMA_URL: + logger.warning("OLLAMA_URL not set - /ask command will fail") + + load_whitelisted_usernames() + logger.info("Syncing commands...") await self.tree.sync() logger.info("Commands synced!") + async def close(self): + if self.http_session: + await self.http_session.close() + await audit_logger.flush() + await super().close() + @tasks.loop(seconds=STATUS_UPDATE_INTERVAL) async def change_status(self): try: @@ -108,6 +433,16 @@ class CustomBot(commands.Bot): logger.info(f"Bot logged in as: {self.user.name}") logger.info(f"Registered commands: {[cmd.name for cmd in self.tree.get_commands()]}") self.change_status.start() + self.flush_audit_logs.start() + + @tasks.loop(seconds=AUDIT_FLUSH_INTERVAL) + async def flush_audit_logs(self): + """Periodically flush batched audit logs""" + await audit_logger.flush() + + @flush_audit_logs.before_loop + async def before_flush_audit_logs(self): + await self.wait_until_ready() client = CustomBot() @@ -124,33 +459,6 @@ def get_role_from_emoji(emoji_name: str) -> str: return EMOJI_ROLE_MAP.get(emoji_name, emoji_name) -async def send_audit_log(message: str, color: int = 0x980000): - """Send a message to the audit log channel.""" - try: - audit_channel = client.get_channel(AUDIT_CHANNEL_ID) - if audit_channel: - embed = discord.Embed( - description=message, - color=color, - timestamp=datetime.now() - ) - await audit_channel.send(embed=embed) - except Exception as e: - logger.error(f"Failed to send audit log: {e}", exc_info=True) - - -async def is_valid_minecraft_username(username: str) -> bool: - """Validate Minecraft username using Mojang API asynchronously.""" - url = f'https://api.mojang.com/users/profiles/minecraft/{username}' - try: - async with aiohttp.ClientSession() as session: - async with session.get(url) as response: - return response.status == 200 - except Exception as e: - logger.error(f"Error validating Minecraft username: {e}") - return False - - def has_role_check(role_id: int): """Check if user has a specific role (for slash commands).""" async def predicate(interaction: discord.Interaction) -> bool: @@ -158,10 +466,13 @@ def has_role_check(role_id: int): return app_commands.check(predicate) +# --- Issue #6: Only log commands, not all message content --- @client.event async def on_message(message): try: - logger.info(f"Channel: [{str(message.channel)}] User: {str(message.author)} Content: {message.content}") + # Only log commands or bot-prefix messages, not all content + if message.content.startswith(('.', '/')): + logger.info(f"Command in {message.channel}: {message.author} - {message.content[:50]}") # Special response for user 331862422996647938 when message starts with a period if message.author.id == 331862422996647938 and message.content.startswith('.'): @@ -174,7 +485,7 @@ async def on_message(message): async def on_member_join(member): try: logger.info(f"New member joined: {member.name} (ID: {member.id})") - await send_audit_log(f"✨ Member Joined\nUser: {member.mention}\nAccount Created: {member.created_at}") + await audit_logger.log(f"Member Joined\nUser: {member.mention}\nAccount Created: {member.created_at}") except Exception as e: logger.error(f"Error in on_member_join: {e}", exc_info=True) @@ -183,8 +494,7 @@ async def on_member_join(member): async def on_member_remove(member): try: logger.info(f"Member left: {member.name} (ID: {member.id})") - # Fix: Use member.name instead of deprecated discriminator - await send_audit_log(f"👋 Member Left\nUser: {member.name} ({member.id})") + await audit_logger.log(f"Member Left\nUser: {member.name} ({member.id})") except Exception as e: logger.error(f"Error in on_member_remove: {e}", exc_info=True) @@ -192,8 +502,8 @@ async def on_member_remove(member): @client.event async def on_message_delete(message): try: - logger.warning(f"Message deleted in {message.channel}: {message.author}: {message.content}") - await send_audit_log(f"🗑️ Message Deleted\nChannel: {message.channel}\nUser: {message.author}\nContent: {message.content}") + logger.warning(f"Message deleted in {message.channel}: {message.author}") + await audit_logger.log(f"Message Deleted\nChannel: {message.channel}\nUser: {message.author}\nContent: {message.content}") except Exception as e: logger.error(f"Error in on_message_delete: {e}", exc_info=True) @@ -203,9 +513,9 @@ async def on_voice_state_update(member, before, after): try: if before.channel != after.channel: if after.channel: - await send_audit_log(f"🎤 Voice Join\nUser: {member.mention}\nChannel: {after.channel.name}") + await audit_logger.log(f"Voice Join\nUser: {member.mention}\nChannel: {after.channel.name}") else: - await send_audit_log(f"🎤 Voice Leave\nUser: {member.mention}\nChannel: {before.channel.name}") + await audit_logger.log(f"Voice Leave\nUser: {member.mention}\nChannel: {before.channel.name}") except Exception as e: logger.error(f"Error in on_voice_state_update: {e}", exc_info=True) @@ -213,7 +523,7 @@ async def on_voice_state_update(member, before, after): @client.event async def on_integration_create(integration): try: - await send_audit_log(f"🔌 New Integration Added\nName: {integration.name}\nType: {integration.type}") + await audit_logger.log(f"New Integration Added\nName: {integration.name}\nType: {integration.type}") except Exception as e: logger.error(f"Error in on_integration_create: {e}", exc_info=True) @@ -221,7 +531,7 @@ async def on_integration_create(integration): @client.event async def on_scheduled_event_create(event): try: - await send_audit_log(f"📅 Event Created\nName: {event.name}\nStart Time: {event.start_time}") + await audit_logger.log(f"Event Created\nName: {event.name}\nStart Time: {event.start_time}") except Exception as e: logger.error(f"Error in on_scheduled_event_create: {e}", exc_info=True) @@ -229,7 +539,7 @@ async def on_scheduled_event_create(event): @client.event async def on_scheduled_event_update(before, after): try: - await send_audit_log(f"📝 Event Updated\nName: {after.name}\nChanges Made") + await audit_logger.log(f"Event Updated\nName: {after.name}\nChanges Made") except Exception as e: logger.error(f"Error in on_scheduled_event_update: {e}", exc_info=True) @@ -238,23 +548,19 @@ async def on_scheduled_event_update(before, after): async def on_member_update(before, after): """Unified handler for all member update events.""" try: - # Check for timeout changes if before.timed_out_until != after.timed_out_until: - await send_audit_log(f"⏰ Member Timeout\nUser: {after.mention}\nUntil: {after.timed_out_until}") + await audit_logger.log(f"Member Timeout\nUser: {after.mention}\nUntil: {after.timed_out_until}") - # Check for server boost changes if before.premium_since != after.premium_since: - await send_audit_log(f"⭐ Server Boost\nUser: {after.mention} boosted the server!") + await audit_logger.log(f"Server Boost\nUser: {after.mention} boosted the server!") - # Check for nickname changes if before.nick != after.nick: - await send_audit_log(f"📛 Nickname Change\nUser: {before.mention}\nBefore: {before.nick}\nAfter: {after.nick}") + await audit_logger.log(f"Nickname Change\nUser: {before.mention}\nBefore: {before.nick}\nAfter: {after.nick}") - # Check for role changes if before.roles != after.roles: logger.info(f"Role change for {before.name}: {before.roles} -> {after.roles}") - await send_audit_log( - f"👥 Role Update\nUser: {before.mention}\n" + await audit_logger.log( + f"Role Update\nUser: {before.mention}\n" f"Before: {', '.join([r.name for r in before.roles])}\n" f"After: {', '.join([r.name for r in after.roles])}" ) @@ -265,7 +571,7 @@ async def on_member_update(before, after): @client.event async def on_member_ban(guild, user): try: - await send_audit_log(f"🔨 Member Banned\nUser: {user.name}") + await audit_logger.log(f"Member Banned\nUser: {user.name}") except Exception as e: logger.error(f"Error in on_member_ban: {e}", exc_info=True) @@ -274,7 +580,7 @@ async def on_member_ban(guild, user): async def on_guild_role_update(before, after): try: if before.permissions != after.permissions: - await send_audit_log(f"🔑 Role Permissions Updated\nRole: {after.name}") + await audit_logger.log(f"Role Permissions Updated\nRole: {after.name}") except Exception as e: logger.error(f"Error in on_guild_role_update: {e}", exc_info=True) @@ -282,7 +588,7 @@ async def on_guild_role_update(before, after): @client.event async def on_guild_emoji_create(emoji): try: - await send_audit_log(f"😄 New Emoji Added\nName: {emoji.name}") + await audit_logger.log(f"New Emoji Added\nName: {emoji.name}") except Exception as e: logger.error(f"Error in on_guild_emoji_create: {e}", exc_info=True) @@ -290,7 +596,7 @@ async def on_guild_emoji_create(emoji): @client.event async def on_guild_sticker_create(sticker): try: - await send_audit_log(f"🎯 New Sticker Added\nName: {sticker.name}") + await audit_logger.log(f"New Sticker Added\nName: {sticker.name}") except Exception as e: logger.error(f"Error in on_guild_sticker_create: {e}", exc_info=True) @@ -298,7 +604,7 @@ async def on_guild_sticker_create(sticker): @client.event async def on_guild_role_create(role): try: - await send_audit_log(f"👑 Role Created\nName: {role.name}\nColor: {role.color}") + await audit_logger.log(f"Role Created\nName: {role.name}\nColor: {role.color}") except Exception as e: logger.error(f"Error in on_guild_role_create: {e}", exc_info=True) @@ -306,7 +612,7 @@ async def on_guild_role_create(role): @client.event async def on_guild_role_delete(role): try: - await send_audit_log(f"🗑️ Role Deleted\nName: {role.name}") + await audit_logger.log(f"Role Deleted\nName: {role.name}") except Exception as e: logger.error(f"Error in on_guild_role_delete: {e}", exc_info=True) @@ -314,14 +620,13 @@ async def on_guild_role_delete(role): @client.event async def on_message_edit(before, after): try: - # Skip if content didn't change (e.g. embed or pin updates) if before.content == after.content: return - logger.info(f"Message edited in {before.channel}\nBefore: {before.content}\nAfter: {after.content}") + logger.info(f"Message edited in {before.channel}") - await send_audit_log( - f"✏️ Message Edited\n" + await audit_logger.log( + f"Message Edited\n" f"Channel: {before.channel.mention}\n" f"User: {before.author.mention}\n" f"Before: {before.content}\n" @@ -363,7 +668,6 @@ async def on_raw_reaction_add(payload): logger.warning(f"Member not found: {payload.user_id}") return - # Get role name from emoji using helper function role_name = get_role_from_emoji(payload.emoji.name) role = discord.utils.get(guild.roles, name=role_name) @@ -398,7 +702,6 @@ async def on_raw_reaction_remove(payload): logger.warning(f"Member not found: {payload.user_id}") return - # Get role name from emoji using helper function role_name = get_role_from_emoji(payload.emoji.name) role = discord.utils.get(guild.roles, name=role_name) @@ -411,676 +714,602 @@ async def on_raw_reaction_remove(payload): logger.error(f"Error in on_raw_reaction_remove: {e}", exc_info=True) +# ==================== SLASH COMMANDS ==================== + @client.tree.command(name="help", description="Shows all available commands") +@handle_command_errors async def help_command(interaction: discord.Interaction): - try: - embed = discord.Embed( - color=discord.Color.from_rgb(152, 0, 0), - title="Command List", - description="Describes what commands do and how to use them." - ) + embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="Command List", + description="Describes what commands do and how to use them." + ) - embed.set_author(name="Help Page (Lotus Bot)", icon_url="https://lotusguild.org/Lotus.png") - embed.set_image(url="https://lotusguild.org/favicon.ico") - embed.set_thumbnail(url="https://lotusguild.org/favicon.ico") + embed.set_author(name="Help Page (Lotus Bot)", icon_url="https://lotusguild.org/Lotus.png") + embed.set_image(url="https://lotusguild.org/favicon.ico") + embed.set_thumbnail(url="https://lotusguild.org/favicon.ico") - # Basic Commands - embed.add_field(name="/help", value="Shows this help message with all available commands", inline=False) - embed.add_field(name="/ping", value="Shows the bot's response time in milliseconds", inline=False) + # Basic Commands + embed.add_field(name="/help", value="Shows this help message with all available commands", inline=False) + embed.add_field(name="/ping", value="Shows the bot's response time in milliseconds", inline=False) - # Fun Commands - embed.add_field(name="/8ball", value="Ask the magic 8-ball a question and receive an answer", inline=False) - embed.add_field(name="/fortune", value="Get your fortune cookie message", inline=False) - embed.add_field(name="/flip", value="Flip a coin and get heads or tails", inline=False) - embed.add_field(name="/roll", value="Roll dice (format: NdS, example: 2d6)", inline=False) - embed.add_field(name="/random", value="Generate a random number between specified range", inline=False) - embed.add_field(name="/rps", value="Play Rock Paper Scissors against the bot", inline=False) - embed.add_field(name="/poll", value="Create a simple yes/no poll", inline=False) + # Fun Commands + embed.add_field(name="/8ball", value="Ask the magic 8-ball a question and receive an answer", inline=False) + embed.add_field(name="/fortune", value="Get your fortune cookie message", inline=False) + embed.add_field(name="/flip", value="Flip a coin and get heads or tails", inline=False) + embed.add_field(name="/roll", value="Roll dice (format: NdS, example: 2d6)", inline=False) + embed.add_field(name="/random", value="Generate a random number between specified range", inline=False) + embed.add_field(name="/rps", value="Play Rock Paper Scissors against the bot", inline=False) + embed.add_field(name="/poll", value="Create a simple yes/no poll", inline=False) - # Game Commands - embed.add_field(name="/agent", value="Get a random Valorant agent with their role", inline=False) - embed.add_field(name="/champion", value="Get a random League of Legends champion with their lane", inline=False) - embed.add_field(name="/minecraft", value="Whitelists a player on the Minecraft server and shows server info", inline=False) - embed.add_field(name="/hytale", value="Request whitelist on the Hytale server", inline=False) - embed.add_field(name="/problem", value="Express your opinion about Canada geese", inline=False) + # Game Commands + embed.add_field(name="/agent", value="Get a random Valorant agent with their role", inline=False) + embed.add_field(name="/champion", value="Get a random League of Legends champion with their lane", inline=False) + embed.add_field(name="/minecraft", value="Whitelists a player on the Minecraft server and shows server info", inline=False) + embed.add_field(name="/hytale", value="Request whitelist on the Hytale server", inline=False) + embed.add_field(name="/problem", value="Express your opinion about Canada geese", inline=False) - # Interaction Commands - embed.add_field(name="/kill", value="Kill another user with a random animation", inline=False) - embed.add_field(name="/punch", value="Punch another user with a random animation", inline=False) - embed.add_field(name="/hug", value="Hug another user with a random animation", inline=False) - embed.add_field(name="/revive", value="Revive another user with a random animation", inline=False) + # Interaction Commands + embed.add_field(name="/kill", value="Kill another user with a random animation", inline=False) + embed.add_field(name="/punch", value="Punch another user with a random animation", inline=False) + embed.add_field(name="/hug", value="Hug another user with a random animation", inline=False) + embed.add_field(name="/revive", value="Revive another user with a random animation", inline=False) - # Admin Commands (Only visible to admins) - if discord.utils.get(interaction.user.roles, id=ADMIN_ROLE_ID): - embed.add_field(name="🛡️ Admin Commands", value="---------------", inline=False) - embed.add_field(name="/clear", value="Clears specified number of messages from the channel", inline=False) - embed.add_field(name="/lockdown", value="Lock a channel to prevent messages", inline=False) - embed.add_field(name="/unlock", value="Unlock a previously locked channel", inline=False) + # Admin Commands (Only visible to admins) + if discord.utils.get(interaction.user.roles, id=ADMIN_ROLE_ID): + embed.add_field(name="Admin Commands", value="---------------", inline=False) + embed.add_field(name="/clear", value="Clears specified number of messages from the channel", inline=False) + embed.add_field(name="/lockdown", value="Lock a channel to prevent messages", inline=False) + embed.add_field(name="/unlock", value="Unlock a previously locked channel", inline=False) + embed.add_field(name="/status", value="Check bot system health and service status", inline=False) - embed.set_footer(text="Made by https://lotusguild.org") - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in help command: {e}", exc_info=True) - await interaction.response.send_message("An error occurred while showing help.", ephemeral=True) + embed.set_footer(text="Made by https://lotusguild.org") + await interaction.response.send_message(embed=embed) @client.tree.command(name="ping", description="Check the bot's latency") +@handle_command_errors async def ping(interaction: discord.Interaction): - try: - await interaction.response.send_message(f"Pong! {round(client.latency * 1000)}ms") - except Exception as e: - logger.error(f"Error in ping command: {e}", exc_info=True) + await interaction.response.send_message(f"Pong! {round(client.latency * 1000)}ms") @client.tree.command(name="problem", description="Express your opinion about Canada geese") +@handle_command_errors async def problem(interaction: discord.Interaction): - try: - media_file = Path("media/canadagoose.gif") - if not media_file.exists(): - logger.warning(f"Media file not found: {media_file}") - await interaction.response.send_message( - "If you got a problem with canada gooses then you got a problem with me and I suggest you let that one marinate." - ) - else: - await interaction.response.send_message( - "If you got a problem with canada gooses then you got a problem with me and I suggest you let that one marinate.", - file=discord.File(str(media_file)) - ) - except Exception as e: - logger.error(f"Error in problem command: {e}", exc_info=True) - await interaction.response.send_message("An error occurred.", ephemeral=True) + media_file = media_manager.get_random("canadagoose") + msg = "If you got a problem with canada gooses then you got a problem with me and I suggest you let that one marinate." + if media_file: + await interaction.response.send_message(msg, file=discord.File(media_file)) + else: + await interaction.response.send_message(msg) @client.tree.command(name="clear", description="Clear messages from the channel") @app_commands.describe(amount="Number of messages to delete") @has_role_check(ADMIN_ROLE_ID) +@handle_command_errors async def clear(interaction: discord.Interaction, amount: int = 5): - try: - await interaction.response.defer(ephemeral=True) - deleted = await interaction.channel.purge(limit=amount) - await interaction.followup.send(f"Successfully cleared {len(deleted)} messages!", ephemeral=True) - except Exception as e: - logger.error(f"Error in clear command: {e}", exc_info=True) - await interaction.followup.send("Failed to clear messages.", ephemeral=True) + await interaction.response.defer(ephemeral=True) + deleted = await interaction.channel.purge(limit=amount) + await interaction.followup.send(f"Successfully cleared {len(deleted)} messages!", ephemeral=True) @client.tree.command(name="lockdown", description="Lock a channel") @has_role_check(ADMIN_ROLE_ID) +@handle_command_errors async def lockdown(interaction: discord.Interaction, channel: discord.TextChannel = None): - try: - channel = channel or interaction.channel - await channel.set_permissions(interaction.guild.default_role, send_messages=False) - await send_audit_log(f"🔒 Channel Locked\nChannel: {channel.name}") - await interaction.response.send_message(f"Locked {channel.mention}") - except Exception as e: - logger.error(f"Error in lockdown command: {e}", exc_info=True) - await interaction.response.send_message("Failed to lock channel.", ephemeral=True) + channel = channel or interaction.channel + await channel.set_permissions(interaction.guild.default_role, send_messages=False) + await audit_logger.log(f"Channel Locked\nChannel: {channel.name}") + await interaction.response.send_message(f"Locked {channel.mention}") @client.tree.command(name="unlock", description="Unlock a channel") @has_role_check(ADMIN_ROLE_ID) +@handle_command_errors async def unlock(interaction: discord.Interaction, channel: discord.TextChannel = None): - try: - channel = channel or interaction.channel - await channel.set_permissions(interaction.guild.default_role, send_messages=True) - await send_audit_log(f"🔓 Channel Unlocked\nChannel: {channel.name}") - await interaction.response.send_message(f"Unlocked {channel.mention}") - except Exception as e: - logger.error(f"Error in unlock command: {e}", exc_info=True) - await interaction.response.send_message("Failed to unlock channel.", ephemeral=True) + channel = channel or interaction.channel + await channel.set_permissions(interaction.guild.default_role, send_messages=True) + await audit_logger.log(f"Channel Unlocked\nChannel: {channel.name}") + await interaction.response.send_message(f"Unlocked {channel.mention}") +# --- Issue #15: Autocomplete for minecraft usernames --- @client.tree.command(name="minecraft", description="Whitelist a player on the Minecraft server") @app_commands.describe(minecraft_username="The Minecraft username to whitelist") +@app_commands.autocomplete(minecraft_username=minecraft_username_autocomplete) @has_role_check(MINECRAFT_ROLE_ID) +@handle_command_errors async def minecraft(interaction: discord.Interaction, minecraft_username: str): + # Validate username using cached checker (Issue #12) + if not await username_cache.is_valid(minecraft_username, client.http_session): + await interaction.response.send_message("Invalid MC Username", ephemeral=True) + return + + if not MINECRAFT_RCON_PASSWORD: + logger.error("MINECRAFT_RCON_PASSWORD not configured") + await interaction.response.send_message( + "Server configuration error. Please contact an administrator.", + ephemeral=True + ) + return + + await interaction.response.defer() + + # --- Issue #5: Async RCON with timeout --- try: - # Validate username asynchronously - if not await is_valid_minecraft_username(minecraft_username): - await interaction.response.send_message("Invalid MC Username", ephemeral=True) - return - - # Check if RCON password is configured - if not MINECRAFT_RCON_PASSWORD: - logger.error("MINECRAFT_RCON_PASSWORD not configured") - await interaction.response.send_message( - "Server configuration error. Please contact an administrator.", - ephemeral=True - ) - return - - await interaction.response.defer() - - try: - with MCRcon(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD) as mcr: - response = mcr.command(f"whitelist add {minecraft_username}") - logger.info(f"RCON response: {response}") - except Exception as e: - logger.error(f"RCON error: {e}", exc_info=True) - await interaction.followup.send( - "An error occurred while whitelisting the player (jared will fix just let him know)." - ) - return - - minecraft_embed = discord.Embed( - color=discord.Color.from_rgb(152, 0, 0), - title="Minecraft" - ) - minecraft_embed.set_author( - name="(Lotus Bot)", - icon_url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" - ) - minecraft_embed.set_image( - url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" - ) - minecraft_embed.set_thumbnail( - url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" - ) - minecraft_embed.add_field(name="You", value="have been whitelisted on the SMP", inline=False) - minecraft_embed.add_field(name="Server Address", value="minecraft.lotusguild.org", inline=False) - minecraft_embed.add_field(name="Version", value="1.21.11", inline=False) - minecraft_embed.set_footer(text="Thanks for using Lotus Minecraft Server!") - await interaction.followup.send(embed=minecraft_embed) + response = await rcon_command(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD, + f"whitelist add {minecraft_username}") + logger.info(f"RCON response: {response}") except Exception as e: - logger.error(f"Error in minecraft command: {e}", exc_info=True) - if not interaction.response.is_done(): - await interaction.response.send_message("An error occurred.", ephemeral=True) - else: - await interaction.followup.send("An error occurred.", ephemeral=True) - + logger.error(f"RCON error: {e}", exc_info=True) + await interaction.followup.send( + "An error occurred while whitelisting the player (jared will fix just let him know)." + ) + return + # Issue #15: Track whitelisted username + save_whitelisted_username(minecraft_username) + minecraft_embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="Minecraft" + ) + minecraft_embed.set_author( + name="(Lotus Bot)", + icon_url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" + ) + minecraft_embed.set_image( + url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" + ) + minecraft_embed.set_thumbnail( + url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=4aoZxX5-FHE3m_Ywwz1uGo3iNW53kmFztxfUw91PdOgphPNxayLFicNuxPvit1OYTpY&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D" + ) + minecraft_embed.add_field(name="You", value="have been whitelisted on the SMP", inline=False) + minecraft_embed.add_field(name="Server Address", value="minecraft.lotusguild.org", inline=False) + minecraft_embed.add_field(name="Version", value="1.21.11", inline=False) + minecraft_embed.set_footer(text="Thanks for using Lotus Minecraft Server!") + await interaction.followup.send(embed=minecraft_embed) @client.tree.command(name="hytale", description="Request whitelist on the Hytale server") @app_commands.describe(hytale_username="Your Hytale username") @has_role_check(HYTALE_ROLE_ID) +@handle_command_errors async def hytale(interaction: discord.Interaction, hytale_username: str): """Request whitelist on the Hytale server""" - try: - await interaction.response.defer() - - # Validate username - if not hytale_username.replace('_', '').replace('-', '').isalnum(): - await interaction.followup.send("Invalid username. Use only letters, numbers, _ and -", ephemeral=True) - return - if len(hytale_username) < 3 or len(hytale_username) > 16: - await interaction.followup.send("Username must be 3-16 characters", ephemeral=True) - return - - # Log to audit - await send_audit_log( - f"🎮 Hytale Whitelist Request\nUser: {interaction.user.mention}\nUsername: `{hytale_username}`\nAction: Run `/whitelist add {hytale_username}`", - color=0x00ff00 + await interaction.response.defer() + + # --- Issue #11: Use constants instead of magic numbers --- + if not hytale_username.replace('_', '').replace('-', '').isalnum(): + await interaction.followup.send("Invalid username. Use only letters, numbers, _ and -", ephemeral=True) + return + if not (MIN_USERNAME_LENGTH <= len(hytale_username) <= MAX_USERNAME_LENGTH): + await interaction.followup.send( + f"Username must be {MIN_USERNAME_LENGTH}-{MAX_USERNAME_LENGTH} characters", + ephemeral=True ) - - # Response embed - embed = discord.Embed(color=discord.Color.from_rgb(152, 0, 0), title="Hytale") - embed.set_author(name="(Lotus Bot)", icon_url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=wd3-Z4zFdrR6WBUfXLnBN8RgeT9tivgQwT6iDN3T0AaBIOfyIuYrbEszABB8OvUplFM&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D") - embed.add_field(name="Request Submitted", value=f"Whitelist request for `{hytale_username}` sent!", inline=False) - embed.add_field(name="Server Address", value="hytale.lotusguild.org", inline=False) - embed.add_field(name="Status", value="An admin will whitelist you shortly!", inline=False) - embed.set_footer(text="Welcome to Lotus Hytale Server!") - await interaction.followup.send(embed=embed) - logger.info(f"Hytale whitelist request: {hytale_username} by {interaction.user.name}") - except Exception as e: - logger.error(f"Error in hytale command: {e}", exc_info=True) - if not interaction.response.is_done(): - await interaction.response.send_message("An error occurred.", ephemeral=True) - else: - await interaction.followup.send("An error occurred.", ephemeral=True) + return -# Track last usage per user -ask_cooldowns = {} -COOLDOWN_MINUTES = 2 + await audit_logger.log( + f"Hytale Whitelist Request\nUser: {interaction.user.mention}\nUsername: `{hytale_username}`\nAction: Run `/whitelist add {hytale_username}`", + color=0x00ff00 + ) + + embed = discord.Embed(color=discord.Color.from_rgb(152, 0, 0), title="Hytale") + embed.set_author(name="(Lotus Bot)", icon_url="https://photos.lotusguild.org/api/assets/3c4eb2da-0d06-407f-bdb7-c9e4cf795f0a/thumbnail?key=wd3-Z4zFdrR6WBUfXLnBN8RgeT9tivgQwT6iDN3T0AaBIOfyIuYrbEszABB8OvUplFM&size=preview&c=jUqDBQAWF9iId3J%2FyAeIcIAICEd4d3BzSA%3D%3D") + embed.add_field(name="Request Submitted", value=f"Whitelist request for `{hytale_username}` sent!", inline=False) + embed.add_field(name="Server Address", value="hytale.lotusguild.org", inline=False) + embed.add_field(name="Status", value="An admin will whitelist you shortly!", inline=False) + embed.set_footer(text="Welcome to Lotus Hytale Server!") + await interaction.followup.send(embed=embed) + logger.info(f"Hytale whitelist request: {hytale_username} by {interaction.user.name}") +# --- Issue #19: Rate Limiting for AI --- @client.tree.command(name="ask", description="Ask a question to Lotus LLM") @app_commands.describe(question="Your question for the AI") +@app_commands.checks.cooldown(1, COOLDOWN_MINUTES * 60, key=lambda i: i.user.id) @has_role_check(COOL_KIDS_ROLE_ID) +@handle_command_errors async def ask(interaction: discord.Interaction, question: str): - try: - # Check cooldown - user_id = interaction.user.id - current_time = datetime.now() - if user_id in ask_cooldowns: - time_diff = current_time - ask_cooldowns[user_id] - if time_diff < timedelta(minutes=COOLDOWN_MINUTES): - remaining = COOLDOWN_MINUTES - (time_diff.seconds / 60) - await interaction.response.send_message( - f"Please wait {remaining:.1f} minutes before asking another question!", - ephemeral=True - ) - return + # --- Issue #8: Sanitize input --- + question = sanitize_input(question) + if not question: + await interaction.response.send_message("Please provide a valid question.", ephemeral=True) + return - await interaction.response.defer() + await interaction.response.defer() - # Select model based on user ID - model = "lotusllmben" if user_id == 460640040096104459 else "lotusllm" - logger.info(f"Sending question to Ollama: {question}") + # Select model based on user ID + model = "lotusllmben" if interaction.user.id == 460640040096104459 else "lotusllm" + logger.info(f"Sending question to Ollama: {question}") - async with aiohttp.ClientSession() as session: - async with session.post( - "http://10.10.10.157:11434/api/generate", - json={ - "model": model, - "prompt": question, - "stream": True - } - ) as response: - full_response = "" - async for line in response.content: - try: - chunk = json.loads(line) - logger.info(f"Received chunk: {chunk}") - if "response" in chunk: - full_response += chunk["response"] - except json.JSONDecodeError as e: - logger.error(f"Failed to parse JSON: {e}") - logger.error(f"Raw line: {line}") + async with client.http_session.post( + f"{OLLAMA_URL}/api/generate", + json={ + "model": model, + "prompt": question, + "stream": True + } + ) as response: + full_response = "" + async for line in response.content: + try: + chunk = json.loads(line) + if "response" in chunk: + full_response += chunk["response"] + except json.JSONDecodeError as e: + logger.error(f"Failed to parse JSON: {e}") - # Update cooldown timestamp - ask_cooldowns[user_id] = current_time - - embed = discord.Embed( - title="Lotus LLM", - description=full_response if full_response else "No response received from server", - color=discord.Color.from_rgb(152, 0, 0) - ) - embed.add_field(name="Question", value=question, inline=False) - embed.set_footer(text=f"Asked by {interaction.user.display_name}") - await interaction.followup.send(embed=embed) - except Exception as e: - logger.error(f"Error in ask command: {e}", exc_info=True) - if not interaction.response.is_done(): - await interaction.response.send_message("An error occurred.", ephemeral=True) - else: - await interaction.followup.send("An error occurred.", ephemeral=True) + embed = discord.Embed( + title="Lotus LLM", + description=full_response if full_response else "No response received from server", + color=discord.Color.from_rgb(152, 0, 0) + ) + embed.add_field(name="Question", value=question, inline=False) + embed.set_footer(text=f"Asked by {interaction.user.display_name}") + await interaction.followup.send(embed=embed) @client.tree.command(name="8ball", description="Ask the magic 8-ball a question") @app_commands.describe(question="What would you like to ask the 8ball?") +@handle_command_errors async def eight_ball(interaction: discord.Interaction, question: str): - try: - possible_responses = [ - # Positive answers - "It is certain", "Without a doubt", "You may rely on it", - "Yes definitely", "It is decidedly so", "As I see it, yes", - "Most likely", "Yes sir!", "Hell yeah my dude", "100% easily", - # Neutral answers - "Reply hazy try again", "Ask again later", "Better not tell you now", - "Cannot predict now", "Concentrate and ask again", "Idk bro", - # Negative answers - "Don't count on it", "My reply is no", "My sources say no", - "Outlook not so good", "Very doubtful", "Hell no", "Prolly not" - ] + possible_responses = [ + # Positive answers + "It is certain", "Without a doubt", "You may rely on it", + "Yes definitely", "It is decidedly so", "As I see it, yes", + "Most likely", "Yes sir!", "Hell yeah my dude", "100% easily", + # Neutral answers + "Reply hazy try again", "Ask again later", "Better not tell you now", + "Cannot predict now", "Concentrate and ask again", "Idk bro", + # Negative answers + "Don't count on it", "My reply is no", "My sources say no", + "Outlook not so good", "Very doubtful", "Hell no", "Prolly not" + ] - embed = discord.Embed( - color=discord.Color.from_rgb(152, 0, 0), - title="🎱 Magic 8-Ball" - ) - embed.add_field(name="Question", value=question, inline=False) - embed.add_field(name="Answer", value=random.choice(possible_responses), inline=False) - embed.set_footer(text=f"Asked by {interaction.user.display_name}") + embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="Magic 8-Ball" + ) + embed.add_field(name="Question", value=question, inline=False) + embed.add_field(name="Answer", value=random.choice(possible_responses), inline=False) + embed.set_footer(text=f"Asked by {interaction.user.display_name}") - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in 8ball command: {e}", exc_info=True) + await interaction.response.send_message(embed=embed) @client.tree.command(name="fortune", description="Get your fortune cookie message") +@handle_command_errors async def fortune(interaction: discord.Interaction): - try: - possible_responses = [ - # Humorous fortunes - "If you eat something & nobody sees you eat it, it has no calories", - "Your pet is plotting world domination", - "Error 404: Fortune not found. Try again after system reboot", - "The fortune you seek is in another cookie", + possible_responses = [ + # Humorous fortunes + "If you eat something & nobody sees you eat it, it has no calories", + "Your pet is plotting world domination", + "Error 404: Fortune not found. Try again after system reboot", + "The fortune you seek is in another cookie", - # Classic fortunes with a twist - "A journey of a thousand miles begins with ordering delivery", - "You will find great fortune... in between your couch cushions", - "A true friend is someone who tells you when your stream is muted", + # Classic fortunes with a twist + "A journey of a thousand miles begins with ordering delivery", + "You will find great fortune... in between your couch cushions", + "A true friend is someone who tells you when your stream is muted", - # Gaming-themed fortunes - "Your next competitive match will be legendary", - "The cake is still a lie", - "Press Alt+F4 for instant success", - "You will not encounter any campers today", - "Your tank will have a healer", - "No one will steal your pentakill", - "Your random teammate will have a mic", - "You will find diamonds on your first dig", - "The boss will drop the rare loot", - "Your speedrun will be WR pace", - "No lag spikes in your next match", - "Your gaming chair will grant you powers", - "The RNG gods will bless you", - "You will not get third partied", - "Your squad will actually stick together", - "The enemy team will forfeit at 15", - "Your aim will be crispy today", - "You will escape the backrooms", - "The imposter will not sus you", - "Your Minecraft bed will remain unbroken", - "You will get Play of the Game", + # Gaming-themed fortunes + "Your next competitive match will be legendary", + "The cake is still a lie", + "Press Alt+F4 for instant success", + "You will not encounter any campers today", + "Your tank will have a healer", + "No one will steal your pentakill", + "Your random teammate will have a mic", + "You will find diamonds on your first dig", + "The boss will drop the rare loot", + "Your speedrun will be WR pace", + "No lag spikes in your next match", + "Your gaming chair will grant you powers", + "The RNG gods will bless you", + "You will not get third partied", + "Your squad will actually stick together", + "The enemy team will forfeit at 15", + "Your aim will be crispy today", + "You will escape the backrooms", + "The imposter will not sus you", + "Your Minecraft bed will remain unbroken", + "You will get Play of the Game", - # Internet culture fortunes - "Your next meme will go viral", - "Someone is talking about you in their Discord server", - "Your FBI agent thinks you're hilarious", - "Your next TikTok will hit the FYP, if the government doesn't ban it first", - "Someone will actually read your Twitter thread", - "Your DMs will be blessed with quality memes today", - "Touch grass (respectfully)", - "The algorithm will be in your favor today", - "Your next Spotify shuffle will hit different", - "Someone saved your Instagram post", - "Your Reddit comment will get gold", - "POV: You're about to go viral", - "Main character energy detected", - "No cap, you're gonna have a great day fr fr", - "Your rizz levels are increasing", - "You will not get ratio'd today", - "Someone will actually use your custom emoji", - "Your next selfie will be iconic", + # Internet culture fortunes + "Your next meme will go viral", + "Someone is talking about you in their Discord server", + "Your FBI agent thinks you're hilarious", + "Your next TikTok will hit the FYP, if the government doesn't ban it first", + "Someone will actually read your Twitter thread", + "Your DMs will be blessed with quality memes today", + "Touch grass (respectfully)", + "The algorithm will be in your favor today", + "Your next Spotify shuffle will hit different", + "Someone saved your Instagram post", + "Your Reddit comment will get gold", + "POV: You're about to go viral", + "Main character energy detected", + "No cap, you're gonna have a great day fr fr", + "Your rizz levels are increasing", + "You will not get ratio'd today", + "Someone will actually use your custom emoji", + "Your next selfie will be iconic", - # Original favorites - "Buy a dolphin - your life will have a porpoise", - "Stop procrastinating - starting tomorrow", - "Catch fire with enthusiasm - people will come for miles to watch you burn", + # Original favorites + "Buy a dolphin - your life will have a porpoise", + "Stop procrastinating - starting tomorrow", + "Catch fire with enthusiasm - people will come for miles to watch you burn", - # Tech & computer nerd fortunes - "Your code will compile on the first try today", - "A semicolon will save your day", - "The bug you've been hunting is just a typo", - "Your next Git commit will be perfect", - "You will find the solution on the first StackOverflow link", - "Your Docker container will build without errors", - "The cloud is just someone else's computer", - "Your backup strategy will soon prove its worth", - "A mechanical keyboard is in your future", - "You will finally understand regex... maybe", - "Your CSS will align perfectly on the first try", - "Someone will star your GitHub repo today", - "Your Linux installation will not break after updates", - "You will remember to push your changes before shutdown", - "Your code comments will actually make sense in 6 months", - "The missing curly brace is on line 247", - "Have you tried turning it off and on again?", - "Your next pull request will be merged without comments", - "Your keyboard RGB will sync perfectly today", - "You will find that memory leak", - "Your next algorithm will have O(1) complexity", - "The force quit was strong with this one", - "Ctrl+S will save you today", - "Your next Python script will need no debugging", - "Your next API call will return 200 OK" - ] + # Tech & computer nerd fortunes + "Your code will compile on the first try today", + "A semicolon will save your day", + "The bug you've been hunting is just a typo", + "Your next Git commit will be perfect", + "You will find the solution on the first StackOverflow link", + "Your Docker container will build without errors", + "The cloud is just someone else's computer", + "Your backup strategy will soon prove its worth", + "A mechanical keyboard is in your future", + "You will finally understand regex... maybe", + "Your CSS will align perfectly on the first try", + "Someone will star your GitHub repo today", + "Your Linux installation will not break after updates", + "You will remember to push your changes before shutdown", + "Your code comments will actually make sense in 6 months", + "The missing curly brace is on line 247", + "Have you tried turning it off and on again?", + "Your next pull request will be merged without comments", + "Your keyboard RGB will sync perfectly today", + "You will find that memory leak", + "Your next algorithm will have O(1) complexity", + "The force quit was strong with this one", + "Ctrl+S will save you today", + "Your next Python script will need no debugging", + "Your next API call will return 200 OK" + ] - embed = discord.Embed( - color=discord.Color.from_rgb(152, 0, 0), - title="🥠 Fortune Cookie" - ) - embed.add_field(name="Your Fortune", value=random.choice(possible_responses), inline=False) - embed.set_footer(text=f"Cracked open by {interaction.user.display_name}") + embed = discord.Embed( + color=discord.Color.from_rgb(152, 0, 0), + title="Fortune Cookie" + ) + embed.add_field(name="Your Fortune", value=random.choice(possible_responses), inline=False) + embed.set_footer(text=f"Cracked open by {interaction.user.display_name}") - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in fortune command: {e}", exc_info=True) + await interaction.response.send_message(embed=embed) @client.tree.command(name="flip", description="Flip a coin") +@handle_command_errors async def flip(interaction: discord.Interaction): - try: - result = random.choice(["Heads", "Tails"]) - embed = discord.Embed(title="🪙 Coin Flip", color=discord.Color.from_rgb(152, 0, 0)) - embed.add_field(name="Result", value=result) - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in flip command: {e}", exc_info=True) + result = random.choice(["Heads", "Tails"]) + embed = discord.Embed(title="Coin Flip", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Result", value=result) + await interaction.response.send_message(embed=embed) @client.tree.command(name="roll", description="Roll dice (e.g. 2d6)") @app_commands.describe(dice="Format: NdS (N=number of dice, S=sides)") +@handle_command_errors async def roll(interaction: discord.Interaction, dice: str = "1d6"): try: num, sides = map(int, dice.lower().split('d')) - results = [random.randint(1, sides) for _ in range(num)] - embed = discord.Embed(title="🎲 Dice Roll", color=discord.Color.from_rgb(152, 0, 0)) - embed.add_field(name="Results", value=f"Rolls: {results}\nTotal: {sum(results)}") - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in roll command: {e}", exc_info=True) + except ValueError: await interaction.response.send_message("Please use format: NdS (example: 2d6)", ephemeral=True) + return + + # --- Issue #11: Validate against constants --- + if num < 1 or num > MAX_DICE_COUNT: + await interaction.response.send_message(f"Number of dice must be 1-{MAX_DICE_COUNT}", ephemeral=True) + return + if sides < 2 or sides > MAX_DICE_SIDES: + await interaction.response.send_message(f"Sides must be 2-{MAX_DICE_SIDES}", ephemeral=True) + return + + results = [random.randint(1, sides) for _ in range(num)] + embed = discord.Embed(title="Dice Roll", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Results", value=f"Rolls: {results}\nTotal: {sum(results)}") + await interaction.response.send_message(embed=embed) @client.tree.command(name="random", description="Generate a random number") @app_commands.describe(min="Minimum number", max="Maximum number") +@handle_command_errors async def random_number(interaction: discord.Interaction, min: int = 1, max: int = 100): - try: - result = random.randint(min, max) - embed = discord.Embed(title="🎯 Random Number", color=discord.Color.from_rgb(152, 0, 0)) - embed.add_field(name="Result", value=str(result)) - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in random command: {e}", exc_info=True) + result = random.randint(min, max) + embed = discord.Embed(title="Random Number", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Result", value=str(result)) + await interaction.response.send_message(embed=embed) @client.tree.command(name="rps", description="Play Rock Paper Scissors") @app_commands.describe(choice="Your choice: rock, paper, or scissors") +@handle_command_errors async def rps(interaction: discord.Interaction, choice: str): - try: - choices = ["rock", "paper", "scissors"] - bot_choice = random.choice(choices) - choice = choice.lower() + choices = ["rock", "paper", "scissors"] + bot_choice = random.choice(choices) + choice = choice.lower() - if choice not in choices: - await interaction.response.send_message("Please choose rock, paper, or scissors!", ephemeral=True) - return + if choice not in choices: + await interaction.response.send_message("Please choose rock, paper, or scissors!", ephemeral=True) + return - embed = discord.Embed(title="✂️ Rock Paper Scissors", color=discord.Color.from_rgb(152, 0, 0)) - embed.add_field(name="Your Choice", value=choice.capitalize()) - embed.add_field(name="Bot's Choice", value=bot_choice.capitalize()) + embed = discord.Embed(title="Rock Paper Scissors", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Your Choice", value=choice.capitalize()) + embed.add_field(name="Bot's Choice", value=bot_choice.capitalize()) - if choice == bot_choice: - result = "It's a tie!" - elif (choice == "rock" and bot_choice == "scissors") or \ - (choice == "paper" and bot_choice == "rock") or \ - (choice == "scissors" and bot_choice == "paper"): - result = "You win!" - else: - result = "Bot wins!" + if choice == bot_choice: + result = "It's a tie!" + elif (choice == "rock" and bot_choice == "scissors") or \ + (choice == "paper" and bot_choice == "rock") or \ + (choice == "scissors" and bot_choice == "paper"): + result = "You win!" + else: + result = "Bot wins!" - embed.add_field(name="Result", value=result, inline=False) - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in rps command: {e}", exc_info=True) + embed.add_field(name="Result", value=result, inline=False) + await interaction.response.send_message(embed=embed) @client.tree.command(name="poll", description="Create a simple yes/no poll") @app_commands.describe(question="The poll question") +@handle_command_errors async def poll(interaction: discord.Interaction, question: str): - try: - embed = discord.Embed(title="📊 Poll", description=question, color=discord.Color.from_rgb(152, 0, 0)) - embed.set_footer(text=f"Created by {interaction.user.display_name}") - await interaction.response.send_message(embed=embed) - message = await interaction.original_response() - await message.add_reaction("👍") - await message.add_reaction("👎") - except Exception as e: - logger.error(f"Error in poll command: {e}", exc_info=True) + embed = discord.Embed(title="Poll", description=question, color=discord.Color.from_rgb(152, 0, 0)) + embed.set_footer(text=f"Created by {interaction.user.display_name}") + await interaction.response.send_message(embed=embed) + message = await interaction.original_response() + await message.add_reaction("👍") + await message.add_reaction("👎") +# --- Issue #10: DRY media commands --- @client.tree.command(name="kill", description="Kill another user") @app_commands.describe(member="The user to kill") +@handle_command_errors async def kill(interaction: discord.Interaction, member: discord.Member): - try: - kills = ["media/kill.gif", "media/kill1.gif", "media/kill2.gif", "media/kill3.gif"] - available_kills = [k for k in kills if Path(k).exists()] - - if not available_kills: - logger.warning("No kill media files found") - await interaction.response.send_message(f"You killed {member}") - else: - await interaction.response.send_message( - f"You killed {member}", - file=discord.File(random.choice(available_kills)) - ) - except Exception as e: - logger.error(f"Error in kill command: {e}", exc_info=True) + await send_interaction_media(interaction, member, 'kill', 'kill') @client.tree.command(name="punch", description="Punch another user") @app_commands.describe(member="The user to punch") +@handle_command_errors async def punch(interaction: discord.Interaction, member: discord.Member): - try: - punches = ["media/punch.gif", "media/punch1.gif", "media/punch2.gif", "media/punch3.gif"] - available_punches = [p for p in punches if Path(p).exists()] - - if not available_punches: - logger.warning("No punch media files found") - await interaction.response.send_message(f"You punched {member}") - else: - await interaction.response.send_message( - f"You punched {member}", - file=discord.File(random.choice(available_punches)) - ) - except Exception as e: - logger.error(f"Error in punch command: {e}", exc_info=True) + await send_interaction_media(interaction, member, 'punch', 'punch') @client.tree.command(name="hug", description="Hug another user") @app_commands.describe(member="The user to hug") +@handle_command_errors async def hug(interaction: discord.Interaction, member: discord.Member): - try: - hugs = ["media/hug.gif", "media/hug1.gif", "media/hug2.gif", "media/hug3.gif"] - available_hugs = [h for h in hugs if Path(h).exists()] - - if not available_hugs: - logger.warning("No hug media files found") - await interaction.response.send_message(f"{member} has been squeezed tightly!") - else: - await interaction.response.send_message( - f"{member} has been squeezed tightly!", - file=discord.File(random.choice(available_hugs)) - ) - except Exception as e: - logger.error(f"Error in hug command: {e}", exc_info=True) + await send_interaction_media(interaction, member, 'hug', 'hug') @client.tree.command(name="revive", description="Revive another user") @app_commands.describe(member="The user to revive") +@handle_command_errors async def revive(interaction: discord.Interaction, member: discord.Member): - try: - revives = ["media/revive.gif", "media/revive1.gif", "media/revive2.gif", "media/revive3.gif"] - available_revives = [r for r in revives if Path(r).exists()] - - if not available_revives: - logger.warning("No revive media files found") - await interaction.response.send_message(f"{member} has been brought back to life") - else: - await interaction.response.send_message( - f"{member} has been brought back to life", - file=discord.File(random.choice(available_revives)) - ) - except Exception as e: - logger.error(f"Error in revive command: {e}", exc_info=True) + await send_interaction_media(interaction, member, 'revive', 'revive') @client.tree.command(name="agent", description="Get a random Valorant agent") +@handle_command_errors async def agent(interaction: discord.Interaction): - try: - agents = { - "Duelists": ["Jett", "Phoenix", "Raze", "Reyna", "Yoru", "Neon", "Iso"], - "Controllers": ["Brimstone", "Viper", "Omen", "Astra", "Harbor"], - "Initiators": ["Sova", "Breach", "Skye", "KAY/O", "Fade", "Gekko"], - "Sentinels": ["Killjoy", "Cypher", "Sage", "Chamber", "Deadlock"] - } + agents = { + "Duelists": ["Jett", "Phoenix", "Raze", "Reyna", "Yoru", "Neon", "Iso"], + "Controllers": ["Brimstone", "Viper", "Omen", "Astra", "Harbor"], + "Initiators": ["Sova", "Breach", "Skye", "KAY/O", "Fade", "Gekko"], + "Sentinels": ["Killjoy", "Cypher", "Sage", "Chamber", "Deadlock"] + } - # Pick a random category and agent - category = random.choice(list(agents.keys())) - agent = random.choice(agents[category]) + category = random.choice(list(agents.keys())) + selected_agent = random.choice(agents[category]) - embed = discord.Embed( - title="🎯 Valorant Agent Picker", - color=discord.Color.from_rgb(152, 0, 0) - ) - embed.add_field(name="Selected Agent", value=agent, inline=False) - embed.add_field(name="Role", value=category, inline=False) - embed.set_footer(text=f"Selected for {interaction.user.display_name}") + embed = discord.Embed( + title="Valorant Agent Picker", + color=discord.Color.from_rgb(152, 0, 0) + ) + embed.add_field(name="Selected Agent", value=selected_agent, inline=False) + embed.add_field(name="Role", value=category, inline=False) + embed.set_footer(text=f"Selected for {interaction.user.display_name}") - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in agent command: {e}", exc_info=True) + await interaction.response.send_message(embed=embed) @client.tree.command(name="champion", description="Get a random League of Legends champion") +@handle_command_errors async def champion(interaction: discord.Interaction): - try: - champions = { - "Top": ["Aatrox", "Camille", "Darius", "Fiora", "Garen", "Irelia", "Jax", "K'Sante", "Malphite", "Mordekaiser", "Nasus", "Ornn", "Riven", "Sett", "Teemo"], - "Jungle": ["Bel'Veth", "Diana", "Elise", "Evelynn", "Graves", "Hecarim", "Kayn", "Kindred", "Lee Sin", "Master Yi", "Nidalee", "Rammus", "Viego", "Warwick"], - "Mid": ["Ahri", "Akali", "Annie", "Cassiopeia", "Fizz", "Katarina", "LeBlanc", "Lux", "Orianna", "Syndra", "Twisted Fate", "Veigar", "Yasuo", "Zed"], - "Bot": ["Aphelios", "Ashe", "Caitlyn", "Draven", "Ezreal", "Jhin", "Jinx", "Kai'Sa", "Lucian", "Miss Fortune", "Samira", "Tristana", "Vayne", "Xayah"], - "Support": ["Blitzcrank", "Brand", "Janna", "Leona", "Lulu", "Morgana", "Nautilus", "Pyke", "Rakan", "Senna", "Seraphine", "Soraka", "Thresh", "Yuumi"] - } + champions = { + "Top": ["Aatrox", "Camille", "Darius", "Fiora", "Garen", "Irelia", "Jax", "K'Sante", "Malphite", "Mordekaiser", "Nasus", "Ornn", "Riven", "Sett", "Teemo"], + "Jungle": ["Bel'Veth", "Diana", "Elise", "Evelynn", "Graves", "Hecarim", "Kayn", "Kindred", "Lee Sin", "Master Yi", "Nidalee", "Rammus", "Viego", "Warwick"], + "Mid": ["Ahri", "Akali", "Annie", "Cassiopeia", "Fizz", "Katarina", "LeBlanc", "Lux", "Orianna", "Syndra", "Twisted Fate", "Veigar", "Yasuo", "Zed"], + "Bot": ["Aphelios", "Ashe", "Caitlyn", "Draven", "Ezreal", "Jhin", "Jinx", "Kai'Sa", "Lucian", "Miss Fortune", "Samira", "Tristana", "Vayne", "Xayah"], + "Support": ["Blitzcrank", "Brand", "Janna", "Leona", "Lulu", "Morgana", "Nautilus", "Pyke", "Rakan", "Senna", "Seraphine", "Soraka", "Thresh", "Yuumi"] + } - # Pick a random lane and champion - lane = random.choice(list(champions.keys())) - champion = random.choice(champions[lane]) + lane = random.choice(list(champions.keys())) + selected_champion = random.choice(champions[lane]) - embed = discord.Embed( - title="⚔️ League Champion Picker", - color=discord.Color.from_rgb(152, 0, 0) - ) - embed.add_field(name="Selected Champion", value=champion, inline=False) - embed.add_field(name="Lane", value=lane, inline=False) - embed.set_footer(text=f"Selected for {interaction.user.display_name}") + embed = discord.Embed( + title="League Champion Picker", + color=discord.Color.from_rgb(152, 0, 0) + ) + embed.add_field(name="Selected Champion", value=selected_champion, inline=False) + embed.add_field(name="Lane", value=lane, inline=False) + embed.set_footer(text=f"Selected for {interaction.user.display_name}") - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in champion command: {e}", exc_info=True) + await interaction.response.send_message(embed=embed) @client.tree.command(name="trivia", description="Play a trivia game") +@handle_command_errors async def trivia(interaction: discord.Interaction): - try: - class TriviaView(discord.ui.View): - def __init__(self, correct_answer): - super().__init__() - self.correct_answer = correct_answer + class TriviaView(discord.ui.View): + def __init__(self, correct_answer): + super().__init__() + self.correct_answer = correct_answer - @discord.ui.button(label="A", style=discord.ButtonStyle.primary) - async def answer_a(self, interaction: discord.Interaction, button: discord.ui.Button): - if "A" == self.correct_answer: - await interaction.response.send_message("Correct!", ephemeral=True) - else: - await interaction.response.send_message("Wrong!", ephemeral=True) + @discord.ui.button(label="A", style=discord.ButtonStyle.primary) + async def answer_a(self, interaction: discord.Interaction, button: discord.ui.Button): + if "A" == self.correct_answer: + await interaction.response.send_message("Correct!", ephemeral=True) + else: + await interaction.response.send_message("Wrong!", ephemeral=True) - # Example usage - embed = discord.Embed(title="Trivia Question", description="What is 2+2?") - embed.add_field(name="A", value="4") - embed.add_field(name="B", value="5") - await interaction.response.send_message(embed=embed, view=TriviaView("A")) - except Exception as e: - logger.error(f"Error in trivia command: {e}", exc_info=True) + embed = discord.Embed(title="Trivia Question", description="What is 2+2?") + embed.add_field(name="A", value="4") + embed.add_field(name="B", value="5") + await interaction.response.send_message(embed=embed, view=TriviaView("A")) @client.tree.command(name="userinfo", description="Get information about a user") @app_commands.describe(member="The user to get information about") +@handle_command_errors async def userinfo(interaction: discord.Interaction, member: discord.Member): - try: - embed = discord.Embed(title="User Information", color=discord.Color.from_rgb(152, 0, 0)) - embed.add_field(name="Joined Server", value=member.joined_at.strftime("%Y-%m-%d")) - embed.add_field(name="Account Created", value=member.created_at.strftime("%Y-%m-%d")) - embed.add_field(name="Roles", value=", ".join([role.name for role in member.roles[1:]])) - await interaction.response.send_message(embed=embed) - except Exception as e: - logger.error(f"Error in userinfo command: {e}", exc_info=True) + embed = discord.Embed(title="User Information", color=discord.Color.from_rgb(152, 0, 0)) + embed.add_field(name="Joined Server", value=member.joined_at.strftime("%Y-%m-%d")) + embed.add_field(name="Account Created", value=member.created_at.strftime("%Y-%m-%d")) + embed.add_field(name="Roles", value=", ".join([role.name for role in member.roles[1:]])) + await interaction.response.send_message(embed=embed) +# --- Issue #16: Health Check Command --- +@client.tree.command(name="status", description="Check bot system status") +@has_role_check(ADMIN_ROLE_ID) +@handle_command_errors +async def status(interaction: discord.Interaction): + """Show bot health metrics""" + stats = metrics.get_stats() + uptime_hours = stats["uptime_seconds"] / 3600 + + embed = discord.Embed(title="Bot Status", color=discord.Color.green()) + + # System info + embed.add_field(name="Latency", value=f"{round(client.latency * 1000)}ms") + embed.add_field(name="Guilds", value=str(len(client.guilds))) + embed.add_field(name="Users", value=str(len(client.users))) + embed.add_field(name="Uptime", value=f"{uptime_hours:.1f}h") + embed.add_field(name="Commands Run", value=str(stats["commands_executed"])) + embed.add_field(name="Errors", value=str(stats["error_count"])) + + # Top commands + if stats["top_commands"]: + top_cmds = "\n".join([f"`{name}`: {count}" for name, count in stats["top_commands"]]) + embed.add_field(name="Top Commands", value=top_cmds, inline=False) + + # Service checks + checks = { + "RCON": bool(MINECRAFT_RCON_PASSWORD), + "Ollama": bool(OLLAMA_URL), + "Pelican": bool(PELICAN_API_KEY) + } + + status_text = "\n".join([f"{'OK' if available else 'N/A'} {service}" for service, available in checks.items()]) + embed.add_field(name="Services", value=status_text, inline=False) + + await interaction.response.send_message(embed=embed, ephemeral=True) @client.tree.error @@ -1095,7 +1324,7 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm logger.warning(f"Permission denied for {interaction.user} on command {interaction.command.name}") elif isinstance(error, app_commands.CommandOnCooldown): await interaction.response.send_message( - f"This command is on cooldown. Try again in {error.retry_after:.2f} seconds.", + f"This command is on cooldown. Try again in {error.retry_after:.0f} seconds.", ephemeral=True ) else: @@ -1108,11 +1337,18 @@ async def on_app_command_error(interaction: discord.Interaction, error: app_comm logger.error(f"Error in on_app_command_error: {e}", exc_info=True) +# --- Issue #17: Validate config on startup --- if __name__ == "__main__": - token = os.getenv('DISCORD_TOKEN') - if not token: - logger.error("DISCORD_TOKEN not found in environment variables") + errors, warnings = ConfigValidator.validate() + + if errors: + for error in errors: + logger.error(error) exit(1) + for warning in warnings: + logger.warning(warning) + + token = os.getenv('DISCORD_TOKEN') logger.info("Starting bot...") client.run(token)