diff --git a/bot.py b/bot.py index 72bda44..0a5cee0 100644 --- a/bot.py +++ b/bot.py @@ -7,27 +7,23 @@ import logging 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) -# --- 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, @@ -46,14 +42,14 @@ def setup_logging(): logger.addHandler(stream_handler) return logger + logger = setup_logging() load_dotenv() -# Configuration - Environment variables with defaults +# Configuration GUILD_ID = int(os.getenv('GUILD_ID', '605864889927467181')) AUDIT_CHANNEL_ID = int(os.getenv('AUDIT_CHANNEL_ID', '1340861451392520233')) -ANNOUNCEMENT_CHANNEL_ID = int(os.getenv('ANNOUNCEMENT_CHANNEL_ID', '605864889940050088')) ADMIN_ROLE_ID = int(os.getenv('ADMIN_ROLE_ID', '605867042104541194')) MINECRAFT_ROLE_ID = int(os.getenv('MINECRAFT_ROLE_ID', '821163520942145556')) HYTALE_ROLE_ID = int(os.getenv('HYTALE_ROLE_ID', '1460750779848589548')) @@ -62,15 +58,11 @@ OWNER_ID = int(os.getenv('OWNER_ID', '238728085342519296')) 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', '') -HYTALE_SERVER_UUID = os.getenv('HYTALE_SERVER_UUID', '7a656836-c3f3-491e-ac55-66affe435e72') 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_HOST', 'http://10.10.10.157:11434') -# --- Issue #11: Magic Numbers - Extract constants --- +# Constants MIN_USERNAME_LENGTH = 3 MAX_USERNAME_LENGTH = 16 MAX_INPUT_LENGTH = 500 @@ -82,7 +74,6 @@ USERNAME_CACHE_TTL_MINUTES = 60 AUDIT_BATCH_SIZE = 5 AUDIT_FLUSH_INTERVAL = 30 -# Emoji to role mapping for reaction roles EMOJI_ROLE_MAP = { "Overwatch": "(Overwatch)", "Minecraft": "(Minecraft)", @@ -108,7 +99,6 @@ EMOJI_ROLE_MAP = { } -# --- Issue #17: Configuration Validator --- class ConfigValidator: REQUIRED = ['DISCORD_TOKEN'] OPTIONAL = { @@ -119,7 +109,6 @@ class ConfigValidator: @classmethod def validate(cls): - """Validate configuration on startup""" errors = [] warnings = [] @@ -134,31 +123,6 @@ class ConfigValidator: 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 = {} @@ -184,13 +148,11 @@ class UsernameCache: 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"] @@ -203,7 +165,6 @@ class MediaManager: 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): @@ -216,7 +177,7 @@ class AuditLogger: 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: + (datetime.now() - self.last_flush).total_seconds() >= self.flush_interval: await self.flush() async def flush(self): @@ -227,23 +188,27 @@ class AuditLogger: 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) + # Process in batches of 10 (Discord embed field limit) + while self.queue: + batch = self.queue[:10] + self.queue = self.queue[10:] - try: - await channel.send(embed=embed) - except Exception as e: - logger.error(f"Failed to send audit log: {e}", exc_info=True) + embed = discord.Embed(color=0x980000, timestamp=datetime.now()) + for msg, _, timestamp in batch: + 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) + break - 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() @@ -269,15 +234,12 @@ class MetricsCollector: 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): @@ -295,15 +257,12 @@ def handle_command_errors(func): 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) @@ -314,14 +273,12 @@ async def rcon_command(host: str, password: str, command: str, timeout: float = 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}", @@ -339,13 +296,11 @@ async def send_interaction_media( 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(): @@ -356,7 +311,6 @@ def load_whitelisted_usernames(): 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) @@ -370,7 +324,6 @@ 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) @@ -378,7 +331,6 @@ async def minecraft_username_autocomplete( ][:25] -# --- Issue #4: Unclosed aiohttp Sessions --- class CustomBot(commands.Bot): def __init__(self): intents = discord.Intents.all() @@ -398,7 +350,6 @@ class CustomBot(commands.Bot): 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: @@ -437,7 +388,6 @@ class CustomBot(commands.Bot): @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 @@ -448,33 +398,24 @@ class CustomBot(commands.Bot): client = CustomBot() -def is_owner(): - async def predicate(ctx): - return ctx.author.id == OWNER_ID - return commands.check(predicate) - - def get_role_from_emoji(emoji_name: str) -> str: - """Helper function to map emoji names to role names.""" return EMOJI_ROLE_MAP.get(emoji_name, emoji_name) def has_role_check(role_id: int): - """Check if user has a specific role (for slash commands).""" async def predicate(interaction: discord.Interaction) -> bool: return any(role.id == role_id for role in interaction.user.roles) return app_commands.check(predicate) -# --- Issue #6: Only log commands, not all message content --- +# ==================== EVENTS ==================== + @client.event async def on_message(message): try: - # 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('.'): await message.channel.send("Whatever conversation is happening right now, Jared is right") except Exception as e: @@ -546,7 +487,6 @@ async def on_scheduled_event_update(before, after): @client.event async def on_member_update(before, after): - """Unified handler for all member update events.""" try: if before.timed_out_until != after.timed_out_until: await audit_logger.log(f"Member Timeout\nUser: {after.mention}\nUntil: {after.timed_out_until}") @@ -654,7 +594,6 @@ async def on_guild_channel_delete(channel): @client.event async def on_raw_reaction_add(payload): - """Handle reaction role additions.""" try: if payload.message_id != REACTION_MESSAGE_ID: return @@ -688,7 +627,6 @@ async def on_raw_reaction_add(payload): @client.event async def on_raw_reaction_remove(payload): - """Handle reaction role removals.""" try: if payload.message_id != REACTION_MESSAGE_ID: return @@ -729,11 +667,9 @@ async def help_command(interaction: discord.Interaction): 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) - # 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) @@ -743,20 +679,17 @@ async def help_command(interaction: discord.Interaction): embed.add_field(name="/poll", value="Create a simple yes/no poll", inline=False) embed.add_field(name="/trivia", value="Play a trivia game with random questions", 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) - # 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) @@ -815,14 +748,12 @@ async def unlock(interaction: discord.Interaction, channel: discord.TextChannel 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 @@ -837,7 +768,6 @@ async def minecraft(interaction: discord.Interaction, minecraft_username: str): await interaction.response.defer() - # --- Issue #5: Async RCON with timeout --- try: response = await rcon_command(MINECRAFT_RCON_HOST, MINECRAFT_RCON_PASSWORD, f"whitelist add {minecraft_username}") @@ -849,7 +779,6 @@ async def minecraft(interaction: discord.Interaction, minecraft_username: str): ) return - # Issue #15: Track whitelisted username save_whitelisted_username(minecraft_username) minecraft_embed = discord.Embed( @@ -878,10 +807,8 @@ async def minecraft(interaction: discord.Interaction, minecraft_username: str): @has_role_check(HYTALE_ROLE_ID) @handle_command_errors async def hytale(interaction: discord.Interaction, hytale_username: str): - """Request whitelist on the Hytale server""" 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 @@ -907,14 +834,12 @@ async def hytale(interaction: discord.Interaction, hytale_username: str): 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): - # --- Issue #8: Sanitize input --- question = sanitize_input(question) if not question: await interaction.response.send_message("Please provide a valid question.", ephemeral=True) @@ -922,7 +847,6 @@ async def ask(interaction: discord.Interaction, question: str): await interaction.response.defer() - # Select model based on user ID model = "lotusllmben" if interaction.user.id == 460640040096104459 else "lotusllm" logger.info(f"Sending question to Ollama: {question}") @@ -958,14 +882,11 @@ async def ask(interaction: discord.Interaction, question: str): @handle_command_errors async def eight_ball(interaction: discord.Interaction, question: str): 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" ] @@ -985,18 +906,13 @@ async def eight_ball(interaction: discord.Interaction, question: str): @handle_command_errors async def fortune(interaction: discord.Interaction): 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", - - # Gaming-themed fortunes "Your next competitive match will be legendary", "The cake is still a lie", "Press Alt+F4 for instant success", @@ -1018,8 +934,6 @@ async def fortune(interaction: discord.Interaction): "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", @@ -1038,13 +952,9 @@ async def fortune(interaction: discord.Interaction): "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", - - # 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", @@ -1101,7 +1011,6 @@ async def roll(interaction: discord.Interaction, dice: str = "1d6"): 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 @@ -1162,11 +1071,10 @@ async def poll(interaction: discord.Interaction, question: str): 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("👎") + await message.add_reaction("\U0001f44d") + await message.add_reaction("\U0001f44e") -# --- 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 @@ -1282,7 +1190,6 @@ async def champion(interaction: discord.Interaction): @handle_command_errors async def trivia(interaction: discord.Interaction): questions = [ - # Gaming {"q": "What year was the original Super Mario Bros. released?", "options": ["1983", "1985", "1987", "1990"], "answer": 1}, {"q": "Which game features the quote 'The cake is a lie'?", "options": ["Half-Life 2", "Portal", "BioShock", "Minecraft"], "answer": 1}, {"q": "What is the max level in League of Legends?", "options": ["16", "18", "20", "25"], "answer": 1}, @@ -1298,7 +1205,6 @@ async def trivia(interaction: discord.Interaction): {"q": "Which company developed Valorant?", "options": ["Blizzard", "Valve", "Riot Games", "Epic Games"], "answer": 2}, {"q": "What is the highest rank in Valorant?", "options": ["Immortal", "Diamond", "Radiant", "Challenger"], "answer": 2}, {"q": "In League of Legends, what is Baron Nashor an anagram of?", "options": ["Baron Roshan", "Roshan", "Nashor Baron", "Nash Robot"], "answer": 1}, - # General/Internet culture {"q": "What does HTTP stand for?", "options": ["HyperText Transfer Protocol", "High Tech Transfer Program", "HyperText Transmission Process", "Home Tool Transfer Protocol"], "answer": 0}, {"q": "What year was Discord founded?", "options": ["2013", "2015", "2017", "2019"], "answer": 1}, {"q": "What programming language has a logo that is a snake?", "options": ["Java", "Ruby", "Python", "Go"], "answer": 2}, @@ -1370,18 +1276,15 @@ async def userinfo(interaction: discord.Interaction, member: discord.Member): await interaction.response.send_message(embed=embed) -# --- Issue #16: Health Check Command --- @client.tree.command(name="health", description="Check bot system status") @has_role_check(ADMIN_ROLE_ID) @handle_command_errors async def health(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))) @@ -1389,12 +1292,10 @@ async def health(interaction: discord.Interaction): 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), @@ -1409,7 +1310,6 @@ async def health(interaction: discord.Interaction): @client.tree.error async def on_app_command_error(interaction: discord.Interaction, error: app_commands.AppCommandError): - """Handle slash command errors.""" try: if isinstance(error, app_commands.CheckFailure): await interaction.response.send_message( @@ -1432,7 +1332,6 @@ 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__": errors, warnings = ConfigValidator.validate()