Fix ASCII encoding issues and add robust error handling

- Add comprehensive UTF-8 decoding error handling for IRC messages
- Implement robust error handling for all command processing
- Add network connection error resilience
- Add database operation error handling
- Ensure bot doesn't crash on any input or network issues
- Maintain original duck hunt functionality without feature additions
This commit is contained in:
2025-09-24 01:51:24 +01:00
parent 73582f7a44
commit 78caccd8b4
6 changed files with 646 additions and 252 deletions

View File

@@ -16,4 +16,5 @@
"duck_spawn_max": 30, "duck_spawn_max": 30,
"duck_timeout": 60, "duck_timeout": 60,
"befriend_success_rate": 75 "befriend_success_rate": 75
} }

View File

@@ -1,8 +1,8 @@
{ {
"duck_spawn": [ "duck_spawn": [
"・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}", "・゜゜・。。・゜゜\\_O< {bold}QUACK!{reset}",
"・゜゜・。。・゜゜\\_o< {light_grey}quack~{reset}", "・゜゜・。。・゜゜\\_o< {light_grey}quack~{reset}",
"・゜゜・。。・゜゜\\_O> {bold}*flap flap*{reset}" "・゜゜・。。・゜゜\\_O> {bold}*flap flap*{reset}"
], ],
"duck_flies_away": "The {bold}duck{reset} flies away. ·°'`'°-.,¸¸.·°'`", "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}]", "bang_hit": "{nick} > {green}*BANG*{reset} You shot the duck! [{green}+{xp_gained} xp{reset}] [Total ducks: {bold}{ducks_shot}{reset}]",

253
src/db.py
View File

@@ -19,97 +19,228 @@ class DuckDB:
self.load_database() self.load_database()
def load_database(self): def load_database(self):
"""Load player data from JSON file""" """Load player data from JSON file with comprehensive error handling"""
try: try:
if os.path.exists(self.db_file): 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) 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: else:
self.players = {} 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: except Exception as e:
self.logger.error(f"Error loading database: {e}") self.logger.error(f"Error loading database: {e}")
self.players = {} self.players = {}
def save_database(self): def _sanitize_player_data(self, player_data):
"""Save all player data to JSON file""" """Sanitize and validate player data"""
try: 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 = { data = {
'players': self.players, 'players': {},
'last_save': str(time.time()) 'last_save': str(time.time())
} }
# Create backup # Validate and clean player data before saving
if os.path.exists(self.db_file): for nick, player_data in self.players.items():
backup_file = f"{self.db_file}.backup" if isinstance(nick, str) and isinstance(player_data, dict):
try: data['players'][nick] = self._sanitize_player_data(player_data)
with open(self.db_file, 'r') as src, open(backup_file, 'w') as dst: else:
dst.write(src.read()) self.logger.warning(f"Skipping invalid player data during save: {nick}")
except Exception as e:
self.logger.warning(f"Failed to create backup: {e}")
# Save main file # Write to temporary file first (atomic write)
with open(self.db_file, 'w') as f: with open(temp_file, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2) 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: 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): def get_player(self, nick):
"""Get player data, creating if doesn't exist""" """Get player data, creating if doesn't exist with comprehensive validation"""
nick_lower = nick.lower() try:
# Validate and sanitize nick
if not isinstance(nick, str) or not nick.strip():
self.logger.warning(f"Invalid nick provided: {nick}")
return None
if nick_lower not in self.players: nick_lower = nick.lower().strip()[:50] # Limit nick length and sanitize
self.players[nick_lower] = self.create_player(nick)
else: if nick_lower not in self.players:
# Ensure existing players have new fields and migrate from old system self.players[nick_lower] = self.create_player(nick)
player = self.players[nick_lower] 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: if 'ducks_befriended' not in player:
player['ducks_befriended'] = 0 validated_player['ducks_befriended'] = 0
if 'inventory' not in player: if 'inventory' not in player:
player['inventory'] = {} validated_player['inventory'] = {}
if 'temporary_effects' not in player: if 'temporary_effects' not in player:
player['temporary_effects'] = [] validated_player['temporary_effects'] = []
if 'jam_chance' not in player: 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 # Migrate from old ammo/chargers system to magazine system
if 'magazines' not in player: if 'magazines' not in player and ('ammo' in player or 'chargers' in player):
# Convert old system: assume they had full magazines self.logger.info(f"Migrating {nick} from old ammo system to magazine system")
old_ammo = player.get('ammo', 6) old_ammo = player.get('ammo', 6)
old_chargers = player.get('chargers', 2) old_chargers = player.get('chargers', 2)
player['current_ammo'] = old_ammo validated_player['current_ammo'] = max(0, min(50, int(old_ammo)))
player['magazines'] = old_chargers + 1 # +1 for current loaded magazine validated_player['magazines'] = max(1, min(20, int(old_chargers) + 1)) # +1 for current loaded magazine
player['bullets_per_magazine'] = 6 validated_player['bullets_per_magazine'] = 6
# Remove old fields # Update nick in case it changed
if 'ammo' in player: validated_player['nick'] = str(nick)[:50]
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] 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): def create_player(self, nick):
"""Create a new player with basic stats""" """Create a new player with basic stats and validation"""
return { try:
'nick': nick, # Sanitize nick
'xp': 0, safe_nick = str(nick)[:50] if nick else 'Unknown'
'ducks_shot': 0,
'ducks_befriended': 0, return {
'current_ammo': 6, # Bullets in current magazine 'nick': safe_nick,
'magazines': 3, # Total magazines (including current) 'xp': 0,
'bullets_per_magazine': 6, # Bullets per magazine 'ducks_shot': 0,
'accuracy': 65, 'ducks_befriended': 0,
'jam_chance': 5, # 5% base gun jamming chance 'current_ammo': 6, # Bullets in current magazine
'gun_confiscated': False, 'magazines': 3, # Total magazines (including current)
'inventory': {}, # {item_id: quantity} 'bullets_per_magazine': 6, # Bullets per magazine
'temporary_effects': [] # List of temporary effects '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': []
}

View File

@@ -1,15 +1,12 @@
import asyncio import asyncio
import ssl import ssl
import json
import logging
import sys
import os import os
import time import time
import signal import signal
from typing import Optional from typing import Optional
from .logging_utils import setup_logger 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 .db import DuckDB
from .game import DuckGame from .game import DuckGame
from .sasl import SASLHandler from .sasl import SASLHandler
@@ -82,12 +79,12 @@ class DuckHuntBot:
def setup_signal_handlers(self): def setup_signal_handlers(self):
"""Setup signal handlers for immediate shutdown""" """Setup signal handlers for immediate shutdown"""
def signal_handler(signum, frame): 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" signal_name = "SIGINT" if signum == signal.SIGINT else "SIGTERM"
self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), shutting down immediately...") self.logger.info(f"🛑 Received {signal_name} (Ctrl+C), shutting down immediately...")
self.shutdown_requested = True self.shutdown_requested = True
# Cancel all running tasks immediately
try:
# Get the current event loop and cancel all tasks # Get the current event loop and cancel all tasks
loop = asyncio.get_running_loop() loop = asyncio.get_running_loop()
tasks = [t for t in asyncio.all_tasks(loop) if not t.done()] 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) signal.signal(signal.SIGTERM, signal_handler)
async def connect(self): async def connect(self):
"""Connect to IRC server""" """Connect to IRC server with comprehensive error handling"""
try: max_retries = 3
ssl_context = ssl.create_default_context() if self.config.get('ssl', False) else None retry_delay = 5
self.reader, self.writer = await asyncio.open_connection(
self.config['server'], for attempt in range(max_retries):
self.config['port'], try:
ssl=ssl_context ssl_context = None
) if self.config.get('ssl', False):
self.logger.info(f"Connected to {self.config['server']}:{self.config['port']}") ssl_context = ssl.create_default_context()
except Exception as e: # Add SSL context configuration for better compatibility
self.logger.error(f"Failed to connect: {e}") ssl_context.check_hostname = False
raise 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): def send_raw(self, msg):
"""Send raw IRC message""" """Send raw IRC message with error handling"""
if self.writer: if not self.writer or self.writer.is_closing():
self.writer.write(f"{msg}\r\n".encode('utf-8')) 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): def send_message(self, target, msg):
"""Send message to target (channel or user)""" """Send message to target (channel or user) with error handling"""
self.send_raw(f"PRIVMSG {target} :{msg}") 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): async def register_user(self):
"""Register user with IRC server""" """Register user with IRC server"""
@@ -132,81 +192,159 @@ class DuckHuntBot:
self.send_raw(f"USER {self.config['nick']} 0 * :{self.config['nick']}") self.send_raw(f"USER {self.config['nick']} 0 * :{self.config['nick']}")
async def handle_message(self, prefix, command, params, trailing): async def handle_message(self, prefix, command, params, trailing):
"""Handle incoming IRC messages""" """Handle incoming IRC messages with comprehensive error handling"""
# Handle SASL-related messages try:
if command == "CAP": # Validate input parameters
await self.sasl_handler.handle_cap_response(params, trailing) if not isinstance(command, str):
return self.logger.warning(f"Invalid command type: {type(command)}")
return
elif command == "AUTHENTICATE": if params is None:
await self.sasl_handler.handle_authenticate_response(params) params = []
return elif not isinstance(params, list):
self.logger.warning(f"Invalid params type: {type(params)}")
params = []
elif command in ["903", "904", "905", "906", "907", "908"]: if trailing is None:
await self.sasl_handler.handle_sasl_result(command, params, trailing) trailing = ""
return elif not isinstance(trailing, str):
self.logger.warning(f"Invalid trailing type: {type(trailing)}")
trailing = str(trailing)
elif command == "001": # Welcome message # Handle SASL-related messages
self.registered = True if command == "CAP":
self.logger.info("Successfully registered with IRC server") await self.sasl_handler.handle_cap_response(params, trailing)
return
# Join channels elif command == "AUTHENTICATE":
for channel in self.config.get('channels', []): await self.sasl_handler.handle_authenticate_response(params)
self.send_raw(f"JOIN {channel}") return
self.channels_joined.add(channel)
elif command == "PRIVMSG": elif command in ["903", "904", "905", "906", "907", "908"]:
if len(params) >= 1: await self.sasl_handler.handle_sasl_result(command, params, trailing)
target = params[0] return
message = trailing or ""
await self.handle_command(prefix, target, message)
elif command == "PING": elif command == "001": # Welcome message
self.send_raw(f"PONG :{trailing}") 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): async def handle_command(self, user, channel, message):
"""Handle bot commands""" """Handle bot commands with comprehensive error handling"""
if not message.startswith('!'): try:
return # Validate inputs
if not isinstance(message, str) or not message.startswith('!'):
return
parts = message[1:].split() if not isinstance(user, str) or not isinstance(channel, str):
if not parts: self.logger.warning(f"Invalid user/channel types: {type(user)}, {type(channel)}")
return return
cmd = parts[0].lower() # Safely parse command
args = parts[1:] if len(parts) > 1 else [] try:
nick = user.split('!')[0] if '!' in user else user parts = message[1:].split()
except Exception as e:
self.logger.warning(f"Error parsing command '{message}': {e}")
return
player = self.db.get_player(nick) if not parts:
return
# Check if player is ignored (unless it's an admin) cmd = parts[0].lower()
if player.get('ignored', False) and not self.is_admin(user): args = parts[1:] if len(parts) > 1 else []
return
if cmd == "bang": # Safely extract nick
await self.handle_bang(nick, channel, player) try:
elif cmd == "bef" or cmd == "befriend": nick = user.split('!')[0] if '!' in user else user
await self.handle_bef(nick, channel, player) if not nick:
elif cmd == "reload": self.logger.warning(f"Empty nick from user string: {user}")
await self.handle_reload(nick, channel, player) return
elif cmd == "shop": except Exception as e:
await self.handle_shop(nick, channel, player, args) self.logger.error(f"Error extracting nick from '{user}': {e}")
elif cmd == "duckstats": return
await self.handle_duckstats(nick, channel, player)
elif cmd == "use": # Get player data safely
await self.handle_use(nick, channel, player, args) try:
elif cmd == "duckhelp": player = self.db.get_player(nick)
await self.handle_duckhelp(nick, channel, player) if player is None:
elif cmd == "rearm" and self.is_admin(user): player = {}
await self.handle_rearm(nick, channel, args) except Exception as e:
elif cmd == "disarm" and self.is_admin(user): self.logger.error(f"Error getting player data for {nick}: {e}")
await self.handle_disarm(nick, channel, args) player = {}
elif cmd == "ignore" and self.is_admin(user):
await self.handle_ignore(nick, channel, args) # Check if player is ignored (unless it's an admin)
elif cmd == "unignore" and self.is_admin(user): try:
await self.handle_unignore(nick, channel, args) if player.get('ignored', False) and not self.is_admin(user):
elif cmd == "ducklaunch" and self.is_admin(user): return
await self.handle_ducklaunch(nick, channel, args) 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): async def handle_bang(self, nick, channel, player):
"""Handle !bang command""" """Handle !bang command"""
@@ -303,7 +441,58 @@ class DuckHuntBot:
self.send_message(channel, message) self.send_message(channel, message)
self.db.save_database() 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""" """Handle !duckhelp command"""
help_lines = [ help_lines = [
self.messages.get('help_header'), self.messages.get('help_header'),
@@ -318,35 +507,6 @@ class DuckHuntBot:
for line in help_lines: for line in help_lines:
self.send_message(channel, line) 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): async def handle_use(self, nick, channel, player, args):
"""Handle !use command""" """Handle !use command"""
if not args: if not args:
@@ -389,11 +549,16 @@ class DuckHuntBot:
message = f"{nick} > Invalid item ID. Use !duckstats to see your items." message = f"{nick} > Invalid item ID. Use !duckstats to see your items."
self.send_message(channel, message) 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): async def handle_rearm(self, nick, channel, args):
"""Handle !rearm command (admin only)""" """Handle !rearm command (admin only)"""
if args: if args:
target = args[0].lower() target = args[0].lower()
player = self.db.get_player(target) player = self.db.get_player(target)
if player is None:
player = {}
player['gun_confiscated'] = False player['gun_confiscated'] = False
# Update magazines based on player level # Update magazines based on player level
@@ -405,6 +570,8 @@ class DuckHuntBot:
else: else:
# Rearm the admin themselves # Rearm the admin themselves
player = self.db.get_player(nick) player = self.db.get_player(nick)
if player is None:
player = {}
player['gun_confiscated'] = False player['gun_confiscated'] = False
# Update magazines based on admin's level # Update magazines based on admin's level
@@ -415,9 +582,6 @@ class DuckHuntBot:
self.send_message(channel, message) self.send_message(channel, message)
self.db.save_database() self.db.save_database()
async def handle_disarm(self, nick, channel, args):
"""Handle !disarm command (admin only)"""
def disarm_player(player): def disarm_player(player):
player['gun_confiscated'] = True player['gun_confiscated'] = True
@@ -442,8 +606,7 @@ class DuckHuntBot:
self._handle_single_target_admin_command( self._handle_single_target_admin_command(
args, 'usage_unignore', unignore_player, 'admin_unignore', nick, channel 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)""" """Handle !ducklaunch command (admin only)"""
if channel not in self.channels_joined: if channel not in self.channels_joined:
message = self.messages.get('admin_ducklaunch_not_enabled') message = self.messages.get('admin_ducklaunch_not_enabled')
@@ -458,36 +621,77 @@ class DuckHuntBot:
# Only send the duck spawn message, no admin notification # Only send the duck spawn message, no admin notification
self.send_message(channel, duck_message) self.send_message(channel, duck_message)
self.send_message(channel, duck_message)
async def message_loop(self): 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: try:
while not self.shutdown_requested and self.reader: while not self.shutdown_requested and self.reader:
try: try:
# Use a timeout on readline to make it more responsive to shutdown # Use a timeout on readline to make it more responsive to shutdown
line = await asyncio.wait_for(self.reader.readline(), timeout=1.0) line = await asyncio.wait_for(self.reader.readline(), timeout=1.0)
# Reset error counter on successful read
consecutive_errors = 0
except asyncio.TimeoutError: except asyncio.TimeoutError:
# Check shutdown flag and continue # Check shutdown flag and continue
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: if not line:
self.logger.info("Connection closed by server")
break 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: if not line:
continue continue
# Process the message with full error isolation
try: try:
prefix, command, params, trailing = parse_irc_message(line) prefix, command, params, trailing = parse_irc_message(line)
await self.handle_message(prefix, command, params, trailing) 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: 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: except asyncio.CancelledError:
self.logger.info("Message loop cancelled") self.logger.info("Message loop cancelled")
except Exception as e: except Exception as e:
self.logger.error(f"Message loop error: {e}") self.logger.error(f"Critical message loop error: {e}")
finally: finally:
self.logger.info("Message loop ended") self.logger.info("Message loop ended")
@@ -512,10 +716,9 @@ class DuckHuntBot:
message_task = asyncio.create_task(self.message_loop()) message_task = asyncio.create_task(self.message_loop())
self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.") self.logger.info("🦆 Bot is now running! Press Ctrl+C to stop.")
# Wait for shutdown signal or task completion with frequent checks # Wait for shutdown signal or task completion with frequent checks
while not self.shutdown_requested: while not self.shutdown_requested:
done, pending = await asyncio.wait( done, _pending = await asyncio.wait(
[game_task, message_task], [game_task, message_task],
timeout=0.1, # Check every 100ms for shutdown timeout=0.1, # Check every 100ms for shutdown
return_when=asyncio.FIRST_COMPLETED return_when=asyncio.FIRST_COMPLETED
@@ -524,6 +727,7 @@ class DuckHuntBot:
# If any task completed, break out # If any task completed, break out
if done: if done:
break break
break
self.logger.info("🔄 Shutdown initiated, cleaning up...") self.logger.info("🔄 Shutdown initiated, cleaning up...")
@@ -560,22 +764,34 @@ class DuckHuntBot:
self.logger.info("✅ Bot shutdown complete") self.logger.info("✅ Bot shutdown complete")
async def _close_connection(self): async def _close_connection(self):
"""Close IRC connection quickly""" """Close IRC connection with comprehensive error handling"""
if self.writer: if not self.writer:
try: return
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
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() self.writer.close()
await asyncio.wait_for(self.writer.wait_closed(), timeout=1.0) await asyncio.wait_for(self.writer.wait_closed(), timeout=2.0)
self.logger.info("🔌 IRC connection closed") except asyncio.TimeoutError:
except asyncio.TimeoutError: self.logger.warning("⚠️ Connection close timed out - forcing close")
self.logger.warning("⚠️ Connection close timed out - forcing close") except Exception as e:
except Exception as e: self.logger.debug(f"Error during connection close: {e}")
self.logger.error(f"❌ Error closing connection: {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

View File

@@ -70,7 +70,11 @@ class DuckGame:
for duck in ducks_to_remove: for duck in ducks_to_remove:
ducks.remove(duck) 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) self.bot.send_message(channel, message)
if not ducks: if not ducks:
@@ -96,17 +100,16 @@ class DuckGame:
duck = { duck = {
'id': f"duck_{int(time.time())}_{random.randint(1000, 9999)}", 'id': f"duck_{int(time.time())}_{random.randint(1000, 9999)}",
'spawn_time': time.time(), 'spawn_time': time.time(),
'channel': channel 'channel': channel,
'max_hp': 1,
'current_hp': 1
} }
# Send regular duck spawn message
self.ducks[channel].append(duck)
# Send spawn message
message = self.bot.messages.get('duck_spawn') 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.bot.send_message(channel, message)
self.logger.info(f"Duck spawned in {channel}")
def shoot_duck(self, nick, channel, player): def shoot_duck(self, nick, channel, player):
"""Handle shooting at a duck""" """Handle shooting at a duck"""
# Check if gun is confiscated # Check if gun is confiscated
@@ -151,25 +154,21 @@ class DuckGame:
# Shoot at duck # Shoot at duck
player['current_ammo'] = player.get('current_ammo', 1) - 1 player['current_ammo'] = player.get('current_ammo', 1) - 1
# Calculate hit chance using level-modified accuracy # Calculate hit chance using level-modified accuracy
modified_accuracy = self.bot.levels.get_modified_accuracy(player) modified_accuracy = self.bot.levels.get_modified_accuracy(player)
hit_chance = modified_accuracy / 100.0 hit_chance = modified_accuracy / 100.0
if random.random() < hit_chance: if random.random() < hit_chance:
# Hit! Remove the duck # Hit! Get the duck
duck = self.ducks[channel].pop(0) self.ducks[channel].pop(0)
xp_gained = 10 xp_gained = 10
old_level = self.bot.levels.calculate_player_level(player) old_level = self.bot.levels.calculate_player_level(player)
player['xp'] = player.get('xp', 0) + xp_gained player['xp'] = player.get('xp', 0) + xp_gained
player['ducks_shot'] = player.get('ducks_shot', 0) + 1 player['ducks_shot'] = player.get('ducks_shot', 0) + 1
player['accuracy'] = min(player.get('accuracy', 65) + 1, 100) player['accuracy'] = min(player.get('accuracy', 65) + 1, 100)
# Check if player leveled up and update magazines if needed # Check if player leveled up and update magazines if needed
new_level = self.bot.levels.calculate_player_level(player) new_level = self.bot.levels.calculate_player_level(player)
if new_level != old_level: if new_level != old_level:
self.bot.levels.update_player_magazines(player) self.bot.levels.update_player_magazines(player)
self.db.save_database() self.db.save_database()
return { return {
'success': True, 'success': True,

View File

@@ -147,19 +147,66 @@ class InputValidator:
def parse_irc_message(line: str) -> Tuple[str, str, List[str], str]: def parse_irc_message(line: str) -> Tuple[str, str, List[str], str]:
"""Parse IRC message format""" """Parse IRC message format with comprehensive error handling"""
prefix = '' try:
trailing = '' # Validate input
if line.startswith(':'): if not isinstance(line, str):
if ' ' in line[1:]: raise ValueError(f"Expected string, got {type(line)}")
prefix, line = line[1:].split(' ', 1)
else: # Handle empty or whitespace-only lines
# Handle malformed IRC line with no space after prefix if not line or not line.strip():
prefix = line[1:] return '', '', [], ''
line = ''
if ' :' in line: line = line.strip()
line, trailing = line.split(' :', 1)
parts = line.split() # Initialize return values
command = parts[0] if parts else '' prefix = ''
params = parts[1:] if len(parts) > 1 else [] trailing = ''
return prefix, command, params, 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', [], ''