Fix database corruption handling and auto-creation
- Added datetime import to fix NameError - Simplified database handling to create new file if missing or corrupted - Removed backup functionality per user request - Fixed duplicate method definitions - Enhanced error handling throughout database operations - Auto-creates duckhunt.json with proper structure on startup
This commit is contained in:
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
179
src/db.py
179
src/db.py
@@ -1,12 +1,14 @@
|
||||
"""
|
||||
Simplified Database management for DuckHunt Bot
|
||||
Focus on fixing missing field errors
|
||||
Enhanced Database management for DuckHunt Bot
|
||||
Focus on fixing missing field errors with improved error handling
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
import os
|
||||
from datetime import datetime
|
||||
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
|
||||
|
||||
|
||||
class DuckDB:
|
||||
@@ -17,45 +19,85 @@ class DuckDB:
|
||||
self.bot = bot
|
||||
self.players = {}
|
||||
self.logger = logging.getLogger('DuckHuntBot.DB')
|
||||
|
||||
# Error recovery configuration
|
||||
self.error_recovery = ErrorRecovery()
|
||||
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
|
||||
|
||||
self.load_database()
|
||||
|
||||
def load_database(self):
|
||||
"""Load player data from JSON file with comprehensive error handling"""
|
||||
def load_database(self) -> dict:
|
||||
"""Load the database, creating it if it doesn't exist"""
|
||||
try:
|
||||
if os.path.exists(self.db_file):
|
||||
with open(self.db_file, 'r', encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
|
||||
# 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")
|
||||
if not os.path.exists(self.db_file):
|
||||
self.logger.info(f"Database file {self.db_file} not found, creating new one")
|
||||
return self._create_default_database()
|
||||
|
||||
with open(self.db_file, 'r') as f:
|
||||
content = f.read().strip()
|
||||
|
||||
# Validate each player entry and ensure required fields
|
||||
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("No existing database found, starting fresh")
|
||||
|
||||
except (json.JSONDecodeError, UnicodeDecodeError) as e:
|
||||
self.logger.error(f"Database file corrupted: {e}")
|
||||
self.players = {}
|
||||
if not content:
|
||||
self.logger.warning("Database file is empty, creating new database")
|
||||
return self._create_default_database()
|
||||
|
||||
data = json.loads(content)
|
||||
|
||||
# Validate basic structure
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError("Database root is not a dictionary")
|
||||
|
||||
# Initialize metadata if missing
|
||||
if 'metadata' not in data:
|
||||
data['metadata'] = {
|
||||
'version': '1.0',
|
||||
'created': datetime.now().isoformat(),
|
||||
'last_modified': datetime.now().isoformat()
|
||||
}
|
||||
|
||||
# Initialize players section if missing
|
||||
if 'players' not in data:
|
||||
data['players'] = {}
|
||||
|
||||
# Update last_modified
|
||||
data['metadata']['last_modified'] = datetime.now().isoformat()
|
||||
|
||||
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
|
||||
return data
|
||||
|
||||
except (json.JSONDecodeError, ValueError) as e:
|
||||
self.logger.error(f"Database corruption detected: {e}. Creating new database.")
|
||||
return self._create_default_database()
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error loading database: {e}")
|
||||
self.players = {}
|
||||
return self._create_default_database()
|
||||
|
||||
def _create_default_database(self) -> dict:
|
||||
"""Create a new default database file with proper structure"""
|
||||
try:
|
||||
default_data = {
|
||||
"players": {},
|
||||
"last_save": str(time.time()),
|
||||
"version": "1.0",
|
||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"description": "DuckHunt Bot Player Database"
|
||||
}
|
||||
|
||||
with open(self.db_file, 'w', encoding='utf-8') as f:
|
||||
json.dump(default_data, f, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
|
||||
self.logger.info(f"Created new database file: {self.db_file}")
|
||||
return default_data
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Failed to create default database: {e}")
|
||||
# Return a minimal valid structure even if file creation fails
|
||||
return {
|
||||
"players": {},
|
||||
"last_save": str(time.time()),
|
||||
"version": "1.0",
|
||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||
"description": "DuckHunt Bot Player Database"
|
||||
}
|
||||
|
||||
def _sanitize_player_data(self, player_data):
|
||||
"""Sanitize and validate player data, ensuring ALL required fields exist"""
|
||||
@@ -146,30 +188,53 @@ class DuckDB:
|
||||
self.logger.error(f"Error sanitizing player data: {e}")
|
||||
return self.create_player(player_data.get('nick', 'Unknown') if isinstance(player_data, dict) else 'Unknown')
|
||||
|
||||
@with_retry(RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0),
|
||||
exceptions=(OSError, PermissionError, IOError))
|
||||
def save_database(self):
|
||||
"""Save all player data to JSON file with comprehensive error handling"""
|
||||
"""Save all player data to JSON file with retry logic and comprehensive error handling"""
|
||||
return self._save_database_impl()
|
||||
|
||||
def _save_database_impl(self):
|
||||
"""Internal implementation of database save"""
|
||||
temp_file = f"{self.db_file}.tmp"
|
||||
|
||||
try:
|
||||
# Prepare data with validation
|
||||
data = {
|
||||
'players': {},
|
||||
'last_save': str(time.time())
|
||||
'last_save': str(time.time()),
|
||||
'version': '1.0'
|
||||
}
|
||||
|
||||
# Validate and clean player data before saving
|
||||
valid_count = 0
|
||||
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)
|
||||
try:
|
||||
sanitized_nick = sanitize_user_input(nick, max_length=50)
|
||||
data['players'][sanitized_nick] = self._sanitize_player_data(player_data)
|
||||
valid_count += 1
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error processing player {nick} during save: {e}")
|
||||
else:
|
||||
self.logger.warning(f"Skipping invalid player data during save: {nick}")
|
||||
|
||||
if valid_count == 0:
|
||||
raise ValueError("No valid player data to save")
|
||||
|
||||
# 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)
|
||||
json.dump(data, f, indent=2, ensure_ascii=False, sort_keys=True)
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
|
||||
# Verify temp file was written correctly
|
||||
try:
|
||||
with open(temp_file, 'r', encoding='utf-8') as f:
|
||||
json.load(f) # Verify it's valid JSON
|
||||
except json.JSONDecodeError:
|
||||
raise IOError("Temporary file contains invalid JSON")
|
||||
|
||||
# Atomic replace
|
||||
if os.name == 'nt': # Windows
|
||||
if os.path.exists(self.db_file):
|
||||
@@ -178,10 +243,12 @@ class DuckDB:
|
||||
else: # Unix-like systems
|
||||
os.rename(temp_file, self.db_file)
|
||||
|
||||
self.logger.debug(f"Database saved successfully with {len(data['players'])} players")
|
||||
self.logger.debug(f"Database saved successfully with {valid_count} players")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error saving database: {e}")
|
||||
self.logger.error(f"Error in database save implementation: {e}")
|
||||
raise # Re-raise for retry mechanism
|
||||
finally:
|
||||
# Clean up temp file if it still exists
|
||||
try:
|
||||
@@ -196,26 +263,42 @@ class DuckDB:
|
||||
# Validate and sanitize nick
|
||||
if not isinstance(nick, str) or not nick.strip():
|
||||
self.logger.warning(f"Invalid nick provided: {nick}")
|
||||
return None
|
||||
return self.error_recovery.safe_execute(
|
||||
lambda: self.create_player('Unknown'),
|
||||
fallback={'nick': 'Unknown', 'xp': 0, 'ducks_shot': 0},
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
nick_lower = nick.lower().strip()[:50]
|
||||
# Sanitize nick input
|
||||
nick_clean = sanitize_user_input(nick, max_length=50,
|
||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
||||
nick_lower = nick_clean.lower().strip()
|
||||
|
||||
if not nick_lower:
|
||||
self.logger.warning(f"Empty nick after sanitization: {nick}")
|
||||
return self.create_player('Unknown')
|
||||
|
||||
if nick_lower not in self.players:
|
||||
self.players[nick_lower] = self.create_player(nick)
|
||||
self.players[nick_lower] = self.create_player(nick_clean)
|
||||
else:
|
||||
# Ensure existing players have all required fields
|
||||
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)
|
||||
self.players[nick_lower] = self.create_player(nick_clean)
|
||||
else:
|
||||
# Migrate and validate existing player data
|
||||
self.players[nick_lower] = self._migrate_and_validate_player(player, nick)
|
||||
# Migrate and validate existing player data with error recovery
|
||||
validated = self.error_recovery.safe_execute(
|
||||
lambda: self._migrate_and_validate_player(player, nick_clean),
|
||||
fallback=self.create_player(nick_clean),
|
||||
logger=self.logger
|
||||
)
|
||||
self.players[nick_lower] = validated
|
||||
|
||||
return self.players[nick_lower]
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error getting player {nick}: {e}")
|
||||
self.logger.error(f"Critical error getting player {nick}: {e}")
|
||||
return self.create_player(nick if isinstance(nick, str) else 'Unknown')
|
||||
|
||||
def _migrate_and_validate_player(self, player, nick):
|
||||
|
||||
@@ -12,6 +12,7 @@ from .game import DuckGame
|
||||
from .sasl import SASLHandler
|
||||
from .shop import ShopManager
|
||||
from .levels import LevelManager
|
||||
from .error_handling import ErrorRecovery, HealthChecker, sanitize_user_input, safe_format_message
|
||||
|
||||
|
||||
class DuckHuntBot:
|
||||
@@ -26,12 +27,19 @@ class DuckHuntBot:
|
||||
|
||||
self.logger.info("🤖 Initializing DuckHunt Bot components...")
|
||||
|
||||
# Initialize error recovery systems
|
||||
self.error_recovery = ErrorRecovery()
|
||||
self.health_checker = HealthChecker(check_interval=60.0)
|
||||
|
||||
self.db = DuckDB(bot=self)
|
||||
self.game = DuckGame(self, self.db)
|
||||
self.messages = MessageManager()
|
||||
|
||||
self.sasl_handler = SASLHandler(self, config)
|
||||
|
||||
# Set up health checks
|
||||
self._setup_health_checks()
|
||||
|
||||
admins_list = self.get_config('admins', ['colby']) or ['colby']
|
||||
self.admins = [admin.lower() for admin in admins_list]
|
||||
self.logger.info(f"Configured {len(self.admins)} admin(s): {', '.join(self.admins)}")
|
||||
@@ -41,6 +49,34 @@ class DuckHuntBot:
|
||||
|
||||
shop_file = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'shop.json')
|
||||
self.shop = ShopManager(shop_file, self.levels)
|
||||
|
||||
def _setup_health_checks(self):
|
||||
"""Set up health monitoring checks"""
|
||||
try:
|
||||
# Database health check
|
||||
self.health_checker.add_check(
|
||||
'database',
|
||||
lambda: self.db is not None and len(self.db.players) >= 0,
|
||||
critical=True
|
||||
)
|
||||
|
||||
# IRC connection health check
|
||||
self.health_checker.add_check(
|
||||
'irc_connection',
|
||||
lambda: self.writer is not None and not self.writer.is_closing(),
|
||||
critical=True
|
||||
)
|
||||
|
||||
# Message system health check
|
||||
self.health_checker.add_check(
|
||||
'messages',
|
||||
lambda: self.messages is not None and len(self.messages.messages) > 0,
|
||||
critical=False
|
||||
)
|
||||
|
||||
self.logger.debug("Health checks configured")
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error setting up health checks: {e}")
|
||||
|
||||
def get_config(self, path, default=None):
|
||||
keys = path.split('.')
|
||||
@@ -233,15 +269,63 @@ class DuckHuntBot:
|
||||
return False
|
||||
|
||||
def send_message(self, target, msg):
|
||||
"""Send message to target (channel or user) with error handling"""
|
||||
"""Send message to target (channel or user) with enhanced 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
|
||||
|
||||
|
||||
return self.error_recovery.safe_execute(
|
||||
lambda: self._send_message_impl(target, msg),
|
||||
fallback=False,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
def _send_message_impl(self, target, msg):
|
||||
"""Internal implementation of send_message"""
|
||||
try:
|
||||
sanitized_msg = msg.replace('\r', '').replace('\n', ' ').strip()
|
||||
if not sanitized_msg:
|
||||
# Sanitize target and message
|
||||
safe_target = sanitize_user_input(target, max_length=100,
|
||||
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
||||
safe_msg = sanitize_user_input(msg, max_length=400)
|
||||
|
||||
if not safe_target or not safe_msg:
|
||||
self.logger.warning(f"Empty target or message after sanitization")
|
||||
return False
|
||||
|
||||
# Split long messages to prevent IRC limits
|
||||
max_msg_length = 400 # IRC message limit minus PRIVMSG overhead
|
||||
|
||||
if len(safe_msg) <= max_msg_length:
|
||||
messages = [safe_msg]
|
||||
else:
|
||||
# Split into chunks
|
||||
messages = []
|
||||
words = safe_msg.split(' ')
|
||||
current_msg = ''
|
||||
|
||||
for word in words:
|
||||
if len(current_msg + ' ' + word) <= max_msg_length:
|
||||
current_msg += (' ' if current_msg else '') + word
|
||||
else:
|
||||
if current_msg:
|
||||
messages.append(current_msg)
|
||||
current_msg = word[:max_msg_length] # Truncate very long words
|
||||
|
||||
if current_msg:
|
||||
messages.append(current_msg)
|
||||
|
||||
# Send all message parts
|
||||
success_count = 0
|
||||
for i, message_part in enumerate(messages):
|
||||
if i > 0: # Small delay between messages to avoid flooding
|
||||
time.sleep(0.1)
|
||||
|
||||
if self.send_raw(f"PRIVMSG {safe_target} :{message_part}"):
|
||||
success_count += 1
|
||||
else:
|
||||
self.logger.error(f"Failed to send message part {i+1}/{len(messages)}")
|
||||
|
||||
return success_count == len(messages)
|
||||
|
||||
return self.send_raw(f"PRIVMSG {target} :{sanitized_msg}")
|
||||
except Exception as e:
|
||||
@@ -322,8 +406,9 @@ class DuckHuntBot:
|
||||
self.logger.error(f"Critical error in handle_message: {e}")
|
||||
|
||||
async def handle_command(self, user, channel, message):
|
||||
"""Handle bot commands with comprehensive error handling"""
|
||||
"""Handle bot commands with enhanced error handling and input validation"""
|
||||
try:
|
||||
# Validate input parameters
|
||||
if not isinstance(message, str) or not message.startswith('!'):
|
||||
return
|
||||
|
||||
@@ -331,8 +416,16 @@ class DuckHuntBot:
|
||||
self.logger.warning(f"Invalid user/channel types: {type(user)}, {type(channel)}")
|
||||
return
|
||||
|
||||
# Sanitize inputs
|
||||
safe_message = sanitize_user_input(message, max_length=500)
|
||||
safe_user = sanitize_user_input(user, max_length=200)
|
||||
safe_channel = sanitize_user_input(channel, max_length=100)
|
||||
|
||||
if not safe_message.startswith('!'):
|
||||
return
|
||||
|
||||
try:
|
||||
parts = message[1:].split()
|
||||
parts = safe_message[1:].split()
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error parsing command '{message}': {e}")
|
||||
return
|
||||
@@ -343,27 +436,39 @@ class DuckHuntBot:
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1:] if len(parts) > 1 else []
|
||||
|
||||
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}")
|
||||
# Extract and validate nick with enhanced error handling
|
||||
nick = self.error_recovery.safe_execute(
|
||||
lambda: safe_user.split('!')[0] if '!' in safe_user else safe_user,
|
||||
fallback='Unknown',
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
if not nick or nick == 'Unknown':
|
||||
self.logger.warning(f"Could not extract valid nick from user string: {user}")
|
||||
return
|
||||
|
||||
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 = {}
|
||||
# Sanitize nick further
|
||||
nick = sanitize_user_input(nick, max_length=50,
|
||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
||||
|
||||
if channel.startswith('#'):
|
||||
player['last_activity_channel'] = channel
|
||||
player['last_activity_time'] = time.time()
|
||||
self.db.players[nick.lower()] = player
|
||||
# Get player data with error recovery
|
||||
player = self.error_recovery.safe_execute(
|
||||
lambda: self.db.get_player(nick),
|
||||
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
if player is None:
|
||||
player = {'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False}
|
||||
|
||||
# Update activity tracking safely
|
||||
if safe_channel.startswith('#'):
|
||||
try:
|
||||
player['last_activity_channel'] = safe_channel
|
||||
player['last_activity_time'] = time.time()
|
||||
self.db.players[nick.lower()] = player
|
||||
except Exception as e:
|
||||
self.logger.warning(f"Error updating player activity for {nick}: {e}")
|
||||
|
||||
try:
|
||||
if player.get('ignored', False) and not self.is_admin(user):
|
||||
@@ -372,49 +477,142 @@ class DuckHuntBot:
|
||||
self.logger.error(f"Error checking admin/ignore status: {e}")
|
||||
return
|
||||
|
||||
await self._execute_command_safely(cmd, nick, channel, player, args, user)
|
||||
await self._execute_command_safely(cmd, nick, safe_channel, player, args, safe_user)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Critical error in handle_command: {e}")
|
||||
|
||||
async def _execute_command_safely(self, cmd, nick, channel, player, args, user):
|
||||
"""Execute individual commands with error isolation"""
|
||||
"""Execute individual commands with enhanced error isolation and user feedback"""
|
||||
try:
|
||||
# Sanitize command arguments
|
||||
safe_args = []
|
||||
for arg in args:
|
||||
safe_arg = sanitize_user_input(str(arg), max_length=100)
|
||||
if safe_arg:
|
||||
safe_args.append(safe_arg)
|
||||
|
||||
# Execute command with error recovery
|
||||
command_executed = False
|
||||
|
||||
if cmd == "bang":
|
||||
await self.handle_bang(nick, channel, player)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_bang(nick, channel, player),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "bef" or cmd == "befriend":
|
||||
await self.handle_bef(nick, channel, player)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_bef(nick, channel, player),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "reload":
|
||||
await self.handle_reload(nick, channel, player)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_reload(nick, channel, player),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "shop":
|
||||
await self.handle_shop(nick, channel, player, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_shop(nick, channel, player, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "duckstats":
|
||||
await self.handle_duckstats(nick, channel, player, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_duckstats(nick, channel, player, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "topduck":
|
||||
await self.handle_topduck(nick, channel)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_topduck(nick, channel),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "use":
|
||||
await self.handle_use(nick, channel, player, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_use(nick, channel, player, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "give":
|
||||
await self.handle_give(nick, channel, player, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_give(nick, channel, player, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "duckhelp":
|
||||
await self.handle_duckhelp(nick, channel, player)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_duckhelp(nick, channel, player),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "rearm" and self.is_admin(user):
|
||||
await self.handle_rearm(nick, channel, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_rearm(nick, channel, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "disarm" and self.is_admin(user):
|
||||
await self.handle_disarm(nick, channel, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_disarm(nick, channel, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "ignore" and self.is_admin(user):
|
||||
await self.handle_ignore(nick, channel, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_ignore(nick, channel, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "unignore" and self.is_admin(user):
|
||||
await self.handle_unignore(nick, channel, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_unignore(nick, channel, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
elif cmd == "ducklaunch" and self.is_admin(user):
|
||||
await self.handle_ducklaunch(nick, channel, args)
|
||||
command_executed = True
|
||||
await self.error_recovery.safe_execute_async(
|
||||
lambda: self.handle_ducklaunch(nick, channel, safe_args),
|
||||
fallback=None,
|
||||
logger=self.logger
|
||||
)
|
||||
|
||||
# If no command was executed, it might be an unknown command
|
||||
if not command_executed:
|
||||
self.logger.debug(f"Unknown command '{cmd}' from {nick}")
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Error executing command '{cmd}' for user {nick}: {e}")
|
||||
self.logger.error(f"Critical error executing command '{cmd}' for user {nick}: {e}")
|
||||
|
||||
# Provide user-friendly error message
|
||||
try:
|
||||
error_msg = f"{nick} > An error occurred processing your command. Please try again."
|
||||
self.send_message(channel, error_msg)
|
||||
if channel.startswith('#'):
|
||||
error_msg = safe_format_message(
|
||||
"{nick} > ⚠️ Something went wrong processing your command. Please try again in a moment.",
|
||||
nick=nick
|
||||
)
|
||||
self.send_message(channel, error_msg)
|
||||
else:
|
||||
self.logger.debug("Skipping error message for private channel")
|
||||
except Exception as send_error:
|
||||
self.logger.error(f"Error sending error message: {send_error}")
|
||||
self.logger.error(f"Error sending user error message: {send_error}")
|
||||
|
||||
def validate_target_player(self, target_nick, channel):
|
||||
"""
|
||||
|
||||
262
src/error_handling.py
Normal file
262
src/error_handling.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""
|
||||
Enhanced error handling utilities for DuckHunt Bot
|
||||
Includes retry mechanisms, circuit breakers, and graceful degradation
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
import time
|
||||
from functools import wraps
|
||||
from typing import Callable, Any, Optional, Union
|
||||
|
||||
|
||||
class RetryConfig:
|
||||
"""Configuration for retry mechanisms"""
|
||||
def __init__(self, max_attempts: int = 3, base_delay: float = 1.0,
|
||||
max_delay: float = 60.0, exponential: bool = True):
|
||||
self.max_attempts = max_attempts
|
||||
self.base_delay = base_delay
|
||||
self.max_delay = max_delay
|
||||
self.exponential = exponential
|
||||
|
||||
|
||||
class CircuitBreaker:
|
||||
"""Circuit breaker pattern for preventing cascading failures"""
|
||||
|
||||
def __init__(self, failure_threshold: int = 5, timeout: float = 60.0):
|
||||
self.failure_threshold = failure_threshold
|
||||
self.timeout = timeout
|
||||
self.failure_count = 0
|
||||
self.last_failure_time = None
|
||||
self.state = 'closed' # closed, open, half-open
|
||||
self.logger = logging.getLogger('DuckHuntBot.CircuitBreaker')
|
||||
|
||||
def __call__(self, func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def wrapper(*args, **kwargs):
|
||||
if self.state == 'open':
|
||||
if self.last_failure_time is not None and time.time() - self.last_failure_time > self.timeout:
|
||||
self.state = 'half-open'
|
||||
self.logger.info("Circuit breaker moving to half-open state")
|
||||
else:
|
||||
raise Exception("Circuit breaker is open - operation blocked")
|
||||
|
||||
try:
|
||||
result = await func(*args, **kwargs)
|
||||
if self.state == 'half-open':
|
||||
self.state = 'closed'
|
||||
self.failure_count = 0
|
||||
self.logger.info("Circuit breaker closed - service recovered")
|
||||
return result
|
||||
except Exception as e:
|
||||
self.failure_count += 1
|
||||
self.last_failure_time = time.time()
|
||||
|
||||
if self.failure_count >= self.failure_threshold:
|
||||
self.state = 'open'
|
||||
self.logger.warning(f"Circuit breaker opened after {self.failure_count} failures")
|
||||
|
||||
raise e
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
def with_retry(config: Optional[RetryConfig] = None,
|
||||
exceptions: tuple = (Exception,)):
|
||||
"""Decorator for adding retry logic to functions"""
|
||||
|
||||
if config is None:
|
||||
config = RetryConfig()
|
||||
|
||||
def decorator(func: Callable) -> Callable:
|
||||
@wraps(func)
|
||||
async def async_wrapper(*args, **kwargs):
|
||||
logger = logging.getLogger(f'DuckHuntBot.Retry.{func.__name__}')
|
||||
|
||||
for attempt in range(config.max_attempts):
|
||||
try:
|
||||
return await func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
if attempt == config.max_attempts - 1:
|
||||
logger.error(f"Function {func.__name__} failed after {config.max_attempts} attempts: {e}")
|
||||
raise
|
||||
|
||||
delay = config.base_delay
|
||||
if config.exponential:
|
||||
delay *= (2 ** attempt)
|
||||
delay = min(delay, config.max_delay)
|
||||
|
||||
logger.warning(f"Attempt {attempt + 1}/{config.max_attempts} failed for {func.__name__}: {e}. Retrying in {delay}s")
|
||||
await asyncio.sleep(delay)
|
||||
|
||||
return None
|
||||
|
||||
@wraps(func)
|
||||
def sync_wrapper(*args, **kwargs):
|
||||
logger = logging.getLogger(f'DuckHuntBot.Retry.{func.__name__}')
|
||||
|
||||
for attempt in range(config.max_attempts):
|
||||
try:
|
||||
return func(*args, **kwargs)
|
||||
except exceptions as e:
|
||||
if attempt == config.max_attempts - 1:
|
||||
logger.error(f"Function {func.__name__} failed after {config.max_attempts} attempts: {e}")
|
||||
raise
|
||||
|
||||
delay = config.base_delay
|
||||
if config.exponential:
|
||||
delay *= (2 ** attempt)
|
||||
delay = min(delay, config.max_delay)
|
||||
|
||||
logger.warning(f"Attempt {attempt + 1}/{config.max_attempts} failed for {func.__name__}: {e}. Retrying in {delay}s")
|
||||
time.sleep(delay)
|
||||
|
||||
return None
|
||||
|
||||
# Return appropriate wrapper based on whether function is async
|
||||
if asyncio.iscoroutinefunction(func):
|
||||
return async_wrapper
|
||||
else:
|
||||
return sync_wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
class ErrorRecovery:
|
||||
"""Error recovery and graceful degradation utilities"""
|
||||
|
||||
@staticmethod
|
||||
def safe_execute(func: Callable, fallback: Any = None,
|
||||
log_errors: bool = True, logger: Optional[logging.Logger] = None) -> Any:
|
||||
"""Safely execute a function with fallback value on error"""
|
||||
if logger is None:
|
||||
logger = logging.getLogger('DuckHuntBot.ErrorRecovery')
|
||||
|
||||
try:
|
||||
return func()
|
||||
except Exception as e:
|
||||
if log_errors:
|
||||
logger.error(f"Error executing {func.__name__}: {e}")
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
async def safe_execute_async(func: Callable, fallback: Any = None,
|
||||
log_errors: bool = True, logger: Optional[logging.Logger] = None) -> Any:
|
||||
"""Safely execute an async function with fallback value on error"""
|
||||
if logger is None:
|
||||
logger = logging.getLogger('DuckHuntBot.ErrorRecovery')
|
||||
|
||||
try:
|
||||
return await func()
|
||||
except Exception as e:
|
||||
if log_errors:
|
||||
logger.error(f"Error executing {func.__name__}: {e}")
|
||||
return fallback
|
||||
|
||||
@staticmethod
|
||||
def validate_input(value: Any, validator: Callable, default: Any = None,
|
||||
field_name: str = "input") -> Any:
|
||||
"""Validate input with fallback to default"""
|
||||
try:
|
||||
if validator(value):
|
||||
return value
|
||||
else:
|
||||
raise ValueError(f"Validation failed for {field_name}")
|
||||
except Exception:
|
||||
return default
|
||||
|
||||
|
||||
class HealthChecker:
|
||||
"""Health monitoring and alerting"""
|
||||
|
||||
def __init__(self, check_interval: float = 30.0):
|
||||
self.check_interval = check_interval
|
||||
self.checks = {}
|
||||
self.logger = logging.getLogger('DuckHuntBot.Health')
|
||||
|
||||
def add_check(self, name: str, check_func: Callable, critical: bool = False):
|
||||
"""Add a health check function"""
|
||||
self.checks[name] = {
|
||||
'func': check_func,
|
||||
'critical': critical,
|
||||
'last_success': None,
|
||||
'failure_count': 0
|
||||
}
|
||||
|
||||
async def run_checks(self) -> dict:
|
||||
"""Run all health checks and return results"""
|
||||
results = {}
|
||||
|
||||
for name, check in self.checks.items():
|
||||
try:
|
||||
result = await check['func']() if asyncio.iscoroutinefunction(check['func']) else check['func']()
|
||||
check['last_success'] = time.time()
|
||||
check['failure_count'] = 0
|
||||
results[name] = {'status': 'healthy', 'result': result}
|
||||
except Exception as e:
|
||||
check['failure_count'] += 1
|
||||
results[name] = {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e),
|
||||
'failure_count': check['failure_count']
|
||||
}
|
||||
|
||||
if check['critical'] and check['failure_count'] >= 3:
|
||||
self.logger.error(f"Critical health check '{name}' failed {check['failure_count']} times: {e}")
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def safe_format_message(template: str, **kwargs) -> str:
|
||||
"""Safely format message templates with error handling"""
|
||||
try:
|
||||
return template.format(**kwargs)
|
||||
except KeyError as e:
|
||||
logger = logging.getLogger('DuckHuntBot.MessageFormat')
|
||||
logger.error(f"Missing template variable {e} in message: {template[:100]}...")
|
||||
|
||||
# Try to provide safe fallback
|
||||
safe_kwargs = {}
|
||||
for key, value in kwargs.items():
|
||||
try:
|
||||
safe_kwargs[key] = str(value) if value is not None else ''
|
||||
except Exception:
|
||||
safe_kwargs[key] = ''
|
||||
|
||||
# Replace missing variables with placeholders
|
||||
import re
|
||||
def replace_missing(match):
|
||||
var_name = match.group(1)
|
||||
if var_name not in safe_kwargs:
|
||||
return f"[{var_name}]"
|
||||
return f"{{{var_name}}}"
|
||||
|
||||
safe_template = re.sub(r'\{([^}]+)\}', replace_missing, template)
|
||||
|
||||
try:
|
||||
return safe_template.format(**safe_kwargs)
|
||||
except Exception:
|
||||
return f"[Message format error in template: {template[:50]}...]"
|
||||
except Exception as e:
|
||||
logger = logging.getLogger('DuckHuntBot.MessageFormat')
|
||||
logger.error(f"Unexpected error formatting message: {e}")
|
||||
return f"[Message error: {template[:50]}...]"
|
||||
|
||||
|
||||
def sanitize_user_input(value: str, max_length: int = 100,
|
||||
allowed_chars: Optional[str] = None) -> str:
|
||||
"""Sanitize user input to prevent injection and errors"""
|
||||
if not isinstance(value, str):
|
||||
value = str(value)
|
||||
|
||||
# Limit length
|
||||
value = value[:max_length]
|
||||
|
||||
# Remove/replace dangerous characters
|
||||
value = value.replace('\r', '').replace('\n', ' ')
|
||||
|
||||
# Filter to allowed characters if specified
|
||||
if allowed_chars:
|
||||
value = ''.join(c for c in value if c in allowed_chars)
|
||||
|
||||
return value.strip()
|
||||
107
src/utils.py
107
src/utils.py
@@ -71,35 +71,88 @@ class MessageManager:
|
||||
}
|
||||
|
||||
def get(self, key: str, **kwargs) -> str:
|
||||
"""Get a formatted message by key with color placeholder replacement"""
|
||||
if key not in self.messages:
|
||||
return f"[Missing message: {key}]"
|
||||
|
||||
message = self.messages[key]
|
||||
|
||||
# If message is an array, randomly select one
|
||||
if isinstance(message, list):
|
||||
if not message:
|
||||
return f"[Empty message array: {key}]"
|
||||
message = random.choice(message)
|
||||
|
||||
# Ensure message is a string
|
||||
if not isinstance(message, str):
|
||||
return f"[Invalid message type: {key}]"
|
||||
|
||||
# Replace color placeholders with IRC codes
|
||||
if "colours" in self.messages and isinstance(self.messages["colours"], dict):
|
||||
for color_name, color_code in self.messages["colours"].items():
|
||||
placeholder = "{" + color_name + "}"
|
||||
message = message.replace(placeholder, color_code)
|
||||
|
||||
# Format with provided variables
|
||||
"""Get a formatted message by key with enhanced error handling"""
|
||||
try:
|
||||
return message.format(**kwargs)
|
||||
except KeyError as e:
|
||||
return f"[Message format error: {e}]"
|
||||
if key not in self.messages:
|
||||
return f"[Missing message: {key}]"
|
||||
|
||||
message = self.messages[key]
|
||||
|
||||
# If message is an array, randomly select one
|
||||
if isinstance(message, list):
|
||||
if not message:
|
||||
return f"[Empty message array: {key}]"
|
||||
message = random.choice(message)
|
||||
|
||||
# Ensure message is a string
|
||||
if not isinstance(message, str):
|
||||
return f"[Invalid message type: {key}]"
|
||||
|
||||
# Replace color placeholders with IRC codes
|
||||
if "colours" in self.messages and isinstance(self.messages["colours"], dict):
|
||||
for color_name, color_code in self.messages["colours"].items():
|
||||
placeholder = "{" + color_name + "}"
|
||||
message = message.replace(placeholder, color_code)
|
||||
|
||||
# Sanitize kwargs to prevent injection and ensure all values are safe
|
||||
safe_kwargs = {}
|
||||
for k, v in kwargs.items():
|
||||
try:
|
||||
# Sanitize key and value
|
||||
safe_key = str(k)[:50] if k is not None else 'unknown'
|
||||
if isinstance(v, (int, float)):
|
||||
safe_kwargs[safe_key] = v
|
||||
elif v is None:
|
||||
safe_kwargs[safe_key] = ''
|
||||
else:
|
||||
# Sanitize string values
|
||||
safe_value = str(v)[:200] # Limit length
|
||||
safe_value = safe_value.replace('\r', '').replace('\n', ' ') # Remove newlines
|
||||
safe_kwargs[safe_key] = safe_value
|
||||
except Exception:
|
||||
safe_kwargs[str(k)] = '[error]'
|
||||
|
||||
# Format with provided variables using safe formatting
|
||||
try:
|
||||
return message.format(**safe_kwargs)
|
||||
except KeyError as e:
|
||||
# Try to identify missing keys and provide defaults
|
||||
missing_key = str(e).strip("'\"")
|
||||
|
||||
# Common defaults for missing keys
|
||||
defaults = {
|
||||
'nick': 'Player',
|
||||
'xp_gained': 0,
|
||||
'ducks_shot': 0,
|
||||
'ducks_befriended': 0,
|
||||
'hp_remaining': 0,
|
||||
'ammo': 0,
|
||||
'max_ammo': 0,
|
||||
'magazines': 0,
|
||||
'target': 'Unknown',
|
||||
'victim': 'someone',
|
||||
'xp_lost': 0,
|
||||
'xp': 0
|
||||
}
|
||||
|
||||
# Add default for missing key
|
||||
if missing_key in defaults:
|
||||
safe_kwargs[missing_key] = defaults[missing_key]
|
||||
else:
|
||||
safe_kwargs[missing_key] = f'[{missing_key}]'
|
||||
|
||||
try:
|
||||
return message.format(**safe_kwargs)
|
||||
except Exception:
|
||||
return f"[Format error in {key}: missing {missing_key}]"
|
||||
|
||||
except ValueError as e:
|
||||
return f"[Format error in {key}: {e}]"
|
||||
except Exception as e:
|
||||
return f"[Message error in {key}: {e}]"
|
||||
|
||||
except Exception as e:
|
||||
return f"[Message error: {e}]"
|
||||
return f"[Critical message error: {e}]"
|
||||
|
||||
def reload(self):
|
||||
"""Reload messages from file"""
|
||||
|
||||
Reference in New Issue
Block a user