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:
@@ -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
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -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
253
src/db.py
@@ -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': []
|
||||||
|
}
|
||||||
@@ -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
|
||||||
27
src/game.py
27
src/game.py
@@ -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,
|
||||||
|
|||||||
79
src/utils.py
79
src/utils.py
@@ -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', [], ''
|
||||||
Reference in New Issue
Block a user