diff --git a/config.json b/config.json index ad0d4ea..5821dd2 100644 --- a/config.json +++ b/config.json @@ -16,4 +16,5 @@ "duck_spawn_max": 30, "duck_timeout": 60, "befriend_success_rate": 75 + } \ No newline at end of file diff --git a/messages.json b/messages.json index 90a296f..6f1eaee 100644 --- a/messages.json +++ b/messages.json @@ -1,8 +1,8 @@ { "duck_spawn": [ - "・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}", - "・゜゜・。。・゜゜\\_o< {light_grey}quack~{reset}", - "・゜゜・。。・゜゜\\_O> {bold}*flap flap*{reset}" + "・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}", + "・゜゜・。。・゜゜\\_o< {light_grey}quack~{reset}", + "・゜゜・。。・゜゜\\_O> {bold}*flap flap*{reset}" ], "duck_flies_away": "The {bold}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`", "bang_hit": "{nick} > {green}*BANG*{reset} You shot the duck! [{green}+{xp_gained} xp{reset}] [Total ducks: {bold}{ducks_shot}{reset}]", diff --git a/src/db.py b/src/db.py index 94c9c9c..d97eb9a 100644 --- a/src/db.py +++ b/src/db.py @@ -19,97 +19,228 @@ class DuckDB: self.load_database() def load_database(self): - """Load player data from JSON file""" + """Load player data from JSON file with comprehensive error handling""" try: if os.path.exists(self.db_file): - with open(self.db_file, 'r') as f: + # Try to load the main database file + with open(self.db_file, 'r', encoding='utf-8') as f: data = json.load(f) - self.players = data.get('players', {}) - self.logger.info(f"Loaded {len(self.players)} players from {self.db_file}") + + # Validate loaded data structure + if not isinstance(data, dict): + raise ValueError("Database root is not a dictionary") + + players_data = data.get('players', {}) + if not isinstance(players_data, dict): + raise ValueError("Players data is not a dictionary") + + # Validate each player entry + valid_players = {} + for nick, player_data in players_data.items(): + if isinstance(player_data, dict) and isinstance(nick, str): + # Sanitize and validate player data + valid_players[nick] = self._sanitize_player_data(player_data) + else: + self.logger.warning(f"Skipping invalid player entry: {nick}") + + self.players = valid_players + self.logger.info(f"Loaded {len(self.players)} players from {self.db_file}") + else: self.players = {} - self.logger.info(f"No existing database found, starting fresh") + self.logger.info("No existing database found, starting fresh") + + except (json.JSONDecodeError, UnicodeDecodeError) as e: + self.logger.error(f"Database file corrupted: {e}") + self.players = {} except Exception as e: self.logger.error(f"Error loading database: {e}") self.players = {} - def save_database(self): - """Save all player data to JSON file""" + def _sanitize_player_data(self, player_data): + """Sanitize and validate player data""" try: + sanitized = {} + + # Ensure required fields with safe defaults + sanitized['nick'] = str(player_data.get('nick', 'Unknown'))[:50] # Limit nick length + sanitized['xp'] = max(0, int(player_data.get('xp', 0))) # Non-negative XP + sanitized['ducks_shot'] = max(0, int(player_data.get('ducks_shot', 0))) + sanitized['ducks_befriended'] = max(0, int(player_data.get('ducks_befriended', 0))) + sanitized['accuracy'] = max(0, min(100, int(player_data.get('accuracy', 65)))) # 0-100 range + sanitized['gun_confiscated'] = bool(player_data.get('gun_confiscated', False)) + + # Ammo system with validation + sanitized['current_ammo'] = max(0, min(50, int(player_data.get('current_ammo', 6)))) + sanitized['magazines'] = max(0, min(20, int(player_data.get('magazines', 3)))) + sanitized['bullets_per_magazine'] = max(1, min(50, int(player_data.get('bullets_per_magazine', 6)))) + sanitized['jam_chance'] = max(0, min(100, int(player_data.get('jam_chance', 5)))) + + # Safe inventory handling + inventory = player_data.get('inventory', {}) + if isinstance(inventory, dict): + sanitized['inventory'] = {str(k)[:10]: max(0, int(v)) for k, v in inventory.items() if isinstance(v, (int, float))} + else: + sanitized['inventory'] = {} + + # Safe temporary effects + temp_effects = player_data.get('temporary_effects', []) + if isinstance(temp_effects, list): + sanitized['temporary_effects'] = temp_effects[:20] # Limit to 20 effects + else: + sanitized['temporary_effects'] = [] + + return sanitized + + except Exception as e: + self.logger.error(f"Error sanitizing player data: {e}") + return self.create_player('Unknown') + + def save_database(self): + """Save all player data to JSON file with comprehensive error handling""" + temp_file = f"{self.db_file}.tmp" + + try: + # Prepare data with validation data = { - 'players': self.players, + 'players': {}, 'last_save': str(time.time()) } - # Create backup - if os.path.exists(self.db_file): - backup_file = f"{self.db_file}.backup" - try: - with open(self.db_file, 'r') as src, open(backup_file, 'w') as dst: - dst.write(src.read()) - except Exception as e: - self.logger.warning(f"Failed to create backup: {e}") + # Validate and clean player data before saving + for nick, player_data in self.players.items(): + if isinstance(nick, str) and isinstance(player_data, dict): + data['players'][nick] = self._sanitize_player_data(player_data) + else: + self.logger.warning(f"Skipping invalid player data during save: {nick}") - # Save main file - with open(self.db_file, 'w') as f: - json.dump(data, f, indent=2) - + # Write to temporary file first (atomic write) + with open(temp_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + f.flush() # Ensure data is written to disk + os.fsync(f.fileno()) # Force write to disk + + # Atomic replace: move temp file to actual file + if os.name == 'nt': # Windows + if os.path.exists(self.db_file): + os.remove(self.db_file) + os.rename(temp_file, self.db_file) + else: # Unix-like systems + os.rename(temp_file, self.db_file) + + self.logger.debug(f"Database saved successfully with {len(data['players'])} players") + + except PermissionError: + self.logger.error("Permission denied when saving database") + except OSError as e: + self.logger.error(f"OS error saving database: {e}") except Exception as e: - self.logger.error(f"Error saving database: {e}") + self.logger.error(f"Unexpected error saving database: {e}") + finally: + # Clean up temp file if it still exists + try: + if os.path.exists(temp_file): + os.remove(temp_file) + except Exception: + pass def get_player(self, nick): - """Get player data, creating if doesn't exist""" - nick_lower = nick.lower() - - if nick_lower not in self.players: - self.players[nick_lower] = self.create_player(nick) - else: - # Ensure existing players have new fields and migrate from old system - player = self.players[nick_lower] + """Get player data, creating if doesn't exist with comprehensive validation""" + try: + # Validate and sanitize nick + if not isinstance(nick, str) or not nick.strip(): + self.logger.warning(f"Invalid nick provided: {nick}") + return None + + nick_lower = nick.lower().strip()[:50] # Limit nick length and sanitize + + if nick_lower not in self.players: + self.players[nick_lower] = self.create_player(nick) + else: + # Ensure existing players have all required fields and sanitize data + player = self.players[nick_lower] + if not isinstance(player, dict): + self.logger.warning(f"Invalid player data for {nick_lower}, recreating") + self.players[nick_lower] = self.create_player(nick) + else: + # Migrate and validate existing player data + self.players[nick_lower] = self._migrate_and_validate_player(player, nick) + + return self.players[nick_lower] + + except Exception as e: + self.logger.error(f"Error getting player {nick}: {e}") + return self.create_player(nick if isinstance(nick, str) else 'Unknown') + + def _migrate_and_validate_player(self, player, nick): + """Migrate old player data and validate all fields""" + try: + # Start with sanitized data + validated_player = self._sanitize_player_data(player) + + # Ensure new fields exist (migration from older versions) if 'ducks_befriended' not in player: - player['ducks_befriended'] = 0 + validated_player['ducks_befriended'] = 0 if 'inventory' not in player: - player['inventory'] = {} + validated_player['inventory'] = {} if 'temporary_effects' not in player: - player['temporary_effects'] = [] + validated_player['temporary_effects'] = [] if 'jam_chance' not in player: - player['jam_chance'] = 5 # Default 5% jam chance + validated_player['jam_chance'] = 5 # Default 5% jam chance # Migrate from old ammo/chargers system to magazine system - if 'magazines' not in player: - # Convert old system: assume they had full magazines + if 'magazines' not in player and ('ammo' in player or 'chargers' in player): + self.logger.info(f"Migrating {nick} from old ammo system to magazine system") + old_ammo = player.get('ammo', 6) old_chargers = player.get('chargers', 2) - player['current_ammo'] = old_ammo - player['magazines'] = old_chargers + 1 # +1 for current loaded magazine - player['bullets_per_magazine'] = 6 - - # Remove old fields - if 'ammo' in player: - del player['ammo'] - if 'max_ammo' in player: - del player['max_ammo'] - if 'chargers' in player: - del player['chargers'] - if 'max_chargers' in player: - del player['max_chargers'] - - return self.players[nick_lower] + validated_player['current_ammo'] = max(0, min(50, int(old_ammo))) + validated_player['magazines'] = max(1, min(20, int(old_chargers) + 1)) # +1 for current loaded magazine + validated_player['bullets_per_magazine'] = 6 + + # Update nick in case it changed + validated_player['nick'] = str(nick)[:50] + + return validated_player + + except Exception as e: + self.logger.error(f"Error migrating player data for {nick}: {e}") + return self.create_player(nick) def create_player(self, nick): - """Create a new player with basic stats""" - return { - 'nick': nick, - 'xp': 0, - 'ducks_shot': 0, - 'ducks_befriended': 0, - 'current_ammo': 6, # Bullets in current magazine - 'magazines': 3, # Total magazines (including current) - 'bullets_per_magazine': 6, # Bullets per magazine - 'accuracy': 65, - 'jam_chance': 5, # 5% base gun jamming chance - 'gun_confiscated': False, - 'inventory': {}, # {item_id: quantity} - 'temporary_effects': [] # List of temporary effects - } \ No newline at end of file + """Create a new player with basic stats and validation""" + try: + # Sanitize nick + safe_nick = str(nick)[:50] if nick else 'Unknown' + + return { + 'nick': safe_nick, + 'xp': 0, + 'ducks_shot': 0, + 'ducks_befriended': 0, + 'current_ammo': 6, # Bullets in current magazine + 'magazines': 3, # Total magazines (including current) + 'bullets_per_magazine': 6, # Bullets per magazine + 'accuracy': 65, + 'jam_chance': 5, # 5% base gun jamming chance + 'gun_confiscated': False, + 'inventory': {}, # {item_id: quantity} + 'temporary_effects': [] # List of temporary effects + } + except Exception as e: + self.logger.error(f"Error creating player for {nick}: {e}") + return { + 'nick': 'Unknown', + 'xp': 0, + 'ducks_shot': 0, + 'ducks_befriended': 0, + 'current_ammo': 6, + 'magazines': 3, + 'bullets_per_magazine': 6, + 'accuracy': 65, + 'jam_chance': 5, + 'gun_confiscated': False, + 'inventory': {}, + 'temporary_effects': [] + } \ No newline at end of file diff --git a/src/duckhuntbot.py b/src/duckhuntbot.py index 4ab37b5..ff725c9 100644 --- a/src/duckhuntbot.py +++ b/src/duckhuntbot.py @@ -1,15 +1,12 @@ import asyncio import ssl -import json -import logging -import sys import os import time import signal from typing import Optional from .logging_utils import setup_logger -from .utils import parse_irc_message, InputValidator, MessageManager +from .utils import parse_irc_message, MessageManager from .db import DuckDB from .game import DuckGame from .sasl import SASLHandler @@ -82,12 +79,12 @@ class DuckHuntBot: def setup_signal_handlers(self): """Setup signal handlers for immediate shutdown""" def signal_handler(signum, frame): + def setup_signal_handlers(self): + """Setup signal handlers for immediate shutdown""" + def signal_handler(signum, _frame): signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM" self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), shutting down immediately...") self.shutdown_requested = True - - # Cancel all running tasks immediately - try: # Get the current event loop and cancel all tasks loop = asyncio.get_running_loop() tasks = [t for t in asyncio.all_tasks(loop) if not t.done()] @@ -101,27 +98,90 @@ class DuckHuntBot: signal.signal(signal.SIGTERM, signal_handler) async def connect(self): - """Connect to IRC server""" - try: - ssl_context = ssl.create_default_context() if self.config.get('ssl', False) else None - self.reader, self.writer = await asyncio.open_connection( - self.config['server'], - self.config['port'], - ssl=ssl_context - ) - self.logger.info(f"Connected to {self.config['server']}:{self.config['port']}") - except Exception as e: - self.logger.error(f"Failed to connect: {e}") - raise + """Connect to IRC server with comprehensive error handling""" + max_retries = 3 + retry_delay = 5 + + for attempt in range(max_retries): + try: + ssl_context = None + if self.config.get('ssl', False): + ssl_context = ssl.create_default_context() + # Add SSL context configuration for better compatibility + ssl_context.check_hostname = False + ssl_context.verify_mode = ssl.CERT_NONE + + self.logger.info(f"Attempting to connect to {self.config['server']}:{self.config['port']} (attempt {attempt + 1}/{max_retries})") + + self.reader, self.writer = await asyncio.wait_for( + asyncio.open_connection( + self.config['server'], + self.config['port'], + ssl=ssl_context + ), + timeout=30.0 # 30 second connection timeout + ) + + self.logger.info(f"✅ Successfully connected to {self.config['server']}:{self.config['port']}") + return + + except asyncio.TimeoutError: + self.logger.error(f"Connection attempt {attempt + 1} timed out after 30 seconds") + except ssl.SSLError as e: + self.logger.error(f"SSL error on attempt {attempt + 1}: {e}") + except OSError as e: + self.logger.error(f"Network error on attempt {attempt + 1}: {e}") + except Exception as e: + self.logger.error(f"Unexpected connection error on attempt {attempt + 1}: {e}") + + if attempt < max_retries - 1: + self.logger.info(f"Retrying connection in {retry_delay} seconds...") + await asyncio.sleep(retry_delay) + retry_delay *= 2 # Exponential backoff + + # If all attempts failed + raise ConnectionError(f"Failed to connect after {max_retries} attempts") def send_raw(self, msg): - """Send raw IRC message""" - if self.writer: - self.writer.write(f"{msg}\r\n".encode('utf-8')) + """Send raw IRC message with error handling""" + if not self.writer or self.writer.is_closing(): + self.logger.warning(f"Cannot send message: connection not available") + return False + + try: + encoded_msg = f"{msg}\r\n".encode('utf-8', errors='replace') + self.writer.write(encoded_msg) + return True + except ConnectionResetError: + self.logger.error("Connection reset while sending message") + return False + except BrokenPipeError: + self.logger.error("Broken pipe while sending message") + return False + except OSError as e: + self.logger.error(f"Network error while sending message: {e}") + return False + except Exception as e: + self.logger.error(f"Unexpected error while sending message: {e}") + return False def send_message(self, target, msg): - """Send message to target (channel or user)""" - self.send_raw(f"PRIVMSG {target} :{msg}") + """Send message to target (channel or user) with error handling""" + if not isinstance(target, str) or not isinstance(msg, str): + self.logger.warning(f"Invalid message parameters: target={type(target)}, msg={type(msg)}") + return False + + # Sanitize message to prevent IRC injection + try: + # Remove potential IRC control characters + sanitized_msg = msg.replace('\r', '').replace('\n', ' ').strip() + if not sanitized_msg: + return False + + return self.send_raw(f"PRIVMSG {target} :{sanitized_msg}") + except Exception as e: + self.logger.error(f"Error sanitizing/sending message: {e}") + return False async def register_user(self): """Register user with IRC server""" @@ -132,81 +192,159 @@ class DuckHuntBot: self.send_raw(f"USER {self.config['nick']} 0 * :{self.config['nick']}") async def handle_message(self, prefix, command, params, trailing): - """Handle incoming IRC messages""" - # Handle SASL-related messages - if command == "CAP": - await self.sasl_handler.handle_cap_response(params, trailing) - return - - elif command == "AUTHENTICATE": - await self.sasl_handler.handle_authenticate_response(params) - return - - elif command in ["903", "904", "905", "906", "907", "908"]: - await self.sasl_handler.handle_sasl_result(command, params, trailing) - return + """Handle incoming IRC messages with comprehensive error handling""" + try: + # Validate input parameters + if not isinstance(command, str): + self.logger.warning(f"Invalid command type: {type(command)}") + return + + if params is None: + params = [] + elif not isinstance(params, list): + self.logger.warning(f"Invalid params type: {type(params)}") + params = [] + + if trailing is None: + trailing = "" + elif not isinstance(trailing, str): + self.logger.warning(f"Invalid trailing type: {type(trailing)}") + trailing = str(trailing) - elif command == "001": # Welcome message - self.registered = True - self.logger.info("Successfully registered with IRC server") + # Handle SASL-related messages + if command == "CAP": + await self.sasl_handler.handle_cap_response(params, trailing) + return + + elif command == "AUTHENTICATE": + await self.sasl_handler.handle_authenticate_response(params) + return + + elif command in ["903", "904", "905", "906", "907", "908"]: + await self.sasl_handler.handle_sasl_result(command, params, trailing) + return - # Join channels - for channel in self.config.get('channels', []): - self.send_raw(f"JOIN {channel}") - self.channels_joined.add(channel) - - elif command == "PRIVMSG": - if len(params) >= 1: - target = params[0] - message = trailing or "" - await self.handle_command(prefix, target, message) - - elif command == "PING": - self.send_raw(f"PONG :{trailing}") + elif command == "001": # Welcome message + self.registered = True + self.logger.info("Successfully registered with IRC server") + + # Join channels + for channel in self.config.get('channels', []): + try: + self.send_raw(f"JOIN {channel}") + self.channels_joined.add(channel) + except Exception as e: + self.logger.error(f"Error joining channel {channel}: {e}") + + elif command == "PRIVMSG": + if len(params) >= 1: + target = params[0] + message = trailing or "" + await self.handle_command(prefix, target, message) + + elif command == "PING": + try: + self.send_raw(f"PONG :{trailing}") + except Exception as e: + self.logger.error(f"Error responding to PING: {e}") + + except Exception as e: + self.logger.error(f"Critical error in handle_message: {e}") + # Continue execution to prevent bot crashes async def handle_command(self, user, channel, message): - """Handle bot commands""" - if not message.startswith('!'): - return - - parts = message[1:].split() - if not parts: - return - - cmd = parts[0].lower() - args = parts[1:] if len(parts) > 1 else [] - nick = user.split('!')[0] if '!' in user else user - - player = self.db.get_player(nick) - - # Check if player is ignored (unless it's an admin) - if player.get('ignored', False) and not self.is_admin(user): - return - - if cmd == "bang": - await self.handle_bang(nick, channel, player) - elif cmd == "bef" or cmd == "befriend": - await self.handle_bef(nick, channel, player) - elif cmd == "reload": - await self.handle_reload(nick, channel, player) - elif cmd == "shop": - await self.handle_shop(nick, channel, player, args) - elif cmd == "duckstats": - await self.handle_duckstats(nick, channel, player) - elif cmd == "use": - await self.handle_use(nick, channel, player, args) - elif cmd == "duckhelp": - await self.handle_duckhelp(nick, channel, player) - elif cmd == "rearm" and self.is_admin(user): - await self.handle_rearm(nick, channel, args) - elif cmd == "disarm" and self.is_admin(user): - await self.handle_disarm(nick, channel, args) - elif cmd == "ignore" and self.is_admin(user): - await self.handle_ignore(nick, channel, args) - elif cmd == "unignore" and self.is_admin(user): - await self.handle_unignore(nick, channel, args) - elif cmd == "ducklaunch" and self.is_admin(user): - await self.handle_ducklaunch(nick, channel, args) + """Handle bot commands with comprehensive error handling""" + try: + # Validate inputs + if not isinstance(message, str) or not message.startswith('!'): + return + + if not isinstance(user, str) or not isinstance(channel, str): + self.logger.warning(f"Invalid user/channel types: {type(user)}, {type(channel)}") + return + + # Safely parse command + try: + parts = message[1:].split() + except Exception as e: + self.logger.warning(f"Error parsing command '{message}': {e}") + return + + if not parts: + return + + cmd = parts[0].lower() + args = parts[1:] if len(parts) > 1 else [] + + # Safely extract nick + try: + nick = user.split('!')[0] if '!' in user else user + if not nick: + self.logger.warning(f"Empty nick from user string: {user}") + return + except Exception as e: + self.logger.error(f"Error extracting nick from '{user}': {e}") + return + + # Get player data safely + try: + player = self.db.get_player(nick) + if player is None: + player = {} + except Exception as e: + self.logger.error(f"Error getting player data for {nick}: {e}") + player = {} + + # Check if player is ignored (unless it's an admin) + try: + if player.get('ignored', False) and not self.is_admin(user): + return + except Exception as e: + self.logger.error(f"Error checking admin/ignore status: {e}") + return + + # Handle commands with individual error isolation + await self._execute_command_safely(cmd, nick, channel, player, args, user) + + except Exception as e: + self.logger.error(f"Critical error in handle_command: {e}") + # Continue execution to prevent bot crashes + + async def _execute_command_safely(self, cmd, nick, channel, player, args, user): + """Execute individual commands with error isolation""" + try: + if cmd == "bang": + await self.handle_bang(nick, channel, player) + elif cmd == "bef" or cmd == "befriend": + await self.handle_bef(nick, channel, player) + elif cmd == "reload": + await self.handle_reload(nick, channel, player) + elif cmd == "shop": + await self.handle_shop(nick, channel, player, args) + elif cmd == "duckstats": + await self.handle_duckstats(nick, channel, player) + elif cmd == "use": + await self.handle_use(nick, channel, player, args) + elif cmd == "duckhelp": + await self.handle_duckhelp(nick, channel, player) + elif cmd == "rearm" and self.is_admin(user): + await self.handle_rearm(nick, channel, args) + elif cmd == "disarm" and self.is_admin(user): + await self.handle_disarm(nick, channel, args) + elif cmd == "ignore" and self.is_admin(user): + await self.handle_ignore(nick, channel, args) + elif cmd == "unignore" and self.is_admin(user): + await self.handle_unignore(nick, channel, args) + elif cmd == "ducklaunch" and self.is_admin(user): + await self.handle_ducklaunch(nick, channel, args) + except Exception as e: + self.logger.error(f"Error executing command '{cmd}' for user {nick}: {e}") + # Send a generic error message to the user to indicate something went wrong + try: + error_msg = f"{nick} > An error occurred processing your command. Please try again." + self.send_message(channel, error_msg) + except Exception as send_error: + self.logger.error(f"Error sending error message: {send_error}") async def handle_bang(self, nick, channel, player): """Handle !bang command""" @@ -303,7 +441,58 @@ class DuckHuntBot: self.send_message(channel, message) self.db.save_database() - async def handle_duckhelp(self, nick, channel, player): + async def handle_duckstats(self, nick, channel, player): + """Handle !duckstats command""" + # Apply color formatting + bold = self.messages.messages.get('colours', {}).get('bold', '') + reset = self.messages.messages.get('colours', {}).get('reset', '') + + # Get player level info + level_info = self.levels.get_player_level_info(player) + level = level_info['level'] + level_name = level_info['level_data']['name'] + + # Build stats message + xp = player.get('xp', 0) + ducks_shot = player.get('ducks_shot', 0) + ducks_befriended = player.get('ducks_befriended', 0) + accuracy = player.get('accuracy', 65) + + # Ammo info + current_ammo = player.get('current_ammo', 0) + magazines = player.get('magazines', 0) + bullets_per_mag = player.get('bullets_per_magazine', 6) + + # Gun status + gun_status = "🔫 Armed" if not player.get('gun_confiscated', False) else "❌ Confiscated" + + stats_lines = [ + f"📊 {bold}Duck Hunt Stats for {nick}{reset}", + f"🏆 Level {level}: {level_name}", + f"⭐ XP: {xp}", + f"🦆 Ducks Shot: {ducks_shot}", + f"💚 Ducks Befriended: {ducks_befriended}", + f"🎯 Accuracy: {accuracy}%", + f"🔫 Status: {gun_status}", + f"💀 Ammo: {current_ammo}/{bullets_per_mag} | Magazines: {magazines}" + ] + + # Add inventory if player has items + inventory = player.get('inventory', {}) + if inventory: + items = [] + for item_id, quantity in inventory.items(): + item = self.shop.get_item(int(item_id)) + if item: + items.append(f"{item['name']} x{quantity}") + if items: + stats_lines.append(f"🎒 Inventory: {', '.join(items)}") + + # Send each line + for line in stats_lines: + self.send_message(channel, line) + + async def handle_duckhelp(self, nick, channel, _player): """Handle !duckhelp command""" help_lines = [ self.messages.get('help_header'), @@ -318,35 +507,6 @@ class DuckHuntBot: for line in help_lines: self.send_message(channel, line) - async def handle_duckstats(self, nick, channel, player): - """Handle !duckstats command - show player stats and inventory""" - # Get player level info - level_info = self.levels.get_player_level_info(player) - level = self.levels.calculate_player_level(player) - - # Build stats message - stats_parts = [ - f"Level {level} {level_info.get('name', 'Unknown')}", - f"XP: {player.get('xp', 0)}", - f"Ducks Shot: {player.get('ducks_shot', 0)}", - f"Ducks Befriended: {player.get('ducks_befriended', 0)}", - f"Accuracy: {player.get('accuracy', 65)}%", - f"Ammo: {player.get('current_ammo', 0)}/{player.get('bullets_per_magazine', 6)}", - f"Magazines: {player.get('magazines', 1)}" - ] - - stats_message = f"{nick} > Stats: {' | '.join(stats_parts)}" - self.send_message(channel, stats_message) - - # Show inventory if not empty - inventory_info = self.shop.get_inventory_display(player) - if not inventory_info["empty"]: - items_text = [] - for item in inventory_info["items"]: - items_text.append(f"{item['id']}: {item['name']} x{item['quantity']}") - inventory_message = f"{nick} > Inventory: {' | '.join(items_text)}" - self.send_message(channel, inventory_message) - async def handle_use(self, nick, channel, player, args): """Handle !use command""" if not args: @@ -389,11 +549,16 @@ class DuckHuntBot: message = f"{nick} > Invalid item ID. Use !duckstats to see your items." self.send_message(channel, message) + async def handle_rearm(self, nick, channel, args): + """Handle !rearm command (admin only)""" + if args: async def handle_rearm(self, nick, channel, args): """Handle !rearm command (admin only)""" if args: target = args[0].lower() player = self.db.get_player(target) + if player is None: + player = {} player['gun_confiscated'] = False # Update magazines based on player level @@ -405,6 +570,8 @@ class DuckHuntBot: else: # Rearm the admin themselves player = self.db.get_player(nick) + if player is None: + player = {} player['gun_confiscated'] = False # Update magazines based on admin's level @@ -415,9 +582,6 @@ class DuckHuntBot: self.send_message(channel, message) self.db.save_database() - - async def handle_disarm(self, nick, channel, args): - """Handle !disarm command (admin only)""" def disarm_player(player): player['gun_confiscated'] = True @@ -442,8 +606,7 @@ class DuckHuntBot: self._handle_single_target_admin_command( args, 'usage_unignore', unignore_player, 'admin_unignore', nick, channel ) - - async def handle_ducklaunch(self, nick, channel, args): + async def handle_ducklaunch(self, _nick, channel, _args): """Handle !ducklaunch command (admin only)""" if channel not in self.channels_joined: message = self.messages.get('admin_ducklaunch_not_enabled') @@ -458,36 +621,77 @@ class DuckHuntBot: # Only send the duck spawn message, no admin notification self.send_message(channel, duck_message) + self.send_message(channel, duck_message) async def message_loop(self): - """Main message processing loop with responsive shutdown""" + """Main message processing loop with comprehensive error handling""" + consecutive_errors = 0 + max_consecutive_errors = 10 + try: while not self.shutdown_requested and self.reader: try: # Use a timeout on readline to make it more responsive to shutdown line = await asyncio.wait_for(self.reader.readline(), timeout=1.0) + + # Reset error counter on successful read + consecutive_errors = 0 + except asyncio.TimeoutError: # Check shutdown flag and continue continue + except ConnectionResetError: + self.logger.error("Connection reset by peer") + break + except OSError as e: + self.logger.error(f"Network error during read: {e}") + consecutive_errors += 1 + if consecutive_errors >= max_consecutive_errors: + self.logger.error("Too many consecutive network errors, breaking message loop") + break + await asyncio.sleep(1) # Brief delay before retry + continue + except Exception as e: + self.logger.error(f"Unexpected error reading from stream: {e}") + consecutive_errors += 1 + if consecutive_errors >= max_consecutive_errors: + self.logger.error("Too many consecutive read errors, breaking message loop") + break + continue + # Check if connection is closed if not line: + self.logger.info("Connection closed by server") break - line = line.decode('utf-8').strip() + # Safely decode with comprehensive error handling + try: + line = line.decode('utf-8', errors='replace').strip() + except (UnicodeDecodeError, AttributeError) as e: + self.logger.warning(f"Failed to decode message: {e}") + continue + except Exception as e: + self.logger.error(f"Unexpected error decoding message: {e}") + continue + if not line: continue + # Process the message with full error isolation try: prefix, command, params, trailing = parse_irc_message(line) await self.handle_message(prefix, command, params, trailing) + except ValueError as e: + self.logger.warning(f"Malformed IRC message ignored: {line[:100]}... Error: {e}") except Exception as e: - self.logger.error(f"Error processing message '{line}': {e}") + self.logger.error(f"Error processing message '{line[:100]}...': {e}") + # Continue processing other messages even if one fails except asyncio.CancelledError: self.logger.info("Message loop cancelled") except Exception as e: - self.logger.error(f"Message loop error: {e}") + self.logger.error(f"Critical message loop error: {e}") finally: self.logger.info("Message loop ended") @@ -512,10 +716,9 @@ class DuckHuntBot: message_task = asyncio.create_task(self.message_loop()) self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.") - # Wait for shutdown signal or task completion with frequent checks while not self.shutdown_requested: - done, pending = await asyncio.wait( + done, _pending = await asyncio.wait( [game_task, message_task], timeout=0.1, # Check every 100ms for shutdown return_when=asyncio.FIRST_COMPLETED @@ -524,6 +727,7 @@ class DuckHuntBot: # If any task completed, break out if done: break + break self.logger.info("🔄 Shutdown initiated, cleaning up...") @@ -560,22 +764,34 @@ class DuckHuntBot: self.logger.info("✅ Bot shutdown complete") async def _close_connection(self): - """Close IRC connection quickly""" - if self.writer: - try: - if not self.writer.is_closing(): - # Send quit message quickly without waiting - try: - quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down') - self.send_raw(f"QUIT :{quit_message}") - await asyncio.sleep(0.1) # Very brief wait - except: - pass # Don't block on quit message - + """Close IRC connection with comprehensive error handling""" + if not self.writer: + return + + try: + if not self.writer.is_closing(): + # Send quit message with timeout + try: + quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down') + if self.send_raw(f"QUIT :{quit_message}"): + await asyncio.sleep(0.2) # Brief wait for message to send + except Exception as e: + self.logger.debug(f"Error sending quit message: {e}") + + # Close the writer + try: self.writer.close() - await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0) - self.logger.info("🔌 IRC connection closed") - except asyncio.TimeoutError: - self.logger.warning("⚠️ Connection close timed out - forcing close") - except Exception as e: - self.logger.error(f"❌ Error closing connection: {e}") \ No newline at end of file + await asyncio.wait_for(self.writer.wait_closed(), timeout=2.0) + except asyncio.TimeoutError: + self.logger.warning("⚠️ Connection close timed out - forcing close") + except Exception as e: + self.logger.debug(f"Error during connection close: {e}") + + self.logger.info("🔌 IRC connection closed") + + except Exception as e: + self.logger.error(f"❌ Critical error closing connection: {e}") + finally: + # Ensure writer is cleared regardless of errors + self.writer = None + self.reader = None \ No newline at end of file diff --git a/src/game.py b/src/game.py index 0325207..2bab845 100644 --- a/src/game.py +++ b/src/game.py @@ -70,7 +70,11 @@ class DuckGame: for duck in ducks_to_remove: ducks.remove(duck) - message = self.bot.messages.get('duck_flies_away') + # Use appropriate fly away message based on duck type + if duck.get('is_golden', False): + message = self.bot.messages.get('golden_duck_flies_away') + else: + message = self.bot.messages.get('duck_flies_away') self.bot.send_message(channel, message) if not ducks: @@ -96,16 +100,15 @@ class DuckGame: duck = { 'id': f"duck_{int(time.time())}_{random.randint(1000, 9999)}", 'spawn_time': time.time(), - 'channel': channel + 'channel': channel, + 'max_hp': 1, + 'current_hp': 1 } - - self.ducks[channel].append(duck) - - # Send spawn message + # Send regular duck spawn message message = self.bot.messages.get('duck_spawn') + self.logger.info(f"Regular duck spawned in {channel}") + self.ducks[channel].append(duck) self.bot.send_message(channel, message) - - self.logger.info(f"Duck spawned in {channel}") def shoot_duck(self, nick, channel, player): """Handle shooting at a duck""" @@ -151,25 +154,21 @@ class DuckGame: # Shoot at duck player['current_ammo'] = player.get('current_ammo', 1) - 1 - # Calculate hit chance using level-modified accuracy modified_accuracy = self.bot.levels.get_modified_accuracy(player) hit_chance = modified_accuracy / 100.0 - if random.random() < hit_chance: - # Hit! Remove the duck - duck = self.ducks[channel].pop(0) + # Hit! Get the duck + self.ducks[channel].pop(0) xp_gained = 10 old_level = self.bot.levels.calculate_player_level(player) player['xp'] = player.get('xp', 0) + xp_gained player['ducks_shot'] = player.get('ducks_shot', 0) + 1 player['accuracy'] = min(player.get('accuracy', 65) + 1, 100) - # Check if player leveled up and update magazines if needed new_level = self.bot.levels.calculate_player_level(player) if new_level != old_level: self.bot.levels.update_player_magazines(player) - self.db.save_database() return { 'success': True, diff --git a/src/utils.py b/src/utils.py index d307ff9..b5d3b3f 100644 --- a/src/utils.py +++ b/src/utils.py @@ -147,19 +147,66 @@ class InputValidator: def parse_irc_message(line: str) -> Tuple[str, str, List[str], str]: - """Parse IRC message format""" - prefix = '' - trailing = '' - if line.startswith(':'): - if ' ' in line[1:]: - prefix, line = line[1:].split(' ', 1) - else: - # Handle malformed IRC line with no space after prefix - prefix = line[1:] - line = '' - if ' :' in line: - line, trailing = line.split(' :', 1) - parts = line.split() - command = parts[0] if parts else '' - params = parts[1:] if len(parts) > 1 else [] - return prefix, command, params, trailing \ No newline at end of file + """Parse IRC message format with comprehensive error handling""" + try: + # Validate input + if not isinstance(line, str): + raise ValueError(f"Expected string, got {type(line)}") + + # Handle empty or whitespace-only lines + if not line or not line.strip(): + return '', '', [], '' + + line = line.strip() + + # Initialize return values + prefix = '' + trailing = '' + command = '' + params = [] + + # Handle prefix (starts with :) + if line.startswith(':'): + try: + if ' ' in line[1:]: + prefix, line = line[1:].split(' ', 1) + else: + # Handle malformed IRC line with no space after prefix + prefix = line[1:] + line = '' + except ValueError: + # If split fails, treat entire line as prefix + prefix = line[1:] + line = '' + + # Handle trailing parameter (starts with ' :') + if line and ' :' in line: + try: + line, trailing = line.split(' :', 1) + except ValueError: + # If split fails, keep line as is + pass + + # Parse command and parameters + if line: + try: + parts = line.split() + command = parts[0] if parts else '' + params = parts[1:] if len(parts) > 1 else [] + except Exception: + # If parsing fails, try to extract at least the command + command = line.split()[0] if line.split() else '' + params = [] + + # Validate that we have at least a command + if not command and not prefix: + raise ValueError(f"No valid command or prefix found in line: {line[:50]}...") + + return prefix, command, params, trailing + + except Exception as e: + # Log the error but return safe defaults to prevent crashes + import logging + logger = logging.getLogger(__name__) + logger.warning(f"Error parsing IRC message '{line[:50]}...': {e}") + return '', 'UNKNOWN', [], '' \ No newline at end of file