From f4be20afb346716bbc057fe3fc655649c5074782 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 20 Feb 2026 09:54:53 -0600 Subject: [PATCH] fix: address 7 security issues across the codebase - Remove hardcoded Giphy API key from config.py, load from env var (#19) - URL-encode query parameters in APIClient._add_params (#20) - URL-encode Giphy search phrases before building request URLs (#21) - Replace internal exception details with generic messages to users (#22) - Replace all bare except: with except Exception: (#23) - Guard interaction.guild access in has_player_role (#24) - Replace MD5 with SHA-256 for command change detection hash (#32) Co-Authored-By: Claude Opus 4.6 --- api/client.py | 235 ++++++++------- bot.py | 269 +++++++++-------- commands/injuries/management.py | 453 +++++++++++++++++----------- commands/league/submit_scorecard.py | 10 +- config.py | 51 ++-- services/giphy_service.py | 189 +++++++----- tasks/transaction_freeze.py | 428 ++++++++++++++++---------- views/transaction_embed.py | 6 +- 8 files changed, 976 insertions(+), 665 deletions(-) diff --git a/api/client.py b/api/client.py index 553ac04..4ce5425 100644 --- a/api/client.py +++ b/api/client.py @@ -4,6 +4,7 @@ API client for Discord Bot v2.0 Modern aiohttp-based HTTP client for communicating with the database API. Provides connection pooling, proper error handling, and session management. """ + import aiohttp import logging from typing import Optional, List, Dict, Any, Union @@ -13,13 +14,13 @@ from contextlib import asynccontextmanager from config import get_config from exceptions import APIException -logger = logging.getLogger(f'{__name__}.APIClient') +logger = logging.getLogger(f"{__name__}.APIClient") class APIClient: """ Async HTTP client for SBA database API communication. - + Features: - Connection pooling with proper session management - Bearer token authentication @@ -27,15 +28,15 @@ class APIClient: - Comprehensive error handling - Debug logging with response truncation """ - + def __init__(self, base_url: Optional[str] = None, api_token: Optional[str] = None): """ Initialize API client with configuration. - + Args: base_url: Override default database URL from config api_token: Override default API token from config - + Raises: ValueError: If required configuration is missing """ @@ -43,24 +44,29 @@ class APIClient: self.base_url = base_url or config.db_url self.api_token = api_token or config.api_token self._session: Optional[aiohttp.ClientSession] = None - + if not self.base_url: raise ValueError("DB_URL must be configured") if not self.api_token: raise ValueError("API_TOKEN must be configured") - + logger.debug(f"APIClient initialized with base_url: {self.base_url}") - + @property def headers(self) -> Dict[str, str]: """Get headers with authentication and content type.""" return { - 'Authorization': f'Bearer {self.api_token}', - 'Content-Type': 'application/json', - 'User-Agent': 'SBA-Discord-Bot-v2/1.0' + "Authorization": f"Bearer {self.api_token}", + "Content-Type": "application/json", + "User-Agent": "SBA-Discord-Bot-v2/1.0", } - - def _build_url(self, endpoint: str, api_version: int = 3, object_id: Optional[Union[int, str]] = None) -> str: + + def _build_url( + self, + endpoint: str, + api_version: int = 3, + object_id: Optional[Union[int, str]] = None, + ) -> str: """ Build complete API URL from components. @@ -73,35 +79,38 @@ class APIClient: Complete URL for API request """ # Handle already complete URLs - if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint: + if endpoint.startswith(("http://", "https://")) or "/api/" in endpoint: return endpoint path = f"v{api_version}/{endpoint}" if object_id is not None: # URL-encode the object_id to handle special characters (e.g., colons in moveids) - encoded_id = quote(str(object_id), safe='') + encoded_id = quote(str(object_id), safe="") path += f"/{encoded_id}" - return urljoin(self.base_url.rstrip('/') + '/', path) - + return urljoin(self.base_url.rstrip("/") + "/", path) + def _add_params(self, url: str, params: Optional[List[tuple]] = None) -> str: """ Add query parameters to URL. - + Args: url: Base URL params: List of (key, value) tuples - + Returns: URL with query parameters appended """ if not params: return url - - param_str = "&".join(f"{key}={value}" for key, value in params) + + param_str = "&".join( + f"{quote(str(key), safe='')}={quote(str(value), safe='')}" + for key, value in params + ) separator = "&" if "?" in url else "?" return f"{url}{separator}{param_str}" - + async def _ensure_session(self) -> None: """Ensure aiohttp session exists and is not closed.""" if self._session is None or self._session.closed: @@ -109,53 +118,51 @@ class APIClient: limit=100, # Total connection pool size limit_per_host=30, # Connections per host ttl_dns_cache=300, # DNS cache TTL - use_dns_cache=True + use_dns_cache=True, ) - + timeout = aiohttp.ClientTimeout(total=30, connect=10) - + self._session = aiohttp.ClientSession( - headers=self.headers, - connector=connector, - timeout=timeout + headers=self.headers, connector=connector, timeout=timeout ) - + logger.debug("Created new aiohttp session with connection pooling") - + async def get( self, endpoint: str, object_id: Optional[Union[int, str]] = None, params: Optional[List[tuple]] = None, api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Make GET request to API. - + Args: endpoint: API endpoint object_id: Optional object ID params: Query parameters api_version: API version (default: 3) timeout: Request timeout override - + Returns: JSON response data or None for 404 - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version, object_id) url = self._add_params(url, params) - + await self._ensure_session() - + try: logger.debug(f"GET: {endpoint} id: {object_id} params: {params}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - + async with self._session.get(url, timeout=request_timeout) as response: if response.status == 404: logger.warning(f"Resource not found: {url}") @@ -169,10 +176,12 @@ class APIClient: elif response.status >= 400: error_text = await response.text() logger.error(f"API error {response.status}: {url} - {error_text}") - raise APIException(f"API request failed with status {response.status}: {error_text}") - + raise APIException( + f"API request failed with status {response.status}: {error_text}" + ) + data = await response.json() - + # Truncate response for logging data_str = str(data) if len(data_str) > 1200: @@ -180,48 +189,50 @@ class APIClient: else: log_data = data_str logger.debug(f"Response: {log_data}") - + return data - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in GET {url}: {e}") raise APIException(f"API call failed: {e}") - + async def post( - self, - endpoint: str, + self, + endpoint: str, data: Dict[str, Any], api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Make POST request to API. - + Args: endpoint: API endpoint data: Request payload api_version: API version (default: 3) timeout: Request timeout override - + Returns: JSON response data - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version) - + await self._ensure_session() - + try: logger.debug(f"POST: {endpoint} data: {data}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - - async with self._session.post(url, json=data, timeout=request_timeout) as response: + + async with self._session.post( + url, json=data, timeout=request_timeout + ) as response: if response.status == 401: logger.error(f"Authentication failed for POST: {url}") raise APIException("Authentication failed - check API token") @@ -231,10 +242,12 @@ class APIClient: elif response.status not in [200, 201]: error_text = await response.text() logger.error(f"POST error {response.status}: {url} - {error_text}") - raise APIException(f"POST request failed with status {response.status}: {error_text}") - + raise APIException( + f"POST request failed with status {response.status}: {error_text}" + ) + result = await response.json() - + # Truncate response for logging result_str = str(result) if len(result_str) > 1200: @@ -242,50 +255,52 @@ class APIClient: else: log_result = result_str logger.debug(f"POST Response: {log_result}") - + return result - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for POST {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in POST {url}: {e}") raise APIException(f"POST failed: {e}") - + async def put( self, endpoint: str, data: Dict[str, Any], object_id: Optional[Union[int, str]] = None, api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> Optional[Dict[str, Any]]: """ Make PUT request to API. - + Args: endpoint: API endpoint data: Request payload object_id: Optional object ID api_version: API version (default: 3) timeout: Request timeout override - + Returns: JSON response data - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version, object_id) - + await self._ensure_session() - + try: logger.debug(f"PUT: {endpoint} id: {object_id} data: {data}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - - async with self._session.put(url, json=data, timeout=request_timeout) as response: + + async with self._session.put( + url, json=data, timeout=request_timeout + ) as response: if response.status == 401: logger.error(f"Authentication failed for PUT: {url}") raise APIException("Authentication failed - check API token") @@ -298,19 +313,23 @@ class APIClient: elif response.status not in [200, 201]: error_text = await response.text() logger.error(f"PUT error {response.status}: {url} - {error_text}") - raise APIException(f"PUT request failed with status {response.status}: {error_text}") - + raise APIException( + f"PUT request failed with status {response.status}: {error_text}" + ) + result = await response.json() - logger.debug(f"PUT Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}") + logger.debug( + f"PUT Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}" + ) return result - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for PUT {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in PUT {url}: {e}") raise APIException(f"PUT failed: {e}") - + async def patch( self, endpoint: str, @@ -318,7 +337,7 @@ class APIClient: object_id: Optional[Union[int, str]] = None, api_version: int = 3, timeout: Optional[int] = None, - use_query_params: bool = False + use_query_params: bool = False, ) -> Optional[Dict[str, Any]]: """ Make PATCH request to API. @@ -344,13 +363,15 @@ class APIClient: # Handle None values by converting to empty string # The database API's PATCH endpoint treats empty strings as NULL for nullable fields # Example: {'il_return': None} → ?il_return= → Database sets il_return to NULL - params = [(k, '' if v is None else str(v)) for k, v in data.items()] + params = [(k, "" if v is None else str(v)) for k, v in data.items()] url = self._add_params(url, params) await self._ensure_session() try: - logger.debug(f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}") + logger.debug( + f"PATCH: {endpoint} id: {object_id} data: {data} use_query_params: {use_query_params}" + ) logger.debug(f"PATCH URL: {url}") request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None @@ -358,10 +379,12 @@ class APIClient: # Use json=data if data is provided and not using query params kwargs = {} if data is not None and not use_query_params: - kwargs['json'] = data + kwargs["json"] = data logger.debug(f"PATCH JSON body: {data}") - async with self._session.patch(url, timeout=request_timeout, **kwargs) as response: + async with self._session.patch( + url, timeout=request_timeout, **kwargs + ) as response: if response.status == 401: logger.error(f"Authentication failed for PATCH: {url}") raise APIException("Authentication failed - check API token") @@ -374,10 +397,14 @@ class APIClient: elif response.status not in [200, 201]: error_text = await response.text() logger.error(f"PATCH error {response.status}: {url} - {error_text}") - raise APIException(f"PATCH request failed with status {response.status}: {error_text}") + raise APIException( + f"PATCH request failed with status {response.status}: {error_text}" + ) result = await response.json() - logger.debug(f"PATCH Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}") + logger.debug( + f"PATCH Response: {str(result)[:1200]}{'...' if len(str(result)) > 1200 else ''}" + ) return result except aiohttp.ClientError as e: @@ -386,38 +413,38 @@ class APIClient: except Exception as e: logger.error(f"Unexpected error in PATCH {url}: {e}") raise APIException(f"PATCH failed: {e}") - + async def delete( self, endpoint: str, object_id: Optional[Union[int, str]] = None, api_version: int = 3, - timeout: Optional[int] = None + timeout: Optional[int] = None, ) -> bool: """ Make DELETE request to API. - + Args: endpoint: API endpoint object_id: Optional object ID api_version: API version (default: 3) timeout: Request timeout override - + Returns: True if deletion successful, False if resource not found - + Raises: APIException: For HTTP errors or network issues """ url = self._build_url(endpoint, api_version, object_id) - + await self._ensure_session() - + try: logger.debug(f"DELETE: {endpoint} id: {object_id}") - + request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None - + async with self._session.delete(url, timeout=request_timeout) as response: if response.status == 401: logger.error(f"Authentication failed for DELETE: {url}") @@ -430,30 +457,34 @@ class APIClient: return False elif response.status not in [200, 204]: error_text = await response.text() - logger.error(f"DELETE error {response.status}: {url} - {error_text}") - raise APIException(f"DELETE request failed with status {response.status}: {error_text}") - + logger.error( + f"DELETE error {response.status}: {url} - {error_text}" + ) + raise APIException( + f"DELETE request failed with status {response.status}: {error_text}" + ) + logger.debug(f"DELETE successful: {url}") return True - + except aiohttp.ClientError as e: logger.error(f"HTTP client error for DELETE {url}: {e}") raise APIException(f"Network error: {e}") except Exception as e: logger.error(f"Unexpected error in DELETE {url}: {e}") raise APIException(f"DELETE failed: {e}") - + async def close(self) -> None: """Close the HTTP session and clean up resources.""" if self._session and not self._session.closed: await self._session.close() logger.debug("Closed aiohttp session") - + async def __aenter__(self): """Async context manager entry.""" await self._ensure_session() return self - + async def __aexit__(self, exc_type, exc_val, exc_tb): """Async context manager exit with cleanup.""" await self.close() @@ -463,7 +494,7 @@ class APIClient: async def get_api_client() -> APIClient: """ Get API client as async context manager. - + Usage: async with get_api_client() as client: data = await client.get('players') @@ -482,14 +513,14 @@ _global_client: Optional[APIClient] = None async def get_global_client() -> APIClient: """ Get global API client instance with automatic session management. - + Returns: Shared APIClient instance """ global _global_client if _global_client is None: _global_client = APIClient() - + await _global_client._ensure_session() return _global_client @@ -499,4 +530,4 @@ async def cleanup_global_client() -> None: global _global_client if _global_client: await _global_client.close() - _global_client = None \ No newline at end of file + _global_client = None diff --git a/bot.py b/bot.py index 4e893dc..1bcd26f 100644 --- a/bot.py +++ b/bot.py @@ -3,6 +3,7 @@ Discord Bot v2.0 - Main Entry Point Modern discord.py bot with application commands and proper error handling. """ + import asyncio import hashlib import json @@ -23,89 +24,91 @@ from views.embeds import EmbedTemplate, EmbedColors def setup_logging(): """Configure hybrid logging: human-readable console + structured JSON files.""" from utils.logging import JSONFormatter - + # Create logs directory if it doesn't exist - os.makedirs('logs', exist_ok=True) - + os.makedirs("logs", exist_ok=True) + # Configure root logger config = get_config() - logger = logging.getLogger('discord_bot_v2') + logger = logging.getLogger("discord_bot_v2") logger.setLevel(getattr(logging, config.log_level.upper())) - + # Console handler - detailed format for development debugging console_handler = logging.StreamHandler() console_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' + "%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s" ) console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) - + # JSON file handler - structured logging for monitoring and analysis json_handler = RotatingFileHandler( - 'logs/discord_bot_v2.json', - maxBytes=5 * 1024 * 1024, # 5MB - backupCount=5 + "logs/discord_bot_v2.json", maxBytes=5 * 1024 * 1024, backupCount=5 # 5MB ) json_handler.setFormatter(JSONFormatter()) logger.addHandler(json_handler) - + # Configure root logger for third-party libraries (discord.py, aiohttp, etc.) root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, config.log_level.upper())) - + # Add handlers to root logger so third-party loggers inherit them if not root_logger.handlers: # Avoid duplicate handlers root_logger.addHandler(console_handler) root_logger.addHandler(json_handler) - + # Prevent discord_bot_v2 logger from propagating to root to avoid duplicate messages # (bot logs will still appear via its own handlers, third-party logs via root handlers) # To revert: remove the line below and bot logs will appear twice logger.propagate = False - + return logger class SBABot(commands.Bot): """Custom bot class for SBA league management.""" - + def __init__(self): # Configure intents intents = discord.Intents.default() intents.message_content = True # For legacy commands if needed intents.members = True # For member management - + super().__init__( - command_prefix='!', # Legacy prefix, primarily using slash commands + command_prefix="!", # Legacy prefix, primarily using slash commands intents=intents, - description="Major Domo v2.0" + description="Major Domo v2.0", ) - - self.logger = logging.getLogger('discord_bot_v2') - + + self.logger = logging.getLogger("discord_bot_v2") + async def setup_hook(self): """Called when the bot is starting up.""" self.logger.info("Setting up bot...") - + # Load command packages await self._load_command_packages() - + # Initialize cleanup tasks await self._setup_background_tasks() - + # Smart command syncing: auto-sync in development if changes detected; !admin-sync for first sync config = get_config() if config.is_development: if await self._should_sync_commands(): - self.logger.info("Development mode: changes detected, syncing commands...") + self.logger.info( + "Development mode: changes detected, syncing commands..." + ) await self._sync_commands() await self._save_command_hash() else: - self.logger.info("Development mode: no command changes detected, skipping sync") + self.logger.info( + "Development mode: no command changes detected, skipping sync" + ) else: self.logger.info("Production mode: commands loaded but not auto-synced") self.logger.info("Use /admin-sync command to manually sync when needed") - + async def _load_command_packages(self): """Load all command packages with resilient error handling.""" from commands.players import setup_players @@ -146,32 +149,42 @@ class SBABot(commands.Bot): ("gameplay", setup_gameplay), ("dev", setup_dev), # Dev-only commands (admin restricted) ] - + total_successful = 0 total_failed = 0 - + for package_name, setup_func in command_packages: try: self.logger.info(f"Loading {package_name} commands...") successful, failed, failed_modules = await setup_func(self) total_successful += successful total_failed += failed - + if failed == 0: - self.logger.info(f"✅ {package_name} commands loaded successfully ({successful} cogs)") + self.logger.info( + f"✅ {package_name} commands loaded successfully ({successful} cogs)" + ) else: - self.logger.warning(f"⚠️ {package_name} commands partially loaded: {successful} successful, {failed} failed") - + self.logger.warning( + f"⚠️ {package_name} commands partially loaded: {successful} successful, {failed} failed" + ) + except Exception as e: - self.logger.error(f"❌ Failed to load {package_name} package: {e}", exc_info=True) + self.logger.error( + f"❌ Failed to load {package_name} package: {e}", exc_info=True + ) total_failed += 1 - + # Log overall summary if total_failed == 0: - self.logger.info(f"🎉 All command packages loaded successfully ({total_successful} total cogs)") + self.logger.info( + f"🎉 All command packages loaded successfully ({total_successful} total cogs)" + ) else: - self.logger.warning(f"⚠️ Command loading completed with issues: {total_successful} successful, {total_failed} failed") - + self.logger.warning( + f"⚠️ Command loading completed with issues: {total_successful} successful, {total_failed} failed" + ) + async def _setup_background_tasks(self): """Initialize background tasks for the bot.""" try: @@ -179,28 +192,34 @@ class SBABot(commands.Bot): # Initialize custom command cleanup task from tasks.custom_command_cleanup import setup_cleanup_task + self.custom_command_cleanup = setup_cleanup_task(self) # Initialize transaction freeze/thaw task from tasks.transaction_freeze import setup_freeze_task + self.transaction_freeze = setup_freeze_task(self) self.logger.info("✅ Transaction freeze/thaw task started") # Initialize voice channel cleanup service from commands.voice.cleanup_service import setup_voice_cleanup + self.voice_cleanup_service = setup_voice_cleanup(self) self.logger.info("✅ Voice channel cleanup service started") # Initialize live scorebug tracker from tasks.live_scorebug_tracker import setup_scorebug_tracker + self.live_scorebug_tracker = setup_scorebug_tracker(self) self.logger.info("✅ Live scorebug tracker started") self.logger.info("✅ Background tasks initialized successfully") except Exception as e: - self.logger.error(f"❌ Failed to initialize background tasks: {e}", exc_info=True) - + self.logger.error( + f"❌ Failed to initialize background tasks: {e}", exc_info=True + ) + async def _should_sync_commands(self) -> bool: """Check if commands have changed since last sync.""" try: @@ -209,50 +228,51 @@ class SBABot(commands.Bot): for cmd in self.tree.get_commands(): # Handle different command types properly cmd_dict = {} - cmd_dict['name'] = cmd.name - cmd_dict['type'] = type(cmd).__name__ - + cmd_dict["name"] = cmd.name + cmd_dict["type"] = type(cmd).__name__ + # Add description if available (most command types have this) - if hasattr(cmd, 'description'): - cmd_dict['description'] = cmd.description # type: ignore - + if hasattr(cmd, "description"): + cmd_dict["description"] = cmd.description # type: ignore + # Add parameters for Command objects if isinstance(cmd, discord.app_commands.Command): - cmd_dict['parameters'] = [ + cmd_dict["parameters"] = [ { - 'name': param.name, - 'description': param.description, - 'required': param.required, - 'type': str(param.type) - } for param in cmd.parameters + "name": param.name, + "description": param.description, + "required": param.required, + "type": str(param.type), + } + for param in cmd.parameters ] elif isinstance(cmd, discord.app_commands.Group): # For groups, include subcommands - cmd_dict['subcommands'] = [subcmd.name for subcmd in cmd.commands] - + cmd_dict["subcommands"] = [subcmd.name for subcmd in cmd.commands] + commands_data.append(cmd_dict) - + # Sort for consistent hashing - commands_data.sort(key=lambda x: x['name']) - current_hash = hashlib.md5( + commands_data.sort(key=lambda x: x["name"]) + current_hash = hashlib.sha256( json.dumps(commands_data, sort_keys=True).encode() ).hexdigest() - + # Compare with stored hash - hash_file = '.last_command_hash' + hash_file = ".last_command_hash" if os.path.exists(hash_file): - with open(hash_file, 'r') as f: + with open(hash_file, "r") as f: last_hash = f.read().strip() return current_hash != last_hash else: # No previous hash = first run, should sync return True - + except Exception as e: self.logger.warning(f"Error checking command hash: {e}") # If we can't determine changes, err on the side of syncing return True - + async def _save_command_hash(self): """Save current command hash for future comparison.""" try: @@ -261,41 +281,42 @@ class SBABot(commands.Bot): for cmd in self.tree.get_commands(): # Handle different command types properly cmd_dict = {} - cmd_dict['name'] = cmd.name - cmd_dict['type'] = type(cmd).__name__ - + cmd_dict["name"] = cmd.name + cmd_dict["type"] = type(cmd).__name__ + # Add description if available (most command types have this) - if hasattr(cmd, 'description'): - cmd_dict['description'] = cmd.description # type: ignore - + if hasattr(cmd, "description"): + cmd_dict["description"] = cmd.description # type: ignore + # Add parameters for Command objects if isinstance(cmd, discord.app_commands.Command): - cmd_dict['parameters'] = [ + cmd_dict["parameters"] = [ { - 'name': param.name, - 'description': param.description, - 'required': param.required, - 'type': str(param.type) - } for param in cmd.parameters + "name": param.name, + "description": param.description, + "required": param.required, + "type": str(param.type), + } + for param in cmd.parameters ] elif isinstance(cmd, discord.app_commands.Group): # For groups, include subcommands - cmd_dict['subcommands'] = [subcmd.name for subcmd in cmd.commands] - + cmd_dict["subcommands"] = [subcmd.name for subcmd in cmd.commands] + commands_data.append(cmd_dict) - - commands_data.sort(key=lambda x: x['name']) - current_hash = hashlib.md5( + + commands_data.sort(key=lambda x: x["name"]) + current_hash = hashlib.sha256( json.dumps(commands_data, sort_keys=True).encode() ).hexdigest() - + # Save hash to file - with open('.last_command_hash', 'w') as f: + with open(".last_command_hash", "w") as f: f.write(current_hash) - + except Exception as e: self.logger.warning(f"Error saving command hash: {e}") - + async def _sync_commands(self): """Internal method to sync commands.""" config = get_config() @@ -303,54 +324,55 @@ class SBABot(commands.Bot): guild = discord.Object(id=config.guild_id) self.tree.copy_global_to(guild=guild) synced = await self.tree.sync(guild=guild) - self.logger.info(f"Synced {len(synced)} commands to guild {config.guild_id}") + self.logger.info( + f"Synced {len(synced)} commands to guild {config.guild_id}" + ) else: synced = await self.tree.sync() self.logger.info(f"Synced {len(synced)} commands globally") - + async def on_ready(self): """Called when the bot is ready.""" self.logger.info(f"Bot ready! Logged in as {self.user}") self.logger.info(f"Connected to {len(self.guilds)} guilds") - + # Set activity status activity = discord.Activity( - type=discord.ActivityType.watching, - name=random_from_list(STARTUP_WATCHING) + type=discord.ActivityType.watching, name=random_from_list(STARTUP_WATCHING) ) await self.change_presence(activity=activity) - + async def on_error(self, event_method: str, /, *args, **kwargs): """Global error handler for events.""" self.logger.error(f"Error in event {event_method}", exc_info=True) - + async def close(self): """Clean shutdown of the bot.""" self.logger.info("Bot shutting down...") # Stop background tasks - if hasattr(self, 'custom_command_cleanup'): + if hasattr(self, "custom_command_cleanup"): try: self.custom_command_cleanup.cleanup_task.cancel() self.logger.info("Custom command cleanup task stopped") except Exception as e: self.logger.error(f"Error stopping cleanup task: {e}") - if hasattr(self, 'transaction_freeze'): + if hasattr(self, "transaction_freeze"): try: self.transaction_freeze.weekly_loop.cancel() self.logger.info("Transaction freeze/thaw task stopped") except Exception as e: self.logger.error(f"Error stopping transaction freeze task: {e}") - if hasattr(self, 'voice_cleanup_service'): + if hasattr(self, "voice_cleanup_service"): try: self.voice_cleanup_service.cog_unload() self.logger.info("Voice channel cleanup service stopped") except Exception as e: self.logger.error(f"Error stopping voice cleanup service: {e}") - if hasattr(self, 'live_scorebug_tracker'): + if hasattr(self, "live_scorebug_tracker"): try: self.live_scorebug_tracker.update_loop.cancel() self.logger.info("Live scorebug tracker stopped") @@ -369,15 +391,15 @@ bot = SBABot() @bot.tree.command(name="health", description="Check bot and API health status") async def health_command(interaction: discord.Interaction): """Health check command to verify bot and API connectivity.""" - logger = logging.getLogger('discord_bot_v2') - + logger = logging.getLogger("discord_bot_v2") + try: # Check API connectivity api_status = "✅ Connected" try: client = await get_global_client() # Test API with a simple request - result = await client.get('current') + result = await client.get("current") if result: api_status = "✅ Connected" else: @@ -385,69 +407,66 @@ async def health_command(interaction: discord.Interaction): except Exception as e: logger.error(f"API health check failed: {e}") api_status = f"❌ Error: {str(e)}" - + # Bot health info guild_count = len(bot.guilds) - + # Create health status embed - embed = EmbedTemplate.success( - title="🏥 Bot Health Check" - ) - + embed = EmbedTemplate.success(title="🏥 Bot Health Check") + embed.add_field(name="Bot Status", value="✅ Online", inline=True) embed.add_field(name="API Status", value=api_status, inline=True) embed.add_field(name="Guilds", value=str(guild_count), inline=True) embed.add_field(name="Latency", value=f"{bot.latency*1000:.1f}ms", inline=True) - + if bot.user: - embed.set_footer(text=f"Bot: {bot.user.name}", icon_url=bot.user.display_avatar.url) - + embed.set_footer( + text=f"Bot: {bot.user.name}", icon_url=bot.user.display_avatar.url + ) + await interaction.response.send_message(embed=embed, ephemeral=True) - + except Exception as e: logger.error(f"Health check command error: {e}", exc_info=True) await interaction.response.send_message( - f"❌ Health check failed: {str(e)}", - ephemeral=True + f"❌ Health check failed: {str(e)}", ephemeral=True ) @bot.tree.error -async def on_app_command_error(interaction: discord.Interaction, error: discord.app_commands.AppCommandError): +async def on_app_command_error( + interaction: discord.Interaction, error: discord.app_commands.AppCommandError +): """Global error handler for application commands.""" - logger = logging.getLogger('discord_bot_v2') - + logger = logging.getLogger("discord_bot_v2") + # Handle specific error types if isinstance(error, discord.app_commands.CommandOnCooldown): await interaction.response.send_message( f"⏰ Command on cooldown. Try again in {error.retry_after:.1f} seconds.", - ephemeral=True + ephemeral=True, ) elif isinstance(error, discord.app_commands.MissingPermissions): await interaction.response.send_message( - "❌ You don't have permission to use this command.", - ephemeral=True + "❌ You don't have permission to use this command.", ephemeral=True ) elif isinstance(error, discord.app_commands.CommandNotFound): await interaction.response.send_message( "❌ Command not found. Use `/help` to see available commands.", - ephemeral=True + ephemeral=True, ) elif isinstance(error, BotException): # Our custom exceptions - show user-friendly message - await interaction.response.send_message( - f"❌ {str(error)}", - ephemeral=True - ) + await interaction.response.send_message(f"❌ {str(error)}", ephemeral=True) else: # Unexpected errors - log and show generic message logger.error(f"Unhandled command error: {error}", exc_info=True) - + message = "❌ An unexpected error occurred. Please try again." config = get_config() if config.is_development: message += f"\n\nDevelopment error: {str(error)}" - + if interaction.response.is_done(): await interaction.followup.send(message, ephemeral=True) else: @@ -457,12 +476,12 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord. async def main(): """Main entry point.""" logger = setup_logging() - + config = get_config() logger.info("Starting Discord Bot v2.0") logger.info(f"Environment: {config.environment}") logger.info(f"Guild ID: {config.guild_id}") - + try: await bot.start(config.bot_token) except KeyboardInterrupt: @@ -475,4 +494,4 @@ async def main(): if __name__ == "__main__": - asyncio.run(main()) \ No newline at end of file + asyncio.run(main()) diff --git a/commands/injuries/management.py b/commands/injuries/management.py index be845cc..9e9fb4b 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -10,6 +10,7 @@ The injury rating format (#p##) encodes both games played and rating: - First character: Games played in series (1-6) - Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20) """ + import math import random import discord @@ -40,11 +41,8 @@ class InjuryGroup(app_commands.Group): """Injury management command group with roll, set-new, and clear subcommands.""" def __init__(self): - super().__init__( - name="injury", - description="Injury management commands" - ) - self.logger = get_contextual_logger(f'{__name__}.InjuryGroup') + super().__init__(name="injury", description="Injury management commands") + self.logger = get_contextual_logger(f"{__name__}.InjuryGroup") self.logger.info("InjuryGroup initialized") def has_player_role(self, interaction: discord.Interaction) -> bool: @@ -53,13 +51,17 @@ class InjuryGroup(app_commands.Group): if not isinstance(interaction.user, discord.Member): return False + if interaction.guild is None: + return False + player_role = discord.utils.get( - interaction.guild.roles, - name=get_config().sba_players_role_name + interaction.guild.roles, name=get_config().sba_players_role_name ) return player_role in interaction.user.roles if player_role else False - @app_commands.command(name="roll", description="Roll for injury based on player's injury rating") + @app_commands.command( + name="roll", description="Roll for injury based on player's injury rating" + ) @app_commands.describe(player_name="Player name") @app_commands.autocomplete(player_name=player_autocomplete) @league_only() @@ -74,12 +76,14 @@ class InjuryGroup(app_commands.Group): raise BotException("Failed to get current season information") # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players(player_name, limit=10, season=current.season) + players = await player_service.search_players( + player_name, limit=10, season=current.season + ) if not players: embed = EmbedTemplate.error( title="Player Not Found", - description=f"I did not find anybody named **{player_name}**." + description=f"I did not find anybody named **{player_name}**.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -89,14 +93,17 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: from services.team_service import team_service + player.team = await team_service.get_team(player.team_id) # Check if player already has an active injury - existing_injury = await injury_service.get_active_injury(player.id, current.season) + existing_injury = await injury_service.get_active_injury( + player.id, current.season + ) if existing_injury: embed = EmbedTemplate.error( title="Already Injured", - description=f"Hm. It looks like {player.name} is already hurt." + description=f"Hm. It looks like {player.name} is already hurt.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -105,7 +112,7 @@ class InjuryGroup(app_commands.Group): if not player.injury_rating: embed = EmbedTemplate.error( title="No Injury Rating", - description=f"{player.name} does not have an injury rating set." + description=f"{player.name} does not have an injury rating set.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -120,13 +127,13 @@ class InjuryGroup(app_commands.Group): raise ValueError("Games played must be between 1 and 6") # Validate rating format (should start with 'p') - if not injury_rating.startswith('p'): + if not injury_rating.startswith("p"): raise ValueError("Invalid rating format") except (ValueError, IndexError): embed = EmbedTemplate.error( title="Invalid Injury Rating Format", - description=f"{player.name} has an invalid injury rating: `{player.injury_rating}`\n\nExpected format: `#p##` (e.g., `1p70`, `4p50`)" + description=f"{player.name} has an invalid injury rating: `{player.injury_rating}`\n\nExpected format: `#p##` (e.g., `1p70`, `4p50`)", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -141,33 +148,25 @@ class InjuryGroup(app_commands.Group): injury_result = self._get_injury_result(injury_rating, games_played, roll_total) # Create response embed - embed = EmbedTemplate.warning( - title=f"Injury roll for {interaction.user.name}" - ) + embed = EmbedTemplate.warning(title=f"Injury roll for {interaction.user.name}") if player.team and player.team.thumbnail: embed.set_thumbnail(url=player.team.thumbnail) embed.add_field( name="Player", value=f"{player.name} ({player.primary_position})", - inline=True + inline=True, ) embed.add_field( - name="Injury Rating", - value=f"{player.injury_rating}", - inline=True + name="Injury Rating", value=f"{player.injury_rating}", inline=True ) # embed.add_field(name='', value='', inline=False) # Embed line break # Format dice roll in markdown (same format as /ab roll) dice_result = f"```md\n# {roll_total}\nDetails:[3d6 ({d1} {d2} {d3})]```" - embed.add_field( - name="Dice Roll", - value=dice_result, - inline=False - ) + embed.add_field(name="Dice Roll", value=dice_result, inline=False) view = None @@ -177,20 +176,20 @@ class InjuryGroup(app_commands.Group): embed.color = discord.Color.orange() if injury_result > 6: - gif_search_text = ['well shit', 'well fuck', 'god dammit'] + gif_search_text = ["well shit", "well fuck", "god dammit"] else: - gif_search_text = ['bummer', 'well damn'] + gif_search_text = ["bummer", "well damn"] if player.is_pitcher: - result_text += ' plus their current rest requirement' + result_text += " plus their current rest requirement" # Pitcher callback shows modal to collect rest games - async def pitcher_confirm_callback(button_interaction: discord.Interaction): + async def pitcher_confirm_callback( + button_interaction: discord.Interaction, + ): """Show modal to collect pitcher rest information.""" modal = PitcherRestModal( - player=player, - injury_games=injury_result, - season=current.season + player=player, injury_games=injury_result, season=current.season ) await button_interaction.response.send_modal(modal) @@ -198,12 +197,12 @@ class InjuryGroup(app_commands.Group): else: # Batter callback shows modal to collect current week/game - async def batter_confirm_callback(button_interaction: discord.Interaction): + async def batter_confirm_callback( + button_interaction: discord.Interaction, + ): """Show modal to collect current week/game information for batter injury.""" modal = BatterInjuryModal( - player=player, - injury_games=injury_result, - season=current.season + player=player, injury_games=injury_result, season=current.season ) await button_interaction.response.send_modal(modal) @@ -213,35 +212,31 @@ class InjuryGroup(app_commands.Group): # Only the player's team GM(s) can log the injury view = ConfirmationView( timeout=180.0, # 3 minutes for confirmation - responders=[player.team.gmid, player.team.gmid2] if player.team else None, + responders=( + [player.team.gmid, player.team.gmid2] if player.team else None + ), confirm_callback=injury_callback, confirm_label="Log Injury", - cancel_label="Ignore Injury" + cancel_label="Ignore Injury", ) - elif injury_result == 'REM': + elif injury_result == "REM": if player.is_pitcher: - result_text = '**FATIGUED**' + result_text = "**FATIGUED**" else: result_text = "**REMAINDER OF GAME**" embed.color = discord.Color.gold() - gif_search_text = ['this is fine', 'not even mad', 'could be worse'] + gif_search_text = ["this is fine", "not even mad", "could be worse"] else: # 'OK' result_text = "**No injury!**" embed.color = discord.Color.green() - gif_search_text = ['we are so back', 'all good', 'totally fine'] + gif_search_text = ["we are so back", "all good", "totally fine"] - embed.add_field( - name="Injury Length", - value=result_text, - inline=True - ) + embed.add_field(name="Injury Length", value=result_text, inline=True) try: - injury_gif = await GiphyService().get_gif( - phrase_options=gif_search_text - ) + injury_gif = await GiphyService().get_gif(phrase_options=gif_search_text) except Exception: - injury_gif = '' + injury_gif = "" embed.set_image(url=injury_gif) @@ -251,7 +246,6 @@ class InjuryGroup(app_commands.Group): else: await interaction.followup.send(embed=embed) - def _get_injury_result(self, rating: str, games_played: int, roll: int): """ Get injury result from the injury table. @@ -266,89 +260,194 @@ class InjuryGroup(app_commands.Group): """ # Injury table mapping inj_data = { - 'one': { - 'p70': ['OK', 'OK', 'OK', 'OK', 'OK', 'OK', 'REM', 'REM', 1, 1, 2, 2, 3, 3, 4, 4], - 'p65': [2, 2, 'OK', 'REM', 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12], - 'p60': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16], - 'p50': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, 'OK'], - 'p40': ['OK', 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, 'REM', 'OK'], - 'p30': ['OK', 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, 'REM', 'OK'], - 'p20': ['OK', 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, 'REM', 'OK'] + "one": { + "p70": [ + "OK", + "OK", + "OK", + "OK", + "OK", + "OK", + "REM", + "REM", + 1, + 1, + 2, + 2, + 3, + 3, + 4, + 4, + ], + "p65": [2, 2, "OK", "REM", 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12], + "p60": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16], + "p50": ["OK", "REM", 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, "OK"], + "p40": ["OK", 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, "REM", "OK"], + "p30": ["OK", 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, "REM", "OK"], + "p20": ["OK", 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, "REM", "OK"], }, - 'two': { - 'p70': [4, 3, 2, 2, 1, 1, 'REM', 'OK', 'REM', 'OK', 2, 1, 2, 2, 3, 4], - 'p65': [8, 5, 4, 2, 2, 'OK', 1, 'OK', 'REM', 1, 'REM', 2, 3, 4, 6, 12], - 'p60': [1, 3, 4, 5, 2, 2, 'OK', 1, 3, 'REM', 4, 4, 6, 8, 12, 3], - 'p50': [4, 'OK', 'OK', 'REM', 1, 2, 4, 3, 4, 5, 4, 6, 8, 12, 12, 'OK'], - 'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 6, 8, 12, 16, 16, 'OK'], - 'p30': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 6, 5, 8, 12, 16, 24, 'REM', 'OK'], - 'p20': ['OK', 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, 'REM'] + "two": { + "p70": [4, 3, 2, 2, 1, 1, "REM", "OK", "REM", "OK", 2, 1, 2, 2, 3, 4], + "p65": [8, 5, 4, 2, 2, "OK", 1, "OK", "REM", 1, "REM", 2, 3, 4, 6, 12], + "p60": [1, 3, 4, 5, 2, 2, "OK", 1, 3, "REM", 4, 4, 6, 8, 12, 3], + "p50": [4, "OK", "OK", "REM", 1, 2, 4, 3, 4, 5, 4, 6, 8, 12, 12, "OK"], + "p40": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 6, 8, 12, 16, 16, "OK"], + "p30": [ + "OK", + "REM", + 1, + 2, + 3, + 4, + 4, + 5, + 6, + 5, + 8, + 12, + 16, + 24, + "REM", + "OK", + ], + "p20": ["OK", 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, "REM"], }, - 'three': { - 'p70': [], - 'p65': ['OK', 'OK', 'REM', 1, 3, 'OK', 'REM', 1, 2, 1, 2, 3, 4, 4, 5, 'REM'], - 'p60': ['OK', 5, 'OK', 'REM', 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, 'REM'], - 'p50': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], - 'p40': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], - 'p30': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, 'REM'], - 'p20': ['OK', 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, 'REM'] + "three": { + "p70": [], + "p65": [ + "OK", + "OK", + "REM", + 1, + 3, + "OK", + "REM", + 1, + 2, + 1, + 2, + 3, + 4, + 4, + 5, + "REM", + ], + "p60": ["OK", 5, "OK", "REM", 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, "REM"], + "p50": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, "REM"], + "p40": ["OK", 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, "REM"], + "p30": ["OK", 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, "REM"], + "p20": ["OK", 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, "REM"], }, - 'four': { - 'p70': [], - 'p65': [], - 'p60': ['OK', 'OK', 'REM', 3, 3, 'OK', 'REM', 1, 2, 1, 4, 4, 5, 6, 8, 'REM'], - 'p50': ['OK', 6, 4, 'OK', 'REM', 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, 'REM'], - 'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], - 'p30': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], - 'p20': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, 'REM'] + "four": { + "p70": [], + "p65": [], + "p60": [ + "OK", + "OK", + "REM", + 3, + 3, + "OK", + "REM", + 1, + 2, + 1, + 4, + 4, + 5, + 6, + 8, + "REM", + ], + "p50": ["OK", 6, 4, "OK", "REM", 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, "REM"], + "p40": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, "REM"], + "p30": ["OK", 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, "REM"], + "p20": ["OK", 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, "REM"], }, - 'five': { - 'p70': [], - 'p65': [], - 'p60': ['OK', 'REM', 'REM', 'REM', 3, 'OK', 1, 'REM', 2, 1, 'OK', 4, 5, 2, 6, 8], - 'p50': ['OK', 'OK', 'REM', 1, 1, 'OK', 'REM', 3, 2, 4, 4, 5, 5, 6, 8, 12], - 'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 12, 1], - 'p30': ['OK', 'OK', 'REM', 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, 'REM'], - 'p20': ['OK', 'REM', 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, 'REM'] + "five": { + "p70": [], + "p65": [], + "p60": [ + "OK", + "REM", + "REM", + "REM", + 3, + "OK", + 1, + "REM", + 2, + 1, + "OK", + 4, + 5, + 2, + 6, + 8, + ], + "p50": [ + "OK", + "OK", + "REM", + 1, + 1, + "OK", + "REM", + 3, + 2, + 4, + 4, + 5, + 5, + 6, + 8, + 12, + ], + "p40": ["OK", 6, 6, "OK", 1, 3, 2, 4, 4, 5, "REM", 3, 8, 6, 12, 1], + "p30": ["OK", "OK", "REM", 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, "REM"], + "p20": ["OK", "REM", 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, "REM"], + }, + "six": { + "p70": [], + "p65": [], + "p60": [], + "p50": [], + "p40": ["OK", 6, 6, "OK", 1, 3, 2, 4, 4, 5, "REM", 3, 8, 6, 1, 12], + "p30": ["OK", "OK", "REM", 5, 1, 3, 6, 4, 5, 2, 4, 8, 3, 5, 12, "REM"], + "p20": ["OK", "REM", 4, 6, 2, 3, 6, 4, 8, 5, 5, 6, 3, 1, 12, "REM"], }, - 'six': { - 'p70': [], - 'p65': [], - 'p60': [], - 'p50': [], - 'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 1, 12], - 'p30': ['OK', 'OK', 'REM', 5, 1, 3, 6, 4, 5, 2, 4, 8, 3, 5, 12, 'REM'], - 'p20': ['OK', 'REM', 4, 6, 2, 3, 6, 4, 8, 5, 5, 6, 3, 1, 12, 'REM'] - } } # Map games_played to key - games_map = {1: 'one', 2: 'two', 3: 'three', 4: 'four', 5: 'five', 6: 'six'} + games_map = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six"} games_key = games_map.get(games_played) if not games_key: - return 'OK' + return "OK" # Get the injury table for this rating and games played injury_table = inj_data.get(games_key, {}).get(rating, []) # If no table exists (e.g., p70 with 3+ games), no injury if not injury_table: - return 'OK' + return "OK" # Get result from table (roll 3-18 maps to index 0-15) table_index = roll - 3 if 0 <= table_index < len(injury_table): return injury_table[table_index] - return 'OK' + return "OK" - @app_commands.command(name="set-new", description="Set a new injury for a player (requires SBA Players role)") + @app_commands.command( + name="set-new", + description="Set a new injury for a player (requires SBA Players role)", + ) @app_commands.describe( player_name="Player name to injure", this_week="Current week number", this_game="Current game number (1-4)", - injury_games="Number of games player will be out" + injury_games="Number of games player will be out", ) @league_only() @logged_command("/injury set-new") @@ -358,14 +457,14 @@ class InjuryGroup(app_commands.Group): player_name: str, this_week: int, this_game: int, - injury_games: int + injury_games: int, ): """Set a new injury for a player on your team.""" # Check role permissions if not self.has_player_role(interaction): embed = EmbedTemplate.error( title="Permission Denied", - description=f"This command requires the **{get_config().sba_players_role_name}** role." + description=f"This command requires the **{get_config().sba_players_role_name}** role.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -376,7 +475,7 @@ class InjuryGroup(app_commands.Group): if this_game < 1 or this_game > 4: embed = EmbedTemplate.error( title="Invalid Input", - description="Game number must be between 1 and 4." + description="Game number must be between 1 and 4.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -384,7 +483,7 @@ class InjuryGroup(app_commands.Group): if injury_games < 1: embed = EmbedTemplate.error( title="Invalid Input", - description="Injury duration must be at least 1 game." + description="Injury duration must be at least 1 game.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -395,12 +494,14 @@ class InjuryGroup(app_commands.Group): raise BotException("Failed to get current season information") # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players(player_name, limit=10, season=current.season) + players = await player_service.search_players( + player_name, limit=10, season=current.season + ) if not players: embed = EmbedTemplate.error( title="Player Not Found", - description=f"I did not find anybody named **{player_name}**." + description=f"I did not find anybody named **{player_name}**.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -410,6 +511,7 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: from services.team_service import team_service + player.team = await team_service.get_team(player.team_id) # Check if player is on user's team @@ -418,7 +520,9 @@ class InjuryGroup(app_commands.Group): # TODO: Add team ownership verification # Check if player already has an active injury - existing_injury = await injury_service.get_active_injury(player.id, current.season) + existing_injury = await injury_service.get_active_injury( + player.id, current.season + ) # Data consistency check: If injury exists but il_return is None, it's stale data if existing_injury: @@ -431,12 +535,14 @@ class InjuryGroup(app_commands.Group): await injury_service.clear_injury(existing_injury.id) # Notify user but allow them to proceed - self.logger.info(f"Cleared stale injury {existing_injury.id} for player {player.id}") + self.logger.info( + f"Cleared stale injury {existing_injury.id} for player {player.id}" + ) else: # Valid active injury - player is actually injured embed = EmbedTemplate.error( title="Already Injured", - description=f"Hm. It looks like {player.name} is already hurt (returns {player.il_return})." + description=f"Hm. It looks like {player.name} is already hurt (returns {player.il_return}).", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -456,7 +562,7 @@ class InjuryGroup(app_commands.Group): start_week = this_week if this_game != 4 else this_week + 1 start_game = this_game + 1 if this_game != 4 else 1 - return_date = f'w{return_week:02d}g{return_game}' + return_date = f"w{return_week:02d}g{return_game}" # Create injury record injury = await injury_service.create_injury( @@ -466,49 +572,43 @@ class InjuryGroup(app_commands.Group): start_week=start_week, start_game=start_game, end_week=return_week, - end_game=return_game + end_game=return_game, ) if not injury: embed = EmbedTemplate.error( title="Error", - description="Well that didn't work. Failed to create injury record." + description="Well that didn't work. Failed to create injury record.", ) await interaction.followup.send(embed=embed, ephemeral=True) return # Update player's il_return field - await player_service.update_player(player.id, {'il_return': return_date}) + await player_service.update_player(player.id, {"il_return": return_date}) # Success response embed = EmbedTemplate.success( title="Injury Recorded", - description=f"{player.name}'s injury has been logged" + description=f"{player.name}'s injury has been logged", ) embed.add_field( - name="Player", - value=f"{player.name} ({player.pos_1})", - inline=True + name="Player", value=f"{player.name} ({player.pos_1})", inline=True ) embed.add_field( name="Duration", value=f"{injury_games} game{'s' if injury_games > 1 else ''}", - inline=True + inline=True, ) - embed.add_field( - name="Return Date", - value=return_date, - inline=True - ) + embed.add_field(name="Return Date", value=return_date, inline=True) if player.team: embed.add_field( name="Team", value=f"{player.team.lname} ({player.team.abbrev})", - inline=False + inline=False, ) await interaction.followup.send(embed=embed) @@ -518,10 +618,12 @@ class InjuryGroup(app_commands.Group): f"Injury set for {player.name}: {injury_games} games, returns {return_date}", player_id=player.id, season=current.season, - injury_id=injury.id + injury_id=injury.id, ) - - def _calc_injury_dates(self, start_week: int, start_game: int, injury_games: int) -> dict: + + def _calc_injury_dates( + self, start_week: int, start_game: int, injury_games: int + ) -> dict: """ Calculate injury dates from start week/game and injury duration. @@ -549,15 +651,16 @@ class InjuryGroup(app_commands.Group): actual_start_game = start_game + 1 if start_game != 4 else 1 return { - 'total_games': injury_games, - 'start_week': actual_start_week, - 'start_game': actual_start_game, - 'end_week': return_week, - 'end_game': return_game + "total_games": injury_games, + "start_week": actual_start_week, + "start_game": actual_start_game, + "end_week": return_week, + "end_game": return_game, } - - @app_commands.command(name="clear", description="Clear a player's injury (requires SBA Players role)") + @app_commands.command( + name="clear", description="Clear a player's injury (requires SBA Players role)" + ) @app_commands.describe(player_name="Player name to clear injury") @app_commands.autocomplete(player_name=player_autocomplete) @league_only() @@ -568,7 +671,7 @@ class InjuryGroup(app_commands.Group): if not self.has_player_role(interaction): embed = EmbedTemplate.error( title="Permission Denied", - description=f"This command requires the **{get_config().sba_players_role_name}** role." + description=f"This command requires the **{get_config().sba_players_role_name}** role.", ) await interaction.response.send_message(embed=embed, ephemeral=True) return @@ -581,12 +684,14 @@ class InjuryGroup(app_commands.Group): raise BotException("Failed to get current season information") # Search for player using the search endpoint (more reliable than name param) - players = await player_service.search_players(player_name, limit=10, season=current.season) + players = await player_service.search_players( + player_name, limit=10, season=current.season + ) if not players: embed = EmbedTemplate.error( title="Player Not Found", - description=f"I did not find anybody named **{player_name}**." + description=f"I did not find anybody named **{player_name}**.", ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -596,6 +701,7 @@ class InjuryGroup(app_commands.Group): # Fetch full team data if team is not populated if player.team_id and not player.team: from services.team_service import team_service + player.team = await team_service.get_team(player.team_id) # Get active injury @@ -603,8 +709,7 @@ class InjuryGroup(app_commands.Group): if not injury: embed = EmbedTemplate.error( - title="No Active Injury", - description=f"{player.name} isn't injured." + title="No Active Injury", description=f"{player.name} isn't injured." ) await interaction.followup.send(embed=embed, ephemeral=True) return @@ -612,7 +717,7 @@ class InjuryGroup(app_commands.Group): # Create confirmation embed embed = EmbedTemplate.info( title=f"{player.name}", - description=f"Is **{player.name}** cleared to return?" + description=f"Is **{player.name}** cleared to return?", ) if player.team and player.team.thumbnail is not None: @@ -621,33 +726,27 @@ class InjuryGroup(app_commands.Group): embed.add_field( name="Player", value=f"{player.name} ({player.primary_position})", - inline=True + inline=True, ) if player.team: embed.add_field( name="Team", value=f"{player.team.lname} ({player.team.abbrev})", - inline=True + inline=True, ) - embed.add_field( - name="Expected Return", - value=injury.return_date, - inline=True - ) + embed.add_field(name="Expected Return", value=injury.return_date, inline=True) - embed.add_field( - name="Games Missed", - value=injury.duration_display, - inline=True - ) + embed.add_field(name="Games Missed", value=injury.duration_display, inline=True) # Initialize responder_team to None for major league teams if player.team.roster_type() == RosterType.MAJOR_LEAGUE: responder_team = player.team else: - responder_team = await team_utils.get_user_major_league_team(interaction.user.id) + responder_team = await team_utils.get_user_major_league_team( + interaction.user.id + ) # Create callback for confirmation async def clear_confirm_callback(button_interaction: discord.Interaction): @@ -658,37 +757,33 @@ class InjuryGroup(app_commands.Group): if not success: error_embed = EmbedTemplate.error( title="Error", - description="Failed to clear the injury. Please try again." + description="Failed to clear the injury. Please try again.", + ) + await button_interaction.response.send_message( + embed=error_embed, ephemeral=True ) - await button_interaction.response.send_message(embed=error_embed, ephemeral=True) return # Clear player's il_return field - await player_service.update_player(player.id, {'il_return': ''}) + await player_service.update_player(player.id, {"il_return": ""}) # Success response success_embed = EmbedTemplate.success( title="Injury Cleared", - description=f"{player.name} has been cleared and is eligible to play again." + description=f"{player.name} has been cleared and is eligible to play again.", ) success_embed.add_field( - name="Injury Return Date", - value=injury.return_date, - inline=True + name="Injury Return Date", value=injury.return_date, inline=True ) success_embed.add_field( - name="Total Games Missed", - value=injury.duration_display, - inline=True + name="Total Games Missed", value=injury.duration_display, inline=True ) if player.team: success_embed.add_field( - name="Team", - value=f"{player.team.lname}", - inline=False + name="Team", value=f"{player.team.lname}", inline=False ) if player.team.thumbnail is not None: success_embed.set_thumbnail(url=player.team.thumbnail) @@ -700,17 +795,19 @@ class InjuryGroup(app_commands.Group): f"Injury cleared for {player.name}", player_id=player.id, season=current.season, - injury_id=injury.id + injury_id=injury.id, ) # Create confirmation view view = ConfirmationView( user_id=interaction.user.id, timeout=180.0, # 3 minutes for confirmation - responders=[responder_team.gmid, responder_team.gmid2] if responder_team else None, + responders=( + [responder_team.gmid, responder_team.gmid2] if responder_team else None + ), confirm_callback=clear_confirm_callback, confirm_label="Clear Injury", - cancel_label="Cancel" + cancel_label="Cancel", ) # Send confirmation embed with view diff --git a/commands/league/submit_scorecard.py b/commands/league/submit_scorecard.py index e70c895..fca00ab 100644 --- a/commands/league/submit_scorecard.py +++ b/commands/league/submit_scorecard.py @@ -175,14 +175,14 @@ class SubmitScorecardCommands(commands.Cog): # Delete old data try: await play_service.delete_plays_for_game(duplicate_game.id) - except: + except Exception: pass # May not exist try: await decision_service.delete_decisions_for_game( duplicate_game.id ) - except: + except Exception: pass # May not exist await game_service.wipe_game_data(duplicate_game.id) @@ -354,7 +354,7 @@ class SubmitScorecardCommands(commands.Cog): try: await standings_service.recalculate_standings(current.season) - except: + except Exception: # Non-critical error self.logger.error("Failed to recalculate standings") @@ -372,11 +372,11 @@ class SubmitScorecardCommands(commands.Cog): await play_service.delete_plays_for_game(game_id) elif rollback_state == "PLAYS_POSTED": await play_service.delete_plays_for_game(game_id) - except: + except Exception: pass # Best effort rollback await interaction.edit_original_response( - content=f"❌ An unexpected error occurred: {str(e)}" + content="❌ An unexpected error occurred. Please try again or contact an admin." ) def _match_manager(self, team: Team, manager_name: str): diff --git a/config.py b/config.py index 98f439f..3964a7d 100644 --- a/config.py +++ b/config.py @@ -1,6 +1,7 @@ """ Configuration management for Discord Bot v2.0 """ + import os from typing import Optional @@ -40,17 +41,18 @@ class BotConfig(BaseSettings): playoff_round_two_games: int = 7 playoff_round_three_games: int = 7 modern_stats_start_season: int = 8 - offseason_flag: bool = False # When True, relaxes roster limits and disables weekly freeze/thaw + offseason_flag: bool = ( + False # When True, relaxes roster limits and disables weekly freeze/thaw + ) # Roster Limits - expand_mil_week: int = 15 # Week when MiL roster expands (early vs late limits) - ml_roster_limit_early: int = 26 # ML limit for weeks before expand_mil_week - ml_roster_limit_late: int = 26 # ML limit for weeks >= expand_mil_week - mil_roster_limit_early: int = 6 # MiL limit for weeks before expand_mil_week - mil_roster_limit_late: int = 14 # MiL limit for weeks >= expand_mil_week + expand_mil_week: int = 15 # Week when MiL roster expands (early vs late limits) + ml_roster_limit_early: int = 26 # ML limit for weeks before expand_mil_week + ml_roster_limit_late: int = 26 # ML limit for weeks >= expand_mil_week + mil_roster_limit_early: int = 6 # MiL limit for weeks before expand_mil_week + mil_roster_limit_late: int = 14 # MiL limit for weeks >= expand_mil_week ml_roster_limit_offseason: int = 69 # ML limit during offseason - mil_roster_limit_offseason: int = 69 # MiL limit during offseason - + mil_roster_limit_offseason: int = 69 # MiL limit during offseason # API Constants api_version: str = "v3" @@ -60,10 +62,10 @@ class BotConfig(BaseSettings): # Draft Constants default_pick_minutes: int = 10 draft_rounds: int = 32 - draft_team_count: int = 16 # Number of teams in draft - draft_linear_rounds: int = 10 # Rounds 1-10 are linear, 11+ are snake - swar_cap_limit: float = 32.00 # Maximum sWAR cap for team roster - cap_player_count: int = 26 # Number of players that count toward cap + draft_team_count: int = 16 # Number of teams in draft + draft_linear_rounds: int = 10 # Rounds 1-10 are linear, 11+ are snake + swar_cap_limit: float = 32.00 # Maximum sWAR cap for team roster + cap_player_count: int = 26 # Number of players that count toward cap # Special Team IDs free_agent_team_id: int = 547 @@ -80,7 +82,7 @@ class BotConfig(BaseSettings): # Base URLs sba_base_url: str = "https://sba.manticorum.com" - sba_logo_url: str = f'{sba_base_url}/images/sba-logo.png' + sba_logo_url: str = f"{sba_base_url}/images/sba-logo.png" # Application settings log_level: str = "INFO" @@ -92,29 +94,33 @@ class BotConfig(BaseSettings): # Draft Sheet settings (for writing picks to Google Sheets) # Sheet IDs can be overridden via environment variables: DRAFT_SHEET_KEY_12, DRAFT_SHEET_KEY_13, etc. - draft_sheet_enabled: bool = True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable + draft_sheet_enabled: bool = ( + True # Feature flag - set DRAFT_SHEET_ENABLED=false to disable + ) draft_sheet_worksheet: str = "Ordered List" # Worksheet name to write picks to - draft_sheet_start_column: str = "D" # Column where pick data starts (D, E, F, G for 4 columns) + draft_sheet_start_column: str = ( + "D" # Column where pick data starts (D, E, F, G for 4 columns) + ) # Giphy API settings - giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" + giphy_api_key: str = "" giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" # Optional Redis caching settings redis_url: str = "" # Empty string means no Redis caching redis_cache_ttl: int = 300 # 5 minutes default TTL - + model_config = SettingsConfigDict( env_file=".env", case_sensitive=False, - extra="ignore" # Ignore extra environment variables + extra="ignore", # Ignore extra environment variables ) - + @property def is_development(self) -> bool: """Check if running in development mode.""" return self.environment.lower() == "development" - + @property def is_testing(self) -> bool: """Check if running in test mode.""" @@ -139,7 +145,7 @@ class BotConfig(BaseSettings): # Default sheet IDs (hardcoded as fallback) default_keys = { 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", - 13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE" + 13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE", } # Check environment variable first (allows runtime override) @@ -165,9 +171,10 @@ class BotConfig(BaseSettings): # Global configuration instance - lazily initialized to avoid import-time errors _config = None + def get_config() -> BotConfig: """Get the global configuration instance.""" global _config if _config is None: _config = BotConfig() # type: ignore - return _config \ No newline at end of file + return _config diff --git a/services/giphy_service.py b/services/giphy_service.py index 6e5d0a1..165d5c3 100644 --- a/services/giphy_service.py +++ b/services/giphy_service.py @@ -4,93 +4,88 @@ Giphy Service for Discord Bot v2.0 Provides async interface to Giphy API with disappointment-based search phrases. Used for Easter egg features like the soak command. """ + import random from typing import List, Optional +from urllib.parse import quote import aiohttp from utils.logging import get_contextual_logger from config import get_config from exceptions import APIException - # Disappointment tier configuration DISAPPOINTMENT_TIERS = { - 'tier_1': { - 'max_seconds': 1800, # 30 minutes - 'phrases': [ + "tier_1": { + "max_seconds": 1800, # 30 minutes + "phrases": [ "extremely disappointed", "so disappointed", "are you kidding me", "seriously", - "unbelievable" + "unbelievable", ], - 'description': "Maximum Disappointment" + "description": "Maximum Disappointment", }, - 'tier_2': { - 'max_seconds': 7200, # 2 hours - 'phrases': [ + "tier_2": { + "max_seconds": 7200, # 2 hours + "phrases": [ "very disappointed", "can't believe you", "not happy", "shame on you", - "facepalm" + "facepalm", ], - 'description': "Severe Disappointment" + "description": "Severe Disappointment", }, - 'tier_3': { - 'max_seconds': 21600, # 6 hours - 'phrases': [ + "tier_3": { + "max_seconds": 21600, # 6 hours + "phrases": [ "disappointed", "not impressed", "shaking head", "eye roll", - "really" + "really", ], - 'description': "Strong Disappointment" + "description": "Strong Disappointment", }, - 'tier_4': { - 'max_seconds': 86400, # 24 hours - 'phrases': [ + "tier_4": { + "max_seconds": 86400, # 24 hours + "phrases": [ "mildly disappointed", "not great", "could be better", "sigh", - "seriously" + "seriously", ], - 'description': "Moderate Disappointment" + "description": "Moderate Disappointment", }, - 'tier_5': { - 'max_seconds': 604800, # 7 days - 'phrases': [ - "slightly disappointed", - "oh well", - "shrug", - "meh", - "not bad" - ], - 'description': "Mild Disappointment" + "tier_5": { + "max_seconds": 604800, # 7 days + "phrases": ["slightly disappointed", "oh well", "shrug", "meh", "not bad"], + "description": "Mild Disappointment", }, - 'tier_6': { - 'max_seconds': float('inf'), # 7+ days - 'phrases': [ + "tier_6": { + "max_seconds": float("inf"), # 7+ days + "phrases": [ "not disappointed", "relieved", "proud", "been worse", - "fine i guess" + "fine i guess", ], - 'description': "Minimal Disappointment" + "description": "Minimal Disappointment", }, - 'first_ever': { - 'phrases': [ + "first_ever": { + "phrases": [ "here we go", "oh boy", "uh oh", "getting started", - "and so it begins" + "and so it begins", ], - 'description': "The Beginning" - } + "description": "The Beginning", + }, } @@ -102,7 +97,7 @@ class GiphyService: self.config = get_config() self.api_key = self.config.giphy_api_key self.translate_url = self.config.giphy_translate_url - self.logger = get_contextual_logger(f'{__name__}.GiphyService') + self.logger = get_contextual_logger(f"{__name__}.GiphyService") def get_tier_for_seconds(self, seconds_elapsed: Optional[int]) -> str: """ @@ -115,13 +110,13 @@ class GiphyService: Tier key string (e.g., 'tier_1', 'first_ever') """ if seconds_elapsed is None: - return 'first_ever' + return "first_ever" - for tier_key in ['tier_1', 'tier_2', 'tier_3', 'tier_4', 'tier_5', 'tier_6']: - if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]['max_seconds']: + for tier_key in ["tier_1", "tier_2", "tier_3", "tier_4", "tier_5", "tier_6"]: + if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]["max_seconds"]: return tier_key - return 'tier_6' # Fallback to lowest disappointment + return "tier_6" # Fallback to lowest disappointment def get_random_phrase_for_tier(self, tier_key: str) -> str: """ @@ -139,7 +134,7 @@ class GiphyService: if tier_key not in DISAPPOINTMENT_TIERS: raise ValueError(f"Invalid tier key: {tier_key}") - phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + phrases = DISAPPOINTMENT_TIERS[tier_key]["phrases"] return random.choice(phrases) def get_tier_description(self, tier_key: str) -> str: @@ -158,7 +153,7 @@ class GiphyService: if tier_key not in DISAPPOINTMENT_TIERS: raise ValueError(f"Invalid tier key: {tier_key}") - return DISAPPOINTMENT_TIERS[tier_key]['description'] + return DISAPPOINTMENT_TIERS[tier_key]["description"] async def get_disappointment_gif(self, tier_key: str) -> str: """ @@ -181,7 +176,7 @@ class GiphyService: if tier_key not in DISAPPOINTMENT_TIERS: raise ValueError(f"Invalid tier key: {tier_key}") - phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] + phrases = DISAPPOINTMENT_TIERS[tier_key]["phrases"] # Shuffle phrases for variety and retry capability shuffled_phrases = random.sample(phrases, len(phrases)) @@ -189,39 +184,61 @@ class GiphyService: async with aiohttp.ClientSession() as session: for phrase in shuffled_phrases: try: - url = f"{self.translate_url}?s={phrase}&api_key={self.api_key}" + url = f"{self.translate_url}?s={quote(phrase)}&api_key={quote(self.api_key)}" - async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: if resp.status == 200: data = await resp.json() # Filter out Trump GIFs (legacy behavior) - gif_title = data.get('data', {}).get('title', '').lower() - if 'trump' in gif_title: - self.logger.debug(f"Filtered out Trump GIF for phrase: {phrase}") + gif_title = data.get("data", {}).get("title", "").lower() + if "trump" in gif_title: + self.logger.debug( + f"Filtered out Trump GIF for phrase: {phrase}" + ) continue # Get the actual GIF image URL, not the web page URL - gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url') + gif_url = ( + data.get("data", {}) + .get("images", {}) + .get("original", {}) + .get("url") + ) if gif_url: - self.logger.info(f"Successfully fetched GIF for phrase: {phrase}", gif_url=gif_url) + self.logger.info( + f"Successfully fetched GIF for phrase: {phrase}", + gif_url=gif_url, + ) return gif_url else: - self.logger.warning(f"No GIF URL in response for phrase: {phrase}") + self.logger.warning( + f"No GIF URL in response for phrase: {phrase}" + ) else: - self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {phrase}") + self.logger.warning( + f"Giphy API returned status {resp.status} for phrase: {phrase}" + ) except aiohttp.ClientError as e: - self.logger.error(f"HTTP error fetching GIF for phrase '{phrase}': {e}") + self.logger.error( + f"HTTP error fetching GIF for phrase '{phrase}': {e}" + ) except Exception as e: - self.logger.error(f"Unexpected error fetching GIF for phrase '{phrase}': {e}") + self.logger.error( + f"Unexpected error fetching GIF for phrase '{phrase}': {e}" + ) # All phrases failed error_msg = f"Failed to fetch any GIF for tier: {tier_key}" self.logger.error(error_msg) raise APIException(error_msg) - - async def get_gif(self, phrase: Optional[str] = None, phrase_options: Optional[List[str]] = None) -> str: + + async def get_gif( + self, phrase: Optional[str] = None, phrase_options: Optional[List[str]] = None + ) -> str: """ Fetch a GIF from Giphy based on a phrase or list of phrase options. @@ -237,9 +254,11 @@ class GiphyService: APIException: If all GIF fetch attempts fail """ if phrase is None and phrase_options is None: - raise ValueError('To get a gif, one of `phrase` or `phrase_options` must be provided') + raise ValueError( + "To get a gif, one of `phrase` or `phrase_options` must be provided" + ) - search_phrase = 'send help' + search_phrase = "send help" if phrase is not None: search_phrase = phrase elif phrase_options is not None: @@ -250,33 +269,53 @@ class GiphyService: while attempts < 3: attempts += 1 try: - url = f"{self.translate_url}?s={search_phrase}&api_key={self.api_key}" + url = f"{self.translate_url}?s={quote(search_phrase)}&api_key={quote(self.api_key)}" - async with session.get(url, timeout=aiohttp.ClientTimeout(total=3)) as resp: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=3) + ) as resp: if resp.status != 200: - self.logger.warning(f"Giphy API returned status {resp.status} for phrase: {search_phrase}") + self.logger.warning( + f"Giphy API returned status {resp.status} for phrase: {search_phrase}" + ) continue data = await resp.json() # Filter out Trump GIFs (legacy behavior) - gif_title = data.get('data', {}).get('title', '').lower() - if 'trump' in gif_title: - self.logger.debug(f"Filtered out Trump GIF for phrase: {search_phrase}") + gif_title = data.get("data", {}).get("title", "").lower() + if "trump" in gif_title: + self.logger.debug( + f"Filtered out Trump GIF for phrase: {search_phrase}" + ) continue # Get the actual GIF image URL, not the web page URL - gif_url = data.get('data', {}).get('images', {}).get('original', {}).get('url') + gif_url = ( + data.get("data", {}) + .get("images", {}) + .get("original", {}) + .get("url") + ) if gif_url: - self.logger.info(f"Successfully fetched GIF for phrase: {search_phrase}", gif_url=gif_url) + self.logger.info( + f"Successfully fetched GIF for phrase: {search_phrase}", + gif_url=gif_url, + ) return gif_url else: - self.logger.warning(f"No GIF URL in response for phrase: {search_phrase}") + self.logger.warning( + f"No GIF URL in response for phrase: {search_phrase}" + ) except aiohttp.ClientError as e: - self.logger.error(f"HTTP error fetching GIF for phrase '{search_phrase}': {e}") + self.logger.error( + f"HTTP error fetching GIF for phrase '{search_phrase}': {e}" + ) except Exception as e: - self.logger.error(f"Unexpected error fetching GIF for phrase '{search_phrase}': {e}") + self.logger.error( + f"Unexpected error fetching GIF for phrase '{search_phrase}': {e}" + ) # All attempts failed error_msg = f"Failed to fetch any GIF for phrase: {search_phrase}" diff --git a/tasks/transaction_freeze.py b/tasks/transaction_freeze.py index fee9ecd..09af9cf 100644 --- a/tasks/transaction_freeze.py +++ b/tasks/transaction_freeze.py @@ -4,6 +4,7 @@ Transaction Freeze/Thaw Task for Discord Bot v2.0 Automated weekly system for freezing and processing transactions. Runs on a schedule to increment weeks and process contested transactions. """ + import asyncio import random from datetime import datetime, UTC @@ -30,6 +31,7 @@ class TransactionPriority: Data class for transaction priority calculation. Used to resolve contested transactions (multiple teams wanting same player). """ + transaction: Transaction team_win_percentage: float tiebreaker: float # win% + small random number for randomized tiebreak @@ -42,6 +44,7 @@ class TransactionPriority: @dataclass class ConflictContender: """A team contending for a contested player.""" + team_abbrev: str wins: int losses: int @@ -52,6 +55,7 @@ class ConflictContender: @dataclass class ConflictResolution: """Details of a conflict resolution for a contested player.""" + player_name: str player_swar: float contenders: List[ConflictContender] @@ -62,6 +66,7 @@ class ConflictResolution: @dataclass class ThawedMove: """A move that was successfully thawed (unfrozen).""" + move_id: str team_abbrev: str players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) @@ -71,6 +76,7 @@ class ThawedMove: @dataclass class CancelledMove: """A move that was cancelled due to conflict.""" + move_id: str team_abbrev: str players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) @@ -81,6 +87,7 @@ class CancelledMove: @dataclass class ThawReport: """Complete thaw report for admin review.""" + week: int season: int timestamp: datetime @@ -94,8 +101,7 @@ class ThawReport: async def resolve_contested_transactions( - transactions: List[Transaction], - season: int + transactions: List[Transaction], season: int ) -> Tuple[List[str], List[str], List[ConflictResolution]]: """ Resolve contested transactions where multiple teams want the same player. @@ -109,7 +115,7 @@ async def resolve_contested_transactions( Returns: Tuple of (winning_move_ids, losing_move_ids, conflict_resolutions) """ - logger = get_contextual_logger(f'{__name__}.resolve_contested_transactions') + logger = get_contextual_logger(f"{__name__}.resolve_contested_transactions") # Group transactions by player name player_transactions: Dict[str, List[Transaction]] = {} @@ -118,7 +124,7 @@ async def resolve_contested_transactions( player_name = transaction.player.name.lower() # Only consider transactions where a team is acquiring a player (not FA drops) - if transaction.newteam.abbrev.upper() != 'FA': + if transaction.newteam.abbrev.upper() != "FA": if player_name not in player_transactions: player_transactions[player_name] = [] player_transactions[player_name].append(transaction) @@ -130,7 +136,9 @@ async def resolve_contested_transactions( for player_name, player_transactions_list in player_transactions.items(): if len(player_transactions_list) > 1: contested_players[player_name] = player_transactions_list - logger.info(f"Contested player: {player_name} ({len(player_transactions_list)} teams)") + logger.info( + f"Contested player: {player_name} ({len(player_transactions_list)} teams)" + ) else: # Non-contested, automatically wins non_contested_moves.add(player_transactions_list[0].moveid) @@ -143,50 +151,66 @@ async def resolve_contested_transactions( for player_name, contested_transactions in contested_players.items(): priorities: List[TransactionPriority] = [] # Track standings data for each team for report - team_standings_data: Dict[str, Tuple[int, int, float]] = {} # abbrev -> (wins, losses, win_pct) + team_standings_data: Dict[str, Tuple[int, int, float]] = ( + {} + ) # abbrev -> (wins, losses, win_pct) for transaction in contested_transactions: # Get team for priority calculation # If adding to MiL team, use the parent ML team for standings - if transaction.newteam.abbrev.endswith('MiL'): + if transaction.newteam.abbrev.endswith("MiL"): team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix else: team_abbrev = transaction.newteam.abbrev try: # Get team standings to calculate win percentage - standings = await standings_service.get_team_standings(team_abbrev, season) + standings = await standings_service.get_team_standings( + team_abbrev, season + ) - if standings and standings.wins is not None and standings.losses is not None: + if ( + standings + and standings.wins is not None + and standings.losses is not None + ): total_games = standings.wins + standings.losses win_pct = standings.wins / total_games if total_games > 0 else 0.0 team_standings_data[transaction.newteam.abbrev] = ( - standings.wins, standings.losses, win_pct + standings.wins, + standings.losses, + win_pct, ) else: win_pct = 0.0 team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) - logger.warning(f"Could not get standings for {team_abbrev}, using 0.0 win%") + logger.warning( + f"Could not get standings for {team_abbrev}, using 0.0 win%" + ) # Add small random component for tiebreaking (5 decimal precision) random_component = random.randint(10000, 99999) * 0.00000001 tiebreaker = win_pct + random_component - priorities.append(TransactionPriority( - transaction=transaction, - team_win_percentage=win_pct, - tiebreaker=tiebreaker - )) + priorities.append( + TransactionPriority( + transaction=transaction, + team_win_percentage=win_pct, + tiebreaker=tiebreaker, + ) + ) except Exception as e: logger.error(f"Error calculating priority for {team_abbrev}: {e}") team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) # Give them 0.0 priority on error - priorities.append(TransactionPriority( - transaction=transaction, - team_win_percentage=0.0, - tiebreaker=random.randint(10000, 99999) * 0.00000001 - )) + priorities.append( + TransactionPriority( + transaction=transaction, + team_win_percentage=0.0, + tiebreaker=random.randint(10000, 99999) * 0.00000001, + ) + ) # Sort by tiebreaker (lowest win% wins - worst teams get priority) priorities.sort() @@ -204,7 +228,7 @@ async def resolve_contested_transactions( wins=winner_standings[0], losses=winner_standings[1], win_pct=winner_standings[2], - move_id=winner.transaction.moveid + move_id=winner.transaction.moveid, ) loser_contenders: List[ConflictContender] = [] @@ -224,7 +248,7 @@ async def resolve_contested_transactions( wins=loser_standings[0], losses=loser_standings[1], win_pct=loser_standings[2], - move_id=loser.transaction.moveid + move_id=loser.transaction.moveid, ) loser_contenders.append(loser_contender) all_contenders.append(loser_contender) @@ -236,13 +260,15 @@ async def resolve_contested_transactions( # Get player info from first transaction (they all have same player) player = contested_transactions[0].player - conflict_resolutions.append(ConflictResolution( - player_name=player.name, - player_swar=player.wara, - contenders=all_contenders, - winner=winner_contender, - losers=loser_contenders - )) + conflict_resolutions.append( + ConflictResolution( + player_name=player.name, + player_swar=player.wara, + contenders=all_contenders, + winner=winner_contender, + losers=loser_contenders, + ) + ) # Add non-contested moves to winners winning_move_ids.update(non_contested_moves) @@ -255,7 +281,7 @@ class TransactionFreezeTask: def __init__(self, bot: commands.Bot): self.bot = bot - self.logger = get_contextual_logger(f'{__name__}.TransactionFreezeTask') + self.logger = get_contextual_logger(f"{__name__}.TransactionFreezeTask") # Track last execution to prevent duplicate operations self.last_freeze_week: int | None = None @@ -288,7 +314,9 @@ class TransactionFreezeTask: # Skip if offseason mode is enabled if config.offseason_flag: - self.logger.info("Skipping freeze/thaw operations - offseason mode enabled") + self.logger.info( + "Skipping freeze/thaw operations - offseason mode enabled" + ) return # Get current league state @@ -304,7 +332,7 @@ class TransactionFreezeTask: weekday=now.weekday(), hour=now.hour, current_week=current.week, - freeze_status=current.freeze + freeze_status=current.freeze, ) # BEGIN FREEZE: Monday at 00:00, not already frozen @@ -312,13 +340,23 @@ class TransactionFreezeTask: # Only run if we haven't already frozen this week # Track the week we're freezing FROM (before increment) if self.last_freeze_week != current.week: - freeze_from_week = current.week # Save BEFORE _begin_freeze modifies it - self.logger.info("Triggering freeze begin", current_week=current.week) + freeze_from_week = ( + current.week + ) # Save BEFORE _begin_freeze modifies it + self.logger.info( + "Triggering freeze begin", current_week=current.week + ) await self._begin_freeze(current) - self.last_freeze_week = freeze_from_week # Track the week we froze FROM - self.error_notification_sent = False # Reset error flag for new cycle + self.last_freeze_week = ( + freeze_from_week # Track the week we froze FROM + ) + self.error_notification_sent = ( + False # Reset error flag for new cycle + ) else: - self.logger.debug("Freeze already executed for week", week=current.week) + self.logger.debug( + "Freeze already executed for week", week=current.week + ) # END FREEZE: Saturday at 00:00, currently frozen elif now.weekday() == 5 and now.hour == 0 and current.freeze: @@ -327,9 +365,13 @@ class TransactionFreezeTask: self.logger.info("Triggering freeze end", current_week=current.week) await self._end_freeze(current) self.last_thaw_week = current.week - self.error_notification_sent = False # Reset error flag for new cycle + self.error_notification_sent = ( + False # Reset error flag for new cycle + ) else: - self.logger.debug("Thaw already executed for week", week=current.week) + self.logger.debug( + "Thaw already executed for week", week=current.week + ) else: self.logger.debug("No freeze/thaw action needed at this time") @@ -375,8 +417,7 @@ class TransactionFreezeTask: # Increment week and set freeze via service new_week = current.week + 1 updated_current = await league_service.update_current_state( - week=new_week, - freeze=True + week=new_week, freeze=True ) if not updated_current: @@ -449,15 +490,18 @@ class TransactionFreezeTask: try: # Get non-frozen, non-cancelled transactions for current week via service transactions = await transaction_service.get_regular_transactions_by_week( - season=current.season, - week=current.week + season=current.season, week=current.week ) if not transactions: - self.logger.info(f"No regular transactions to process for week {current.week}") + self.logger.info( + f"No regular transactions to process for week {current.week}" + ) return - self.logger.info(f"Processing {len(transactions)} regular transactions for week {current.week}") + self.logger.info( + f"Processing {len(transactions)} regular transactions for week {current.week}" + ) # Execute player roster updates for all transactions success_count = 0 @@ -470,7 +514,7 @@ class TransactionFreezeTask: player_id=transaction.player.id, new_team_id=transaction.newteam.id, player_name=transaction.player.name, - dem_week=current.week + 2 + dem_week=current.week + 2, ) success_count += 1 @@ -482,7 +526,7 @@ class TransactionFreezeTask: f"Failed to execute transaction for {transaction.player.name}", player_id=transaction.player.id, new_team_id=transaction.newteam.id, - error=str(e) + error=str(e), ) failure_count += 1 @@ -490,7 +534,7 @@ class TransactionFreezeTask: f"Transaction execution complete for week {current.week}", success=success_count, failures=failure_count, - total=len(transactions) + total=len(transactions), ) except Exception as e: @@ -514,11 +558,13 @@ class TransactionFreezeTask: transactions = await transaction_service.get_frozen_transactions_by_week( season=current.season, week_start=current.week, - week_end=current.week + 1 + week_end=current.week + 1, ) if not transactions: - self.logger.warning(f"No frozen transactions to process for week {current.week}") + self.logger.warning( + f"No frozen transactions to process for week {current.week}" + ) # Still post an empty report for visibility empty_report = ThawReport( week=current.week, @@ -530,23 +576,26 @@ class TransactionFreezeTask: conflict_count=0, conflicts=[], thawed_moves=[], - cancelled_moves=[] + cancelled_moves=[], ) await self._post_thaw_report(empty_report) return - self.logger.info(f"Processing {len(transactions)} frozen transactions for week {current.week}") + self.logger.info( + f"Processing {len(transactions)} frozen transactions for week {current.week}" + ) # Resolve contested transactions - winning_move_ids, losing_move_ids, conflict_resolutions = await resolve_contested_transactions( - transactions, - current.season + winning_move_ids, losing_move_ids, conflict_resolutions = ( + await resolve_contested_transactions(transactions, current.season) ) # Build mapping from conflict player to winner for cancelled move tracking conflict_player_to_winner: Dict[str, str] = {} for conflict in conflict_resolutions: - conflict_player_to_winner[conflict.player_name.lower()] = conflict.winner.team_abbrev + conflict_player_to_winner[conflict.player_name.lower()] = ( + conflict.winner.team_abbrev + ) # Track cancelled moves for report cancelled_moves_report: List[CancelledMove] = [] @@ -555,24 +604,34 @@ class TransactionFreezeTask: for losing_move_id in losing_move_ids: try: # Get all moves with this moveid (could be multiple players in one transaction) - losing_moves = [t for t in transactions if t.moveid == losing_move_id] + losing_moves = [ + t for t in transactions if t.moveid == losing_move_id + ] if losing_moves: # Cancel the entire transaction (all moves with same moveid) for move in losing_moves: - success = await transaction_service.cancel_transaction(move.moveid) + success = await transaction_service.cancel_transaction( + move.moveid + ) if not success: - self.logger.warning(f"Failed to cancel transaction {move.moveid}") + self.logger.warning( + f"Failed to cancel transaction {move.moveid}" + ) # Notify the GM(s) about cancellation first_move = losing_moves[0] # Determine which team to notify (the team that was trying to acquire) - team_for_notification = (first_move.newteam - if first_move.newteam.abbrev.upper() != 'FA' - else first_move.oldteam) + team_for_notification = ( + first_move.newteam + if first_move.newteam.abbrev.upper() != "FA" + else first_move.oldteam + ) - await self._notify_gm_of_cancellation(first_move, team_for_notification) + await self._notify_gm_of_cancellation( + first_move, team_for_notification + ) # Find which player caused the conflict contested_player = "" @@ -586,16 +645,23 @@ class TransactionFreezeTask: # Build report entry players = [ - (move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev) + ( + move.player.name, + move.player.wara, + move.oldteam.abbrev, + move.newteam.abbrev, + ) for move in losing_moves ] - cancelled_moves_report.append(CancelledMove( - move_id=losing_move_id, - team_abbrev=team_for_notification.abbrev, - players=players, - lost_to=lost_to, - contested_player=contested_player - )) + cancelled_moves_report.append( + CancelledMove( + move_id=losing_move_id, + team_abbrev=team_for_notification.abbrev, + players=players, + lost_to=lost_to, + contested_player=contested_player, + ) + ) contested_players = [move.player.name for move in losing_moves] self.logger.info( @@ -604,7 +670,9 @@ class TransactionFreezeTask: ) except Exception as e: - self.logger.error(f"Error cancelling transaction {losing_move_id}: {e}") + self.logger.error( + f"Error cancelling transaction {losing_move_id}: {e}" + ) # Track thawed moves for report thawed_moves_report: List[ThawedMove] = [] @@ -613,13 +681,19 @@ class TransactionFreezeTask: for winning_move_id in winning_move_ids: try: # Get all moves with this moveid - winning_moves = [t for t in transactions if t.moveid == winning_move_id] + winning_moves = [ + t for t in transactions if t.moveid == winning_move_id + ] for move in winning_moves: # Unfreeze the transaction via service - success = await transaction_service.unfreeze_transaction(move.moveid) + success = await transaction_service.unfreeze_transaction( + move.moveid + ) if not success: - self.logger.warning(f"Failed to unfreeze transaction {move.moveid}") + self.logger.warning( + f"Failed to unfreeze transaction {move.moveid}" + ) # Post to transaction log await self._post_transaction_to_log(winning_move_id, transactions) @@ -629,32 +703,43 @@ class TransactionFreezeTask: first_move = winning_moves[0] # Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS) try: - parts = winning_move_id.split('-') + parts = winning_move_id.split("-") submitted_at = parts[-1] if len(parts) >= 6 else "Unknown" except Exception: submitted_at = "Unknown" # Determine team abbrev - if first_move.newteam.abbrev.upper() != 'FA': + if first_move.newteam.abbrev.upper() != "FA": team_abbrev = first_move.newteam.abbrev else: team_abbrev = first_move.oldteam.abbrev players = [ - (move.player.name, move.player.wara, move.oldteam.abbrev, move.newteam.abbrev) + ( + move.player.name, + move.player.wara, + move.oldteam.abbrev, + move.newteam.abbrev, + ) for move in winning_moves ] - thawed_moves_report.append(ThawedMove( - move_id=winning_move_id, - team_abbrev=team_abbrev, - players=players, - submitted_at=submitted_at - )) + thawed_moves_report.append( + ThawedMove( + move_id=winning_move_id, + team_abbrev=team_abbrev, + players=players, + submitted_at=submitted_at, + ) + ) - self.logger.info(f"Processed successful transaction {winning_move_id}") + self.logger.info( + f"Processed successful transaction {winning_move_id}" + ) except Exception as e: - self.logger.error(f"Error processing winning transaction {winning_move_id}: {e}") + self.logger.error( + f"Error processing winning transaction {winning_move_id}: {e}" + ) # Generate and post thaw report thaw_report = ThawReport( @@ -667,7 +752,7 @@ class TransactionFreezeTask: conflict_count=len(conflict_resolutions), conflicts=conflict_resolutions, thawed_moves=thawed_moves_report, - cancelled_moves=cancelled_moves_report + cancelled_moves=cancelled_moves_report, ) await self._post_thaw_report(thaw_report) @@ -685,7 +770,7 @@ class TransactionFreezeTask: player_id: int, new_team_id: int, player_name: str, - dem_week: Optional[int] = None + dem_week: Optional[int] = None, ) -> bool: """ Execute a player roster update via API PATCH. @@ -708,13 +793,11 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) updated_player = await player_service.update_player_team( - player_id, - new_team_id, - dem_week=dem_week + player_id, new_team_id, dem_week=dem_week ) # Verify response (200 or 204 indicates success) @@ -724,7 +807,7 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) return True else: @@ -733,7 +816,7 @@ class TransactionFreezeTask: player_id=player_id, player_name=player_name, new_team_id=new_team_id, - dem_week=dem_week + dem_week=dem_week, ) return False @@ -745,7 +828,7 @@ class TransactionFreezeTask: new_team_id=new_team_id, dem_week=dem_week, error=str(e), - exc_info=True + exc_info=True, ) raise @@ -764,34 +847,36 @@ class TransactionFreezeTask: self.logger.warning("Could not find guild for freeze announcement") return - channel = discord.utils.get(guild.text_channels, name='transaction-log') + channel = discord.utils.get(guild.text_channels, name="transaction-log") if not channel: self.logger.warning("Could not find transaction-log channel") return # Create announcement message (formatted like legacy bot) - week_num = f'Week {week}' - stars = '*' * 32 + week_num = f"Week {week}" + stars = "*" * 32 if is_beginning: message = ( - f'```\n' - f'{stars}\n' - f'{week_num:>9} Freeze Period Begins\n' - f'{stars}\n' - f'```' + f"```\n" + f"{stars}\n" + f"{week_num:>9} Freeze Period Begins\n" + f"{stars}\n" + f"```" ) else: message = ( - f'```\n' + f"```\n" f'{"*" * 30}\n' - f'{week_num:>9} Freeze Period Ends\n' + f"{week_num:>9} Freeze Period Ends\n" f'{"*" * 30}\n' - f'```' + f"```" ) await channel.send(message) - self.logger.info(f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})") + self.logger.info( + f"Freeze announcement sent for week {week} ({'begin' if is_beginning else 'end'})" + ) except Exception as e: self.logger.error(f"Error sending freeze announcement: {e}") @@ -809,7 +894,7 @@ class TransactionFreezeTask: if not guild: return - info_channel = discord.utils.get(guild.text_channels, name='weekly-info') + info_channel = discord.utils.get(guild.text_channels, name="weekly-info") if not info_channel: self.logger.warning("Could not find weekly-info channel") return @@ -818,7 +903,7 @@ class TransactionFreezeTask: async for message in info_channel.history(limit=25): try: await message.delete() - except: + except Exception: pass # Ignore deletion errors # Determine season emoji @@ -835,17 +920,17 @@ class TransactionFreezeTask: is_div_week = current.week in [1, 3, 6, 14, 16, 18] weekly_str = ( - f'**Season**: {season_str}\n' - f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / ' - f'{night_str} / {day_str}' + f"**Season**: {season_str}\n" + f"**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / " + f"{night_str} / {day_str}" ) # Send info messages await info_channel.send( content=( - f'Each team has manage permissions in their home ballpark. ' - f'They may pin messages and rename the channel.\n\n' - f'**Make sure your ballpark starts with your team abbreviation.**' + f"Each team has manage permissions in their home ballpark. " + f"They may pin messages and rename the channel.\n\n" + f"**Make sure your ballpark starts with your team abbreviation.**" ) ) await info_channel.send(weekly_str) @@ -856,9 +941,7 @@ class TransactionFreezeTask: self.logger.error(f"Error posting weekly info: {e}") async def _post_transaction_to_log( - self, - move_id: str, - all_transactions: List[Transaction] + self, move_id: str, all_transactions: List[Transaction] ): """ Post a transaction to the transaction log channel. @@ -873,7 +956,7 @@ class TransactionFreezeTask: if not guild: return - channel = discord.utils.get(guild.text_channels, name='transaction-log') + channel = discord.utils.get(guild.text_channels, name="transaction-log") if not channel: return @@ -884,9 +967,15 @@ class TransactionFreezeTask: # Determine the team for the embed (team making the moves) first_move = moves[0] - if first_move.newteam.abbrev.upper() != 'FA' and 'IL' not in first_move.newteam.abbrev: + if ( + first_move.newteam.abbrev.upper() != "FA" + and "IL" not in first_move.newteam.abbrev + ): this_team = first_move.newteam - elif first_move.oldteam.abbrev.upper() != 'FA' and 'IL' not in first_move.oldteam.abbrev: + elif ( + first_move.oldteam.abbrev.upper() != "FA" + and "IL" not in first_move.oldteam.abbrev + ): this_team = first_move.oldteam else: # Default to newteam if both are FA/IL @@ -898,25 +987,29 @@ class TransactionFreezeTask: for move in moves: move_string += ( - f'**{move.player.name}** ({move.player.wara:.2f}) ' - f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n' + f"**{move.player.name}** ({move.player.wara:.2f}) " + f"from {move.oldteam.abbrev} to {move.newteam.abbrev}\n" ) # Create embed embed = EmbedTemplate.create_base_embed( - title=f'Week {week_num} Transaction', - description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname, - color=EmbedColors.INFO + title=f"Week {week_num} Transaction", + description=( + this_team.sname if hasattr(this_team, "sname") else this_team.lname + ), + color=EmbedColors.INFO, ) # Set team color if available - if hasattr(this_team, 'color') and this_team.color: + if hasattr(this_team, "color") and this_team.color: try: - embed.color = discord.Color(int(this_team.color.replace('#', ''), 16)) - except: + embed.color = discord.Color( + int(this_team.color.replace("#", ""), 16) + ) + except Exception: pass # Use default color on error - embed.add_field(name='Player Moves', value=move_string, inline=False) + embed.add_field(name="Player Moves", value=move_string, inline=False) await channel.send(embed=embed) self.logger.info(f"Transaction posted to log: {move_id}") @@ -924,11 +1017,7 @@ class TransactionFreezeTask: except Exception as e: self.logger.error(f"Error posting transaction to log: {e}") - async def _notify_gm_of_cancellation( - self, - transaction: Transaction, - team - ): + async def _notify_gm_of_cancellation(self, transaction: Transaction, team): """ Send DM to GM(s) about cancelled transaction. @@ -943,27 +1032,31 @@ class TransactionFreezeTask: return cancel_text = ( - f'Your transaction for **{transaction.player.name}** has been cancelled ' - f'because another team successfully claimed them during the freeze period.' + f"Your transaction for **{transaction.player.name}** has been cancelled " + f"because another team successfully claimed them during the freeze period." ) # Notify GM1 - if hasattr(team, 'gmid') and team.gmid: + if hasattr(team, "gmid") and team.gmid: try: gm_one = guild.get_member(team.gmid) if gm_one: await gm_one.send(cancel_text) - self.logger.info(f"Cancellation notification sent to GM1 of {team.abbrev}") + self.logger.info( + f"Cancellation notification sent to GM1 of {team.abbrev}" + ) except Exception as e: self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}") # Notify GM2 if exists - if hasattr(team, 'gmid2') and team.gmid2: + if hasattr(team, "gmid2") and team.gmid2: try: gm_two = guild.get_member(team.gmid2) if gm_two: await gm_two.send(cancel_text) - self.logger.info(f"Cancellation notification sent to GM2 of {team.abbrev}") + self.logger.info( + f"Cancellation notification sent to GM2 of {team.abbrev}" + ) except Exception as e: self.logger.error(f"Could not notify GM2 of {team.abbrev}: {e}") @@ -986,30 +1079,43 @@ class TransactionFreezeTask: admin_channel = self.bot.get_channel(config.thaw_report_channel_id) if not admin_channel: - self.logger.warning("Could not find thaw report channel", channel_id=config.thaw_report_channel_id) + self.logger.warning( + "Could not find thaw report channel", + channel_id=config.thaw_report_channel_id, + ) return # Build the report content report_lines = [] # Header with summary - timestamp_str = report.timestamp.strftime('%B %d, %Y %H:%M UTC') + timestamp_str = report.timestamp.strftime("%B %d, %Y %H:%M UTC") report_lines.append(f"# Transaction Thaw Report") - report_lines.append(f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}") - report_lines.append(f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}") + report_lines.append( + f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}" + ) + report_lines.append( + f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}" + ) report_lines.append("") # Conflict Resolution section (if any) if report.conflicts: report_lines.append("## Conflict Resolution") for conflict in report.conflicts: - report_lines.append(f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})") - contenders_str = " vs ".join([ - f"{c.team_abbrev} ({c.wins}-{c.losses})" - for c in conflict.contenders - ]) + report_lines.append( + f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})" + ) + contenders_str = " vs ".join( + [ + f"{c.team_abbrev} ({c.wins}-{c.losses})" + for c in conflict.contenders + ] + ) report_lines.append(f"- Contested by: {contenders_str}") - report_lines.append(f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)") + report_lines.append( + f"- **Awarded to: {conflict.winner.team_abbrev}** (worst record wins)" + ) report_lines.append("") # Thawed Moves section @@ -1018,7 +1124,9 @@ class TransactionFreezeTask: for move in report.thawed_moves: report_lines.append(f"**{move.move_id}** | {move.team_abbrev}") for player_name, swar, old_team, new_team in move.players: - report_lines.append(f" - {player_name} ({swar:.1f}): {old_team} → {new_team}") + report_lines.append( + f" - {player_name} ({swar:.1f}): {old_team} → {new_team}" + ) else: report_lines.append("*No moves thawed*") report_lines.append("") @@ -1027,10 +1135,18 @@ class TransactionFreezeTask: report_lines.append("## Cancelled Moves") if report.cancelled_moves: for move in report.cancelled_moves: - lost_info = f" (lost {move.contested_player} to {move.lost_to})" if move.lost_to else "" - report_lines.append(f"**{move.move_id}** | {move.team_abbrev}{lost_info}") + lost_info = ( + f" (lost {move.contested_player} to {move.lost_to})" + if move.lost_to + else "" + ) + report_lines.append( + f"**{move.move_id}** | {move.team_abbrev}{lost_info}" + ) for player_name, swar, old_team, new_team in move.players: - report_lines.append(f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}") + report_lines.append( + f" - ❌ {player_name} ({swar:.1f}): {old_team} → {new_team}" + ) else: report_lines.append("*No moves cancelled*") diff --git a/views/transaction_embed.py b/views/transaction_embed.py index cca7153..69a12fb 100644 --- a/views/transaction_embed.py +++ b/views/transaction_embed.py @@ -380,12 +380,14 @@ class SubmitConfirmationModal(discord.ui.Modal): if "Transaction Builder" in message.embeds[0].title: # type: ignore await message.edit(embed=completion_embed, view=view) break - except: + except Exception: pass except Exception as e: + self.logger.error(f"Error submitting transaction: {e}", exc_info=True) await interaction.followup.send( - f"❌ Error submitting transaction: {str(e)}", ephemeral=True + "❌ Error submitting transaction. Please try again or contact an admin.", + ephemeral=True, )