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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2026-02-20 09:54:53 -06:00
parent eaaa9496a3
commit f4be20afb3
8 changed files with 976 additions and 665 deletions

View File

@ -4,6 +4,7 @@ API client for Discord Bot v2.0
Modern aiohttp-based HTTP client for communicating with the database API. Modern aiohttp-based HTTP client for communicating with the database API.
Provides connection pooling, proper error handling, and session management. Provides connection pooling, proper error handling, and session management.
""" """
import aiohttp import aiohttp
import logging import logging
from typing import Optional, List, Dict, Any, Union from typing import Optional, List, Dict, Any, Union
@ -13,13 +14,13 @@ from contextlib import asynccontextmanager
from config import get_config from config import get_config
from exceptions import APIException from exceptions import APIException
logger = logging.getLogger(f'{__name__}.APIClient') logger = logging.getLogger(f"{__name__}.APIClient")
class APIClient: class APIClient:
""" """
Async HTTP client for SBA database API communication. Async HTTP client for SBA database API communication.
Features: Features:
- Connection pooling with proper session management - Connection pooling with proper session management
- Bearer token authentication - Bearer token authentication
@ -27,15 +28,15 @@ class APIClient:
- Comprehensive error handling - Comprehensive error handling
- Debug logging with response truncation - Debug logging with response truncation
""" """
def __init__(self, base_url: Optional[str] = None, api_token: Optional[str] = None): def __init__(self, base_url: Optional[str] = None, api_token: Optional[str] = None):
""" """
Initialize API client with configuration. Initialize API client with configuration.
Args: Args:
base_url: Override default database URL from config base_url: Override default database URL from config
api_token: Override default API token from config api_token: Override default API token from config
Raises: Raises:
ValueError: If required configuration is missing ValueError: If required configuration is missing
""" """
@ -43,24 +44,29 @@ class APIClient:
self.base_url = base_url or config.db_url self.base_url = base_url or config.db_url
self.api_token = api_token or config.api_token self.api_token = api_token or config.api_token
self._session: Optional[aiohttp.ClientSession] = None self._session: Optional[aiohttp.ClientSession] = None
if not self.base_url: if not self.base_url:
raise ValueError("DB_URL must be configured") raise ValueError("DB_URL must be configured")
if not self.api_token: if not self.api_token:
raise ValueError("API_TOKEN must be configured") raise ValueError("API_TOKEN must be configured")
logger.debug(f"APIClient initialized with base_url: {self.base_url}") logger.debug(f"APIClient initialized with base_url: {self.base_url}")
@property @property
def headers(self) -> Dict[str, str]: def headers(self) -> Dict[str, str]:
"""Get headers with authentication and content type.""" """Get headers with authentication and content type."""
return { return {
'Authorization': f'Bearer {self.api_token}', "Authorization": f"Bearer {self.api_token}",
'Content-Type': 'application/json', "Content-Type": "application/json",
'User-Agent': 'SBA-Discord-Bot-v2/1.0' "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. Build complete API URL from components.
@ -73,35 +79,38 @@ class APIClient:
Complete URL for API request Complete URL for API request
""" """
# Handle already complete URLs # Handle already complete URLs
if endpoint.startswith(('http://', 'https://')) or '/api/' in endpoint: if endpoint.startswith(("http://", "https://")) or "/api/" in endpoint:
return endpoint return endpoint
path = f"v{api_version}/{endpoint}" path = f"v{api_version}/{endpoint}"
if object_id is not None: if object_id is not None:
# URL-encode the object_id to handle special characters (e.g., colons in moveids) # 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}" 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: def _add_params(self, url: str, params: Optional[List[tuple]] = None) -> str:
""" """
Add query parameters to URL. Add query parameters to URL.
Args: Args:
url: Base URL url: Base URL
params: List of (key, value) tuples params: List of (key, value) tuples
Returns: Returns:
URL with query parameters appended URL with query parameters appended
""" """
if not params: if not params:
return url 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 "?" separator = "&" if "?" in url else "?"
return f"{url}{separator}{param_str}" return f"{url}{separator}{param_str}"
async def _ensure_session(self) -> None: async def _ensure_session(self) -> None:
"""Ensure aiohttp session exists and is not closed.""" """Ensure aiohttp session exists and is not closed."""
if self._session is None or self._session.closed: if self._session is None or self._session.closed:
@ -109,53 +118,51 @@ class APIClient:
limit=100, # Total connection pool size limit=100, # Total connection pool size
limit_per_host=30, # Connections per host limit_per_host=30, # Connections per host
ttl_dns_cache=300, # DNS cache TTL ttl_dns_cache=300, # DNS cache TTL
use_dns_cache=True use_dns_cache=True,
) )
timeout = aiohttp.ClientTimeout(total=30, connect=10) timeout = aiohttp.ClientTimeout(total=30, connect=10)
self._session = aiohttp.ClientSession( self._session = aiohttp.ClientSession(
headers=self.headers, headers=self.headers, connector=connector, timeout=timeout
connector=connector,
timeout=timeout
) )
logger.debug("Created new aiohttp session with connection pooling") logger.debug("Created new aiohttp session with connection pooling")
async def get( async def get(
self, self,
endpoint: str, endpoint: str,
object_id: Optional[Union[int, str]] = None, object_id: Optional[Union[int, str]] = None,
params: Optional[List[tuple]] = None, params: Optional[List[tuple]] = None,
api_version: int = 3, api_version: int = 3,
timeout: Optional[int] = None timeout: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Make GET request to API. Make GET request to API.
Args: Args:
endpoint: API endpoint endpoint: API endpoint
object_id: Optional object ID object_id: Optional object ID
params: Query parameters params: Query parameters
api_version: API version (default: 3) api_version: API version (default: 3)
timeout: Request timeout override timeout: Request timeout override
Returns: Returns:
JSON response data or None for 404 JSON response data or None for 404
Raises: Raises:
APIException: For HTTP errors or network issues APIException: For HTTP errors or network issues
""" """
url = self._build_url(endpoint, api_version, object_id) url = self._build_url(endpoint, api_version, object_id)
url = self._add_params(url, params) url = self._add_params(url, params)
await self._ensure_session() await self._ensure_session()
try: try:
logger.debug(f"GET: {endpoint} id: {object_id} params: {params}") logger.debug(f"GET: {endpoint} id: {object_id} params: {params}")
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
async with self._session.get(url, timeout=request_timeout) as response: async with self._session.get(url, timeout=request_timeout) as response:
if response.status == 404: if response.status == 404:
logger.warning(f"Resource not found: {url}") logger.warning(f"Resource not found: {url}")
@ -169,10 +176,12 @@ class APIClient:
elif response.status >= 400: elif response.status >= 400:
error_text = await response.text() error_text = await response.text()
logger.error(f"API error {response.status}: {url} - {error_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() data = await response.json()
# Truncate response for logging # Truncate response for logging
data_str = str(data) data_str = str(data)
if len(data_str) > 1200: if len(data_str) > 1200:
@ -180,48 +189,50 @@ class APIClient:
else: else:
log_data = data_str log_data = data_str
logger.debug(f"Response: {log_data}") logger.debug(f"Response: {log_data}")
return data return data
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"HTTP client error for {url}: {e}") logger.error(f"HTTP client error for {url}: {e}")
raise APIException(f"Network error: {e}") raise APIException(f"Network error: {e}")
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in GET {url}: {e}") logger.error(f"Unexpected error in GET {url}: {e}")
raise APIException(f"API call failed: {e}") raise APIException(f"API call failed: {e}")
async def post( async def post(
self, self,
endpoint: str, endpoint: str,
data: Dict[str, Any], data: Dict[str, Any],
api_version: int = 3, api_version: int = 3,
timeout: Optional[int] = None timeout: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Make POST request to API. Make POST request to API.
Args: Args:
endpoint: API endpoint endpoint: API endpoint
data: Request payload data: Request payload
api_version: API version (default: 3) api_version: API version (default: 3)
timeout: Request timeout override timeout: Request timeout override
Returns: Returns:
JSON response data JSON response data
Raises: Raises:
APIException: For HTTP errors or network issues APIException: For HTTP errors or network issues
""" """
url = self._build_url(endpoint, api_version) url = self._build_url(endpoint, api_version)
await self._ensure_session() await self._ensure_session()
try: try:
logger.debug(f"POST: {endpoint} data: {data}") logger.debug(f"POST: {endpoint} data: {data}")
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None 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: if response.status == 401:
logger.error(f"Authentication failed for POST: {url}") logger.error(f"Authentication failed for POST: {url}")
raise APIException("Authentication failed - check API token") raise APIException("Authentication failed - check API token")
@ -231,10 +242,12 @@ class APIClient:
elif response.status not in [200, 201]: elif response.status not in [200, 201]:
error_text = await response.text() error_text = await response.text()
logger.error(f"POST error {response.status}: {url} - {error_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() result = await response.json()
# Truncate response for logging # Truncate response for logging
result_str = str(result) result_str = str(result)
if len(result_str) > 1200: if len(result_str) > 1200:
@ -242,50 +255,52 @@ class APIClient:
else: else:
log_result = result_str log_result = result_str
logger.debug(f"POST Response: {log_result}") logger.debug(f"POST Response: {log_result}")
return result return result
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"HTTP client error for POST {url}: {e}") logger.error(f"HTTP client error for POST {url}: {e}")
raise APIException(f"Network error: {e}") raise APIException(f"Network error: {e}")
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in POST {url}: {e}") logger.error(f"Unexpected error in POST {url}: {e}")
raise APIException(f"POST failed: {e}") raise APIException(f"POST failed: {e}")
async def put( async def put(
self, self,
endpoint: str, endpoint: str,
data: Dict[str, Any], data: Dict[str, Any],
object_id: Optional[Union[int, str]] = None, object_id: Optional[Union[int, str]] = None,
api_version: int = 3, api_version: int = 3,
timeout: Optional[int] = None timeout: Optional[int] = None,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Make PUT request to API. Make PUT request to API.
Args: Args:
endpoint: API endpoint endpoint: API endpoint
data: Request payload data: Request payload
object_id: Optional object ID object_id: Optional object ID
api_version: API version (default: 3) api_version: API version (default: 3)
timeout: Request timeout override timeout: Request timeout override
Returns: Returns:
JSON response data JSON response data
Raises: Raises:
APIException: For HTTP errors or network issues APIException: For HTTP errors or network issues
""" """
url = self._build_url(endpoint, api_version, object_id) url = self._build_url(endpoint, api_version, object_id)
await self._ensure_session() await self._ensure_session()
try: try:
logger.debug(f"PUT: {endpoint} id: {object_id} data: {data}") logger.debug(f"PUT: {endpoint} id: {object_id} data: {data}")
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None 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: if response.status == 401:
logger.error(f"Authentication failed for PUT: {url}") logger.error(f"Authentication failed for PUT: {url}")
raise APIException("Authentication failed - check API token") raise APIException("Authentication failed - check API token")
@ -298,19 +313,23 @@ class APIClient:
elif response.status not in [200, 201]: elif response.status not in [200, 201]:
error_text = await response.text() error_text = await response.text()
logger.error(f"PUT error {response.status}: {url} - {error_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() 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 return result
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"HTTP client error for PUT {url}: {e}") logger.error(f"HTTP client error for PUT {url}: {e}")
raise APIException(f"Network error: {e}") raise APIException(f"Network error: {e}")
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in PUT {url}: {e}") logger.error(f"Unexpected error in PUT {url}: {e}")
raise APIException(f"PUT failed: {e}") raise APIException(f"PUT failed: {e}")
async def patch( async def patch(
self, self,
endpoint: str, endpoint: str,
@ -318,7 +337,7 @@ class APIClient:
object_id: Optional[Union[int, str]] = None, object_id: Optional[Union[int, str]] = None,
api_version: int = 3, api_version: int = 3,
timeout: Optional[int] = None, timeout: Optional[int] = None,
use_query_params: bool = False use_query_params: bool = False,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
""" """
Make PATCH request to API. Make PATCH request to API.
@ -344,13 +363,15 @@ class APIClient:
# Handle None values by converting to empty string # Handle None values by converting to empty string
# The database API's PATCH endpoint treats empty strings as NULL for nullable fields # 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 # 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) url = self._add_params(url, params)
await self._ensure_session() await self._ensure_session()
try: 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}") logger.debug(f"PATCH URL: {url}")
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None 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 # Use json=data if data is provided and not using query params
kwargs = {} kwargs = {}
if data is not None and not use_query_params: if data is not None and not use_query_params:
kwargs['json'] = data kwargs["json"] = data
logger.debug(f"PATCH JSON body: {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: if response.status == 401:
logger.error(f"Authentication failed for PATCH: {url}") logger.error(f"Authentication failed for PATCH: {url}")
raise APIException("Authentication failed - check API token") raise APIException("Authentication failed - check API token")
@ -374,10 +397,14 @@ class APIClient:
elif response.status not in [200, 201]: elif response.status not in [200, 201]:
error_text = await response.text() error_text = await response.text()
logger.error(f"PATCH error {response.status}: {url} - {error_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() 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 return result
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
@ -386,38 +413,38 @@ class APIClient:
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in PATCH {url}: {e}") logger.error(f"Unexpected error in PATCH {url}: {e}")
raise APIException(f"PATCH failed: {e}") raise APIException(f"PATCH failed: {e}")
async def delete( async def delete(
self, self,
endpoint: str, endpoint: str,
object_id: Optional[Union[int, str]] = None, object_id: Optional[Union[int, str]] = None,
api_version: int = 3, api_version: int = 3,
timeout: Optional[int] = None timeout: Optional[int] = None,
) -> bool: ) -> bool:
""" """
Make DELETE request to API. Make DELETE request to API.
Args: Args:
endpoint: API endpoint endpoint: API endpoint
object_id: Optional object ID object_id: Optional object ID
api_version: API version (default: 3) api_version: API version (default: 3)
timeout: Request timeout override timeout: Request timeout override
Returns: Returns:
True if deletion successful, False if resource not found True if deletion successful, False if resource not found
Raises: Raises:
APIException: For HTTP errors or network issues APIException: For HTTP errors or network issues
""" """
url = self._build_url(endpoint, api_version, object_id) url = self._build_url(endpoint, api_version, object_id)
await self._ensure_session() await self._ensure_session()
try: try:
logger.debug(f"DELETE: {endpoint} id: {object_id}") logger.debug(f"DELETE: {endpoint} id: {object_id}")
request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None request_timeout = aiohttp.ClientTimeout(total=timeout) if timeout else None
async with self._session.delete(url, timeout=request_timeout) as response: async with self._session.delete(url, timeout=request_timeout) as response:
if response.status == 401: if response.status == 401:
logger.error(f"Authentication failed for DELETE: {url}") logger.error(f"Authentication failed for DELETE: {url}")
@ -430,30 +457,34 @@ class APIClient:
return False return False
elif response.status not in [200, 204]: elif response.status not in [200, 204]:
error_text = await response.text() error_text = await response.text()
logger.error(f"DELETE error {response.status}: {url} - {error_text}") logger.error(
raise APIException(f"DELETE request failed with status {response.status}: {error_text}") 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}") logger.debug(f"DELETE successful: {url}")
return True return True
except aiohttp.ClientError as e: except aiohttp.ClientError as e:
logger.error(f"HTTP client error for DELETE {url}: {e}") logger.error(f"HTTP client error for DELETE {url}: {e}")
raise APIException(f"Network error: {e}") raise APIException(f"Network error: {e}")
except Exception as e: except Exception as e:
logger.error(f"Unexpected error in DELETE {url}: {e}") logger.error(f"Unexpected error in DELETE {url}: {e}")
raise APIException(f"DELETE failed: {e}") raise APIException(f"DELETE failed: {e}")
async def close(self) -> None: async def close(self) -> None:
"""Close the HTTP session and clean up resources.""" """Close the HTTP session and clean up resources."""
if self._session and not self._session.closed: if self._session and not self._session.closed:
await self._session.close() await self._session.close()
logger.debug("Closed aiohttp session") logger.debug("Closed aiohttp session")
async def __aenter__(self): async def __aenter__(self):
"""Async context manager entry.""" """Async context manager entry."""
await self._ensure_session() await self._ensure_session()
return self return self
async def __aexit__(self, exc_type, exc_val, exc_tb): async def __aexit__(self, exc_type, exc_val, exc_tb):
"""Async context manager exit with cleanup.""" """Async context manager exit with cleanup."""
await self.close() await self.close()
@ -463,7 +494,7 @@ class APIClient:
async def get_api_client() -> APIClient: async def get_api_client() -> APIClient:
""" """
Get API client as async context manager. Get API client as async context manager.
Usage: Usage:
async with get_api_client() as client: async with get_api_client() as client:
data = await client.get('players') data = await client.get('players')
@ -482,14 +513,14 @@ _global_client: Optional[APIClient] = None
async def get_global_client() -> APIClient: async def get_global_client() -> APIClient:
""" """
Get global API client instance with automatic session management. Get global API client instance with automatic session management.
Returns: Returns:
Shared APIClient instance Shared APIClient instance
""" """
global _global_client global _global_client
if _global_client is None: if _global_client is None:
_global_client = APIClient() _global_client = APIClient()
await _global_client._ensure_session() await _global_client._ensure_session()
return _global_client return _global_client
@ -499,4 +530,4 @@ async def cleanup_global_client() -> None:
global _global_client global _global_client
if _global_client: if _global_client:
await _global_client.close() await _global_client.close()
_global_client = None _global_client = None

269
bot.py
View File

@ -3,6 +3,7 @@ Discord Bot v2.0 - Main Entry Point
Modern discord.py bot with application commands and proper error handling. Modern discord.py bot with application commands and proper error handling.
""" """
import asyncio import asyncio
import hashlib import hashlib
import json import json
@ -23,89 +24,91 @@ from views.embeds import EmbedTemplate, EmbedColors
def setup_logging(): def setup_logging():
"""Configure hybrid logging: human-readable console + structured JSON files.""" """Configure hybrid logging: human-readable console + structured JSON files."""
from utils.logging import JSONFormatter from utils.logging import JSONFormatter
# Create logs directory if it doesn't exist # Create logs directory if it doesn't exist
os.makedirs('logs', exist_ok=True) os.makedirs("logs", exist_ok=True)
# Configure root logger # Configure root logger
config = get_config() config = get_config()
logger = logging.getLogger('discord_bot_v2') logger = logging.getLogger("discord_bot_v2")
logger.setLevel(getattr(logging, config.log_level.upper())) logger.setLevel(getattr(logging, config.log_level.upper()))
# Console handler - detailed format for development debugging # Console handler - detailed format for development debugging
console_handler = logging.StreamHandler() console_handler = logging.StreamHandler()
console_formatter = logging.Formatter( 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) console_handler.setFormatter(console_formatter)
logger.addHandler(console_handler) logger.addHandler(console_handler)
# JSON file handler - structured logging for monitoring and analysis # JSON file handler - structured logging for monitoring and analysis
json_handler = RotatingFileHandler( json_handler = RotatingFileHandler(
'logs/discord_bot_v2.json', "logs/discord_bot_v2.json", maxBytes=5 * 1024 * 1024, backupCount=5 # 5MB
maxBytes=5 * 1024 * 1024, # 5MB
backupCount=5
) )
json_handler.setFormatter(JSONFormatter()) json_handler.setFormatter(JSONFormatter())
logger.addHandler(json_handler) logger.addHandler(json_handler)
# Configure root logger for third-party libraries (discord.py, aiohttp, etc.) # Configure root logger for third-party libraries (discord.py, aiohttp, etc.)
root_logger = logging.getLogger() root_logger = logging.getLogger()
root_logger.setLevel(getattr(logging, config.log_level.upper())) root_logger.setLevel(getattr(logging, config.log_level.upper()))
# Add handlers to root logger so third-party loggers inherit them # Add handlers to root logger so third-party loggers inherit them
if not root_logger.handlers: # Avoid duplicate handlers if not root_logger.handlers: # Avoid duplicate handlers
root_logger.addHandler(console_handler) root_logger.addHandler(console_handler)
root_logger.addHandler(json_handler) root_logger.addHandler(json_handler)
# Prevent discord_bot_v2 logger from propagating to root to avoid duplicate messages # 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) # (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 # To revert: remove the line below and bot logs will appear twice
logger.propagate = False logger.propagate = False
return logger return logger
class SBABot(commands.Bot): class SBABot(commands.Bot):
"""Custom bot class for SBA league management.""" """Custom bot class for SBA league management."""
def __init__(self): def __init__(self):
# Configure intents # Configure intents
intents = discord.Intents.default() intents = discord.Intents.default()
intents.message_content = True # For legacy commands if needed intents.message_content = True # For legacy commands if needed
intents.members = True # For member management intents.members = True # For member management
super().__init__( super().__init__(
command_prefix='!', # Legacy prefix, primarily using slash commands command_prefix="!", # Legacy prefix, primarily using slash commands
intents=intents, 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): async def setup_hook(self):
"""Called when the bot is starting up.""" """Called when the bot is starting up."""
self.logger.info("Setting up bot...") self.logger.info("Setting up bot...")
# Load command packages # Load command packages
await self._load_command_packages() await self._load_command_packages()
# Initialize cleanup tasks # Initialize cleanup tasks
await self._setup_background_tasks() await self._setup_background_tasks()
# Smart command syncing: auto-sync in development if changes detected; !admin-sync for first sync # Smart command syncing: auto-sync in development if changes detected; !admin-sync for first sync
config = get_config() config = get_config()
if config.is_development: if config.is_development:
if await self._should_sync_commands(): 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._sync_commands()
await self._save_command_hash() await self._save_command_hash()
else: else:
self.logger.info("Development mode: no command changes detected, skipping sync") self.logger.info(
"Development mode: no command changes detected, skipping sync"
)
else: else:
self.logger.info("Production mode: commands loaded but not auto-synced") self.logger.info("Production mode: commands loaded but not auto-synced")
self.logger.info("Use /admin-sync command to manually sync when needed") self.logger.info("Use /admin-sync command to manually sync when needed")
async def _load_command_packages(self): async def _load_command_packages(self):
"""Load all command packages with resilient error handling.""" """Load all command packages with resilient error handling."""
from commands.players import setup_players from commands.players import setup_players
@ -146,32 +149,42 @@ class SBABot(commands.Bot):
("gameplay", setup_gameplay), ("gameplay", setup_gameplay),
("dev", setup_dev), # Dev-only commands (admin restricted) ("dev", setup_dev), # Dev-only commands (admin restricted)
] ]
total_successful = 0 total_successful = 0
total_failed = 0 total_failed = 0
for package_name, setup_func in command_packages: for package_name, setup_func in command_packages:
try: try:
self.logger.info(f"Loading {package_name} commands...") self.logger.info(f"Loading {package_name} commands...")
successful, failed, failed_modules = await setup_func(self) successful, failed, failed_modules = await setup_func(self)
total_successful += successful total_successful += successful
total_failed += failed total_failed += failed
if failed == 0: 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: 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: 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 total_failed += 1
# Log overall summary # Log overall summary
if total_failed == 0: 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: 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): async def _setup_background_tasks(self):
"""Initialize background tasks for the bot.""" """Initialize background tasks for the bot."""
try: try:
@ -179,28 +192,34 @@ class SBABot(commands.Bot):
# Initialize custom command cleanup task # Initialize custom command cleanup task
from tasks.custom_command_cleanup import setup_cleanup_task from tasks.custom_command_cleanup import setup_cleanup_task
self.custom_command_cleanup = setup_cleanup_task(self) self.custom_command_cleanup = setup_cleanup_task(self)
# Initialize transaction freeze/thaw task # Initialize transaction freeze/thaw task
from tasks.transaction_freeze import setup_freeze_task from tasks.transaction_freeze import setup_freeze_task
self.transaction_freeze = setup_freeze_task(self) self.transaction_freeze = setup_freeze_task(self)
self.logger.info("✅ Transaction freeze/thaw task started") self.logger.info("✅ Transaction freeze/thaw task started")
# Initialize voice channel cleanup service # Initialize voice channel cleanup service
from commands.voice.cleanup_service import setup_voice_cleanup from commands.voice.cleanup_service import setup_voice_cleanup
self.voice_cleanup_service = setup_voice_cleanup(self) self.voice_cleanup_service = setup_voice_cleanup(self)
self.logger.info("✅ Voice channel cleanup service started") self.logger.info("✅ Voice channel cleanup service started")
# Initialize live scorebug tracker # Initialize live scorebug tracker
from tasks.live_scorebug_tracker import setup_scorebug_tracker from tasks.live_scorebug_tracker import setup_scorebug_tracker
self.live_scorebug_tracker = setup_scorebug_tracker(self) self.live_scorebug_tracker = setup_scorebug_tracker(self)
self.logger.info("✅ Live scorebug tracker started") self.logger.info("✅ Live scorebug tracker started")
self.logger.info("✅ Background tasks initialized successfully") self.logger.info("✅ Background tasks initialized successfully")
except Exception as e: 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: async def _should_sync_commands(self) -> bool:
"""Check if commands have changed since last sync.""" """Check if commands have changed since last sync."""
try: try:
@ -209,50 +228,51 @@ class SBABot(commands.Bot):
for cmd in self.tree.get_commands(): for cmd in self.tree.get_commands():
# Handle different command types properly # Handle different command types properly
cmd_dict = {} cmd_dict = {}
cmd_dict['name'] = cmd.name cmd_dict["name"] = cmd.name
cmd_dict['type'] = type(cmd).__name__ cmd_dict["type"] = type(cmd).__name__
# Add description if available (most command types have this) # Add description if available (most command types have this)
if hasattr(cmd, 'description'): if hasattr(cmd, "description"):
cmd_dict['description'] = cmd.description # type: ignore cmd_dict["description"] = cmd.description # type: ignore
# Add parameters for Command objects # Add parameters for Command objects
if isinstance(cmd, discord.app_commands.Command): if isinstance(cmd, discord.app_commands.Command):
cmd_dict['parameters'] = [ cmd_dict["parameters"] = [
{ {
'name': param.name, "name": param.name,
'description': param.description, "description": param.description,
'required': param.required, "required": param.required,
'type': str(param.type) "type": str(param.type),
} for param in cmd.parameters }
for param in cmd.parameters
] ]
elif isinstance(cmd, discord.app_commands.Group): elif isinstance(cmd, discord.app_commands.Group):
# For groups, include subcommands # 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.append(cmd_dict)
# Sort for consistent hashing # Sort for consistent hashing
commands_data.sort(key=lambda x: x['name']) commands_data.sort(key=lambda x: x["name"])
current_hash = hashlib.md5( current_hash = hashlib.sha256(
json.dumps(commands_data, sort_keys=True).encode() json.dumps(commands_data, sort_keys=True).encode()
).hexdigest() ).hexdigest()
# Compare with stored hash # Compare with stored hash
hash_file = '.last_command_hash' hash_file = ".last_command_hash"
if os.path.exists(hash_file): 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() last_hash = f.read().strip()
return current_hash != last_hash return current_hash != last_hash
else: else:
# No previous hash = first run, should sync # No previous hash = first run, should sync
return True return True
except Exception as e: except Exception as e:
self.logger.warning(f"Error checking command hash: {e}") self.logger.warning(f"Error checking command hash: {e}")
# If we can't determine changes, err on the side of syncing # If we can't determine changes, err on the side of syncing
return True return True
async def _save_command_hash(self): async def _save_command_hash(self):
"""Save current command hash for future comparison.""" """Save current command hash for future comparison."""
try: try:
@ -261,41 +281,42 @@ class SBABot(commands.Bot):
for cmd in self.tree.get_commands(): for cmd in self.tree.get_commands():
# Handle different command types properly # Handle different command types properly
cmd_dict = {} cmd_dict = {}
cmd_dict['name'] = cmd.name cmd_dict["name"] = cmd.name
cmd_dict['type'] = type(cmd).__name__ cmd_dict["type"] = type(cmd).__name__
# Add description if available (most command types have this) # Add description if available (most command types have this)
if hasattr(cmd, 'description'): if hasattr(cmd, "description"):
cmd_dict['description'] = cmd.description # type: ignore cmd_dict["description"] = cmd.description # type: ignore
# Add parameters for Command objects # Add parameters for Command objects
if isinstance(cmd, discord.app_commands.Command): if isinstance(cmd, discord.app_commands.Command):
cmd_dict['parameters'] = [ cmd_dict["parameters"] = [
{ {
'name': param.name, "name": param.name,
'description': param.description, "description": param.description,
'required': param.required, "required": param.required,
'type': str(param.type) "type": str(param.type),
} for param in cmd.parameters }
for param in cmd.parameters
] ]
elif isinstance(cmd, discord.app_commands.Group): elif isinstance(cmd, discord.app_commands.Group):
# For groups, include subcommands # 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.append(cmd_dict)
commands_data.sort(key=lambda x: x['name']) commands_data.sort(key=lambda x: x["name"])
current_hash = hashlib.md5( current_hash = hashlib.sha256(
json.dumps(commands_data, sort_keys=True).encode() json.dumps(commands_data, sort_keys=True).encode()
).hexdigest() ).hexdigest()
# Save hash to file # Save hash to file
with open('.last_command_hash', 'w') as f: with open(".last_command_hash", "w") as f:
f.write(current_hash) f.write(current_hash)
except Exception as e: except Exception as e:
self.logger.warning(f"Error saving command hash: {e}") self.logger.warning(f"Error saving command hash: {e}")
async def _sync_commands(self): async def _sync_commands(self):
"""Internal method to sync commands.""" """Internal method to sync commands."""
config = get_config() config = get_config()
@ -303,54 +324,55 @@ class SBABot(commands.Bot):
guild = discord.Object(id=config.guild_id) guild = discord.Object(id=config.guild_id)
self.tree.copy_global_to(guild=guild) self.tree.copy_global_to(guild=guild)
synced = await self.tree.sync(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: else:
synced = await self.tree.sync() synced = await self.tree.sync()
self.logger.info(f"Synced {len(synced)} commands globally") self.logger.info(f"Synced {len(synced)} commands globally")
async def on_ready(self): async def on_ready(self):
"""Called when the bot is ready.""" """Called when the bot is ready."""
self.logger.info(f"Bot ready! Logged in as {self.user}") self.logger.info(f"Bot ready! Logged in as {self.user}")
self.logger.info(f"Connected to {len(self.guilds)} guilds") self.logger.info(f"Connected to {len(self.guilds)} guilds")
# Set activity status # Set activity status
activity = discord.Activity( activity = discord.Activity(
type=discord.ActivityType.watching, type=discord.ActivityType.watching, name=random_from_list(STARTUP_WATCHING)
name=random_from_list(STARTUP_WATCHING)
) )
await self.change_presence(activity=activity) await self.change_presence(activity=activity)
async def on_error(self, event_method: str, /, *args, **kwargs): async def on_error(self, event_method: str, /, *args, **kwargs):
"""Global error handler for events.""" """Global error handler for events."""
self.logger.error(f"Error in event {event_method}", exc_info=True) self.logger.error(f"Error in event {event_method}", exc_info=True)
async def close(self): async def close(self):
"""Clean shutdown of the bot.""" """Clean shutdown of the bot."""
self.logger.info("Bot shutting down...") self.logger.info("Bot shutting down...")
# Stop background tasks # Stop background tasks
if hasattr(self, 'custom_command_cleanup'): if hasattr(self, "custom_command_cleanup"):
try: try:
self.custom_command_cleanup.cleanup_task.cancel() self.custom_command_cleanup.cleanup_task.cancel()
self.logger.info("Custom command cleanup task stopped") self.logger.info("Custom command cleanup task stopped")
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping cleanup task: {e}") self.logger.error(f"Error stopping cleanup task: {e}")
if hasattr(self, 'transaction_freeze'): if hasattr(self, "transaction_freeze"):
try: try:
self.transaction_freeze.weekly_loop.cancel() self.transaction_freeze.weekly_loop.cancel()
self.logger.info("Transaction freeze/thaw task stopped") self.logger.info("Transaction freeze/thaw task stopped")
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping transaction freeze task: {e}") self.logger.error(f"Error stopping transaction freeze task: {e}")
if hasattr(self, 'voice_cleanup_service'): if hasattr(self, "voice_cleanup_service"):
try: try:
self.voice_cleanup_service.cog_unload() self.voice_cleanup_service.cog_unload()
self.logger.info("Voice channel cleanup service stopped") self.logger.info("Voice channel cleanup service stopped")
except Exception as e: except Exception as e:
self.logger.error(f"Error stopping voice cleanup service: {e}") self.logger.error(f"Error stopping voice cleanup service: {e}")
if hasattr(self, 'live_scorebug_tracker'): if hasattr(self, "live_scorebug_tracker"):
try: try:
self.live_scorebug_tracker.update_loop.cancel() self.live_scorebug_tracker.update_loop.cancel()
self.logger.info("Live scorebug tracker stopped") 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") @bot.tree.command(name="health", description="Check bot and API health status")
async def health_command(interaction: discord.Interaction): async def health_command(interaction: discord.Interaction):
"""Health check command to verify bot and API connectivity.""" """Health check command to verify bot and API connectivity."""
logger = logging.getLogger('discord_bot_v2') logger = logging.getLogger("discord_bot_v2")
try: try:
# Check API connectivity # Check API connectivity
api_status = "✅ Connected" api_status = "✅ Connected"
try: try:
client = await get_global_client() client = await get_global_client()
# Test API with a simple request # Test API with a simple request
result = await client.get('current') result = await client.get("current")
if result: if result:
api_status = "✅ Connected" api_status = "✅ Connected"
else: else:
@ -385,69 +407,66 @@ async def health_command(interaction: discord.Interaction):
except Exception as e: except Exception as e:
logger.error(f"API health check failed: {e}") logger.error(f"API health check failed: {e}")
api_status = f"❌ Error: {str(e)}" api_status = f"❌ Error: {str(e)}"
# Bot health info # Bot health info
guild_count = len(bot.guilds) guild_count = len(bot.guilds)
# Create health status embed # Create health status embed
embed = EmbedTemplate.success( embed = EmbedTemplate.success(title="🏥 Bot Health Check")
title="🏥 Bot Health Check"
)
embed.add_field(name="Bot Status", value="✅ Online", inline=True) 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="API Status", value=api_status, inline=True)
embed.add_field(name="Guilds", value=str(guild_count), 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) embed.add_field(name="Latency", value=f"{bot.latency*1000:.1f}ms", inline=True)
if bot.user: 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) await interaction.response.send_message(embed=embed, ephemeral=True)
except Exception as e: except Exception as e:
logger.error(f"Health check command error: {e}", exc_info=True) logger.error(f"Health check command error: {e}", exc_info=True)
await interaction.response.send_message( await interaction.response.send_message(
f"❌ Health check failed: {str(e)}", f"❌ Health check failed: {str(e)}", ephemeral=True
ephemeral=True
) )
@bot.tree.error @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.""" """Global error handler for application commands."""
logger = logging.getLogger('discord_bot_v2') logger = logging.getLogger("discord_bot_v2")
# Handle specific error types # Handle specific error types
if isinstance(error, discord.app_commands.CommandOnCooldown): if isinstance(error, discord.app_commands.CommandOnCooldown):
await interaction.response.send_message( await interaction.response.send_message(
f"⏰ Command on cooldown. Try again in {error.retry_after:.1f} seconds.", f"⏰ Command on cooldown. Try again in {error.retry_after:.1f} seconds.",
ephemeral=True ephemeral=True,
) )
elif isinstance(error, discord.app_commands.MissingPermissions): elif isinstance(error, discord.app_commands.MissingPermissions):
await interaction.response.send_message( await interaction.response.send_message(
"❌ You don't have permission to use this command.", "❌ You don't have permission to use this command.", ephemeral=True
ephemeral=True
) )
elif isinstance(error, discord.app_commands.CommandNotFound): elif isinstance(error, discord.app_commands.CommandNotFound):
await interaction.response.send_message( await interaction.response.send_message(
"❌ Command not found. Use `/help` to see available commands.", "❌ Command not found. Use `/help` to see available commands.",
ephemeral=True ephemeral=True,
) )
elif isinstance(error, BotException): elif isinstance(error, BotException):
# Our custom exceptions - show user-friendly message # Our custom exceptions - show user-friendly message
await interaction.response.send_message( await interaction.response.send_message(f"{str(error)}", ephemeral=True)
f"{str(error)}",
ephemeral=True
)
else: else:
# Unexpected errors - log and show generic message # Unexpected errors - log and show generic message
logger.error(f"Unhandled command error: {error}", exc_info=True) logger.error(f"Unhandled command error: {error}", exc_info=True)
message = "❌ An unexpected error occurred. Please try again." message = "❌ An unexpected error occurred. Please try again."
config = get_config() config = get_config()
if config.is_development: if config.is_development:
message += f"\n\nDevelopment error: {str(error)}" message += f"\n\nDevelopment error: {str(error)}"
if interaction.response.is_done(): if interaction.response.is_done():
await interaction.followup.send(message, ephemeral=True) await interaction.followup.send(message, ephemeral=True)
else: else:
@ -457,12 +476,12 @@ async def on_app_command_error(interaction: discord.Interaction, error: discord.
async def main(): async def main():
"""Main entry point.""" """Main entry point."""
logger = setup_logging() logger = setup_logging()
config = get_config() config = get_config()
logger.info("Starting Discord Bot v2.0") logger.info("Starting Discord Bot v2.0")
logger.info(f"Environment: {config.environment}") logger.info(f"Environment: {config.environment}")
logger.info(f"Guild ID: {config.guild_id}") logger.info(f"Guild ID: {config.guild_id}")
try: try:
await bot.start(config.bot_token) await bot.start(config.bot_token)
except KeyboardInterrupt: except KeyboardInterrupt:
@ -475,4 +494,4 @@ async def main():
if __name__ == "__main__": if __name__ == "__main__":
asyncio.run(main()) asyncio.run(main())

View File

@ -10,6 +10,7 @@ The injury rating format (#p##) encodes both games played and rating:
- First character: Games played in series (1-6) - First character: Games played in series (1-6)
- Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20) - Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20)
""" """
import math import math
import random import random
import discord import discord
@ -40,11 +41,8 @@ class InjuryGroup(app_commands.Group):
"""Injury management command group with roll, set-new, and clear subcommands.""" """Injury management command group with roll, set-new, and clear subcommands."""
def __init__(self): def __init__(self):
super().__init__( super().__init__(name="injury", description="Injury management commands")
name="injury", self.logger = get_contextual_logger(f"{__name__}.InjuryGroup")
description="Injury management commands"
)
self.logger = get_contextual_logger(f'{__name__}.InjuryGroup')
self.logger.info("InjuryGroup initialized") self.logger.info("InjuryGroup initialized")
def has_player_role(self, interaction: discord.Interaction) -> bool: 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): if not isinstance(interaction.user, discord.Member):
return False return False
if interaction.guild is None:
return False
player_role = discord.utils.get( player_role = discord.utils.get(
interaction.guild.roles, interaction.guild.roles, name=get_config().sba_players_role_name
name=get_config().sba_players_role_name
) )
return player_role in interaction.user.roles if player_role else False 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.describe(player_name="Player name")
@app_commands.autocomplete(player_name=player_autocomplete) @app_commands.autocomplete(player_name=player_autocomplete)
@league_only() @league_only()
@ -74,12 +76,14 @@ class InjuryGroup(app_commands.Group):
raise BotException("Failed to get current season information") raise BotException("Failed to get current season information")
# Search for player using the search endpoint (more reliable than name param) # 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: if not players:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Player Not Found", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -89,14 +93,17 @@ class InjuryGroup(app_commands.Group):
# Fetch full team data if team is not populated # Fetch full team data if team is not populated
if player.team_id and not player.team: if player.team_id and not player.team:
from services.team_service import team_service from services.team_service import team_service
player.team = await team_service.get_team(player.team_id) player.team = await team_service.get_team(player.team_id)
# Check if player already has an active injury # 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: if existing_injury:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Already Injured", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -105,7 +112,7 @@ class InjuryGroup(app_commands.Group):
if not player.injury_rating: if not player.injury_rating:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="No Injury Rating", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -120,13 +127,13 @@ class InjuryGroup(app_commands.Group):
raise ValueError("Games played must be between 1 and 6") raise ValueError("Games played must be between 1 and 6")
# Validate rating format (should start with 'p') # Validate rating format (should start with 'p')
if not injury_rating.startswith('p'): if not injury_rating.startswith("p"):
raise ValueError("Invalid rating format") raise ValueError("Invalid rating format")
except (ValueError, IndexError): except (ValueError, IndexError):
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Invalid Injury Rating Format", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -141,33 +148,25 @@ class InjuryGroup(app_commands.Group):
injury_result = self._get_injury_result(injury_rating, games_played, roll_total) injury_result = self._get_injury_result(injury_rating, games_played, roll_total)
# Create response embed # Create response embed
embed = EmbedTemplate.warning( embed = EmbedTemplate.warning(title=f"Injury roll for {interaction.user.name}")
title=f"Injury roll for {interaction.user.name}"
)
if player.team and player.team.thumbnail: if player.team and player.team.thumbnail:
embed.set_thumbnail(url=player.team.thumbnail) embed.set_thumbnail(url=player.team.thumbnail)
embed.add_field( embed.add_field(
name="Player", name="Player",
value=f"{player.name} ({player.primary_position})", value=f"{player.name} ({player.primary_position})",
inline=True inline=True,
) )
embed.add_field( embed.add_field(
name="Injury Rating", name="Injury Rating", value=f"{player.injury_rating}", inline=True
value=f"{player.injury_rating}",
inline=True
) )
# embed.add_field(name='', value='', inline=False) # Embed line break # embed.add_field(name='', value='', inline=False) # Embed line break
# Format dice roll in markdown (same format as /ab roll) # Format dice roll in markdown (same format as /ab roll)
dice_result = f"```md\n# {roll_total}\nDetails:[3d6 ({d1} {d2} {d3})]```" dice_result = f"```md\n# {roll_total}\nDetails:[3d6 ({d1} {d2} {d3})]```"
embed.add_field( embed.add_field(name="Dice Roll", value=dice_result, inline=False)
name="Dice Roll",
value=dice_result,
inline=False
)
view = None view = None
@ -177,20 +176,20 @@ class InjuryGroup(app_commands.Group):
embed.color = discord.Color.orange() embed.color = discord.Color.orange()
if injury_result > 6: if injury_result > 6:
gif_search_text = ['well shit', 'well fuck', 'god dammit'] gif_search_text = ["well shit", "well fuck", "god dammit"]
else: else:
gif_search_text = ['bummer', 'well damn'] gif_search_text = ["bummer", "well damn"]
if player.is_pitcher: 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 # 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.""" """Show modal to collect pitcher rest information."""
modal = PitcherRestModal( modal = PitcherRestModal(
player=player, player=player, injury_games=injury_result, season=current.season
injury_games=injury_result,
season=current.season
) )
await button_interaction.response.send_modal(modal) await button_interaction.response.send_modal(modal)
@ -198,12 +197,12 @@ class InjuryGroup(app_commands.Group):
else: else:
# Batter callback shows modal to collect current week/game # 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.""" """Show modal to collect current week/game information for batter injury."""
modal = BatterInjuryModal( modal = BatterInjuryModal(
player=player, player=player, injury_games=injury_result, season=current.season
injury_games=injury_result,
season=current.season
) )
await button_interaction.response.send_modal(modal) 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 # Only the player's team GM(s) can log the injury
view = ConfirmationView( view = ConfirmationView(
timeout=180.0, # 3 minutes for confirmation 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_callback=injury_callback,
confirm_label="Log Injury", confirm_label="Log Injury",
cancel_label="Ignore Injury" cancel_label="Ignore Injury",
) )
elif injury_result == 'REM': elif injury_result == "REM":
if player.is_pitcher: if player.is_pitcher:
result_text = '**FATIGUED**' result_text = "**FATIGUED**"
else: else:
result_text = "**REMAINDER OF GAME**" result_text = "**REMAINDER OF GAME**"
embed.color = discord.Color.gold() 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' else: # 'OK'
result_text = "**No injury!**" result_text = "**No injury!**"
embed.color = discord.Color.green() 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( embed.add_field(name="Injury Length", value=result_text, inline=True)
name="Injury Length",
value=result_text,
inline=True
)
try: try:
injury_gif = await GiphyService().get_gif( injury_gif = await GiphyService().get_gif(phrase_options=gif_search_text)
phrase_options=gif_search_text
)
except Exception: except Exception:
injury_gif = '' injury_gif = ""
embed.set_image(url=injury_gif) embed.set_image(url=injury_gif)
@ -251,7 +246,6 @@ class InjuryGroup(app_commands.Group):
else: else:
await interaction.followup.send(embed=embed) await interaction.followup.send(embed=embed)
def _get_injury_result(self, rating: str, games_played: int, roll: int): def _get_injury_result(self, rating: str, games_played: int, roll: int):
""" """
Get injury result from the injury table. Get injury result from the injury table.
@ -266,89 +260,194 @@ class InjuryGroup(app_commands.Group):
""" """
# Injury table mapping # Injury table mapping
inj_data = { inj_data = {
'one': { "one": {
'p70': ['OK', 'OK', 'OK', 'OK', 'OK', 'OK', 'REM', 'REM', 1, 1, 2, 2, 3, 3, 4, 4], "p70": [
'p65': [2, 2, 'OK', 'REM', 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12], "OK",
'p60': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16], "OK",
'p50': ['OK', 'REM', 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, 'OK'], "OK",
'p40': ['OK', 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, 'REM', 'OK'], "OK",
'p30': ['OK', 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, 'REM', 'OK'], "OK",
'p20': ['OK', 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, 'REM', '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': { "two": {
'p70': [4, 3, 2, 2, 1, 1, 'REM', 'OK', 'REM', 'OK', 2, 1, 2, 2, 3, 4], "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], "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], "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'], "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'], "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'], "p30": [
'p20': ['OK', 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, 'REM'] "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': { "three": {
'p70': [], "p70": [],
'p65': ['OK', 'OK', 'REM', 1, 3, 'OK', 'REM', 1, 2, 1, 2, 3, 4, 4, 5, 'REM'], "p65": [
'p60': ['OK', 5, 'OK', 'REM', 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, 'REM'], "OK",
'p50': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], "OK",
'p40': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], "REM",
'p30': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, 'REM'], 1,
'p20': ['OK', 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, 'REM'] 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': { "four": {
'p70': [], "p70": [],
'p65': [], "p65": [],
'p60': ['OK', 'OK', 'REM', 3, 3, 'OK', 'REM', 1, 2, 1, 4, 4, 5, 6, 8, 'REM'], "p60": [
'p50': ['OK', 6, 4, 'OK', 'REM', 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, 'REM'], "OK",
'p40': ['OK', 'OK', 'REM', 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, 'REM'], "OK",
'p30': ['OK', 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, 'REM'], "REM",
'p20': ['OK', 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, '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': { "five": {
'p70': [], "p70": [],
'p65': [], "p65": [],
'p60': ['OK', 'REM', 'REM', 'REM', 3, 'OK', 1, 'REM', 2, 1, 'OK', 4, 5, 2, 6, 8], "p60": [
'p50': ['OK', 'OK', 'REM', 1, 1, 'OK', 'REM', 3, 2, 4, 4, 5, 5, 6, 8, 12], "OK",
'p40': ['OK', 6, 6, 'OK', 1, 3, 2, 4, 4, 5, 'REM', 3, 8, 6, 12, 1], "REM",
'p30': ['OK', 'OK', 'REM', 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, 'REM'], "REM",
'p20': ['OK', 'REM', 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, '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 # 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) games_key = games_map.get(games_played)
if not games_key: if not games_key:
return 'OK' return "OK"
# Get the injury table for this rating and games played # Get the injury table for this rating and games played
injury_table = inj_data.get(games_key, {}).get(rating, []) injury_table = inj_data.get(games_key, {}).get(rating, [])
# If no table exists (e.g., p70 with 3+ games), no injury # If no table exists (e.g., p70 with 3+ games), no injury
if not injury_table: if not injury_table:
return 'OK' return "OK"
# Get result from table (roll 3-18 maps to index 0-15) # Get result from table (roll 3-18 maps to index 0-15)
table_index = roll - 3 table_index = roll - 3
if 0 <= table_index < len(injury_table): if 0 <= table_index < len(injury_table):
return injury_table[table_index] 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( @app_commands.describe(
player_name="Player name to injure", player_name="Player name to injure",
this_week="Current week number", this_week="Current week number",
this_game="Current game number (1-4)", 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() @league_only()
@logged_command("/injury set-new") @logged_command("/injury set-new")
@ -358,14 +457,14 @@ class InjuryGroup(app_commands.Group):
player_name: str, player_name: str,
this_week: int, this_week: int,
this_game: int, this_game: int,
injury_games: int injury_games: int,
): ):
"""Set a new injury for a player on your team.""" """Set a new injury for a player on your team."""
# Check role permissions # Check role permissions
if not self.has_player_role(interaction): if not self.has_player_role(interaction):
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Permission Denied", 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) await interaction.response.send_message(embed=embed, ephemeral=True)
return return
@ -376,7 +475,7 @@ class InjuryGroup(app_commands.Group):
if this_game < 1 or this_game > 4: if this_game < 1 or this_game > 4:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Invalid Input", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -384,7 +483,7 @@ class InjuryGroup(app_commands.Group):
if injury_games < 1: if injury_games < 1:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Invalid Input", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -395,12 +494,14 @@ class InjuryGroup(app_commands.Group):
raise BotException("Failed to get current season information") raise BotException("Failed to get current season information")
# Search for player using the search endpoint (more reliable than name param) # 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: if not players:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Player Not Found", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -410,6 +511,7 @@ class InjuryGroup(app_commands.Group):
# Fetch full team data if team is not populated # Fetch full team data if team is not populated
if player.team_id and not player.team: if player.team_id and not player.team:
from services.team_service import team_service from services.team_service import team_service
player.team = await team_service.get_team(player.team_id) player.team = await team_service.get_team(player.team_id)
# Check if player is on user's team # Check if player is on user's team
@ -418,7 +520,9 @@ class InjuryGroup(app_commands.Group):
# TODO: Add team ownership verification # TODO: Add team ownership verification
# Check if player already has an active injury # 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 # Data consistency check: If injury exists but il_return is None, it's stale data
if existing_injury: if existing_injury:
@ -431,12 +535,14 @@ class InjuryGroup(app_commands.Group):
await injury_service.clear_injury(existing_injury.id) await injury_service.clear_injury(existing_injury.id)
# Notify user but allow them to proceed # 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: else:
# Valid active injury - player is actually injured # Valid active injury - player is actually injured
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Already Injured", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -456,7 +562,7 @@ class InjuryGroup(app_commands.Group):
start_week = this_week if this_game != 4 else this_week + 1 start_week = this_week if this_game != 4 else this_week + 1
start_game = this_game + 1 if this_game != 4 else 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 # Create injury record
injury = await injury_service.create_injury( injury = await injury_service.create_injury(
@ -466,49 +572,43 @@ class InjuryGroup(app_commands.Group):
start_week=start_week, start_week=start_week,
start_game=start_game, start_game=start_game,
end_week=return_week, end_week=return_week,
end_game=return_game end_game=return_game,
) )
if not injury: if not injury:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
# Update player's il_return field # 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 # Success response
embed = EmbedTemplate.success( embed = EmbedTemplate.success(
title="Injury Recorded", title="Injury Recorded",
description=f"{player.name}'s injury has been logged" description=f"{player.name}'s injury has been logged",
) )
embed.add_field( embed.add_field(
name="Player", name="Player", value=f"{player.name} ({player.pos_1})", inline=True
value=f"{player.name} ({player.pos_1})",
inline=True
) )
embed.add_field( embed.add_field(
name="Duration", name="Duration",
value=f"{injury_games} game{'s' if injury_games > 1 else ''}", value=f"{injury_games} game{'s' if injury_games > 1 else ''}",
inline=True inline=True,
) )
embed.add_field( embed.add_field(name="Return Date", value=return_date, inline=True)
name="Return Date",
value=return_date,
inline=True
)
if player.team: if player.team:
embed.add_field( embed.add_field(
name="Team", name="Team",
value=f"{player.team.lname} ({player.team.abbrev})", value=f"{player.team.lname} ({player.team.abbrev})",
inline=False inline=False,
) )
await interaction.followup.send(embed=embed) 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}", f"Injury set for {player.name}: {injury_games} games, returns {return_date}",
player_id=player.id, player_id=player.id,
season=current.season, 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. 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 actual_start_game = start_game + 1 if start_game != 4 else 1
return { return {
'total_games': injury_games, "total_games": injury_games,
'start_week': actual_start_week, "start_week": actual_start_week,
'start_game': actual_start_game, "start_game": actual_start_game,
'end_week': return_week, "end_week": return_week,
'end_game': return_game "end_game": return_game,
} }
@app_commands.command(
@app_commands.command(name="clear", description="Clear a player's injury (requires SBA Players role)") name="clear", description="Clear a player's injury (requires SBA Players role)"
)
@app_commands.describe(player_name="Player name to clear injury") @app_commands.describe(player_name="Player name to clear injury")
@app_commands.autocomplete(player_name=player_autocomplete) @app_commands.autocomplete(player_name=player_autocomplete)
@league_only() @league_only()
@ -568,7 +671,7 @@ class InjuryGroup(app_commands.Group):
if not self.has_player_role(interaction): if not self.has_player_role(interaction):
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Permission Denied", 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) await interaction.response.send_message(embed=embed, ephemeral=True)
return return
@ -581,12 +684,14 @@ class InjuryGroup(app_commands.Group):
raise BotException("Failed to get current season information") raise BotException("Failed to get current season information")
# Search for player using the search endpoint (more reliable than name param) # 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: if not players:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="Player Not Found", 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) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -596,6 +701,7 @@ class InjuryGroup(app_commands.Group):
# Fetch full team data if team is not populated # Fetch full team data if team is not populated
if player.team_id and not player.team: if player.team_id and not player.team:
from services.team_service import team_service from services.team_service import team_service
player.team = await team_service.get_team(player.team_id) player.team = await team_service.get_team(player.team_id)
# Get active injury # Get active injury
@ -603,8 +709,7 @@ class InjuryGroup(app_commands.Group):
if not injury: if not injury:
embed = EmbedTemplate.error( embed = EmbedTemplate.error(
title="No Active Injury", title="No Active Injury", description=f"{player.name} isn't injured."
description=f"{player.name} isn't injured."
) )
await interaction.followup.send(embed=embed, ephemeral=True) await interaction.followup.send(embed=embed, ephemeral=True)
return return
@ -612,7 +717,7 @@ class InjuryGroup(app_commands.Group):
# Create confirmation embed # Create confirmation embed
embed = EmbedTemplate.info( embed = EmbedTemplate.info(
title=f"{player.name}", 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: if player.team and player.team.thumbnail is not None:
@ -621,33 +726,27 @@ class InjuryGroup(app_commands.Group):
embed.add_field( embed.add_field(
name="Player", name="Player",
value=f"{player.name} ({player.primary_position})", value=f"{player.name} ({player.primary_position})",
inline=True inline=True,
) )
if player.team: if player.team:
embed.add_field( embed.add_field(
name="Team", name="Team",
value=f"{player.team.lname} ({player.team.abbrev})", value=f"{player.team.lname} ({player.team.abbrev})",
inline=True inline=True,
) )
embed.add_field( embed.add_field(name="Expected Return", value=injury.return_date, inline=True)
name="Expected Return",
value=injury.return_date,
inline=True
)
embed.add_field( embed.add_field(name="Games Missed", value=injury.duration_display, inline=True)
name="Games Missed",
value=injury.duration_display,
inline=True
)
# Initialize responder_team to None for major league teams # Initialize responder_team to None for major league teams
if player.team.roster_type() == RosterType.MAJOR_LEAGUE: if player.team.roster_type() == RosterType.MAJOR_LEAGUE:
responder_team = player.team responder_team = player.team
else: 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 # Create callback for confirmation
async def clear_confirm_callback(button_interaction: discord.Interaction): async def clear_confirm_callback(button_interaction: discord.Interaction):
@ -658,37 +757,33 @@ class InjuryGroup(app_commands.Group):
if not success: if not success:
error_embed = EmbedTemplate.error( error_embed = EmbedTemplate.error(
title="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 return
# Clear player's il_return field # 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 response
success_embed = EmbedTemplate.success( success_embed = EmbedTemplate.success(
title="Injury Cleared", 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( success_embed.add_field(
name="Injury Return Date", name="Injury Return Date", value=injury.return_date, inline=True
value=injury.return_date,
inline=True
) )
success_embed.add_field( success_embed.add_field(
name="Total Games Missed", name="Total Games Missed", value=injury.duration_display, inline=True
value=injury.duration_display,
inline=True
) )
if player.team: if player.team:
success_embed.add_field( success_embed.add_field(
name="Team", name="Team", value=f"{player.team.lname}", inline=False
value=f"{player.team.lname}",
inline=False
) )
if player.team.thumbnail is not None: if player.team.thumbnail is not None:
success_embed.set_thumbnail(url=player.team.thumbnail) success_embed.set_thumbnail(url=player.team.thumbnail)
@ -700,17 +795,19 @@ class InjuryGroup(app_commands.Group):
f"Injury cleared for {player.name}", f"Injury cleared for {player.name}",
player_id=player.id, player_id=player.id,
season=current.season, season=current.season,
injury_id=injury.id injury_id=injury.id,
) )
# Create confirmation view # Create confirmation view
view = ConfirmationView( view = ConfirmationView(
user_id=interaction.user.id, user_id=interaction.user.id,
timeout=180.0, # 3 minutes for confirmation 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_callback=clear_confirm_callback,
confirm_label="Clear Injury", confirm_label="Clear Injury",
cancel_label="Cancel" cancel_label="Cancel",
) )
# Send confirmation embed with view # Send confirmation embed with view

View File

@ -175,14 +175,14 @@ class SubmitScorecardCommands(commands.Cog):
# Delete old data # Delete old data
try: try:
await play_service.delete_plays_for_game(duplicate_game.id) await play_service.delete_plays_for_game(duplicate_game.id)
except: except Exception:
pass # May not exist pass # May not exist
try: try:
await decision_service.delete_decisions_for_game( await decision_service.delete_decisions_for_game(
duplicate_game.id duplicate_game.id
) )
except: except Exception:
pass # May not exist pass # May not exist
await game_service.wipe_game_data(duplicate_game.id) await game_service.wipe_game_data(duplicate_game.id)
@ -354,7 +354,7 @@ class SubmitScorecardCommands(commands.Cog):
try: try:
await standings_service.recalculate_standings(current.season) await standings_service.recalculate_standings(current.season)
except: except Exception:
# Non-critical error # Non-critical error
self.logger.error("Failed to recalculate standings") self.logger.error("Failed to recalculate standings")
@ -372,11 +372,11 @@ class SubmitScorecardCommands(commands.Cog):
await play_service.delete_plays_for_game(game_id) await play_service.delete_plays_for_game(game_id)
elif rollback_state == "PLAYS_POSTED": elif rollback_state == "PLAYS_POSTED":
await play_service.delete_plays_for_game(game_id) await play_service.delete_plays_for_game(game_id)
except: except Exception:
pass # Best effort rollback pass # Best effort rollback
await interaction.edit_original_response( 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): def _match_manager(self, team: Team, manager_name: str):

View File

@ -1,6 +1,7 @@
""" """
Configuration management for Discord Bot v2.0 Configuration management for Discord Bot v2.0
""" """
import os import os
from typing import Optional from typing import Optional
@ -40,17 +41,18 @@ class BotConfig(BaseSettings):
playoff_round_two_games: int = 7 playoff_round_two_games: int = 7
playoff_round_three_games: int = 7 playoff_round_three_games: int = 7
modern_stats_start_season: int = 8 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 # Roster Limits
expand_mil_week: int = 15 # Week when MiL roster expands (early vs late 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_early: int = 26 # ML limit for weeks before expand_mil_week
ml_roster_limit_late: int = 26 # ML limit for weeks >= 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_early: int = 6 # MiL limit for weeks before expand_mil_week
mil_roster_limit_late: int = 14 # MiL limit for weeks >= 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 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 Constants
api_version: str = "v3" api_version: str = "v3"
@ -60,10 +62,10 @@ class BotConfig(BaseSettings):
# Draft Constants # Draft Constants
default_pick_minutes: int = 10 default_pick_minutes: int = 10
draft_rounds: int = 32 draft_rounds: int = 32
draft_team_count: int = 16 # Number of teams in draft draft_team_count: int = 16 # Number of teams in draft
draft_linear_rounds: int = 10 # Rounds 1-10 are linear, 11+ are snake 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 swar_cap_limit: float = 32.00 # Maximum sWAR cap for team roster
cap_player_count: int = 26 # Number of players that count toward cap cap_player_count: int = 26 # Number of players that count toward cap
# Special Team IDs # Special Team IDs
free_agent_team_id: int = 547 free_agent_team_id: int = 547
@ -80,7 +82,7 @@ class BotConfig(BaseSettings):
# Base URLs # Base URLs
sba_base_url: str = "https://sba.manticorum.com" 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 # Application settings
log_level: str = "INFO" log_level: str = "INFO"
@ -92,29 +94,33 @@ class BotConfig(BaseSettings):
# Draft Sheet settings (for writing picks to Google Sheets) # 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. # 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_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 settings
giphy_api_key: str = "H86xibttEuUcslgmMM6uu74IgLEZ7UOD" giphy_api_key: str = ""
giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate" giphy_translate_url: str = "https://api.giphy.com/v1/gifs/translate"
# Optional Redis caching settings # Optional Redis caching settings
redis_url: str = "" # Empty string means no Redis caching redis_url: str = "" # Empty string means no Redis caching
redis_cache_ttl: int = 300 # 5 minutes default TTL redis_cache_ttl: int = 300 # 5 minutes default TTL
model_config = SettingsConfigDict( model_config = SettingsConfigDict(
env_file=".env", env_file=".env",
case_sensitive=False, case_sensitive=False,
extra="ignore" # Ignore extra environment variables extra="ignore", # Ignore extra environment variables
) )
@property @property
def is_development(self) -> bool: def is_development(self) -> bool:
"""Check if running in development mode.""" """Check if running in development mode."""
return self.environment.lower() == "development" return self.environment.lower() == "development"
@property @property
def is_testing(self) -> bool: def is_testing(self) -> bool:
"""Check if running in test mode.""" """Check if running in test mode."""
@ -139,7 +145,7 @@ class BotConfig(BaseSettings):
# Default sheet IDs (hardcoded as fallback) # Default sheet IDs (hardcoded as fallback)
default_keys = { default_keys = {
12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU", 12: "1OF-sAFykebc_2BrcYCgxCR-4rJo0GaNmTstagV-PMBU",
13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE" 13: "1vWJfvuz9jN5BU2ZR0X0oC9BAVr_R8o-dWZsF2KXQMsE",
} }
# Check environment variable first (allows runtime override) # Check environment variable first (allows runtime override)
@ -165,9 +171,10 @@ class BotConfig(BaseSettings):
# Global configuration instance - lazily initialized to avoid import-time errors # Global configuration instance - lazily initialized to avoid import-time errors
_config = None _config = None
def get_config() -> BotConfig: def get_config() -> BotConfig:
"""Get the global configuration instance.""" """Get the global configuration instance."""
global _config global _config
if _config is None: if _config is None:
_config = BotConfig() # type: ignore _config = BotConfig() # type: ignore
return _config return _config

View File

@ -4,93 +4,88 @@ Giphy Service for Discord Bot v2.0
Provides async interface to Giphy API with disappointment-based search phrases. Provides async interface to Giphy API with disappointment-based search phrases.
Used for Easter egg features like the soak command. Used for Easter egg features like the soak command.
""" """
import random import random
from typing import List, Optional from typing import List, Optional
from urllib.parse import quote
import aiohttp import aiohttp
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from config import get_config from config import get_config
from exceptions import APIException from exceptions import APIException
# Disappointment tier configuration # Disappointment tier configuration
DISAPPOINTMENT_TIERS = { DISAPPOINTMENT_TIERS = {
'tier_1': { "tier_1": {
'max_seconds': 1800, # 30 minutes "max_seconds": 1800, # 30 minutes
'phrases': [ "phrases": [
"extremely disappointed", "extremely disappointed",
"so disappointed", "so disappointed",
"are you kidding me", "are you kidding me",
"seriously", "seriously",
"unbelievable" "unbelievable",
], ],
'description': "Maximum Disappointment" "description": "Maximum Disappointment",
}, },
'tier_2': { "tier_2": {
'max_seconds': 7200, # 2 hours "max_seconds": 7200, # 2 hours
'phrases': [ "phrases": [
"very disappointed", "very disappointed",
"can't believe you", "can't believe you",
"not happy", "not happy",
"shame on you", "shame on you",
"facepalm" "facepalm",
], ],
'description': "Severe Disappointment" "description": "Severe Disappointment",
}, },
'tier_3': { "tier_3": {
'max_seconds': 21600, # 6 hours "max_seconds": 21600, # 6 hours
'phrases': [ "phrases": [
"disappointed", "disappointed",
"not impressed", "not impressed",
"shaking head", "shaking head",
"eye roll", "eye roll",
"really" "really",
], ],
'description': "Strong Disappointment" "description": "Strong Disappointment",
}, },
'tier_4': { "tier_4": {
'max_seconds': 86400, # 24 hours "max_seconds": 86400, # 24 hours
'phrases': [ "phrases": [
"mildly disappointed", "mildly disappointed",
"not great", "not great",
"could be better", "could be better",
"sigh", "sigh",
"seriously" "seriously",
], ],
'description': "Moderate Disappointment" "description": "Moderate Disappointment",
}, },
'tier_5': { "tier_5": {
'max_seconds': 604800, # 7 days "max_seconds": 604800, # 7 days
'phrases': [ "phrases": ["slightly disappointed", "oh well", "shrug", "meh", "not bad"],
"slightly disappointed", "description": "Mild Disappointment",
"oh well",
"shrug",
"meh",
"not bad"
],
'description': "Mild Disappointment"
}, },
'tier_6': { "tier_6": {
'max_seconds': float('inf'), # 7+ days "max_seconds": float("inf"), # 7+ days
'phrases': [ "phrases": [
"not disappointed", "not disappointed",
"relieved", "relieved",
"proud", "proud",
"been worse", "been worse",
"fine i guess" "fine i guess",
], ],
'description': "Minimal Disappointment" "description": "Minimal Disappointment",
}, },
'first_ever': { "first_ever": {
'phrases': [ "phrases": [
"here we go", "here we go",
"oh boy", "oh boy",
"uh oh", "uh oh",
"getting started", "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.config = get_config()
self.api_key = self.config.giphy_api_key self.api_key = self.config.giphy_api_key
self.translate_url = self.config.giphy_translate_url 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: 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') Tier key string (e.g., 'tier_1', 'first_ever')
""" """
if seconds_elapsed is None: 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']: 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']: if seconds_elapsed <= DISAPPOINTMENT_TIERS[tier_key]["max_seconds"]:
return tier_key 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: def get_random_phrase_for_tier(self, tier_key: str) -> str:
""" """
@ -139,7 +134,7 @@ class GiphyService:
if tier_key not in DISAPPOINTMENT_TIERS: if tier_key not in DISAPPOINTMENT_TIERS:
raise ValueError(f"Invalid tier key: {tier_key}") raise ValueError(f"Invalid tier key: {tier_key}")
phrases = DISAPPOINTMENT_TIERS[tier_key]['phrases'] phrases = DISAPPOINTMENT_TIERS[tier_key]["phrases"]
return random.choice(phrases) return random.choice(phrases)
def get_tier_description(self, tier_key: str) -> str: def get_tier_description(self, tier_key: str) -> str:
@ -158,7 +153,7 @@ class GiphyService:
if tier_key not in DISAPPOINTMENT_TIERS: if tier_key not in DISAPPOINTMENT_TIERS:
raise ValueError(f"Invalid tier key: {tier_key}") 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: async def get_disappointment_gif(self, tier_key: str) -> str:
""" """
@ -181,7 +176,7 @@ class GiphyService:
if tier_key not in DISAPPOINTMENT_TIERS: if tier_key not in DISAPPOINTMENT_TIERS:
raise ValueError(f"Invalid tier key: {tier_key}") 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 # Shuffle phrases for variety and retry capability
shuffled_phrases = random.sample(phrases, len(phrases)) shuffled_phrases = random.sample(phrases, len(phrases))
@ -189,39 +184,61 @@ class GiphyService:
async with aiohttp.ClientSession() as session: async with aiohttp.ClientSession() as session:
for phrase in shuffled_phrases: for phrase in shuffled_phrases:
try: 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: if resp.status == 200:
data = await resp.json() data = await resp.json()
# Filter out Trump GIFs (legacy behavior) # Filter out Trump GIFs (legacy behavior)
gif_title = data.get('data', {}).get('title', '').lower() gif_title = data.get("data", {}).get("title", "").lower()
if 'trump' in gif_title: if "trump" in gif_title:
self.logger.debug(f"Filtered out Trump GIF for phrase: {phrase}") self.logger.debug(
f"Filtered out Trump GIF for phrase: {phrase}"
)
continue continue
# Get the actual GIF image URL, not the web page URL # 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: 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 return gif_url
else: 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: 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: 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: 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 # All phrases failed
error_msg = f"Failed to fetch any GIF for tier: {tier_key}" error_msg = f"Failed to fetch any GIF for tier: {tier_key}"
self.logger.error(error_msg) self.logger.error(error_msg)
raise APIException(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. 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 APIException: If all GIF fetch attempts fail
""" """
if phrase is None and phrase_options is None: 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: if phrase is not None:
search_phrase = phrase search_phrase = phrase
elif phrase_options is not None: elif phrase_options is not None:
@ -250,33 +269,53 @@ class GiphyService:
while attempts < 3: while attempts < 3:
attempts += 1 attempts += 1
try: 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: 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 continue
data = await resp.json() data = await resp.json()
# Filter out Trump GIFs (legacy behavior) # Filter out Trump GIFs (legacy behavior)
gif_title = data.get('data', {}).get('title', '').lower() gif_title = data.get("data", {}).get("title", "").lower()
if 'trump' in gif_title: if "trump" in gif_title:
self.logger.debug(f"Filtered out Trump GIF for phrase: {search_phrase}") self.logger.debug(
f"Filtered out Trump GIF for phrase: {search_phrase}"
)
continue continue
# Get the actual GIF image URL, not the web page URL # 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: 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 return gif_url
else: 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: 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: 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 # All attempts failed
error_msg = f"Failed to fetch any GIF for phrase: {search_phrase}" error_msg = f"Failed to fetch any GIF for phrase: {search_phrase}"

View File

@ -4,6 +4,7 @@ Transaction Freeze/Thaw Task for Discord Bot v2.0
Automated weekly system for freezing and processing transactions. Automated weekly system for freezing and processing transactions.
Runs on a schedule to increment weeks and process contested transactions. Runs on a schedule to increment weeks and process contested transactions.
""" """
import asyncio import asyncio
import random import random
from datetime import datetime, UTC from datetime import datetime, UTC
@ -30,6 +31,7 @@ class TransactionPriority:
Data class for transaction priority calculation. Data class for transaction priority calculation.
Used to resolve contested transactions (multiple teams wanting same player). Used to resolve contested transactions (multiple teams wanting same player).
""" """
transaction: Transaction transaction: Transaction
team_win_percentage: float team_win_percentage: float
tiebreaker: float # win% + small random number for randomized tiebreak tiebreaker: float # win% + small random number for randomized tiebreak
@ -42,6 +44,7 @@ class TransactionPriority:
@dataclass @dataclass
class ConflictContender: class ConflictContender:
"""A team contending for a contested player.""" """A team contending for a contested player."""
team_abbrev: str team_abbrev: str
wins: int wins: int
losses: int losses: int
@ -52,6 +55,7 @@ class ConflictContender:
@dataclass @dataclass
class ConflictResolution: class ConflictResolution:
"""Details of a conflict resolution for a contested player.""" """Details of a conflict resolution for a contested player."""
player_name: str player_name: str
player_swar: float player_swar: float
contenders: List[ConflictContender] contenders: List[ConflictContender]
@ -62,6 +66,7 @@ class ConflictResolution:
@dataclass @dataclass
class ThawedMove: class ThawedMove:
"""A move that was successfully thawed (unfrozen).""" """A move that was successfully thawed (unfrozen)."""
move_id: str move_id: str
team_abbrev: str team_abbrev: str
players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team)
@ -71,6 +76,7 @@ class ThawedMove:
@dataclass @dataclass
class CancelledMove: class CancelledMove:
"""A move that was cancelled due to conflict.""" """A move that was cancelled due to conflict."""
move_id: str move_id: str
team_abbrev: str team_abbrev: str
players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team) players: List[Tuple[str, float, str, str]] # (name, sWAR, old_team, new_team)
@ -81,6 +87,7 @@ class CancelledMove:
@dataclass @dataclass
class ThawReport: class ThawReport:
"""Complete thaw report for admin review.""" """Complete thaw report for admin review."""
week: int week: int
season: int season: int
timestamp: datetime timestamp: datetime
@ -94,8 +101,7 @@ class ThawReport:
async def resolve_contested_transactions( async def resolve_contested_transactions(
transactions: List[Transaction], transactions: List[Transaction], season: int
season: int
) -> Tuple[List[str], List[str], List[ConflictResolution]]: ) -> Tuple[List[str], List[str], List[ConflictResolution]]:
""" """
Resolve contested transactions where multiple teams want the same player. Resolve contested transactions where multiple teams want the same player.
@ -109,7 +115,7 @@ async def resolve_contested_transactions(
Returns: Returns:
Tuple of (winning_move_ids, losing_move_ids, conflict_resolutions) 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 # Group transactions by player name
player_transactions: Dict[str, List[Transaction]] = {} player_transactions: Dict[str, List[Transaction]] = {}
@ -118,7 +124,7 @@ async def resolve_contested_transactions(
player_name = transaction.player.name.lower() player_name = transaction.player.name.lower()
# Only consider transactions where a team is acquiring a player (not FA drops) # 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: if player_name not in player_transactions:
player_transactions[player_name] = [] player_transactions[player_name] = []
player_transactions[player_name].append(transaction) 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(): for player_name, player_transactions_list in player_transactions.items():
if len(player_transactions_list) > 1: if len(player_transactions_list) > 1:
contested_players[player_name] = player_transactions_list 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: else:
# Non-contested, automatically wins # Non-contested, automatically wins
non_contested_moves.add(player_transactions_list[0].moveid) 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(): for player_name, contested_transactions in contested_players.items():
priorities: List[TransactionPriority] = [] priorities: List[TransactionPriority] = []
# Track standings data for each team for report # 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: for transaction in contested_transactions:
# Get team for priority calculation # Get team for priority calculation
# If adding to MiL team, use the parent ML team for standings # 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 team_abbrev = transaction.newteam.abbrev[:-3] # Remove 'MiL' suffix
else: else:
team_abbrev = transaction.newteam.abbrev team_abbrev = transaction.newteam.abbrev
try: try:
# Get team standings to calculate win percentage # 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 total_games = standings.wins + standings.losses
win_pct = standings.wins / total_games if total_games > 0 else 0.0 win_pct = standings.wins / total_games if total_games > 0 else 0.0
team_standings_data[transaction.newteam.abbrev] = ( team_standings_data[transaction.newteam.abbrev] = (
standings.wins, standings.losses, win_pct standings.wins,
standings.losses,
win_pct,
) )
else: else:
win_pct = 0.0 win_pct = 0.0
team_standings_data[transaction.newteam.abbrev] = (0, 0, 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) # Add small random component for tiebreaking (5 decimal precision)
random_component = random.randint(10000, 99999) * 0.00000001 random_component = random.randint(10000, 99999) * 0.00000001
tiebreaker = win_pct + random_component tiebreaker = win_pct + random_component
priorities.append(TransactionPriority( priorities.append(
transaction=transaction, TransactionPriority(
team_win_percentage=win_pct, transaction=transaction,
tiebreaker=tiebreaker team_win_percentage=win_pct,
)) tiebreaker=tiebreaker,
)
)
except Exception as e: except Exception as e:
logger.error(f"Error calculating priority for {team_abbrev}: {e}") logger.error(f"Error calculating priority for {team_abbrev}: {e}")
team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0) team_standings_data[transaction.newteam.abbrev] = (0, 0, 0.0)
# Give them 0.0 priority on error # Give them 0.0 priority on error
priorities.append(TransactionPriority( priorities.append(
transaction=transaction, TransactionPriority(
team_win_percentage=0.0, transaction=transaction,
tiebreaker=random.randint(10000, 99999) * 0.00000001 team_win_percentage=0.0,
)) tiebreaker=random.randint(10000, 99999) * 0.00000001,
)
)
# Sort by tiebreaker (lowest win% wins - worst teams get priority) # Sort by tiebreaker (lowest win% wins - worst teams get priority)
priorities.sort() priorities.sort()
@ -204,7 +228,7 @@ async def resolve_contested_transactions(
wins=winner_standings[0], wins=winner_standings[0],
losses=winner_standings[1], losses=winner_standings[1],
win_pct=winner_standings[2], win_pct=winner_standings[2],
move_id=winner.transaction.moveid move_id=winner.transaction.moveid,
) )
loser_contenders: List[ConflictContender] = [] loser_contenders: List[ConflictContender] = []
@ -224,7 +248,7 @@ async def resolve_contested_transactions(
wins=loser_standings[0], wins=loser_standings[0],
losses=loser_standings[1], losses=loser_standings[1],
win_pct=loser_standings[2], win_pct=loser_standings[2],
move_id=loser.transaction.moveid move_id=loser.transaction.moveid,
) )
loser_contenders.append(loser_contender) loser_contenders.append(loser_contender)
all_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) # Get player info from first transaction (they all have same player)
player = contested_transactions[0].player player = contested_transactions[0].player
conflict_resolutions.append(ConflictResolution( conflict_resolutions.append(
player_name=player.name, ConflictResolution(
player_swar=player.wara, player_name=player.name,
contenders=all_contenders, player_swar=player.wara,
winner=winner_contender, contenders=all_contenders,
losers=loser_contenders winner=winner_contender,
)) losers=loser_contenders,
)
)
# Add non-contested moves to winners # Add non-contested moves to winners
winning_move_ids.update(non_contested_moves) winning_move_ids.update(non_contested_moves)
@ -255,7 +281,7 @@ class TransactionFreezeTask:
def __init__(self, bot: commands.Bot): def __init__(self, bot: commands.Bot):
self.bot = 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 # Track last execution to prevent duplicate operations
self.last_freeze_week: int | None = None self.last_freeze_week: int | None = None
@ -288,7 +314,9 @@ class TransactionFreezeTask:
# Skip if offseason mode is enabled # Skip if offseason mode is enabled
if config.offseason_flag: 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 return
# Get current league state # Get current league state
@ -304,7 +332,7 @@ class TransactionFreezeTask:
weekday=now.weekday(), weekday=now.weekday(),
hour=now.hour, hour=now.hour,
current_week=current.week, current_week=current.week,
freeze_status=current.freeze freeze_status=current.freeze,
) )
# BEGIN FREEZE: Monday at 00:00, not already frozen # 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 # Only run if we haven't already frozen this week
# Track the week we're freezing FROM (before increment) # Track the week we're freezing FROM (before increment)
if self.last_freeze_week != current.week: if self.last_freeze_week != current.week:
freeze_from_week = current.week # Save BEFORE _begin_freeze modifies it freeze_from_week = (
self.logger.info("Triggering freeze begin", current_week=current.week) current.week
) # Save BEFORE _begin_freeze modifies it
self.logger.info(
"Triggering freeze begin", current_week=current.week
)
await self._begin_freeze(current) await self._begin_freeze(current)
self.last_freeze_week = freeze_from_week # Track the week we froze FROM self.last_freeze_week = (
self.error_notification_sent = False # Reset error flag for new cycle freeze_from_week # Track the week we froze FROM
)
self.error_notification_sent = (
False # Reset error flag for new cycle
)
else: 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 # END FREEZE: Saturday at 00:00, currently frozen
elif now.weekday() == 5 and now.hour == 0 and current.freeze: 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) self.logger.info("Triggering freeze end", current_week=current.week)
await self._end_freeze(current) await self._end_freeze(current)
self.last_thaw_week = current.week 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: else:
self.logger.debug("Thaw already executed for week", week=current.week) self.logger.debug(
"Thaw already executed for week", week=current.week
)
else: else:
self.logger.debug("No freeze/thaw action needed at this time") self.logger.debug("No freeze/thaw action needed at this time")
@ -375,8 +417,7 @@ class TransactionFreezeTask:
# Increment week and set freeze via service # Increment week and set freeze via service
new_week = current.week + 1 new_week = current.week + 1
updated_current = await league_service.update_current_state( updated_current = await league_service.update_current_state(
week=new_week, week=new_week, freeze=True
freeze=True
) )
if not updated_current: if not updated_current:
@ -449,15 +490,18 @@ class TransactionFreezeTask:
try: try:
# Get non-frozen, non-cancelled transactions for current week via service # Get non-frozen, non-cancelled transactions for current week via service
transactions = await transaction_service.get_regular_transactions_by_week( transactions = await transaction_service.get_regular_transactions_by_week(
season=current.season, season=current.season, week=current.week
week=current.week
) )
if not transactions: 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 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 # Execute player roster updates for all transactions
success_count = 0 success_count = 0
@ -470,7 +514,7 @@ class TransactionFreezeTask:
player_id=transaction.player.id, player_id=transaction.player.id,
new_team_id=transaction.newteam.id, new_team_id=transaction.newteam.id,
player_name=transaction.player.name, player_name=transaction.player.name,
dem_week=current.week + 2 dem_week=current.week + 2,
) )
success_count += 1 success_count += 1
@ -482,7 +526,7 @@ class TransactionFreezeTask:
f"Failed to execute transaction for {transaction.player.name}", f"Failed to execute transaction for {transaction.player.name}",
player_id=transaction.player.id, player_id=transaction.player.id,
new_team_id=transaction.newteam.id, new_team_id=transaction.newteam.id,
error=str(e) error=str(e),
) )
failure_count += 1 failure_count += 1
@ -490,7 +534,7 @@ class TransactionFreezeTask:
f"Transaction execution complete for week {current.week}", f"Transaction execution complete for week {current.week}",
success=success_count, success=success_count,
failures=failure_count, failures=failure_count,
total=len(transactions) total=len(transactions),
) )
except Exception as e: except Exception as e:
@ -514,11 +558,13 @@ class TransactionFreezeTask:
transactions = await transaction_service.get_frozen_transactions_by_week( transactions = await transaction_service.get_frozen_transactions_by_week(
season=current.season, season=current.season,
week_start=current.week, week_start=current.week,
week_end=current.week + 1 week_end=current.week + 1,
) )
if not transactions: 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 # Still post an empty report for visibility
empty_report = ThawReport( empty_report = ThawReport(
week=current.week, week=current.week,
@ -530,23 +576,26 @@ class TransactionFreezeTask:
conflict_count=0, conflict_count=0,
conflicts=[], conflicts=[],
thawed_moves=[], thawed_moves=[],
cancelled_moves=[] cancelled_moves=[],
) )
await self._post_thaw_report(empty_report) await self._post_thaw_report(empty_report)
return 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 # Resolve contested transactions
winning_move_ids, losing_move_ids, conflict_resolutions = await resolve_contested_transactions( winning_move_ids, losing_move_ids, conflict_resolutions = (
transactions, await resolve_contested_transactions(transactions, current.season)
current.season
) )
# Build mapping from conflict player to winner for cancelled move tracking # Build mapping from conflict player to winner for cancelled move tracking
conflict_player_to_winner: Dict[str, str] = {} conflict_player_to_winner: Dict[str, str] = {}
for conflict in conflict_resolutions: 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 # Track cancelled moves for report
cancelled_moves_report: List[CancelledMove] = [] cancelled_moves_report: List[CancelledMove] = []
@ -555,24 +604,34 @@ class TransactionFreezeTask:
for losing_move_id in losing_move_ids: for losing_move_id in losing_move_ids:
try: try:
# Get all moves with this moveid (could be multiple players in one transaction) # 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: if losing_moves:
# Cancel the entire transaction (all moves with same moveid) # Cancel the entire transaction (all moves with same moveid)
for move in losing_moves: for move in losing_moves:
success = await transaction_service.cancel_transaction(move.moveid) success = await transaction_service.cancel_transaction(
move.moveid
)
if not success: 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 # Notify the GM(s) about cancellation
first_move = losing_moves[0] first_move = losing_moves[0]
# Determine which team to notify (the team that was trying to acquire) # Determine which team to notify (the team that was trying to acquire)
team_for_notification = (first_move.newteam team_for_notification = (
if first_move.newteam.abbrev.upper() != 'FA' first_move.newteam
else first_move.oldteam) 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 # Find which player caused the conflict
contested_player = "" contested_player = ""
@ -586,16 +645,23 @@ class TransactionFreezeTask:
# Build report entry # Build report entry
players = [ 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 for move in losing_moves
] ]
cancelled_moves_report.append(CancelledMove( cancelled_moves_report.append(
move_id=losing_move_id, CancelledMove(
team_abbrev=team_for_notification.abbrev, move_id=losing_move_id,
players=players, team_abbrev=team_for_notification.abbrev,
lost_to=lost_to, players=players,
contested_player=contested_player lost_to=lost_to,
)) contested_player=contested_player,
)
)
contested_players = [move.player.name for move in losing_moves] contested_players = [move.player.name for move in losing_moves]
self.logger.info( self.logger.info(
@ -604,7 +670,9 @@ class TransactionFreezeTask:
) )
except Exception as e: 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 # Track thawed moves for report
thawed_moves_report: List[ThawedMove] = [] thawed_moves_report: List[ThawedMove] = []
@ -613,13 +681,19 @@ class TransactionFreezeTask:
for winning_move_id in winning_move_ids: for winning_move_id in winning_move_ids:
try: try:
# Get all moves with this moveid # 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: for move in winning_moves:
# Unfreeze the transaction via service # Unfreeze the transaction via service
success = await transaction_service.unfreeze_transaction(move.moveid) success = await transaction_service.unfreeze_transaction(
move.moveid
)
if not success: 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 # Post to transaction log
await self._post_transaction_to_log(winning_move_id, transactions) await self._post_transaction_to_log(winning_move_id, transactions)
@ -629,32 +703,43 @@ class TransactionFreezeTask:
first_move = winning_moves[0] first_move = winning_moves[0]
# Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS) # Extract timestamp from moveid (format: Season-XXX-Week-XX-DD-HH:MM:SS)
try: try:
parts = winning_move_id.split('-') parts = winning_move_id.split("-")
submitted_at = parts[-1] if len(parts) >= 6 else "Unknown" submitted_at = parts[-1] if len(parts) >= 6 else "Unknown"
except Exception: except Exception:
submitted_at = "Unknown" submitted_at = "Unknown"
# Determine team abbrev # Determine team abbrev
if first_move.newteam.abbrev.upper() != 'FA': if first_move.newteam.abbrev.upper() != "FA":
team_abbrev = first_move.newteam.abbrev team_abbrev = first_move.newteam.abbrev
else: else:
team_abbrev = first_move.oldteam.abbrev team_abbrev = first_move.oldteam.abbrev
players = [ 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 for move in winning_moves
] ]
thawed_moves_report.append(ThawedMove( thawed_moves_report.append(
move_id=winning_move_id, ThawedMove(
team_abbrev=team_abbrev, move_id=winning_move_id,
players=players, team_abbrev=team_abbrev,
submitted_at=submitted_at 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: 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 # Generate and post thaw report
thaw_report = ThawReport( thaw_report = ThawReport(
@ -667,7 +752,7 @@ class TransactionFreezeTask:
conflict_count=len(conflict_resolutions), conflict_count=len(conflict_resolutions),
conflicts=conflict_resolutions, conflicts=conflict_resolutions,
thawed_moves=thawed_moves_report, thawed_moves=thawed_moves_report,
cancelled_moves=cancelled_moves_report cancelled_moves=cancelled_moves_report,
) )
await self._post_thaw_report(thaw_report) await self._post_thaw_report(thaw_report)
@ -685,7 +770,7 @@ class TransactionFreezeTask:
player_id: int, player_id: int,
new_team_id: int, new_team_id: int,
player_name: str, player_name: str,
dem_week: Optional[int] = None dem_week: Optional[int] = None,
) -> bool: ) -> bool:
""" """
Execute a player roster update via API PATCH. Execute a player roster update via API PATCH.
@ -708,13 +793,11 @@ class TransactionFreezeTask:
player_id=player_id, player_id=player_id,
player_name=player_name, player_name=player_name,
new_team_id=new_team_id, new_team_id=new_team_id,
dem_week=dem_week dem_week=dem_week,
) )
updated_player = await player_service.update_player_team( updated_player = await player_service.update_player_team(
player_id, player_id, new_team_id, dem_week=dem_week
new_team_id,
dem_week=dem_week
) )
# Verify response (200 or 204 indicates success) # Verify response (200 or 204 indicates success)
@ -724,7 +807,7 @@ class TransactionFreezeTask:
player_id=player_id, player_id=player_id,
player_name=player_name, player_name=player_name,
new_team_id=new_team_id, new_team_id=new_team_id,
dem_week=dem_week dem_week=dem_week,
) )
return True return True
else: else:
@ -733,7 +816,7 @@ class TransactionFreezeTask:
player_id=player_id, player_id=player_id,
player_name=player_name, player_name=player_name,
new_team_id=new_team_id, new_team_id=new_team_id,
dem_week=dem_week dem_week=dem_week,
) )
return False return False
@ -745,7 +828,7 @@ class TransactionFreezeTask:
new_team_id=new_team_id, new_team_id=new_team_id,
dem_week=dem_week, dem_week=dem_week,
error=str(e), error=str(e),
exc_info=True exc_info=True,
) )
raise raise
@ -764,34 +847,36 @@ class TransactionFreezeTask:
self.logger.warning("Could not find guild for freeze announcement") self.logger.warning("Could not find guild for freeze announcement")
return return
channel = discord.utils.get(guild.text_channels, name='transaction-log') channel = discord.utils.get(guild.text_channels, name="transaction-log")
if not channel: if not channel:
self.logger.warning("Could not find transaction-log channel") self.logger.warning("Could not find transaction-log channel")
return return
# Create announcement message (formatted like legacy bot) # Create announcement message (formatted like legacy bot)
week_num = f'Week {week}' week_num = f"Week {week}"
stars = '*' * 32 stars = "*" * 32
if is_beginning: if is_beginning:
message = ( message = (
f'```\n' f"```\n"
f'{stars}\n' f"{stars}\n"
f'{week_num:>9} Freeze Period Begins\n' f"{week_num:>9} Freeze Period Begins\n"
f'{stars}\n' f"{stars}\n"
f'```' f"```"
) )
else: else:
message = ( message = (
f'```\n' f"```\n"
f'{"*" * 30}\n' f'{"*" * 30}\n'
f'{week_num:>9} Freeze Period Ends\n' f"{week_num:>9} Freeze Period Ends\n"
f'{"*" * 30}\n' f'{"*" * 30}\n'
f'```' f"```"
) )
await channel.send(message) 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: except Exception as e:
self.logger.error(f"Error sending freeze announcement: {e}") self.logger.error(f"Error sending freeze announcement: {e}")
@ -809,7 +894,7 @@ class TransactionFreezeTask:
if not guild: if not guild:
return 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: if not info_channel:
self.logger.warning("Could not find weekly-info channel") self.logger.warning("Could not find weekly-info channel")
return return
@ -818,7 +903,7 @@ class TransactionFreezeTask:
async for message in info_channel.history(limit=25): async for message in info_channel.history(limit=25):
try: try:
await message.delete() await message.delete()
except: except Exception:
pass # Ignore deletion errors pass # Ignore deletion errors
# Determine season emoji # Determine season emoji
@ -835,17 +920,17 @@ class TransactionFreezeTask:
is_div_week = current.week in [1, 3, 6, 14, 16, 18] is_div_week = current.week in [1, 3, 6, 14, 16, 18]
weekly_str = ( weekly_str = (
f'**Season**: {season_str}\n' f"**Season**: {season_str}\n"
f'**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / ' f"**Time of Day**: {night_str} / {night_str if is_div_week else day_str} / "
f'{night_str} / {day_str}' f"{night_str} / {day_str}"
) )
# Send info messages # Send info messages
await info_channel.send( await info_channel.send(
content=( content=(
f'Each team has manage permissions in their home ballpark. ' f"Each team has manage permissions in their home ballpark. "
f'They may pin messages and rename the channel.\n\n' f"They may pin messages and rename the channel.\n\n"
f'**Make sure your ballpark starts with your team abbreviation.**' f"**Make sure your ballpark starts with your team abbreviation.**"
) )
) )
await info_channel.send(weekly_str) await info_channel.send(weekly_str)
@ -856,9 +941,7 @@ class TransactionFreezeTask:
self.logger.error(f"Error posting weekly info: {e}") self.logger.error(f"Error posting weekly info: {e}")
async def _post_transaction_to_log( async def _post_transaction_to_log(
self, self, move_id: str, all_transactions: List[Transaction]
move_id: str,
all_transactions: List[Transaction]
): ):
""" """
Post a transaction to the transaction log channel. Post a transaction to the transaction log channel.
@ -873,7 +956,7 @@ class TransactionFreezeTask:
if not guild: if not guild:
return return
channel = discord.utils.get(guild.text_channels, name='transaction-log') channel = discord.utils.get(guild.text_channels, name="transaction-log")
if not channel: if not channel:
return return
@ -884,9 +967,15 @@ class TransactionFreezeTask:
# Determine the team for the embed (team making the moves) # Determine the team for the embed (team making the moves)
first_move = moves[0] 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 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 this_team = first_move.oldteam
else: else:
# Default to newteam if both are FA/IL # Default to newteam if both are FA/IL
@ -898,25 +987,29 @@ class TransactionFreezeTask:
for move in moves: for move in moves:
move_string += ( move_string += (
f'**{move.player.name}** ({move.player.wara:.2f}) ' f"**{move.player.name}** ({move.player.wara:.2f}) "
f'from {move.oldteam.abbrev} to {move.newteam.abbrev}\n' f"from {move.oldteam.abbrev} to {move.newteam.abbrev}\n"
) )
# Create embed # Create embed
embed = EmbedTemplate.create_base_embed( embed = EmbedTemplate.create_base_embed(
title=f'Week {week_num} Transaction', title=f"Week {week_num} Transaction",
description=this_team.sname if hasattr(this_team, 'sname') else this_team.lname, description=(
color=EmbedColors.INFO this_team.sname if hasattr(this_team, "sname") else this_team.lname
),
color=EmbedColors.INFO,
) )
# Set team color if available # Set team color if available
if hasattr(this_team, 'color') and this_team.color: if hasattr(this_team, "color") and this_team.color:
try: try:
embed.color = discord.Color(int(this_team.color.replace('#', ''), 16)) embed.color = discord.Color(
except: int(this_team.color.replace("#", ""), 16)
)
except Exception:
pass # Use default color on error 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) await channel.send(embed=embed)
self.logger.info(f"Transaction posted to log: {move_id}") self.logger.info(f"Transaction posted to log: {move_id}")
@ -924,11 +1017,7 @@ class TransactionFreezeTask:
except Exception as e: except Exception as e:
self.logger.error(f"Error posting transaction to log: {e}") self.logger.error(f"Error posting transaction to log: {e}")
async def _notify_gm_of_cancellation( async def _notify_gm_of_cancellation(self, transaction: Transaction, team):
self,
transaction: Transaction,
team
):
""" """
Send DM to GM(s) about cancelled transaction. Send DM to GM(s) about cancelled transaction.
@ -943,27 +1032,31 @@ class TransactionFreezeTask:
return return
cancel_text = ( cancel_text = (
f'Your transaction for **{transaction.player.name}** has been cancelled ' f"Your transaction for **{transaction.player.name}** has been cancelled "
f'because another team successfully claimed them during the freeze period.' f"because another team successfully claimed them during the freeze period."
) )
# Notify GM1 # Notify GM1
if hasattr(team, 'gmid') and team.gmid: if hasattr(team, "gmid") and team.gmid:
try: try:
gm_one = guild.get_member(team.gmid) gm_one = guild.get_member(team.gmid)
if gm_one: if gm_one:
await gm_one.send(cancel_text) 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: except Exception as e:
self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}") self.logger.error(f"Could not notify GM1 of {team.abbrev}: {e}")
# Notify GM2 if exists # Notify GM2 if exists
if hasattr(team, 'gmid2') and team.gmid2: if hasattr(team, "gmid2") and team.gmid2:
try: try:
gm_two = guild.get_member(team.gmid2) gm_two = guild.get_member(team.gmid2)
if gm_two: if gm_two:
await gm_two.send(cancel_text) 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: except Exception as e:
self.logger.error(f"Could not notify GM2 of {team.abbrev}: {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) admin_channel = self.bot.get_channel(config.thaw_report_channel_id)
if not admin_channel: 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 return
# Build the report content # Build the report content
report_lines = [] report_lines = []
# Header with summary # 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"# Transaction Thaw Report")
report_lines.append(f"**Week {report.week}** | **Season {report.season}** | {timestamp_str}") report_lines.append(
report_lines.append(f"**Total:** {report.total_moves} moves | **Thawed:** {report.thawed_count} | **Cancelled:** {report.cancelled_count} | **Conflicts:** {report.conflict_count}") 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("") report_lines.append("")
# Conflict Resolution section (if any) # Conflict Resolution section (if any)
if report.conflicts: if report.conflicts:
report_lines.append("## Conflict Resolution") report_lines.append("## Conflict Resolution")
for conflict in report.conflicts: for conflict in report.conflicts:
report_lines.append(f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})") report_lines.append(
contenders_str = " vs ".join([ f"**{conflict.player_name}** (sWAR: {conflict.player_swar:.1f})"
f"{c.team_abbrev} ({c.wins}-{c.losses})" )
for c in conflict.contenders 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"- 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("") report_lines.append("")
# Thawed Moves section # Thawed Moves section
@ -1018,7 +1124,9 @@ class TransactionFreezeTask:
for move in report.thawed_moves: for move in report.thawed_moves:
report_lines.append(f"**{move.move_id}** | {move.team_abbrev}") report_lines.append(f"**{move.move_id}** | {move.team_abbrev}")
for player_name, swar, old_team, new_team in move.players: 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: else:
report_lines.append("*No moves thawed*") report_lines.append("*No moves thawed*")
report_lines.append("") report_lines.append("")
@ -1027,10 +1135,18 @@ class TransactionFreezeTask:
report_lines.append("## Cancelled Moves") report_lines.append("## Cancelled Moves")
if report.cancelled_moves: if report.cancelled_moves:
for move in 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 "" lost_info = (
report_lines.append(f"**{move.move_id}** | {move.team_abbrev}{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: 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: else:
report_lines.append("*No moves cancelled*") report_lines.append("*No moves cancelled*")

View File

@ -380,12 +380,14 @@ class SubmitConfirmationModal(discord.ui.Modal):
if "Transaction Builder" in message.embeds[0].title: # type: ignore if "Transaction Builder" in message.embeds[0].title: # type: ignore
await message.edit(embed=completion_embed, view=view) await message.edit(embed=completion_embed, view=view)
break break
except: except Exception:
pass pass
except Exception as e: except Exception as e:
self.logger.error(f"Error submitting transaction: {e}", exc_info=True)
await interaction.followup.send( await interaction.followup.send(
f"❌ Error submitting transaction: {str(e)}", ephemeral=True "❌ Error submitting transaction. Please try again or contact an admin.",
ephemeral=True,
) )