Revert to original working code - clean slate
- Start from original code that was known to work - Will add features back incrementally - This ensures we know exactly what breaks/works
This commit is contained in:
215
src/db.py
215
src/db.py
@@ -8,7 +8,6 @@ import logging
|
|||||||
import time
|
import time
|
||||||
import os
|
import os
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
from typing import Optional
|
|
||||||
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
|
from .error_handling import with_retry, RetryConfig, ErrorRecovery, sanitize_user_input
|
||||||
|
|
||||||
|
|
||||||
@@ -24,8 +23,6 @@ class DuckDB:
|
|||||||
project_root = os.path.dirname(os.path.dirname(__file__))
|
project_root = os.path.dirname(os.path.dirname(__file__))
|
||||||
self.db_file = os.path.join(project_root, db_file)
|
self.db_file = os.path.join(project_root, db_file)
|
||||||
self.bot = bot
|
self.bot = bot
|
||||||
# Channel-scoped player storage:
|
|
||||||
# {"#channel": {"nick": {player_data}}, ...}
|
|
||||||
self.players = {}
|
self.players = {}
|
||||||
self.logger = logging.getLogger('DuckHuntBot.DB')
|
self.logger = logging.getLogger('DuckHuntBot.DB')
|
||||||
|
|
||||||
@@ -34,61 +31,12 @@ class DuckDB:
|
|||||||
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
|
self.save_retry_config = RetryConfig(max_attempts=3, base_delay=0.5, max_delay=5.0)
|
||||||
|
|
||||||
data = self.load_database()
|
data = self.load_database()
|
||||||
self._hydrate_from_data(data)
|
# Hydrate in-memory state from disk.
|
||||||
|
# Previously, load_database() returned data but self.players stayed empty,
|
||||||
def _default_channel(self) -> str:
|
# making it look like everything reset after restart.
|
||||||
"""Pick a reasonable default channel context."""
|
if isinstance(data, dict) and isinstance(data.get('players'), dict):
|
||||||
if self.bot:
|
self.players = data['players']
|
||||||
channels = self.bot.get_config('connection.channels', []) or []
|
|
||||||
if isinstance(channels, list) and channels:
|
|
||||||
first = channels[0]
|
|
||||||
if isinstance(first, str) and first.strip():
|
|
||||||
return first.strip()
|
|
||||||
return "#duckhunt"
|
|
||||||
|
|
||||||
def _normalize_channel(self, channel: Optional[str]) -> str:
|
|
||||||
if isinstance(channel, str) and channel.strip().startswith(('#', '&')):
|
|
||||||
return channel.strip()
|
|
||||||
return self._default_channel()
|
|
||||||
|
|
||||||
def _hydrate_from_data(self, data: dict) -> None:
|
|
||||||
"""Load in-memory channel->players structure from parsed JSON."""
|
|
||||||
try:
|
|
||||||
players_by_channel: dict = {}
|
|
||||||
|
|
||||||
if isinstance(data, dict) and isinstance(data.get('channels'), dict):
|
|
||||||
# New format
|
|
||||||
for ch, ch_data in data['channels'].items():
|
|
||||||
if not isinstance(ch, str):
|
|
||||||
continue
|
|
||||||
if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict):
|
|
||||||
players = ch_data.get('players', {})
|
|
||||||
elif isinstance(ch_data, dict):
|
|
||||||
# Support legacy "channels: {"#c": {nick: {...}}}" shape
|
|
||||||
players = ch_data
|
|
||||||
else:
|
else:
|
||||||
continue
|
|
||||||
|
|
||||||
# Keep only dict players
|
|
||||||
clean_players = {}
|
|
||||||
for nick, pdata in players.items():
|
|
||||||
if isinstance(nick, str) and isinstance(pdata, dict):
|
|
||||||
clean_players[nick.lower()] = pdata
|
|
||||||
if clean_players:
|
|
||||||
players_by_channel[ch] = clean_players
|
|
||||||
|
|
||||||
elif isinstance(data, dict) and isinstance(data.get('players'), dict):
|
|
||||||
# Old format: single global player dictionary
|
|
||||||
default_channel = self._default_channel()
|
|
||||||
migrated = {}
|
|
||||||
for nick, pdata in data['players'].items():
|
|
||||||
if isinstance(nick, str) and isinstance(pdata, dict):
|
|
||||||
migrated[nick.lower()] = pdata
|
|
||||||
players_by_channel[default_channel] = migrated
|
|
||||||
|
|
||||||
self.players = players_by_channel if isinstance(players_by_channel, dict) else {}
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error hydrating database in-memory state: {e}")
|
|
||||||
self.players = {}
|
self.players = {}
|
||||||
|
|
||||||
def load_database(self) -> dict:
|
def load_database(self) -> dict:
|
||||||
@@ -119,28 +67,14 @@ class DuckDB:
|
|||||||
'last_modified': datetime.now().isoformat()
|
'last_modified': datetime.now().isoformat()
|
||||||
}
|
}
|
||||||
|
|
||||||
# Initialize channels section if missing
|
# Initialize players section if missing
|
||||||
if 'channels' not in data:
|
if 'players' not in data:
|
||||||
# If old format has players, keep it for migration.
|
data['players'] = {}
|
||||||
data.setdefault('players', {})
|
|
||||||
else:
|
|
||||||
if not isinstance(data.get('channels'), dict):
|
|
||||||
data['channels'] = {}
|
|
||||||
|
|
||||||
# Update last_modified
|
# Update last_modified
|
||||||
data['metadata']['last_modified'] = datetime.now().isoformat()
|
data['metadata']['last_modified'] = datetime.now().isoformat()
|
||||||
|
|
||||||
try:
|
|
||||||
if isinstance(data.get('channels'), dict):
|
|
||||||
total_players = 0
|
|
||||||
for ch_data in data['channels'].values():
|
|
||||||
if isinstance(ch_data, dict) and isinstance(ch_data.get('players'), dict):
|
|
||||||
total_players += len(ch_data.get('players', {}))
|
|
||||||
self.logger.info(f"Successfully loaded database with {total_players} total players across {len(data.get('channels', {}))} channels")
|
|
||||||
else:
|
|
||||||
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
|
self.logger.info(f"Successfully loaded database with {len(data.get('players', {}))} players")
|
||||||
except Exception:
|
|
||||||
self.logger.info("Successfully loaded database")
|
|
||||||
return data
|
return data
|
||||||
|
|
||||||
except (json.JSONDecodeError, ValueError) as e:
|
except (json.JSONDecodeError, ValueError) as e:
|
||||||
@@ -154,9 +88,9 @@ class DuckDB:
|
|||||||
"""Create a new default database file with proper structure"""
|
"""Create a new default database file with proper structure"""
|
||||||
try:
|
try:
|
||||||
default_data = {
|
default_data = {
|
||||||
"channels": {},
|
"players": {},
|
||||||
"last_save": str(time.time()),
|
"last_save": str(time.time()),
|
||||||
"version": "2.0",
|
"version": "1.0",
|
||||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"description": "DuckHunt Bot Player Database"
|
"description": "DuckHunt Bot Player Database"
|
||||||
}
|
}
|
||||||
@@ -171,9 +105,9 @@ class DuckDB:
|
|||||||
self.logger.error(f"Failed to create default database: {e}")
|
self.logger.error(f"Failed to create default database: {e}")
|
||||||
# Return a minimal valid structure even if file creation fails
|
# Return a minimal valid structure even if file creation fails
|
||||||
return {
|
return {
|
||||||
"channels": {},
|
"players": {},
|
||||||
"last_save": str(time.time()),
|
"last_save": str(time.time()),
|
||||||
"version": "2.0",
|
"version": "1.0",
|
||||||
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
"created": time.strftime("%Y-%m-%d %H:%M:%S"),
|
||||||
"description": "DuckHunt Bot Player Database"
|
"description": "DuckHunt Bot Player Database"
|
||||||
}
|
}
|
||||||
@@ -280,37 +214,23 @@ class DuckDB:
|
|||||||
try:
|
try:
|
||||||
# Prepare data with validation
|
# Prepare data with validation
|
||||||
data = {
|
data = {
|
||||||
'channels': {},
|
'players': {},
|
||||||
'last_save': str(time.time()),
|
'last_save': str(time.time()),
|
||||||
'version': '2.0'
|
'version': '1.0'
|
||||||
}
|
}
|
||||||
|
|
||||||
# Validate and clean player data before saving
|
# Validate and clean player data before saving
|
||||||
valid_count = 0
|
valid_count = 0
|
||||||
for channel_name, channel_players in self.players.items():
|
for nick, player_data in self.players.items():
|
||||||
if not isinstance(channel_name, str) or not isinstance(channel_players, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
safe_channel = sanitize_user_input(
|
|
||||||
channel_name,
|
|
||||||
max_length=100,
|
|
||||||
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
|
||||||
)
|
|
||||||
if not safe_channel or not (safe_channel.startswith('#') or safe_channel.startswith('&')):
|
|
||||||
continue
|
|
||||||
|
|
||||||
data['channels'].setdefault(safe_channel, {'players': {}})
|
|
||||||
|
|
||||||
for nick, player_data in channel_players.items():
|
|
||||||
if isinstance(nick, str) and isinstance(player_data, dict):
|
if isinstance(nick, str) and isinstance(player_data, dict):
|
||||||
try:
|
try:
|
||||||
sanitized_nick = sanitize_user_input(nick, max_length=50)
|
sanitized_nick = sanitize_user_input(nick, max_length=50)
|
||||||
if not sanitized_nick:
|
data['players'][sanitized_nick] = self._sanitize_player_data(player_data)
|
||||||
continue
|
|
||||||
data['channels'][safe_channel]['players'][sanitized_nick.lower()] = self._sanitize_player_data(player_data)
|
|
||||||
valid_count += 1
|
valid_count += 1
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error processing player {nick} in {safe_channel} during save: {e}")
|
self.logger.warning(f"Error processing player {nick} during save: {e}")
|
||||||
|
else:
|
||||||
|
self.logger.warning(f"Skipping invalid player data during save: {nick}")
|
||||||
|
|
||||||
# Saving an empty database is valid (e.g., first run or after admin wipes).
|
# Saving an empty database is valid (e.g., first run or after admin wipes).
|
||||||
# Previously this raised and prevented the file from being written/updated.
|
# Previously this raised and prevented the file from being written/updated.
|
||||||
@@ -350,79 +270,7 @@ class DuckDB:
|
|||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def get_players_for_channel(self, channel: Optional[str]) -> dict:
|
def get_player(self, nick):
|
||||||
"""Get the mutable player dict for a channel, creating the channel bucket if needed."""
|
|
||||||
ch = self._normalize_channel(channel)
|
|
||||||
if ch not in self.players or not isinstance(self.players.get(ch), dict):
|
|
||||||
self.players[ch] = {}
|
|
||||||
return self.players[ch]
|
|
||||||
|
|
||||||
def get_player_if_exists(self, nick: str, channel: Optional[str]) -> Optional[dict]:
|
|
||||||
"""Get an existing player record for a channel without creating one."""
|
|
||||||
try:
|
|
||||||
if not isinstance(nick, str) or not nick.strip():
|
|
||||||
return None
|
|
||||||
nick_clean = sanitize_user_input(
|
|
||||||
nick,
|
|
||||||
max_length=50,
|
|
||||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
|
||||||
)
|
|
||||||
nick_lower = (nick_clean or '').lower().strip()
|
|
||||||
if not nick_lower:
|
|
||||||
return None
|
|
||||||
|
|
||||||
ch = self._normalize_channel(channel)
|
|
||||||
channel_players = self.players.get(ch)
|
|
||||||
if not isinstance(channel_players, dict):
|
|
||||||
return None
|
|
||||||
|
|
||||||
player = channel_players.get(nick_lower)
|
|
||||||
if isinstance(player, dict):
|
|
||||||
return player
|
|
||||||
return None
|
|
||||||
except Exception:
|
|
||||||
return None
|
|
||||||
|
|
||||||
def get_global_duck_totals(self, nick: str, channels: list) -> dict:
|
|
||||||
"""Sum ducks_shot/ducks_befriended for a user across the provided channels."""
|
|
||||||
total_shot = 0
|
|
||||||
total_bef = 0
|
|
||||||
channels_counted = 0
|
|
||||||
|
|
||||||
for ch in channels or []:
|
|
||||||
if not isinstance(ch, str):
|
|
||||||
continue
|
|
||||||
player = self.get_player_if_exists(nick, ch)
|
|
||||||
if not player:
|
|
||||||
continue
|
|
||||||
channels_counted += 1
|
|
||||||
try:
|
|
||||||
total_shot += int(player.get('ducks_shot', 0) or 0)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
try:
|
|
||||||
total_bef += int(player.get('ducks_befriended', 0) or 0)
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
return {
|
|
||||||
'nick': nick,
|
|
||||||
'ducks_shot': total_shot,
|
|
||||||
'ducks_befriended': total_bef,
|
|
||||||
'total_ducks': total_shot + total_bef,
|
|
||||||
'channels_counted': channels_counted,
|
|
||||||
}
|
|
||||||
|
|
||||||
def iter_all_players(self):
|
|
||||||
"""Yield (channel, nick, player_dict) for all players."""
|
|
||||||
for ch, players in (self.players or {}).items():
|
|
||||||
if not isinstance(players, dict):
|
|
||||||
continue
|
|
||||||
for nick, pdata in players.items():
|
|
||||||
if isinstance(nick, str) and isinstance(pdata, dict):
|
|
||||||
yield ch, nick, pdata
|
|
||||||
|
|
||||||
def get_player(self, nick, channel: Optional[str] = None):
|
|
||||||
"""Get player data, creating if doesn't exist with comprehensive validation"""
|
"""Get player data, creating if doesn't exist with comprehensive validation"""
|
||||||
try:
|
try:
|
||||||
# Validate and sanitize nick
|
# Validate and sanitize nick
|
||||||
@@ -443,16 +291,14 @@ class DuckDB:
|
|||||||
self.logger.warning(f"Empty nick after sanitization: {nick}")
|
self.logger.warning(f"Empty nick after sanitization: {nick}")
|
||||||
return self.create_player('Unknown')
|
return self.create_player('Unknown')
|
||||||
|
|
||||||
channel_players = self.get_players_for_channel(channel)
|
if nick_lower not in self.players:
|
||||||
|
self.players[nick_lower] = self.create_player(nick_clean)
|
||||||
if nick_lower not in channel_players:
|
|
||||||
channel_players[nick_lower] = self.create_player(nick_clean)
|
|
||||||
else:
|
else:
|
||||||
# Ensure existing players have all required fields
|
# Ensure existing players have all required fields
|
||||||
player = channel_players[nick_lower]
|
player = self.players[nick_lower]
|
||||||
if not isinstance(player, dict):
|
if not isinstance(player, dict):
|
||||||
self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
|
self.logger.warning(f"Invalid player data for {nick_lower}, recreating")
|
||||||
channel_players[nick_lower] = self.create_player(nick_clean)
|
self.players[nick_lower] = self.create_player(nick_clean)
|
||||||
else:
|
else:
|
||||||
# Migrate and validate existing player data with error recovery
|
# Migrate and validate existing player data with error recovery
|
||||||
validated = self.error_recovery.safe_execute(
|
validated = self.error_recovery.safe_execute(
|
||||||
@@ -460,9 +306,9 @@ class DuckDB:
|
|||||||
fallback=self.create_player(nick_clean),
|
fallback=self.create_player(nick_clean),
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
channel_players[nick_lower] = validated
|
self.players[nick_lower] = validated
|
||||||
|
|
||||||
return channel_players[nick_lower]
|
return self.players[nick_lower]
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Critical error getting player {nick}: {e}")
|
self.logger.error(f"Critical error getting player {nick}: {e}")
|
||||||
@@ -576,16 +422,11 @@ class DuckDB:
|
|||||||
}
|
}
|
||||||
|
|
||||||
def get_leaderboard(self, category='xp', limit=3):
|
def get_leaderboard(self, category='xp', limit=3):
|
||||||
"""Get top players by specified category (default channel)."""
|
"""Get top players by specified category"""
|
||||||
return self.get_leaderboard_for_channel(self._default_channel(), category=category, limit=limit)
|
|
||||||
|
|
||||||
def get_leaderboard_for_channel(self, channel: Optional[str], category='xp', limit=3):
|
|
||||||
"""Get top players for a channel by specified category"""
|
|
||||||
try:
|
try:
|
||||||
leaderboard = []
|
leaderboard = []
|
||||||
|
|
||||||
channel_players = self.get_players_for_channel(channel)
|
for nick, player_data in self.players.items():
|
||||||
for nick, player_data in channel_players.items():
|
|
||||||
sanitized_data = self._sanitize_player_data(player_data)
|
sanitized_data = self._sanitize_player_data(player_data)
|
||||||
|
|
||||||
if category == 'xp':
|
if category == 'xp':
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ class DuckHuntBot:
|
|||||||
self.writer: Optional[asyncio.StreamWriter] = None
|
self.writer: Optional[asyncio.StreamWriter] = None
|
||||||
self.registered = False
|
self.registered = False
|
||||||
self.channels_joined = set()
|
self.channels_joined = set()
|
||||||
# Track requested joins so we can report success/failure.
|
|
||||||
# channel -> requester nick (or None for startup/rejoin)
|
|
||||||
self.pending_joins = {}
|
|
||||||
self.shutdown_requested = False
|
self.shutdown_requested = False
|
||||||
self.rejoin_attempts = {} # Track rejoin attempts per channel
|
self.rejoin_attempts = {} # Track rejoin attempts per channel
|
||||||
self.rejoin_tasks = {} # Track active rejoin tasks
|
self.rejoin_tasks = {} # Track active rejoin tasks
|
||||||
@@ -42,9 +39,6 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
self.sasl_handler = SASLHandler(self, config)
|
self.sasl_handler = SASLHandler(self, config)
|
||||||
|
|
||||||
# Config file path for persisting runtime config changes (e.g., admin join/leave).
|
|
||||||
self.config_file_path = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'config.json')
|
|
||||||
|
|
||||||
# Set up health checks
|
# Set up health checks
|
||||||
self._setup_health_checks()
|
self._setup_health_checks()
|
||||||
|
|
||||||
@@ -64,7 +58,7 @@ class DuckHuntBot:
|
|||||||
# Database health check
|
# Database health check
|
||||||
self.health_checker.add_check(
|
self.health_checker.add_check(
|
||||||
'database',
|
'database',
|
||||||
lambda: self.db is not None,
|
lambda: self.db is not None and len(self.db.players) >= 0,
|
||||||
critical=True
|
critical=True
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -96,15 +90,6 @@ class DuckHuntBot:
|
|||||||
return default
|
return default
|
||||||
return value
|
return value
|
||||||
|
|
||||||
def _channel_key(self, channel: str) -> str:
|
|
||||||
"""Normalize channel names for internal comparisons (IRC channels are case-insensitive)."""
|
|
||||||
if not isinstance(channel, str):
|
|
||||||
return ""
|
|
||||||
channel = channel.strip()
|
|
||||||
if channel.startswith('#') or channel.startswith('&'):
|
|
||||||
return channel.lower()
|
|
||||||
return channel
|
|
||||||
|
|
||||||
def is_admin(self, user):
|
def is_admin(self, user):
|
||||||
if '!' not in user:
|
if '!' not in user:
|
||||||
return False
|
return False
|
||||||
@@ -137,6 +122,24 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
return False
|
return False
|
||||||
|
|
||||||
|
def _handle_single_target_admin_command(self, args, usage_message_key, action_func, success_message_key, nick, channel):
|
||||||
|
"""Helper for admin commands that target a single player"""
|
||||||
|
if not args:
|
||||||
|
message = self.messages.get(usage_message_key)
|
||||||
|
self.send_message(channel, message)
|
||||||
|
return False
|
||||||
|
|
||||||
|
target = args[0].lower()
|
||||||
|
player = self.db.get_player(target)
|
||||||
|
if player is None:
|
||||||
|
player = self.db.create_player(target)
|
||||||
|
self.db.players[target] = player
|
||||||
|
action_func(player)
|
||||||
|
|
||||||
|
message = self.messages.get(success_message_key, target=target, admin=nick)
|
||||||
|
self.send_message(channel, message)
|
||||||
|
self.db.save_database()
|
||||||
|
return True
|
||||||
|
|
||||||
def _get_admin_target_player(self, nick, channel, target_nick):
|
def _get_admin_target_player(self, nick, channel, target_nick):
|
||||||
"""
|
"""
|
||||||
@@ -144,21 +147,29 @@ class DuckHuntBot:
|
|||||||
Returns (player, error_message) - if error_message is not None, command should return early.
|
Returns (player, error_message) - if error_message is not None, command should return early.
|
||||||
"""
|
"""
|
||||||
is_private_msg = not channel.startswith('#')
|
is_private_msg = not channel.startswith('#')
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
|
||||||
|
|
||||||
if not is_private_msg:
|
if not is_private_msg:
|
||||||
if target_nick.lower() == nick.lower():
|
if target_nick.lower() == nick.lower():
|
||||||
target_nick = target_nick.lower()
|
target_nick = target_nick.lower()
|
||||||
player = self.db.get_player(target_nick, channel_ctx)
|
player = self.db.get_player(target_nick)
|
||||||
|
if player is None:
|
||||||
|
player = self.db.create_player(target_nick)
|
||||||
|
self.db.players[target_nick] = player
|
||||||
return player, None
|
return player, None
|
||||||
else:
|
else:
|
||||||
is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
|
is_valid, player, error_msg = self.validate_target_player(target_nick, channel)
|
||||||
if not is_valid:
|
if not is_valid:
|
||||||
return None, error_msg
|
return None, error_msg
|
||||||
|
target_nick = target_nick.lower()
|
||||||
|
if target_nick not in self.db.players:
|
||||||
|
self.db.players[target_nick] = player
|
||||||
return player, None
|
return player, None
|
||||||
else:
|
else:
|
||||||
target_nick = target_nick.lower()
|
target_nick = target_nick.lower()
|
||||||
player = self.db.get_player(target_nick, channel_ctx)
|
player = self.db.get_player(target_nick)
|
||||||
|
if player is None:
|
||||||
|
player = self.db.create_player(target_nick)
|
||||||
|
self.db.players[target_nick] = player
|
||||||
return player, None
|
return player, None
|
||||||
|
|
||||||
def _get_validated_target_player(self, nick, channel, target_nick):
|
def _get_validated_target_player(self, nick, channel, target_nick):
|
||||||
@@ -304,16 +315,19 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
# Attempt to rejoin
|
# Attempt to rejoin
|
||||||
if self.send_raw(f"JOIN {channel}"):
|
if self.send_raw(f"JOIN {channel}"):
|
||||||
self.pending_joins[channel] = None
|
self.channels_joined.add(channel)
|
||||||
self.logger.info(f"Sent JOIN for {channel} (waiting for server confirmation)")
|
self.logger.info(f"Successfully rejoined {channel}")
|
||||||
|
|
||||||
|
# Reset attempt counter and remove task
|
||||||
|
self.rejoin_attempts[channel] = 0
|
||||||
|
if channel in self.rejoin_tasks:
|
||||||
|
del self.rejoin_tasks[channel]
|
||||||
|
return
|
||||||
else:
|
else:
|
||||||
self.logger.warning(f"Failed to send JOIN command for {channel}")
|
self.logger.warning(f"Failed to send JOIN command for {channel}")
|
||||||
|
|
||||||
# If we've exceeded max attempts or channel was successfully joined
|
# If we've exceeded max attempts or channel was successfully joined
|
||||||
if channel in self.channels_joined:
|
if self.rejoin_attempts[channel] >= max_attempts:
|
||||||
self.rejoin_attempts[channel] = 0
|
|
||||||
self.logger.info(f"Rejoin confirmed for {channel}")
|
|
||||||
elif self.rejoin_attempts[channel] >= max_attempts:
|
|
||||||
self.logger.error(f"Exhausted all {max_attempts} rejoin attempts for {channel}")
|
self.logger.error(f"Exhausted all {max_attempts} rejoin attempts for {channel}")
|
||||||
|
|
||||||
# Clean up
|
# Clean up
|
||||||
@@ -347,9 +361,7 @@ class DuckHuntBot:
|
|||||||
# Sanitize target and message
|
# Sanitize target and message
|
||||||
safe_target = sanitize_user_input(target, max_length=100,
|
safe_target = sanitize_user_input(target, max_length=100,
|
||||||
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
||||||
# Sanitize message (preserve IRC formatting codes - only remove CR/LF)
|
safe_msg = sanitize_user_input(msg, max_length=400)
|
||||||
safe_msg = msg[:400] if isinstance(msg, str) else str(msg)[:400]
|
|
||||||
safe_msg = safe_msg.replace('\r', '').replace('\n', ' ').strip()
|
|
||||||
|
|
||||||
if not safe_target or not safe_msg:
|
if not safe_target or not safe_msg:
|
||||||
self.logger.warning(f"Empty target or message after sanitization")
|
self.logger.warning(f"Empty target or message after sanitization")
|
||||||
@@ -380,12 +392,17 @@ class DuckHuntBot:
|
|||||||
# Send all message parts
|
# Send all message parts
|
||||||
success_count = 0
|
success_count = 0
|
||||||
for i, message_part in enumerate(messages):
|
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}"):
|
if self.send_raw(f"PRIVMSG {safe_target} :{message_part}"):
|
||||||
success_count += 1
|
success_count += 1
|
||||||
else:
|
else:
|
||||||
self.logger.error(f"Failed to send message part {i+1}/{len(messages)}")
|
self.logger.error(f"Failed to send message part {i+1}/{len(messages)}")
|
||||||
|
|
||||||
return success_count == len(messages)
|
return success_count == len(messages)
|
||||||
|
|
||||||
|
return self.send_raw(f"PRIVMSG {target} :{sanitized_msg}")
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error sanitizing/sending message: {e}")
|
self.logger.error(f"Error sanitizing/sending message: {e}")
|
||||||
return False
|
return False
|
||||||
@@ -444,75 +461,29 @@ class DuckHuntBot:
|
|||||||
for channel in channels:
|
for channel in channels:
|
||||||
try:
|
try:
|
||||||
self.send_raw(f"JOIN {channel}")
|
self.send_raw(f"JOIN {channel}")
|
||||||
self.pending_joins[self._channel_key(channel)] = None
|
self.channels_joined.add(channel)
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error(f"Error joining channel {channel}: {e}")
|
self.logger.error(f"Error joining channel {channel}: {e}")
|
||||||
|
|
||||||
# JOIN failures (numeric replies)
|
|
||||||
elif command in {"403", "405", "437", "471", "473", "474", "475", "477"}:
|
|
||||||
# Common formats:
|
|
||||||
# 471 <me> <#chan> :Cannot join channel (+l)
|
|
||||||
# 475 <me> <#chan> :Cannot join channel (+k)
|
|
||||||
# 477 <me> <#chan> :You need to be identified...
|
|
||||||
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
|
|
||||||
if params and len(params) >= 2 and params[0].lower() == our_nick.lower():
|
|
||||||
failed_channel = params[1]
|
|
||||||
reason = trailing or "Join rejected"
|
|
||||||
failed_key = self._channel_key(failed_channel)
|
|
||||||
self.channels_joined.discard(failed_key)
|
|
||||||
requester = self.pending_joins.pop(failed_key, None)
|
|
||||||
self.logger.warning(f"Failed to join {failed_channel}: ({command}) {reason}")
|
|
||||||
if requester:
|
|
||||||
try:
|
|
||||||
self.send_message(requester, f"{requester} > Failed to join {failed_channel}: {reason}")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
return
|
|
||||||
|
|
||||||
elif command == "JOIN":
|
elif command == "JOIN":
|
||||||
if prefix:
|
if len(params) >= 1 and prefix:
|
||||||
# Some servers send: ":nick!user@host JOIN :#chan" (channel in trailing)
|
|
||||||
channel = None
|
|
||||||
if len(params) >= 1:
|
|
||||||
channel = params[0]
|
channel = params[0]
|
||||||
elif trailing and isinstance(trailing, str) and trailing.startswith('#'):
|
|
||||||
channel = trailing
|
|
||||||
|
|
||||||
if not channel:
|
|
||||||
return
|
|
||||||
|
|
||||||
joiner_nick = prefix.split('!')[0] if '!' in prefix else prefix
|
joiner_nick = prefix.split('!')[0] if '!' in prefix else prefix
|
||||||
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
|
our_nick = self.get_config('connection.nick', 'DuckHunt') or 'DuckHunt'
|
||||||
|
|
||||||
# Check if we successfully joined (or rejoined) a channel
|
# Check if we successfully joined (or rejoined) a channel
|
||||||
if joiner_nick and joiner_nick.lower() == our_nick.lower():
|
if joiner_nick and joiner_nick.lower() == our_nick.lower():
|
||||||
channel_key = self._channel_key(channel)
|
self.channels_joined.add(channel)
|
||||||
self.channels_joined.add(channel_key)
|
|
||||||
self.logger.info(f"Successfully joined channel {channel}")
|
self.logger.info(f"Successfully joined channel {channel}")
|
||||||
|
|
||||||
# If this was an admin-requested join, persist it now.
|
|
||||||
requester = self.pending_joins.pop(channel_key, None)
|
|
||||||
if requester:
|
|
||||||
try:
|
|
||||||
channels = self._config_channels_list()
|
|
||||||
if not any(self._channel_key(c) == channel_key for c in channels if isinstance(c, str)):
|
|
||||||
channels.append(channel)
|
|
||||||
self._persist_config()
|
|
||||||
self.send_message(requester, f"{requester} > Joined {channel}.")
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
else:
|
|
||||||
# Startup/rejoin joins shouldn't change config here.
|
|
||||||
self.pending_joins.pop(channel_key, None)
|
|
||||||
|
|
||||||
# Cancel any pending rejoin attempts for this channel
|
# Cancel any pending rejoin attempts for this channel
|
||||||
if channel_key in self.rejoin_tasks:
|
if channel in self.rejoin_tasks:
|
||||||
self.rejoin_tasks[channel_key].cancel()
|
self.rejoin_tasks[channel].cancel()
|
||||||
del self.rejoin_tasks[channel_key]
|
del self.rejoin_tasks[channel]
|
||||||
|
|
||||||
# Reset rejoin attempts counter
|
# Reset rejoin attempts counter
|
||||||
if channel_key in self.rejoin_attempts:
|
if channel in self.rejoin_attempts:
|
||||||
self.rejoin_attempts[channel_key] = 0
|
self.rejoin_attempts[channel] = 0
|
||||||
|
|
||||||
elif command == "PRIVMSG":
|
elif command == "PRIVMSG":
|
||||||
if len(params) >= 1:
|
if len(params) >= 1:
|
||||||
@@ -533,12 +504,11 @@ class DuckHuntBot:
|
|||||||
self.logger.warning(f"Kicked from {channel} by {kicker}: {reason}")
|
self.logger.warning(f"Kicked from {channel} by {kicker}: {reason}")
|
||||||
|
|
||||||
# Remove from joined channels
|
# Remove from joined channels
|
||||||
channel_key = self._channel_key(channel)
|
self.channels_joined.discard(channel)
|
||||||
self.channels_joined.discard(channel_key)
|
|
||||||
|
|
||||||
# Schedule rejoin if auto-rejoin is enabled
|
# Schedule rejoin if auto-rejoin is enabled
|
||||||
if self.get_config('connection.auto_rejoin.enabled', True):
|
if self.get_config('connection.auto_rejoin.enabled', True):
|
||||||
asyncio.create_task(self.schedule_rejoin(channel_key))
|
asyncio.create_task(self.schedule_rejoin(channel))
|
||||||
|
|
||||||
elif command == "PING":
|
elif command == "PING":
|
||||||
try:
|
try:
|
||||||
@@ -553,12 +523,7 @@ class DuckHuntBot:
|
|||||||
"""Handle bot commands with enhanced error handling and input validation"""
|
"""Handle bot commands with enhanced error handling and input validation"""
|
||||||
try:
|
try:
|
||||||
# Validate input parameters
|
# Validate input parameters
|
||||||
if not isinstance(message, str):
|
if not isinstance(message, str) or not message.startswith('!'):
|
||||||
return
|
|
||||||
|
|
||||||
# Some clients/users may prefix commands with whitespace (e.g. " !bang").
|
|
||||||
message = message.lstrip()
|
|
||||||
if not message.startswith('!'):
|
|
||||||
return
|
return
|
||||||
|
|
||||||
if not isinstance(user, str) or not isinstance(channel, str):
|
if not isinstance(user, str) or not isinstance(channel, str):
|
||||||
@@ -566,14 +531,10 @@ class DuckHuntBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Sanitize inputs
|
# Sanitize inputs
|
||||||
safe_message = sanitize_user_input(message, max_length=500).lstrip()
|
safe_message = sanitize_user_input(message, max_length=500)
|
||||||
safe_user = sanitize_user_input(user, max_length=200)
|
safe_user = sanitize_user_input(user, max_length=200)
|
||||||
safe_channel = sanitize_user_input(channel, max_length=100)
|
safe_channel = sanitize_user_input(channel, max_length=100)
|
||||||
|
|
||||||
# Normalize channel casing for internal consistency.
|
|
||||||
if isinstance(safe_channel, str) and (safe_channel.startswith('#') or safe_channel.startswith('&')):
|
|
||||||
safe_channel = self._channel_key(safe_channel)
|
|
||||||
|
|
||||||
if not safe_message.startswith('!'):
|
if not safe_message.startswith('!'):
|
||||||
return
|
return
|
||||||
|
|
||||||
@@ -605,9 +566,8 @@ class DuckHuntBot:
|
|||||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\')
|
||||||
|
|
||||||
# Get player data with error recovery
|
# Get player data with error recovery
|
||||||
channel_ctx = safe_channel if isinstance(safe_channel, str) and safe_channel.startswith('#') else None
|
|
||||||
player = self.error_recovery.safe_execute(
|
player = self.error_recovery.safe_execute(
|
||||||
lambda: self.db.get_player(nick, channel_ctx),
|
lambda: self.db.get_player(nick),
|
||||||
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
|
fallback={'nick': nick, 'xp': 0, 'ducks_shot': 0, 'gun_confiscated': False},
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
@@ -620,6 +580,7 @@ class DuckHuntBot:
|
|||||||
try:
|
try:
|
||||||
player['last_activity_channel'] = safe_channel
|
player['last_activity_channel'] = safe_channel
|
||||||
player['last_activity_time'] = time.time()
|
player['last_activity_time'] = time.time()
|
||||||
|
self.db.players[nick.lower()] = player
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.warning(f"Error updating player activity for {nick}: {e}")
|
self.logger.warning(f"Error updating player activity for {nick}: {e}")
|
||||||
|
|
||||||
@@ -650,28 +611,25 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
if cmd == "bang":
|
if cmd == "bang":
|
||||||
command_executed = True
|
command_executed = True
|
||||||
try:
|
await self.error_recovery.safe_execute_async(
|
||||||
await self.handle_bang(nick, channel, player)
|
lambda: self.handle_bang(nick, channel, player),
|
||||||
except Exception as e:
|
fallback=None,
|
||||||
self.logger.error(f"Error in handle_bang for {nick}: {e}")
|
logger=self.logger
|
||||||
error_msg = f"{nick} > ⚠️ Error processing !bang command. Please try again."
|
)
|
||||||
self.send_message(channel, error_msg)
|
|
||||||
elif cmd == "bef" or cmd == "befriend":
|
elif cmd == "bef" or cmd == "befriend":
|
||||||
command_executed = True
|
command_executed = True
|
||||||
try:
|
await self.error_recovery.safe_execute_async(
|
||||||
await self.handle_bef(nick, channel, player)
|
lambda: self.handle_bef(nick, channel, player),
|
||||||
except Exception as e:
|
fallback=None,
|
||||||
self.logger.error(f"Error in handle_bef for {nick}: {e}")
|
logger=self.logger
|
||||||
error_msg = f"{nick} > ⚠️ Error processing !bef command. Please try again."
|
)
|
||||||
self.send_message(channel, error_msg)
|
|
||||||
elif cmd == "reload":
|
elif cmd == "reload":
|
||||||
command_executed = True
|
command_executed = True
|
||||||
try:
|
await self.error_recovery.safe_execute_async(
|
||||||
await self.handle_reload(nick, channel, player)
|
lambda: self.handle_reload(nick, channel, player),
|
||||||
except Exception as e:
|
fallback=None,
|
||||||
self.logger.error(f"Error in handle_reload for {nick}: {e}")
|
logger=self.logger
|
||||||
error_msg = f"{nick} > ⚠️ Error processing !reload command. Please try again."
|
)
|
||||||
self.send_message(channel, error_msg)
|
|
||||||
elif cmd == "shop":
|
elif cmd == "shop":
|
||||||
command_executed = True
|
command_executed = True
|
||||||
await self.error_recovery.safe_execute_async(
|
await self.error_recovery.safe_execute_async(
|
||||||
@@ -714,13 +672,6 @@ class DuckHuntBot:
|
|||||||
fallback=None,
|
fallback=None,
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
elif cmd == "globalducks":
|
|
||||||
command_executed = True
|
|
||||||
await self.error_recovery.safe_execute_async(
|
|
||||||
lambda: self.handle_globalducks(nick, channel, safe_args, user),
|
|
||||||
fallback=None,
|
|
||||||
logger=self.logger
|
|
||||||
)
|
|
||||||
elif cmd == "rearm" and self.is_admin(user):
|
elif cmd == "rearm" and self.is_admin(user):
|
||||||
command_executed = True
|
command_executed = True
|
||||||
await self.error_recovery.safe_execute_async(
|
await self.error_recovery.safe_execute_async(
|
||||||
@@ -756,20 +707,6 @@ class DuckHuntBot:
|
|||||||
fallback=None,
|
fallback=None,
|
||||||
logger=self.logger
|
logger=self.logger
|
||||||
)
|
)
|
||||||
elif cmd == "join" and self.is_admin(user):
|
|
||||||
command_executed = True
|
|
||||||
await self.error_recovery.safe_execute_async(
|
|
||||||
lambda: self.handle_join_channel(nick, channel, safe_args),
|
|
||||||
fallback=None,
|
|
||||||
logger=self.logger
|
|
||||||
)
|
|
||||||
elif (cmd == "leave" or cmd == "part") and self.is_admin(user):
|
|
||||||
command_executed = True
|
|
||||||
await self.error_recovery.safe_execute_async(
|
|
||||||
lambda: self.handle_leave_channel(nick, channel, safe_args),
|
|
||||||
fallback=None,
|
|
||||||
logger=self.logger
|
|
||||||
)
|
|
||||||
|
|
||||||
# If no command was executed, it might be an unknown command
|
# If no command was executed, it might be an unknown command
|
||||||
if not command_executed:
|
if not command_executed:
|
||||||
@@ -807,8 +744,7 @@ class DuckHuntBot:
|
|||||||
if not target_nick:
|
if not target_nick:
|
||||||
return False, None, "Invalid target nickname"
|
return False, None, "Invalid target nickname"
|
||||||
|
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
player = self.db.get_player(target_nick)
|
||||||
player = self.db.get_player(target_nick, channel_ctx)
|
|
||||||
if not player:
|
if not player:
|
||||||
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
|
return False, None, f"Player '{target_nick}' not found. They need to participate in the game first."
|
||||||
|
|
||||||
@@ -832,8 +768,7 @@ class DuckHuntBot:
|
|||||||
We assume if someone has been active recently, they're still in the channel.
|
We assume if someone has been active recently, they're still in the channel.
|
||||||
"""
|
"""
|
||||||
try:
|
try:
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
player = self.db.get_player(nick)
|
||||||
player = self.db.get_player(nick, channel_ctx)
|
|
||||||
if not player:
|
if not player:
|
||||||
return False
|
return False
|
||||||
|
|
||||||
@@ -952,8 +887,7 @@ class DuckHuntBot:
|
|||||||
"""Handle !duckstats command"""
|
"""Handle !duckstats command"""
|
||||||
if args and len(args) > 0:
|
if args and len(args) > 0:
|
||||||
target_nick = args[0]
|
target_nick = args[0]
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
target_player = self.db.get_player(target_nick)
|
||||||
target_player = self.db.get_player(target_nick, channel_ctx)
|
|
||||||
if not target_player:
|
if not target_player:
|
||||||
message = f"{nick} > Player '{target_nick}' not found."
|
message = f"{nick} > Player '{target_nick}' not found."
|
||||||
self.send_message(channel, message)
|
self.send_message(channel, message)
|
||||||
@@ -1046,13 +980,11 @@ class DuckHuntBot:
|
|||||||
bold = self.messages.messages.get('colours', {}).get('bold', '')
|
bold = self.messages.messages.get('colours', {}).get('bold', '')
|
||||||
reset = self.messages.messages.get('colours', {}).get('reset', '')
|
reset = self.messages.messages.get('colours', {}).get('reset', '')
|
||||||
|
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
# Get top 3 by XP
|
||||||
|
top_xp = self.db.get_leaderboard('xp', 3)
|
||||||
|
|
||||||
# Get top 3 by XP (channel-scoped)
|
# Get top 3 by ducks shot
|
||||||
top_xp = self.db.get_leaderboard_for_channel(channel_ctx, 'xp', 3)
|
top_ducks = self.db.get_leaderboard('ducks_shot', 3)
|
||||||
|
|
||||||
# Get top 3 by ducks shot (channel-scoped)
|
|
||||||
top_ducks = self.db.get_leaderboard_for_channel(channel_ctx, 'ducks_shot', 3)
|
|
||||||
|
|
||||||
# Format XP leaderboard as single line
|
# Format XP leaderboard as single line
|
||||||
if top_xp:
|
if top_xp:
|
||||||
@@ -1081,208 +1013,19 @@ class DuckHuntBot:
|
|||||||
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
|
self.send_message(channel, f"{nick} > Error retrieving leaderboard data.")
|
||||||
|
|
||||||
async def handle_duckhelp(self, nick, channel, _player):
|
async def handle_duckhelp(self, nick, channel, _player):
|
||||||
"""Handle !duckhelp command
|
"""Handle !duckhelp command"""
|
||||||
|
|
||||||
Sends help to the user via private message (PM/DM) with examples.
|
|
||||||
"""
|
|
||||||
|
|
||||||
dm_target = nick # PRIVMSG target for a PM is the nick
|
|
||||||
|
|
||||||
help_lines = [
|
help_lines = [
|
||||||
"DuckHunt Commands (sent via PM)",
|
self.messages.get('help_header'),
|
||||||
"Player commands:",
|
self.messages.get('help_user_commands'),
|
||||||
"- !bang — shoot when a duck appears. Example: !bang",
|
self.messages.get('help_help_command')
|
||||||
"- !reload — reload your weapon. Example: !reload",
|
|
||||||
"- !shop — list shop items. Example: !shop",
|
|
||||||
"- !buy <item_id> — buy from shop. Example: !buy 3",
|
|
||||||
"- !use <item_id> [target] — use an inventory item. Example: !use 7 OR !use 9 SomeNick",
|
|
||||||
"- !duckstats [player] — view stats/inventory. Example: !duckstats OR !duckstats SomeNick",
|
|
||||||
"- !topduck — show leaderboards. Example: !topduck",
|
|
||||||
"- !give <item_id> <player> — gift an owned item. Example: !give 2 SomeNick",
|
|
||||||
"- !globalducks [player] — duck totals across all configured channels. Example: !globalducks OR !globalducks SomeNick",
|
|
||||||
"- !duckhelp — show this help. Example: !duckhelp",
|
|
||||||
]
|
]
|
||||||
|
|
||||||
# Include admin commands only for admins.
|
# Add admin commands if user is admin
|
||||||
# (Using nick list avoids relying on hostmask parsing.)
|
if self.is_admin(f"{nick}!user@host"):
|
||||||
if nick.lower() in self.admins:
|
help_lines.append(self.messages.get('help_admin_commands'))
|
||||||
help_lines.extend([
|
|
||||||
"Admin commands:",
|
|
||||||
"- !rearm <player|all> — rearm a player. Example: !rearm SomeNick OR !rearm all",
|
|
||||||
"- !disarm <player> — confiscate gun. Example: !disarm SomeNick",
|
|
||||||
"- !ignore <player> — ignore a player. Example: !ignore SomeNick",
|
|
||||||
"- !unignore <player> — unignore a player. Example: !unignore SomeNick",
|
|
||||||
"- !ducklaunch [duck_type] — force spawn. Example: !ducklaunch golden",
|
|
||||||
"- (PM) !ducklaunch <#channel> [duck_type] — Example: !ducklaunch #ct fast",
|
|
||||||
"- !join <#channel> — make the bot join a channel. Example: !join #ct",
|
|
||||||
"- !leave <#channel> — make the bot leave a channel. Example: !leave #ct",
|
|
||||||
])
|
|
||||||
|
|
||||||
for line in help_lines:
|
for line in help_lines:
|
||||||
self.send_message(dm_target, line)
|
self.send_message(channel, line)
|
||||||
|
|
||||||
# If invoked in a channel, add a brief confirmation to check PMs.
|
|
||||||
if isinstance(channel, str) and channel.startswith('#'):
|
|
||||||
self.send_message(channel, f"{nick} > I sent you a PM with commands and examples. Please check your PM window.")
|
|
||||||
|
|
||||||
async def handle_globalducks(self, nick, channel, args, user):
|
|
||||||
"""User: !globalducks [player] — totals across all configured channels.
|
|
||||||
|
|
||||||
Non-admins can query themselves only. Admins can query other nicks.
|
|
||||||
"""
|
|
||||||
try:
|
|
||||||
channels = self.get_config('connection.channels', []) or []
|
|
||||||
if not isinstance(channels, list):
|
|
||||||
channels = []
|
|
||||||
|
|
||||||
target_nick = nick
|
|
||||||
if args and len(args) >= 1:
|
|
||||||
requested = sanitize_user_input(
|
|
||||||
str(args[0]),
|
|
||||||
max_length=50,
|
|
||||||
allowed_chars='abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
|
||||||
)
|
|
||||||
if requested:
|
|
||||||
target_nick = requested
|
|
||||||
|
|
||||||
# Anyone can query anyone via !globalducks <player>
|
|
||||||
|
|
||||||
totals = self.db.get_global_duck_totals(target_nick, channels)
|
|
||||||
shot = totals.get('ducks_shot', 0)
|
|
||||||
bef = totals.get('ducks_befriended', 0)
|
|
||||||
total = totals.get('total_ducks', shot + bef)
|
|
||||||
counted = totals.get('channels_counted', 0)
|
|
||||||
|
|
||||||
if not channels:
|
|
||||||
self.send_message(channel, f"{nick} > No configured channels to total.")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_message(
|
|
||||||
channel,
|
|
||||||
f"{nick} > Global totals for {target_nick} across configured channels: {shot} shot, {bef} befriended ({total} total) [{counted}/{len(channels)} channels have stats]."
|
|
||||||
)
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Error in handle_globalducks: {e}")
|
|
||||||
self.send_message(channel, f"{nick} > Error calculating global totals.")
|
|
||||||
|
|
||||||
def _sanitize_channel_name(self, channel_name: str) -> str:
|
|
||||||
"""Validate/sanitize an IRC channel name."""
|
|
||||||
if not isinstance(channel_name, str):
|
|
||||||
return ""
|
|
||||||
safe = sanitize_user_input(
|
|
||||||
channel_name.strip(),
|
|
||||||
max_length=100,
|
|
||||||
allowed_chars='#&+!abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-[]{}^`|\\'
|
|
||||||
)
|
|
||||||
if not safe:
|
|
||||||
return ""
|
|
||||||
if not (safe.startswith('#') or safe.startswith('&')):
|
|
||||||
return ""
|
|
||||||
return safe
|
|
||||||
|
|
||||||
def _config_channels_list(self):
|
|
||||||
"""Return the mutable in-memory config channel list, creating it if needed."""
|
|
||||||
if not isinstance(self.config, dict):
|
|
||||||
self.config = {}
|
|
||||||
connection = self.config.get('connection')
|
|
||||||
if not isinstance(connection, dict):
|
|
||||||
connection = {}
|
|
||||||
self.config['connection'] = connection
|
|
||||||
channels = connection.get('channels')
|
|
||||||
if not isinstance(channels, list):
|
|
||||||
channels = []
|
|
||||||
connection['channels'] = channels
|
|
||||||
return channels
|
|
||||||
|
|
||||||
def _persist_config(self) -> bool:
|
|
||||||
"""Persist current config to disk (best-effort, atomic write)."""
|
|
||||||
try:
|
|
||||||
config_dir = os.path.dirname(self.config_file_path)
|
|
||||||
os.makedirs(config_dir, exist_ok=True)
|
|
||||||
|
|
||||||
tmp_path = f"{self.config_file_path}.tmp"
|
|
||||||
with open(tmp_path, 'w', encoding='utf-8') as f:
|
|
||||||
# Keep it stable/human-readable.
|
|
||||||
import json
|
|
||||||
json.dump(self.config, f, indent=4, ensure_ascii=False)
|
|
||||||
f.write("\n")
|
|
||||||
f.flush()
|
|
||||||
os.fsync(f.fileno())
|
|
||||||
|
|
||||||
os.replace(tmp_path, self.config_file_path)
|
|
||||||
return True
|
|
||||||
except Exception as e:
|
|
||||||
self.logger.error(f"Failed to persist config to disk: {e}")
|
|
||||||
return False
|
|
||||||
|
|
||||||
async def handle_join_channel(self, nick, channel, args):
|
|
||||||
"""Admin: !join <#channel> (supports PM and channel invocation)."""
|
|
||||||
if not args:
|
|
||||||
self.send_message(channel, f"{nick} > Usage: !join <#channel>")
|
|
||||||
return
|
|
||||||
|
|
||||||
target_channel = self._sanitize_channel_name(args[0])
|
|
||||||
if not target_channel:
|
|
||||||
self.send_message(channel, f"{nick} > Invalid channel. Usage: !join <#channel>")
|
|
||||||
return
|
|
||||||
|
|
||||||
target_key = self._channel_key(target_channel)
|
|
||||||
|
|
||||||
if target_key in self.channels_joined:
|
|
||||||
self.send_message(channel, f"{nick} > I'm already in {target_channel}.")
|
|
||||||
return
|
|
||||||
|
|
||||||
if not self.send_raw(f"JOIN {target_channel}"):
|
|
||||||
self.send_message(channel, f"{nick} > Couldn't send JOIN (not connected?).")
|
|
||||||
return
|
|
||||||
|
|
||||||
# Wait for server JOIN confirmation before marking joined/persisting.
|
|
||||||
self.pending_joins[target_key] = nick
|
|
||||||
self.send_message(channel, f"{nick} > Attempting to join {target_channel}...")
|
|
||||||
|
|
||||||
async def handle_leave_channel(self, nick, channel, args):
|
|
||||||
"""Admin: !leave <#channel> / !part <#channel> (supports PM and channel invocation)."""
|
|
||||||
if not args:
|
|
||||||
self.send_message(channel, f"{nick} > Usage: !leave <#channel>")
|
|
||||||
return
|
|
||||||
|
|
||||||
target_channel = self._sanitize_channel_name(args[0])
|
|
||||||
if not target_channel:
|
|
||||||
self.send_message(channel, f"{nick} > Invalid channel. Usage: !leave <#channel>")
|
|
||||||
return
|
|
||||||
|
|
||||||
target_key = self._channel_key(target_channel)
|
|
||||||
|
|
||||||
# Cancel any pending rejoin attempts and forget state.
|
|
||||||
if target_key in self.rejoin_tasks:
|
|
||||||
try:
|
|
||||||
self.rejoin_tasks[target_key].cancel()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
del self.rejoin_tasks[target_key]
|
|
||||||
if target_key in self.rejoin_attempts:
|
|
||||||
del self.rejoin_attempts[target_key]
|
|
||||||
|
|
||||||
self.channels_joined.discard(target_key)
|
|
||||||
|
|
||||||
# Update in-memory config so reconnects do not rejoin the channel.
|
|
||||||
channels = self._config_channels_list()
|
|
||||||
try:
|
|
||||||
channels[:] = [c for c in channels if not (isinstance(c, str) and self._channel_key(c) == target_key)]
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
|
|
||||||
# Persist across restarts
|
|
||||||
if not self._persist_config():
|
|
||||||
self.send_message(channel, f"{nick} > Removed {target_channel} from my channel list, but failed to write config.json (check permissions).")
|
|
||||||
# Continue attempting PART anyway.
|
|
||||||
|
|
||||||
# Send PART even if we don't think we're in it (server will ignore or error).
|
|
||||||
if not self.send_raw(f"PART {target_channel} :Requested by {nick}"):
|
|
||||||
self.send_message(channel, f"{nick} > Couldn't send PART (not connected?).")
|
|
||||||
return
|
|
||||||
|
|
||||||
self.send_message(channel, f"{nick} > Leaving {target_channel}.")
|
|
||||||
|
|
||||||
async def handle_use(self, nick, channel, player, args):
|
async def handle_use(self, nick, channel, player, args):
|
||||||
"""Handle !use command"""
|
"""Handle !use command"""
|
||||||
@@ -1345,72 +1088,6 @@ class DuckHuntBot:
|
|||||||
message = self.messages.get('use_dry_clothes', nick=nick)
|
message = self.messages.get('use_dry_clothes', nick=nick)
|
||||||
else:
|
else:
|
||||||
message = self.messages.get('use_dry_clothes_not_needed', nick=nick)
|
message = self.messages.get('use_dry_clothes_not_needed', nick=nick)
|
||||||
elif effect_type == 'perfect_aim':
|
|
||||||
duration_seconds = int(effect.get('duration', 1800))
|
|
||||||
minutes = max(1, duration_seconds // 60)
|
|
||||||
message = self.messages.get('use_perfect_aim', nick=nick, duration_minutes=minutes)
|
|
||||||
elif effect_type == 'duck_radar':
|
|
||||||
duration_seconds = int(effect.get('duration', 21600))
|
|
||||||
hours = max(1, duration_seconds // 3600)
|
|
||||||
message = self.messages.get('use_duck_radar', nick=nick, duration_hours=hours)
|
|
||||||
elif effect_type == 'summon_duck':
|
|
||||||
# Summoning needs a channel context. If used in PM, pick the first configured channel.
|
|
||||||
delay = int(effect.get('delay', 0))
|
|
||||||
target_channel = channel
|
|
||||||
is_private_msg = not isinstance(target_channel, str) or not target_channel.startswith('#')
|
|
||||||
if is_private_msg:
|
|
||||||
channels = self.get_config('connection.channels', []) or []
|
|
||||||
target_channel = channels[0] if channels else None
|
|
||||||
|
|
||||||
if not target_channel:
|
|
||||||
message = f"{nick} > I don't know which channel to summon a duck in. Use this in a channel."
|
|
||||||
else:
|
|
||||||
# PM commands should only spawn normal/fast/golden.
|
|
||||||
spawn_type = None
|
|
||||||
if is_private_msg:
|
|
||||||
import random
|
|
||||||
golden_chance = self.get_config('duck_types.golden.chance', self.get_config('golden_duck_chance', 0.15))
|
|
||||||
fast_chance = self.get_config('duck_types.fast.chance', self.get_config('fast_duck_chance', 0.25))
|
|
||||||
try:
|
|
||||||
golden_chance = float(golden_chance)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
golden_chance = 0.15
|
|
||||||
try:
|
|
||||||
fast_chance = float(fast_chance)
|
|
||||||
except (TypeError, ValueError):
|
|
||||||
fast_chance = 0.25
|
|
||||||
|
|
||||||
r = random.random()
|
|
||||||
if r < max(0.0, golden_chance):
|
|
||||||
spawn_type = 'golden'
|
|
||||||
elif r < max(0.0, golden_chance) + max(0.0, fast_chance):
|
|
||||||
spawn_type = 'fast'
|
|
||||||
else:
|
|
||||||
spawn_type = 'normal'
|
|
||||||
|
|
||||||
if delay <= 0:
|
|
||||||
if spawn_type:
|
|
||||||
await self.game.force_spawn_duck(target_channel, spawn_type)
|
|
||||||
else:
|
|
||||||
await self.game.spawn_duck(target_channel)
|
|
||||||
message = self.messages.get('use_summon_duck', nick=nick, channel=target_channel)
|
|
||||||
else:
|
|
||||||
async def delayed_summon():
|
|
||||||
try:
|
|
||||||
await asyncio.sleep(delay)
|
|
||||||
if target_channel in self.channels_joined:
|
|
||||||
if spawn_type:
|
|
||||||
await self.game.force_spawn_duck(target_channel, spawn_type)
|
|
||||||
else:
|
|
||||||
await self.game.spawn_duck(target_channel)
|
|
||||||
except asyncio.CancelledError:
|
|
||||||
return
|
|
||||||
except Exception:
|
|
||||||
return
|
|
||||||
|
|
||||||
asyncio.create_task(delayed_summon())
|
|
||||||
minutes = max(1, delay // 60)
|
|
||||||
message = self.messages.get('use_summon_duck_delayed', nick=nick, channel=target_channel, delay_minutes=minutes)
|
|
||||||
elif result.get("target_affected"):
|
elif result.get("target_affected"):
|
||||||
# Check if it's a gift (beneficial effect to target)
|
# Check if it's a gift (beneficial effect to target)
|
||||||
if effect.get('is_gift', False):
|
if effect.get('is_gift', False):
|
||||||
@@ -1535,8 +1212,7 @@ class DuckHuntBot:
|
|||||||
# Check if admin wants to rearm all players
|
# Check if admin wants to rearm all players
|
||||||
if target_nick.lower() == 'all':
|
if target_nick.lower() == 'all':
|
||||||
rearmed_count = 0
|
rearmed_count = 0
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
for player_nick, player in self.db.players.items():
|
||||||
for player_nick, player in self.db.get_players_for_channel(channel_ctx).items():
|
|
||||||
if player.get('gun_confiscated', False):
|
if player.get('gun_confiscated', False):
|
||||||
player['gun_confiscated'] = False
|
player['gun_confiscated'] = False
|
||||||
self.levels.update_player_magazines(player)
|
self.levels.update_player_magazines(player)
|
||||||
@@ -1575,8 +1251,10 @@ class DuckHuntBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
# Rearm the admin themselves (only in channels)
|
# Rearm the admin themselves (only in channels)
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
player = self.db.get_player(nick)
|
||||||
player = self.db.get_player(nick, channel_ctx)
|
if player is None:
|
||||||
|
player = self.db.create_player(nick)
|
||||||
|
self.db.players[nick.lower()] = player
|
||||||
|
|
||||||
player['gun_confiscated'] = False
|
player['gun_confiscated'] = False
|
||||||
|
|
||||||
@@ -1639,8 +1317,10 @@ class DuckHuntBot:
|
|||||||
return
|
return
|
||||||
|
|
||||||
target = args[0].lower()
|
target = args[0].lower()
|
||||||
channel_ctx = channel if isinstance(channel, str) and channel.startswith('#') else None
|
player = self.db.get_player(target)
|
||||||
player = self.db.get_player(target, channel_ctx)
|
if player is None:
|
||||||
|
player = self.db.create_player(target)
|
||||||
|
self.db.players[target] = player
|
||||||
|
|
||||||
action_func(player)
|
action_func(player)
|
||||||
|
|
||||||
@@ -1681,17 +1361,15 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
if is_private_msg:
|
if is_private_msg:
|
||||||
if not args:
|
if not args:
|
||||||
self.send_message(channel, f"{nick} > Usage: !ducklaunch [channel] [duck_type]")
|
self.send_message(channel, f"{nick} > Usage: !ducklaunch [channel] [duck_type] - duck_type can be: normal, golden, fast")
|
||||||
return
|
return
|
||||||
target_channel = args[0]
|
target_channel = args[0]
|
||||||
duck_type_arg = args[1] if len(args) > 1 else "normal"
|
duck_type_arg = args[1] if len(args) > 1 else "normal"
|
||||||
else:
|
else:
|
||||||
duck_type_arg = args[0] if args else "normal"
|
duck_type_arg = args[0] if args else "normal"
|
||||||
|
|
||||||
target_key = self._channel_key(target_channel)
|
|
||||||
|
|
||||||
# Validate target channel
|
# Validate target channel
|
||||||
if target_key not in self.channels_joined:
|
if target_channel not in self.channels_joined:
|
||||||
if is_private_msg:
|
if is_private_msg:
|
||||||
self.send_message(channel, f"{nick} > Channel {target_channel} is not available for duckhunt")
|
self.send_message(channel, f"{nick} > Channel {target_channel} is not available for duckhunt")
|
||||||
else:
|
else:
|
||||||
@@ -1701,21 +1379,52 @@ class DuckHuntBot:
|
|||||||
|
|
||||||
# Validate duck type
|
# Validate duck type
|
||||||
duck_type_arg = duck_type_arg.lower()
|
duck_type_arg = duck_type_arg.lower()
|
||||||
if is_private_msg:
|
valid_types = ["normal", "golden", "fast"]
|
||||||
valid_types = {'normal', 'fast', 'golden'}
|
|
||||||
else:
|
|
||||||
duck_types_cfg = self.get_config('duck_types', {}) or {}
|
|
||||||
if not isinstance(duck_types_cfg, dict):
|
|
||||||
duck_types_cfg = {}
|
|
||||||
valid_types = set(['normal']) | set(duck_types_cfg.keys())
|
|
||||||
|
|
||||||
if duck_type_arg not in valid_types:
|
if duck_type_arg not in valid_types:
|
||||||
valid_list = ', '.join(sorted(valid_types))
|
self.send_message(channel, f"{nick} > Invalid duck type '{duck_type_arg}'. Valid types: {', '.join(valid_types)}")
|
||||||
self.send_message(channel, f"{nick} > Invalid duck type '{duck_type_arg}'. Valid types: {valid_list}")
|
|
||||||
return
|
return
|
||||||
|
|
||||||
# Force spawn the specified duck type (supports multi-spawn types like couple/family)
|
# Force spawn the specified duck type
|
||||||
await self.game.force_spawn_duck(target_key, duck_type_arg)
|
import time
|
||||||
|
import random
|
||||||
|
|
||||||
|
if target_channel not in self.game.ducks:
|
||||||
|
self.game.ducks[target_channel] = []
|
||||||
|
|
||||||
|
# Create duck based on specified type
|
||||||
|
current_time = time.time()
|
||||||
|
duck_id = f"{duck_type_arg}_duck_{int(current_time)}_{random.randint(1000, 9999)}"
|
||||||
|
|
||||||
|
if duck_type_arg == "golden":
|
||||||
|
min_hp_val = self.get_config('duck_types.golden.min_hp', 3)
|
||||||
|
max_hp_val = self.get_config('duck_types.golden.max_hp', 5)
|
||||||
|
min_hp = int(min_hp_val) if min_hp_val is not None else 3
|
||||||
|
max_hp = int(max_hp_val) if max_hp_val is not None else 5
|
||||||
|
hp = random.randint(min_hp, max_hp)
|
||||||
|
duck = {
|
||||||
|
'id': duck_id,
|
||||||
|
'spawn_time': current_time,
|
||||||
|
'channel': target_channel,
|
||||||
|
'duck_type': 'golden',
|
||||||
|
'max_hp': hp,
|
||||||
|
'current_hp': hp
|
||||||
|
}
|
||||||
|
else:
|
||||||
|
# Both normal and fast ducks have 1 HP
|
||||||
|
duck = {
|
||||||
|
'id': duck_id,
|
||||||
|
'spawn_time': current_time,
|
||||||
|
'channel': target_channel,
|
||||||
|
'duck_type': duck_type_arg,
|
||||||
|
'max_hp': 1,
|
||||||
|
'current_hp': 1
|
||||||
|
}
|
||||||
|
|
||||||
|
self.game.ducks[target_channel].append(duck)
|
||||||
|
duck_message = self.messages.get('duck_spawn')
|
||||||
|
|
||||||
|
# Send duck spawn message to target channel
|
||||||
|
self.send_message(target_channel, duck_message)
|
||||||
|
|
||||||
# Send confirmation to admin (either in channel or private message)
|
# Send confirmation to admin (either in channel or private message)
|
||||||
if is_private_msg:
|
if is_private_msg:
|
||||||
|
|||||||
49
src/game.py
49
src/game.py
@@ -165,52 +165,6 @@ class DuckGame:
|
|||||||
self.ducks[channel].append(duck)
|
self.ducks[channel].append(duck)
|
||||||
self.bot.send_message(channel, message)
|
self.bot.send_message(channel, message)
|
||||||
|
|
||||||
async def force_spawn_duck(self, channel, duck_type='normal'):
|
|
||||||
"""Force spawn a specific duck type (admin command)"""
|
|
||||||
if channel not in self.ducks:
|
|
||||||
self.ducks[channel] = []
|
|
||||||
|
|
||||||
# Validate duck type
|
|
||||||
duck_type = (duck_type or 'normal').lower()
|
|
||||||
if duck_type not in ['normal', 'golden', 'fast']:
|
|
||||||
duck_type = 'normal'
|
|
||||||
|
|
||||||
# Create the specified duck type
|
|
||||||
if duck_type == 'golden':
|
|
||||||
min_hp = self.bot.get_config('golden_duck_min_hp', 3)
|
|
||||||
max_hp = self.bot.get_config('golden_duck_max_hp', 5)
|
|
||||||
hp = random.randint(min_hp, max_hp)
|
|
||||||
duck = {
|
|
||||||
'id': f"golden_duck_{int(time.time())}_{random.randint(1000, 9999)}",
|
|
||||||
'spawn_time': time.time(),
|
|
||||||
'channel': channel,
|
|
||||||
'duck_type': 'golden',
|
|
||||||
'max_hp': hp,
|
|
||||||
'current_hp': hp
|
|
||||||
}
|
|
||||||
elif duck_type == 'fast':
|
|
||||||
duck = {
|
|
||||||
'id': f"fast_duck_{int(time.time())}_{random.randint(1000, 9999)}",
|
|
||||||
'spawn_time': time.time(),
|
|
||||||
'channel': channel,
|
|
||||||
'duck_type': 'fast',
|
|
||||||
'max_hp': 1,
|
|
||||||
'current_hp': 1
|
|
||||||
}
|
|
||||||
else: # normal
|
|
||||||
duck = {
|
|
||||||
'id': f"duck_{int(time.time())}_{random.randint(1000, 9999)}",
|
|
||||||
'spawn_time': time.time(),
|
|
||||||
'channel': channel,
|
|
||||||
'duck_type': 'normal',
|
|
||||||
'max_hp': 1,
|
|
||||||
'current_hp': 1
|
|
||||||
}
|
|
||||||
|
|
||||||
self.ducks[channel].append(duck)
|
|
||||||
message = self.bot.messages.get('duck_spawn')
|
|
||||||
self.bot.send_message(channel, message)
|
|
||||||
|
|
||||||
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
|
||||||
@@ -285,9 +239,6 @@ class DuckGame:
|
|||||||
|
|
||||||
if duck['current_hp'] > 0:
|
if duck['current_hp'] > 0:
|
||||||
# Still alive, reveal it's golden but don't remove
|
# Still alive, reveal it's golden but don't remove
|
||||||
# Award XP for hitting (but not killing) the golden duck
|
|
||||||
player['xp'] = player.get('xp', 0) + xp_gained
|
|
||||||
|
|
||||||
accuracy_gain = self.bot.get_config('accuracy_gain_on_hit', 1)
|
accuracy_gain = self.bot.get_config('accuracy_gain_on_hit', 1)
|
||||||
max_accuracy = self.bot.get_config('max_accuracy', 100)
|
max_accuracy = self.bot.get_config('max_accuracy', 100)
|
||||||
player['accuracy'] = min(player.get('accuracy', self.bot.get_config('default_accuracy', 75)) + accuracy_gain, max_accuracy)
|
player['accuracy'] = min(player.get('accuracy', self.bot.get_config('default_accuracy', 75)) + accuracy_gain, max_accuracy)
|
||||||
|
|||||||
43
src/shop.py
43
src/shop.py
@@ -389,49 +389,6 @@ class ShopManager:
|
|||||||
"duration": duration // 60 # return duration in minutes
|
"duration": duration // 60 # return duration in minutes
|
||||||
}
|
}
|
||||||
|
|
||||||
elif item_type == 'perfect_aim':
|
|
||||||
# Temporarily force shots to hit (bot/game enforces this)
|
|
||||||
if 'temporary_effects' not in player:
|
|
||||||
player['temporary_effects'] = []
|
|
||||||
|
|
||||||
duration = int(item.get('duration', 1800)) # seconds
|
|
||||||
effect = {
|
|
||||||
'type': 'perfect_aim',
|
|
||||||
'expires_at': time.time() + max(1, duration)
|
|
||||||
}
|
|
||||||
player['temporary_effects'].append(effect)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": "perfect_aim",
|
|
||||||
"duration": duration
|
|
||||||
}
|
|
||||||
|
|
||||||
elif item_type == 'duck_radar':
|
|
||||||
# DM alert on duck spawns (game loop sends the DM)
|
|
||||||
if 'temporary_effects' not in player:
|
|
||||||
player['temporary_effects'] = []
|
|
||||||
|
|
||||||
duration = int(item.get('duration', 21600)) # seconds
|
|
||||||
effect = {
|
|
||||||
'type': 'duck_radar',
|
|
||||||
'expires_at': time.time() + max(1, duration)
|
|
||||||
}
|
|
||||||
player['temporary_effects'].append(effect)
|
|
||||||
|
|
||||||
return {
|
|
||||||
"type": "duck_radar",
|
|
||||||
"duration": duration
|
|
||||||
}
|
|
||||||
|
|
||||||
elif item_type == 'summon_duck':
|
|
||||||
# Actual spawning is handled by the bot (needs channel context)
|
|
||||||
delay = int(item.get('delay', 0))
|
|
||||||
delay = max(0, min(delay, 86400)) # cap to 24h
|
|
||||||
return {
|
|
||||||
"type": "summon_duck",
|
|
||||||
"delay": delay
|
|
||||||
}
|
|
||||||
|
|
||||||
elif item_type == 'insurance':
|
elif item_type == 'insurance':
|
||||||
# Add insurance protection against friendly fire
|
# Add insurance protection against friendly fire
|
||||||
if 'temporary_effects' not in player:
|
if 'temporary_effects' not in player:
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ class MessageManager:
|
|||||||
"help_header": "DuckHunt Commands:",
|
"help_header": "DuckHunt Commands:",
|
||||||
"help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop",
|
"help_user_commands": "!bang - Shoot at ducks | !reload - Reload your gun | !shop - View the shop",
|
||||||
"help_help_command": "!duckhelp - Show this help",
|
"help_help_command": "!duckhelp - Show this help",
|
||||||
"help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch | !join <#channel> | !leave <#channel>",
|
"help_admin_commands": "Admin: !rearm <player> | !disarm <player> | !ignore <player> | !unignore <player> | !ducklaunch",
|
||||||
"admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}",
|
"admin_rearm_player": "[ADMIN] {target} has been rearmed by {admin}",
|
||||||
"admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}",
|
"admin_rearm_all": "[ADMIN] All players have been rearmed by {admin}",
|
||||||
"admin_disarm": "[ADMIN] {target} has been disarmed by {admin}",
|
"admin_disarm": "[ADMIN] {target} has been disarmed by {admin}",
|
||||||
|
|||||||
Reference in New Issue
Block a user