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,7 +14,7 @@ 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:
@ -55,12 +56,17 @@ class APIClient:
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,16 +79,16 @@ 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:
""" """
@ -98,7 +104,10 @@ class APIClient:
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}"
@ -109,15 +118,13 @@ 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")
@ -128,7 +135,7 @@ class APIClient:
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.
@ -169,7 +176,9 @@ 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()
@ -195,7 +204,7 @@ class APIClient:
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.
@ -221,7 +230,9 @@ class APIClient:
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,7 +242,9 @@ 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()
@ -258,7 +271,7 @@ class APIClient:
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.
@ -285,7 +298,9 @@ class APIClient:
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,10 +313,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"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:
@ -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:
@ -392,7 +419,7 @@ class APIClient:
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.
@ -430,8 +457,12 @@ 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

161
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
@ -25,26 +26,24 @@ def setup_logging():
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)
@ -76,12 +75,12 @@ class SBABot(commands.Bot):
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."""
@ -97,11 +96,15 @@ class SBABot(commands.Bot):
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")
@ -158,19 +161,29 @@ class SBABot(commands.Bot):
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."""
@ -179,27 +192,33 @@ 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."""
@ -209,39 +228,40 @@ 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:
@ -261,36 +281,37 @@ 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:
@ -303,7 +324,9 @@ 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")
@ -315,8 +338,7 @@ class SBABot(commands.Bot):
# 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)
@ -329,28 +351,28 @@ class SBABot(commands.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,7 +391,7 @@ 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
@ -377,7 +399,7 @@ async def health_command(interaction: discord.Interaction):
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:
@ -390,9 +412,7 @@ async def health_command(interaction: discord.Interaction):
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)
@ -400,45 +420,44 @@ async def health_command(interaction: discord.Interaction):
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)

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,12 +94,16 @@ 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
@ -107,7 +113,7 @@ class BotConfig(BaseSettings):
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
@ -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,6 +171,7 @@ 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

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,
) )