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
} }

237
src/db.py
View File

@@ -19,88 +19,203 @@ 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', {})
# 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}") 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
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}")
# 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): if os.path.exists(self.db_file):
backup_file = f"{self.db_file}.backup" 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"Unexpected error saving database: {e}")
finally:
# Clean up temp file if it still exists
try: try:
with open(self.db_file, 'r') as src, open(backup_file, 'w') as dst: if os.path.exists(temp_file):
dst.write(src.read()) os.remove(temp_file)
except Exception as e: except Exception:
self.logger.warning(f"Failed to create backup: {e}") pass
# Save main file
with open(self.db_file, 'w') as f:
json.dump(data, f, indent=2)
except Exception as e:
self.logger.error(f"Error saving database: {e}")
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
nick_lower = nick.lower().strip()[:50] # Limit nick length and sanitize
if nick_lower not in self.players: if nick_lower not in self.players:
self.players[nick_lower] = self.create_player(nick) self.players[nick_lower] = self.create_player(nick)
else: else:
# Ensure existing players have new fields and migrate from old system # Ensure existing players have all required fields and sanitize data
player = self.players[nick_lower] player = self.players[nick_lower]
if 'ducks_befriended' not in player: if not isinstance(player, dict):
player['ducks_befriended'] = 0 self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
if 'inventory' not in player: self.players[nick_lower] = self.create_player(nick)
player['inventory'] = {} else:
if 'temporary_effects' not in player: # Migrate and validate existing player data
player['temporary_effects'] = [] self.players[nick_lower] = self._migrate_and_validate_player(player, nick)
if 'jam_chance' not in player:
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
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] 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:
validated_player['ducks_befriended'] = 0
if 'inventory' not in player:
validated_player['inventory'] = {}
if 'temporary_effects' not in player:
validated_player['temporary_effects'] = []
if 'jam_chance' not in player:
validated_player['jam_chance'] = 5 # Default 5% jam chance
# Migrate from old ammo/chargers system to magazine system
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)
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): def create_player(self, nick):
"""Create a new player with basic stats""" """Create a new player with basic stats and validation"""
try:
# Sanitize nick
safe_nick = str(nick)[:50] if nick else 'Unknown'
return { return {
'nick': nick, 'nick': safe_nick,
'xp': 0, 'xp': 0,
'ducks_shot': 0, 'ducks_shot': 0,
'ducks_befriended': 0, 'ducks_befriended': 0,
@@ -113,3 +228,19 @@ class DuckDB:
'inventory': {}, # {item_id: quantity} 'inventory': {}, # {item_id: quantity}
'temporary_effects': [] # List of temporary effects '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"""
max_retries = 3
retry_delay = 5
for attempt in range(max_retries):
try: try:
ssl_context = ssl.create_default_context() if self.config.get('ssl', False) else None ssl_context = None
self.reader, self.writer = await asyncio.open_connection( 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['server'],
self.config['port'], self.config['port'],
ssl=ssl_context ssl=ssl_context
),
timeout=30.0 # 30 second connection timeout
) )
self.logger.info(f"Connected to {self.config['server']}:{self.config['port']}")
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: except Exception as e:
self.logger.error(f"Failed to connect: {e}") self.logger.error(f"Unexpected connection error on attempt {attempt + 1}: {e}")
raise
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,7 +192,25 @@ 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"""
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)
# Handle SASL-related messages # Handle SASL-related messages
if command == "CAP": if command == "CAP":
await self.sasl_handler.handle_cap_response(params, trailing) await self.sasl_handler.handle_cap_response(params, trailing)
@@ -152,8 +230,11 @@ class DuckHuntBot:
# Join channels # Join channels
for channel in self.config.get('channels', []): for channel in self.config.get('channels', []):
try:
self.send_raw(f"JOIN {channel}") self.send_raw(f"JOIN {channel}")
self.channels_joined.add(channel) self.channels_joined.add(channel)
except Exception as e:
self.logger.error(f"Error joining channel {channel}: {e}")
elif command == "PRIVMSG": elif command == "PRIVMSG":
if len(params) >= 1: if len(params) >= 1:
@@ -162,27 +243,76 @@ class DuckHuntBot:
await self.handle_command(prefix, target, message) await self.handle_command(prefix, target, message)
elif command == "PING": elif command == "PING":
try:
self.send_raw(f"PONG :{trailing}") 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:
# Validate inputs
if not isinstance(message, str) or not message.startswith('!'):
return 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() parts = message[1:].split()
except Exception as e:
self.logger.warning(f"Error parsing command '{message}': {e}")
return
if not parts: if not parts:
return return
cmd = parts[0].lower() cmd = parts[0].lower()
args = parts[1:] if len(parts) > 1 else [] args = parts[1:] if len(parts) > 1 else []
# Safely extract nick
try:
nick = user.split('!')[0] if '!' in user else user nick = user.split('!')[0] if '!' in user else user
if not nick:
player = self.db.get_player(nick) self.logger.warning(f"Empty nick from user string: {user}")
return
# Check if player is ignored (unless it's an admin) except Exception as e:
if player.get('ignored', False) and not self.is_admin(user): self.logger.error(f"Error extracting nick from '{user}': {e}")
return 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": if cmd == "bang":
await self.handle_bang(nick, channel, player) await self.handle_bang(nick, channel, player)
elif cmd == "bef" or cmd == "befriend": elif cmd == "bef" or cmd == "befriend":
@@ -207,6 +337,14 @@ class DuckHuntBot:
await self.handle_unignore(nick, channel, args) await self.handle_unignore(nick, channel, args)
elif cmd == "ducklaunch" and self.is_admin(user): elif cmd == "ducklaunch" and self.is_admin(user):
await self.handle_ducklaunch(nick, channel, args) 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:
return
try: try:
if not self.writer.is_closing(): if not self.writer.is_closing():
# Send quit message quickly without waiting # Send quit message with timeout
try: try:
quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down') quit_message = self.config.get('quit_message', 'DuckHunt Bot shutting down')
self.send_raw(f"QUIT :{quit_message}") if self.send_raw(f"QUIT :{quit_message}"):
await asyncio.sleep(0.1) # Very brief wait await asyncio.sleep(0.2) # Brief wait for message to send
except: except Exception as e:
pass # Don't block on quit message 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.error(f"Error closing connection: {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

View File

@@ -70,6 +70,10 @@ class DuckGame:
for duck in ducks_to_remove: for duck in ducks_to_remove:
ducks.remove(duck) ducks.remove(duck)
# 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') message = self.bot.messages.get('duck_flies_away')
self.bot.send_message(channel, message) self.bot.send_message(channel, message)
@@ -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"""
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 = '' prefix = ''
trailing = '' trailing = ''
command = ''
params = []
# Handle prefix (starts with :)
if line.startswith(':'): if line.startswith(':'):
try:
if ' ' in line[1:]: if ' ' in line[1:]:
prefix, line = line[1:].split(' ', 1) prefix, line = line[1:].split(' ', 1)
else: else:
# Handle malformed IRC line with no space after prefix # Handle malformed IRC line with no space after prefix
prefix = line[1:] prefix = line[1:]
line = '' line = ''
if ' :' in 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) 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() parts = line.split()
command = parts[0] if parts else '' command = parts[0] if parts else ''
params = parts[1:] if len(parts) > 1 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 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', [], ''