""" Modal Components for Discord Bot v2.0 Interactive forms and input dialogs for collecting user data. """ from typing import Optional, Callable, Awaitable, Dict, Any, List import re import discord from discord.ext import commands from .embeds import EmbedTemplate, EmbedColors from utils.logging import get_contextual_logger class BaseModal(discord.ui.Modal): """Base modal class with consistent error handling and validation.""" def __init__( self, *, title: str, timeout: Optional[float] = 300.0, custom_id: Optional[str] = None ): kwargs = {"title": title, "timeout": timeout} if custom_id is not None: kwargs["custom_id"] = custom_id super().__init__(**kwargs) self.logger = get_contextual_logger(f'{__name__}.{self.__class__.__name__}') self.result: Optional[Dict[str, Any]] = None self.is_submitted = False async def on_error(self, interaction: discord.Interaction, error: Exception) -> None: """Handle modal errors.""" self.logger.error("Modal error occurred", error=error, modal_title=self.title, user_id=interaction.user.id) try: embed = EmbedTemplate.error( title="Form Error", description="An error occurred while processing your form. Please try again." ) if not interaction.response.is_done(): await interaction.response.send_message(embed=embed, ephemeral=True) else: await interaction.followup.send(embed=embed, ephemeral=True) except Exception as e: self.logger.error("Failed to send error message", error=e) def validate_input(self, field_name: str, value: str, validators: Optional[List[Callable[[str], bool]]] = None) -> tuple[bool, str]: """Validate input field with optional custom validators.""" if not value.strip(): return False, f"{field_name} cannot be empty." if validators: for validator in validators: try: if not validator(value): return False, f"Invalid {field_name} format." except Exception: return False, f"Validation error for {field_name}." return True, "" class PlayerSearchModal(BaseModal): """Modal for collecting detailed player search criteria.""" def __init__(self, *, timeout: Optional[float] = 300.0): super().__init__(title="Player Search", timeout=timeout) self.player_name = discord.ui.TextInput( label="Player Name", placeholder="Enter player name (required)", required=True, max_length=100 ) self.position = discord.ui.TextInput( label="Position", placeholder="e.g., SS, OF, P (optional)", required=False, max_length=10 ) self.team = discord.ui.TextInput( label="Team", placeholder="Team abbreviation (optional)", required=False, max_length=5 ) self.season = discord.ui.TextInput( label="Season", placeholder="Season number (optional)", required=False, max_length=4 ) self.add_item(self.player_name) self.add_item(self.position) self.add_item(self.team) self.add_item(self.season) async def on_submit(self, interaction: discord.Interaction): """Handle form submission.""" # Validate season if provided season_value = None if self.season.value: try: season_value = int(self.season.value) if season_value < 1 or season_value > 50: # Reasonable bounds raise ValueError("Season out of range") except ValueError: embed = EmbedTemplate.error( title="Invalid Season", description="Season must be a valid number between 1 and 50." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Store results self.result = { 'name': self.player_name.value.strip(), 'position': self.position.value.strip() if self.position.value else None, 'team': self.team.value.strip().upper() if self.team.value else None, 'season': season_value } self.is_submitted = True # Acknowledge submission embed = EmbedTemplate.info( title="Search Submitted", description=f"Searching for player: **{self.result['name']}**" ) if self.result['position']: embed.add_field(name="Position", value=self.result['position'], inline=True) if self.result['team']: embed.add_field(name="Team", value=self.result['team'], inline=True) if self.result['season']: embed.add_field(name="Season", value=str(self.result['season']), inline=True) await interaction.response.send_message(embed=embed, ephemeral=True) class TeamSearchModal(BaseModal): """Modal for collecting team search criteria.""" def __init__(self, *, timeout: Optional[float] = 300.0): super().__init__(title="Team Search", timeout=timeout) self.team_input = discord.ui.TextInput( label="Team Name or Abbreviation", placeholder="Enter team name or abbreviation", required=True, max_length=50 ) self.season = discord.ui.TextInput( label="Season", placeholder="Season number (optional)", required=False, max_length=4 ) self.add_item(self.team_input) self.add_item(self.season) async def on_submit(self, interaction: discord.Interaction): """Handle form submission.""" # Validate season if provided season_value = None if self.season.value: try: season_value = int(self.season.value) if season_value < 1 or season_value > 50: raise ValueError("Season out of range") except ValueError: embed = EmbedTemplate.error( title="Invalid Season", description="Season must be a valid number between 1 and 50." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Store results self.result = { 'team': self.team_input.value.strip(), 'season': season_value } self.is_submitted = True # Acknowledge submission embed = EmbedTemplate.info( title="Search Submitted", description=f"Searching for team: **{self.result['team']}**" ) if self.result['season']: embed.add_field(name="Season", value=str(self.result['season']), inline=True) await interaction.response.send_message(embed=embed, ephemeral=True) class FeedbackModal(BaseModal): """Modal for collecting user feedback.""" def __init__( self, *, timeout: Optional[float] = 600.0, submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None ): super().__init__(title="Submit Feedback", timeout=timeout) self.submit_callback = submit_callback self.feedback_type = discord.ui.TextInput( label="Feedback Type", placeholder="e.g., Bug Report, Feature Request, General", required=True, max_length=50 ) self.subject = discord.ui.TextInput( label="Subject", placeholder="Brief description of your feedback", required=True, max_length=100 ) self.description = discord.ui.TextInput( label="Description", placeholder="Detailed description of your feedback", style=discord.TextStyle.paragraph, required=True, max_length=2000 ) self.contact = discord.ui.TextInput( label="Contact Info (Optional)", placeholder="How to reach you for follow-up", required=False, max_length=100 ) self.add_item(self.feedback_type) self.add_item(self.subject) self.add_item(self.description) self.add_item(self.contact) async def on_submit(self, interaction: discord.Interaction): """Handle feedback submission.""" # Store results self.result = { 'type': self.feedback_type.value.strip(), 'subject': self.subject.value.strip(), 'description': self.description.value.strip(), 'contact': self.contact.value.strip() if self.contact.value else None, 'user_id': interaction.user.id, 'username': str(interaction.user), 'submitted_at': discord.utils.utcnow() } self.is_submitted = True # Process feedback if self.submit_callback: try: success = await self.submit_callback(self.result) if success: embed = EmbedTemplate.success( title="Feedback Submitted", description="Thank you for your feedback! We'll review it shortly." ) else: embed = EmbedTemplate.error( title="Submission Failed", description="Failed to submit feedback. Please try again later." ) except Exception as e: self.logger.error("Feedback submission error", error=e) embed = EmbedTemplate.error( title="Submission Error", description="An error occurred while submitting feedback." ) else: embed = EmbedTemplate.success( title="Feedback Received", description="Your feedback has been recorded." ) await interaction.response.send_message(embed=embed, ephemeral=True) class ConfigurationModal(BaseModal): """Modal for configuration settings with validation.""" def __init__( self, current_config: Dict[str, Any], *, timeout: Optional[float] = 300.0, save_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None ): super().__init__(title="Configuration Settings", timeout=timeout) self.current_config = current_config self.save_callback = save_callback # Add configuration fields (customize based on needs) self.setting1 = discord.ui.TextInput( label="Setting 1", placeholder="Enter value for setting 1", default=str(current_config.get('setting1', '')), required=False, max_length=100 ) self.setting2 = discord.ui.TextInput( label="Setting 2", placeholder="Enter value for setting 2", default=str(current_config.get('setting2', '')), required=False, max_length=100 ) self.add_item(self.setting1) self.add_item(self.setting2) async def on_submit(self, interaction: discord.Interaction): """Handle configuration submission.""" # Validate and store new configuration new_config = self.current_config.copy() if self.setting1.value: new_config['setting1'] = self.setting1.value.strip() if self.setting2.value: new_config['setting2'] = self.setting2.value.strip() self.result = new_config self.is_submitted = True # Save configuration if self.save_callback: try: success = await self.save_callback(new_config) if success: embed = EmbedTemplate.success( title="Configuration Saved", description="Your configuration has been updated successfully." ) else: embed = EmbedTemplate.error( title="Save Failed", description="Failed to save configuration. Please try again." ) except Exception as e: self.logger.error("Configuration save error", error=e) embed = EmbedTemplate.error( title="Save Error", description="An error occurred while saving configuration." ) else: embed = EmbedTemplate.success( title="Configuration Updated", description="Configuration has been updated." ) await interaction.response.send_message(embed=embed, ephemeral=True) class CustomInputModal(BaseModal): """Flexible modal for custom input collection.""" def __init__( self, title: str, fields: List[Dict[str, Any]], *, timeout: Optional[float] = 300.0, submit_callback: Optional[Callable[[Dict[str, Any]], Awaitable[None]]] = None ): super().__init__(title=title, timeout=timeout) self.submit_callback = submit_callback self.fields_config = fields # Add text inputs based on field configuration for field in fields[:5]: # Discord limit of 5 text inputs text_input = discord.ui.TextInput( label=field.get('label', 'Field'), placeholder=field.get('placeholder', ''), default=field.get('default', ''), required=field.get('required', False), max_length=field.get('max_length', 4000), style=getattr(discord.TextStyle, field.get('style', 'short')) ) self.add_item(text_input) async def on_submit(self, interaction: discord.Interaction): """Handle custom form submission.""" # Collect all input values results = {} for i, item in enumerate(self.children): if isinstance(item, discord.ui.TextInput): field_config = self.fields_config[i] if i < len(self.fields_config) else {} field_key = field_config.get('key', f'field_{i}') # Apply validation if specified validators = field_config.get('validators', []) if validators: is_valid, error_msg = self.validate_input( field_config.get('label', 'Field'), item.value, validators ) if not is_valid: embed = EmbedTemplate.error( title="Validation Error", description=error_msg ) await interaction.response.send_message(embed=embed, ephemeral=True) return results[field_key] = item.value.strip() if item.value else None self.result = results self.is_submitted = True # Execute callback if provided if self.submit_callback: await self.submit_callback(results) else: embed = EmbedTemplate.success( title="Form Submitted", description="Your form has been submitted successfully." ) await interaction.response.send_message(embed=embed, ephemeral=True) # Validation helper functions def validate_email(email: str) -> bool: """Validate email format.""" pattern = r'^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$' return bool(re.match(pattern, email)) def validate_numeric(value: str) -> bool: """Validate numeric input.""" try: float(value) return True except ValueError: return False def validate_integer(value: str) -> bool: """Validate integer input.""" try: int(value) return True except ValueError: return False def validate_team_abbreviation(abbrev: str) -> bool: """Validate team abbreviation format.""" return len(abbrev) >= 2 and len(abbrev) <= 5 and abbrev.isalpha() def validate_season(season: str) -> bool: """Validate season number.""" try: season_num = int(season) return 1 <= season_num <= 50 except ValueError: return False class BatterInjuryModal(BaseModal): """Modal for collecting current week/game when logging batter injury.""" def __init__( self, player: 'Player', injury_games: int, season: int, *, timeout: Optional[float] = 300.0 ): """ Initialize batter injury modal. Args: player: Player object for the injured batter injury_games: Injury games from roll season: Current season number timeout: Modal timeout in seconds """ super().__init__(title=f"Batter Injury - {player.name}", timeout=timeout) self.player = player self.injury_games = injury_games self.season = season # Current week input self.current_week = discord.ui.TextInput( label="Current Week", placeholder="Enter current week number (e.g., 5)", required=True, max_length=2, style=discord.TextStyle.short ) # Current game input self.current_game = discord.ui.TextInput( label="Current Game", placeholder="Enter current game number (1-4)", required=True, max_length=1, style=discord.TextStyle.short ) self.add_item(self.current_week) self.add_item(self.current_game) async def on_submit(self, interaction: discord.Interaction): """Handle batter injury input and log injury.""" from services.player_service import player_service from services.injury_service import injury_service from config import get_config import math config = get_config() max_week = config.weeks_per_season + config.playoff_weeks_per_season # Validate current week try: week = int(self.current_week.value) if week < 1 or week > max_week: raise ValueError(f"Week must be between 1 and {max_week}") except ValueError: embed = EmbedTemplate.error( title="Invalid Week", description=f"Current week must be a number between 1 and {max_week} (including playoffs)." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Determine max games based on week (regular season vs playoff rounds) if week <= config.weeks_per_season: max_game = config.games_per_week elif week == config.weeks_per_season + 1: max_game = config.playoff_round_one_games elif week == config.weeks_per_season + 2: max_game = config.playoff_round_two_games elif week == config.weeks_per_season + 3: max_game = config.playoff_round_three_games else: max_game = config.games_per_week # Fallback # Validate current game try: game = int(self.current_game.value) if game < 1 or game > max_game: raise ValueError(f"Game must be between 1 and {max_game}") except ValueError: embed = EmbedTemplate.error( title="Invalid Game", description=f"Current game must be a number between 1 and {max_game}." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Calculate injury dates out_weeks = math.floor(self.injury_games / config.games_per_week) out_games = self.injury_games % config.games_per_week return_week = week + out_weeks return_game = game + 1 + out_games if return_game > config.games_per_week: return_week += 1 return_game -= config.games_per_week # Adjust start date if injury starts after game 4 start_week = week if game != config.games_per_week else week + 1 start_game = game + 1 if game != config.games_per_week else 1 return_date = f'w{return_week:02d}g{return_game}' # Create injury record try: injury = await injury_service.create_injury( season=self.season, player_id=self.player.id, total_games=self.injury_games, start_week=start_week, start_game=start_game, end_week=return_week, end_game=return_game ) if not injury: raise ValueError("Failed to create injury record") # Update player's il_return field await player_service.update_player(self.player.id, {'il_return': return_date}) # Success response embed = EmbedTemplate.success( title="Injury Logged", description=f"{self.player.name}'s injury has been logged." ) embed.add_field( name="Duration", value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}", inline=True ) embed.add_field( name="Return Date", value=return_date, inline=True ) if self.player.team: embed.add_field( name="Team", value=f"{self.player.team.lname} ({self.player.team.abbrev})", inline=False ) self.is_submitted = True self.result = { 'injury_id': injury.id, 'total_games': self.injury_games, 'return_date': return_date } await interaction.response.send_message(embed=embed) # Post injury news and update injury log channel try: from utils.injury_log import post_injury_and_update_log await post_injury_and_update_log( bot=interaction.client, player=self.player, injury_games=self.injury_games, return_date=return_date, season=self.season ) except Exception as log_error: self.logger.warning( f"Failed to post injury to channels (injury was still logged): {log_error}", player_id=self.player.id ) except Exception as e: self.logger.error("Failed to create batter injury", error=e, player_id=self.player.id) embed = EmbedTemplate.error( title="Error", description="Failed to log the injury. Please try again or contact an administrator." ) await interaction.response.send_message(embed=embed, ephemeral=True) class PitcherRestModal(BaseModal): """Modal for collecting pitcher rest games when logging injury.""" def __init__( self, player: 'Player', injury_games: int, season: int, *, timeout: Optional[float] = 300.0 ): """ Initialize pitcher rest modal. Args: player: Player object for the injured pitcher injury_games: Base injury games from roll season: Current season number timeout: Modal timeout in seconds """ super().__init__(title=f"Pitcher Rest - {player.name}", timeout=timeout) self.player = player self.injury_games = injury_games self.season = season # Current week input self.current_week = discord.ui.TextInput( label="Current Week", placeholder="Enter current week number (e.g., 5)", required=True, max_length=2, style=discord.TextStyle.short ) # Current game input self.current_game = discord.ui.TextInput( label="Current Game", placeholder="Enter current game number (1-4)", required=True, max_length=1, style=discord.TextStyle.short ) # Rest games input self.rest_games = discord.ui.TextInput( label="Pitcher Rest Games", placeholder="Enter number of rest games (0 or more)", required=True, max_length=2, style=discord.TextStyle.short ) self.add_item(self.current_week) self.add_item(self.current_game) self.add_item(self.rest_games) async def on_submit(self, interaction: discord.Interaction): """Handle pitcher rest input and log injury.""" from services.player_service import player_service from services.injury_service import injury_service from models.injury import Injury from config import get_config import math config = get_config() max_week = config.weeks_per_season + config.playoff_weeks_per_season # Validate current week try: week = int(self.current_week.value) if week < 1 or week > max_week: raise ValueError(f"Week must be between 1 and {max_week}") except ValueError: embed = EmbedTemplate.error( title="Invalid Week", description=f"Current week must be a number between 1 and {max_week} (including playoffs)." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Determine max games based on week (regular season vs playoff rounds) if week <= config.weeks_per_season: max_game = config.games_per_week elif week == config.weeks_per_season + 1: max_game = config.playoff_round_one_games elif week == config.weeks_per_season + 2: max_game = config.playoff_round_two_games elif week == config.weeks_per_season + 3: max_game = config.playoff_round_three_games else: max_game = config.games_per_week # Fallback # Validate current game try: game = int(self.current_game.value) if game < 1 or game > max_game: raise ValueError(f"Game must be between 1 and {max_game}") except ValueError: embed = EmbedTemplate.error( title="Invalid Game", description=f"Current game must be a number between 1 and {max_game}." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Validate rest games try: rest = int(self.rest_games.value) if rest < 0: raise ValueError("Rest games cannot be negative") except ValueError: embed = EmbedTemplate.error( title="Invalid Rest Games", description="Rest games must be a non-negative number." ) await interaction.response.send_message(embed=embed, ephemeral=True) return # Calculate total injury total_injury_games = self.injury_games + rest # Calculate injury dates out_weeks = math.floor(total_injury_games / 4) out_games = total_injury_games % 4 return_week = week + out_weeks return_game = game + 1 + out_games if return_game > 4: return_week += 1 return_game -= 4 # Adjust start date if injury starts after game 4 start_week = week if game != 4 else week + 1 start_game = game + 1 if game != 4 else 1 return_date = f'w{return_week:02d}g{return_game}' # Create injury record try: injury = await injury_service.create_injury( season=self.season, player_id=self.player.id, total_games=total_injury_games, start_week=start_week, start_game=start_game, end_week=return_week, end_game=return_game ) if not injury: raise ValueError("Failed to create injury record") # Update player's il_return field await player_service.update_player(self.player.id, {'il_return': return_date}) # Success response embed = EmbedTemplate.success( title="Injury Logged", description=f"{self.player.name}'s injury has been logged." ) embed.add_field( name="Base Injury", value=f"{self.injury_games} game{'s' if self.injury_games > 1 else ''}", inline=True ) embed.add_field( name="Rest Requirement", value=f"{rest} game{'s' if rest > 1 else ''}", inline=True ) embed.add_field( name="Total Duration", value=f"{total_injury_games} game{'s' if total_injury_games > 1 else ''}", inline=True ) embed.add_field( name="Return Date", value=return_date, inline=True ) if self.player.team: embed.add_field( name="Team", value=f"{self.player.team.lname} ({self.player.team.abbrev})", inline=False ) self.is_submitted = True self.result = { 'injury_id': injury.id, 'total_games': total_injury_games, 'return_date': return_date } await interaction.response.send_message(embed=embed) # Post injury news and update injury log channel try: from utils.injury_log import post_injury_and_update_log await post_injury_and_update_log( bot=interaction.client, player=self.player, injury_games=total_injury_games, return_date=return_date, season=self.season ) except Exception as log_error: self.logger.warning( f"Failed to post injury to channels (injury was still logged): {log_error}", player_id=self.player.id ) except Exception as e: self.logger.error("Failed to create pitcher injury", error=e, player_id=self.player.id) embed = EmbedTemplate.error( title="Error", description="Failed to log the injury. Please try again or contact an administrator." ) await interaction.response.send_message(embed=embed, ephemeral=True)