From e6a30af604926948a659428e56923d93c6fb1cb5 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Sat, 16 Aug 2025 07:36:47 -0500 Subject: [PATCH] CLAUDE: SUCCESSFUL STARTUP - Discord Bot v2.0 fully operational MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ✅ **MAJOR MILESTONE**: Bot successfully starts and loads all commands 🔧 **Key Fixes Applied**: - Fixed Pydantic configuration (SettingsConfigDict vs ConfigDict) - Resolved duplicate logging with hybrid propagation approach - Enhanced console logging with detailed format (function:line) - Eliminated redundant .log file handler (kept console + JSON) - Fixed Pylance type errors across views and modals - Added newline termination to JSON logs for better tool compatibility - Enabled league commands package in bot.py - Enhanced command tree hashing for proper type support đŸ“Ļ **New Components Added**: - Complete views package (base.py, common.py, embeds.py, modals.py) - League service and commands integration - Comprehensive test coverage improvements - Enhanced decorator functionality with proper signature preservation đŸŽ¯ **Architecture Improvements**: - Hybrid logging: detailed console for dev + structured JSON for monitoring - Type-safe command tree handling for future extensibility - Proper optional parameter handling in Pydantic models - Eliminated duplicate log messages while preserving third-party library logs 🚀 **Ready for Production**: Bot loads all command packages successfully with no errors 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude --- bot.py | 83 ++-- commands/examples/__init__.py | 6 + commands/examples/enhanced_player.py | 372 +++++++++++++++++ commands/examples/migration_example.py | 311 +++++++++++++++ config.py | 9 +- services/__init__.py | 4 +- test_real_data.py | 5 + tests/test_api_client.py | 64 ++- tests/test_config.py | 2 +- tests/test_utils_logging.py | 361 +++++++++++++++++ utils/README.md | 56 ++- utils/decorators.py | 2 +- utils/logging.py | 39 +- views/__init__.py | 170 +++++++- views/base.py | 274 +++++++++++++ views/common.py | 528 +++++++++++++++++++++++++ views/embeds.py | 391 ++++++++++++++++++ views/modals.py | 488 +++++++++++++++++++++++ 18 files changed, 3105 insertions(+), 60 deletions(-) create mode 100644 commands/examples/__init__.py create mode 100644 commands/examples/enhanced_player.py create mode 100644 commands/examples/migration_example.py create mode 100644 tests/test_utils_logging.py create mode 100644 views/base.py create mode 100644 views/common.py create mode 100644 views/embeds.py create mode 100644 views/modals.py diff --git a/bot.py b/bot.py index 6812ae0..43f9345 100644 --- a/bot.py +++ b/bot.py @@ -30,27 +30,15 @@ def setup_logging(): logger = logging.getLogger('discord_bot_v2') logger.setLevel(getattr(logging, config.log_level.upper())) - # Console handler - human readable for development + # Console handler - detailed format for development debugging console_handler = logging.StreamHandler() console_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(message)s' + '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' ) console_handler.setFormatter(console_formatter) logger.addHandler(console_handler) - # Traditional file handler - human readable with debug info - file_handler = RotatingFileHandler( - 'logs/discord_bot_v2.log', - maxBytes=5 * 1024 * 1024, # 5MB - backupCount=5 - ) - file_formatter = logging.Formatter( - '%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s' - ) - file_handler.setFormatter(file_formatter) - logger.addHandler(file_handler) - - # JSON file handler - structured logging for analysis + # JSON file handler - structured logging for monitoring and analysis json_handler = RotatingFileHandler( 'logs/discord_bot_v2.json', maxBytes=5 * 1024 * 1024, # 5MB @@ -59,16 +47,20 @@ def setup_logging(): json_handler.setFormatter(JSONFormatter()) logger.addHandler(json_handler) - # Apply to all loggers (not just root) + # Configure root logger for third-party libraries (discord.py, aiohttp, etc.) root_logger = logging.getLogger() root_logger.setLevel(getattr(logging, config.log_level.upper())) - # Add handlers to root logger so all child loggers inherit them + # Add handlers to root logger so third-party loggers inherit them if not root_logger.handlers: # Avoid duplicate handlers root_logger.addHandler(console_handler) - root_logger.addHandler(file_handler) root_logger.addHandler(json_handler) + # Prevent discord_bot_v2 logger from propagating to root to avoid duplicate messages + # (bot logs will still appear via its own handlers, third-party logs via root handlers) + # To revert: remove the line below and bot logs will appear twice + logger.propagate = False + return logger @@ -84,7 +76,7 @@ class SBABot(commands.Bot): super().__init__( command_prefix='!', # Legacy prefix, primarily using slash commands intents=intents, - description="SBA League Management Bot v2.0" + description="Major Domo v2.0" ) self.logger = logging.getLogger('discord_bot_v2') @@ -112,13 +104,15 @@ class SBABot(commands.Bot): async def _load_command_packages(self): """Load all command packages with resilient error handling.""" from commands.players import setup_players + from commands.teams import setup_teams + from commands.league import setup_league # Define command packages to load command_packages = [ ("players", setup_players), + ("teams", setup_teams), + ("league", setup_league), # Future packages: - # ("teams", setup_teams), - # ("league", setup_league), # ("admin", setup_admin), ] @@ -153,19 +147,29 @@ class SBABot(commands.Bot): # Create hash of current command tree commands_data = [] for cmd in self.tree.get_commands(): - # Include relevant command data for comparison - cmd_dict = { - 'name': cmd.name, - 'description': cmd.description, - 'parameters': [ + # Handle different command types properly + cmd_dict = {} + cmd_dict['name'] = cmd.name + cmd_dict['type'] = type(cmd).__name__ + + # Add description if available (most command types have this) + if hasattr(cmd, 'description'): + cmd_dict['description'] = cmd.description # type: ignore + + # Add parameters for Command objects + if isinstance(cmd, discord.app_commands.Command): + cmd_dict['parameters'] = [ { 'name': param.name, 'description': param.description, 'required': param.required, 'type': str(param.type) } for param in cmd.parameters - ] if hasattr(cmd, 'parameters') else [] - } + ] + elif isinstance(cmd, discord.app_commands.Group): + # For groups, include subcommands + cmd_dict['subcommands'] = [subcmd.name for subcmd in cmd.commands] + commands_data.append(cmd_dict) # Sort for consistent hashing @@ -195,18 +199,29 @@ class SBABot(commands.Bot): # Create hash of current command tree (same logic as _should_sync_commands) commands_data = [] for cmd in self.tree.get_commands(): - cmd_dict = { - 'name': cmd.name, - 'description': cmd.description, - 'parameters': [ + # Handle different command types properly + cmd_dict = {} + cmd_dict['name'] = cmd.name + cmd_dict['type'] = type(cmd).__name__ + + # Add description if available (most command types have this) + if hasattr(cmd, 'description'): + cmd_dict['description'] = cmd.description # type: ignore + + # Add parameters for Command objects + if isinstance(cmd, discord.app_commands.Command): + cmd_dict['parameters'] = [ { 'name': param.name, 'description': param.description, 'required': param.required, 'type': str(param.type) } for param in cmd.parameters - ] if hasattr(cmd, 'parameters') else [] - } + ] + elif isinstance(cmd, discord.app_commands.Group): + # For groups, include subcommands + cmd_dict['subcommands'] = [subcmd.name for subcmd in cmd.commands] + commands_data.append(cmd_dict) commands_data.sort(key=lambda x: x['name']) diff --git a/commands/examples/__init__.py b/commands/examples/__init__.py new file mode 100644 index 0000000..28bd35c --- /dev/null +++ b/commands/examples/__init__.py @@ -0,0 +1,6 @@ +""" +Example Commands using Views v2.0 + +Demonstrates how to use the modern Discord UI components and view system. +These examples show best practices for implementing interactive commands. +""" \ No newline at end of file diff --git a/commands/examples/enhanced_player.py b/commands/examples/enhanced_player.py new file mode 100644 index 0000000..e8b7f89 --- /dev/null +++ b/commands/examples/enhanced_player.py @@ -0,0 +1,372 @@ +""" +Enhanced Player Command Example using Views v2.0 + +Demonstrates modern Discord UI components with the new view system. +This is an example of how to upgrade existing commands to use the new views. +""" +from typing import Optional, List + +import discord +from discord.ext import commands + +from services.player_service import player_service +from models.player import Player +from constants import SBA_CURRENT_SEASON +from utils.logging import get_contextual_logger +from utils.decorators import logged_command +from exceptions import BotException + +# Import our new view components +from views import ( + SBAEmbedTemplate, + EmbedTemplate, + EmbedColors, + PlayerSelectionView, + DetailedInfoView, + SearchResultsView, + PlayerSearchModal, + PaginationView +) + + +class EnhancedPlayerCommands(commands.Cog): + """Enhanced player commands using modern view system.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.EnhancedPlayerCommands') + self.logger.info("EnhancedPlayerCommands cog initialized") + + @discord.app_commands.command( + name="player-enhanced", + description="Enhanced player search with modern UI" + ) + @discord.app_commands.describe( + name="Player name to search for (optional - leave blank for advanced search)", + season="Season to show stats for (defaults to current season)" + ) + @logged_command("/player-enhanced") + async def enhanced_player_info( + self, + interaction: discord.Interaction, + name: Optional[str] = None, + season: Optional[int] = None + ): + """Enhanced player search with modern UI components.""" + await interaction.response.defer() + + # If no name provided, show search modal + if not name: + modal = PlayerSearchModal() + await interaction.followup.send("Please fill out the search form:", ephemeral=True) + await interaction.user.send("Opening player search form...") + + # Note: In real implementation, you'd handle modal differently + # This is just for demonstration + embed = EmbedTemplate.info( + title="Advanced Player Search", + description="Use the `/player-enhanced` command with a name, or we'll add modal support soon!" + ) + await interaction.followup.send(embed=embed) + return + + # Use current season if not specified + search_season = season or SBA_CURRENT_SEASON + + # Search for players + players = await player_service.get_players_by_name(name, search_season) + + if not players: + # Try fuzzy search + fuzzy_players = await player_service.search_players_fuzzy(name, limit=25) + + if not fuzzy_players: + embed = EmbedTemplate.error( + title="No Players Found", + description=f"No players found matching '{name}' in season {search_season}." + ) + await interaction.followup.send(embed=embed) + return + + # Show search results with selection + await self._show_search_results(interaction, fuzzy_players, name, search_season) + return + + # Handle multiple exact matches + if len(players) > 1: + await self._show_player_selection(interaction, players, search_season) + return + + # Single player found - show detailed view + await self._show_player_details(interaction, players[0], search_season) + + async def _show_search_results( + self, + interaction: discord.Interaction, + players: List[Player], + search_term: str, + season: int + ): + """Show search results with modern pagination and selection.""" + # Prepare results for SearchResultsView + results = [] + for player in players: + results.append({ + 'name': player.name, + 'detail': f"{player.primary_position} â€ĸ WARA: {player.wara:.1f}", + 'player_obj': player + }) + + async def handle_selection(interaction: discord.Interaction, result: dict): + """Handle player selection from search results.""" + selected_player = result['player_obj'] + await self._show_player_details(interaction, selected_player, season) + + # Create search results view with selection + view = SearchResultsView( + results=results, + search_term=search_term, + user_id=interaction.user.id, + selection_callback=handle_selection, + results_per_page=10 + ) + + # Send with first page + embed = view.get_current_embed() + await interaction.followup.send(embed=embed, view=view) + + async def _show_player_selection( + self, + interaction: discord.Interaction, + players: List[Player], + season: int + ): + """Show player selection dropdown for multiple exact matches.""" + async def handle_player_choice(interaction: discord.Interaction, player: Player): + """Handle player selection.""" + await self._show_player_details(interaction, player, season) + + # Create player selection view + view = PlayerSelectionView( + players=players, + user_id=interaction.user.id, + callback=handle_player_choice + ) + + # Setup the select options + view.setup_options() + + # Create embed for selection + embed = EmbedTemplate.info( + title="Multiple Players Found", + description=f"Found {len(players)} players matching your search. Please select one:" + ) + + await interaction.followup.send(embed=embed, view=view) + + async def _show_player_details( + self, + interaction: discord.Interaction, + player: Player, + season: int + ): + """Show detailed player information with action buttons.""" + # Get full player data with team information + player_with_team = await player_service.get_player_with_team(player.id) + if player_with_team is None: + player_with_team = player + + # Create comprehensive player embed + embed = self._create_enhanced_player_embed(player_with_team, season) + + # Create detailed info view with action buttons + async def refresh_player_data(interaction: discord.Interaction) -> discord.Embed: + """Refresh player data.""" + updated_player = await player_service.get_player_with_team(player.id) + return self._create_enhanced_player_embed(updated_player or player, season) + + async def show_more_details(interaction: discord.Interaction): + """Show additional player details.""" + # Create detailed stats embed + stats_embed = self._create_player_stats_embed(player_with_team, season) + await interaction.response.send_message(embed=stats_embed, ephemeral=True) + + view = DetailedInfoView( + embed=embed, + user_id=interaction.user.id, + show_refresh=True, + show_details=True, + refresh_callback=refresh_player_data, + details_callback=show_more_details + ) + + if interaction.response.is_done(): + await interaction.followup.send(embed=embed, view=view) + else: + await interaction.response.send_message(embed=embed, view=view) + + def _create_enhanced_player_embed(self, player: Player, season: int) -> discord.Embed: + """Create enhanced player embed with additional information.""" + # Get team info if available + team_abbrev = None + team_name = None + team_color = None + + if hasattr(player, 'team') and player.team: + team_abbrev = player.team.abbrev + team_name = player.team.sname + team_color = getattr(player.team, 'color', None) + + # Create base embed + embed = SBAEmbedTemplate.player_card( + player_name=player.name, + position=player.primary_position, + team_abbrev=team_abbrev, + team_name=team_name, + wara=player.wara, + season=season, + player_image=getattr(player, 'image', None), + team_color=team_color + ) + + # Add additional fields + additional_fields = [] + + # All positions if multiple + if len(player.positions) > 1: + additional_fields.append({ + 'name': 'All Positions', + 'value': ', '.join(player.positions), + 'inline': True + }) + + # Add salary info if available + if hasattr(player, 'salary') and player.salary: + additional_fields.append({ + 'name': 'Salary', + 'value': f"${player.salary:,}", + 'inline': True + }) + + # Add contract info if available + if hasattr(player, 'contract_years') and player.contract_years: + additional_fields.append({ + 'name': 'Contract', + 'value': f"{player.contract_years} years", + 'inline': True + }) + + # Add the additional fields to embed + for field in additional_fields: + embed.add_field( + name=field['name'], + value=field['value'], + inline=field['inline'] + ) + + # Add footer with player ID + embed.set_footer(text=f"Player ID: {player.id} â€ĸ Use buttons below for more options") + + return embed + + def _create_player_stats_embed(self, player: Player, season: int) -> discord.Embed: + """Create detailed player statistics embed.""" + embed = EmbedTemplate.create_base_embed( + title=f"📊 {player.name} - Detailed Stats", + description=f"Season {season} Statistics", + color=EmbedColors.INFO + ) + + # Add batting stats if available + if hasattr(player, 'batting_avg') and player.batting_avg is not None: + embed.add_field( + name="Batting Average", + value=f"{player.batting_avg:.3f}", + inline=True + ) + + if hasattr(player, 'home_runs') and player.home_runs is not None: + embed.add_field( + name="Home Runs", + value=str(player.home_runs), + inline=True + ) + + if hasattr(player, 'rbi') and player.rbi is not None: + embed.add_field( + name="RBI", + value=str(player.rbi), + inline=True + ) + + # Add pitching stats if available + if hasattr(player, 'era') and player.era is not None: + embed.add_field( + name="ERA", + value=f"{player.era:.2f}", + inline=True + ) + + if hasattr(player, 'wins') and player.wins is not None: + embed.add_field( + name="Wins", + value=str(player.wins), + inline=True + ) + + if hasattr(player, 'strikeouts') and player.strikeouts is not None: + embed.add_field( + name="Strikeouts", + value=str(player.strikeouts), + inline=True + ) + + return embed + + @discord.app_commands.command( + name="player-search-modal", + description="Advanced player search using modal form" + ) + @logged_command("/player-search-modal") + async def player_search_modal(self, interaction: discord.Interaction): + """Demonstrate modal-based player search.""" + modal = PlayerSearchModal() + await interaction.response.send_modal(modal) + + # Wait for modal completion + await modal.wait() + + if modal.is_submitted and modal.result: + search_criteria = modal.result + + # Perform search based on criteria + players = await player_service.get_players_by_name( + search_criteria['name'], + search_criteria['season'] or SBA_CURRENT_SEASON + ) + + if players: + # Show results using our views + if len(players) == 1: + await self._show_player_details( + interaction, + players[0], + search_criteria['season'] or SBA_CURRENT_SEASON + ) + else: + await self._show_player_selection( + interaction, + players, + search_criteria['season'] or SBA_CURRENT_SEASON + ) + else: + embed = EmbedTemplate.warning( + title="No Results", + description=f"No players found matching your search criteria." + ) + await interaction.followup.send(embed=embed, ephemeral=True) + + +async def setup(bot: commands.Bot): + """Load the enhanced player commands cog.""" + await bot.add_cog(EnhancedPlayerCommands(bot)) \ No newline at end of file diff --git a/commands/examples/migration_example.py b/commands/examples/migration_example.py new file mode 100644 index 0000000..6c6a8f7 --- /dev/null +++ b/commands/examples/migration_example.py @@ -0,0 +1,311 @@ +""" +Migration Example: Before and After Views v2.0 + +Shows how to upgrade existing commands to use the modern view system. +This demonstrates the transformation from basic embeds to interactive views. +""" +from typing import Optional, List + +import discord +from discord.ext import commands + +from services.team_service import team_service +from models.team import Team +from constants import SBA_CURRENT_SEASON +from utils.logging import get_contextual_logger +from utils.decorators import logged_command + +# Import new view components +from views import ( + SBAEmbedTemplate, + EmbedTemplate, + EmbedColors, + TeamSelectionView, + PaginationView, + ConfirmationView, + DetailedInfoView +) + + +class MigrationExampleCommands(commands.Cog): + """Example showing before/after migration to Views v2.0.""" + + def __init__(self, bot: commands.Bot): + self.bot = bot + self.logger = get_contextual_logger(f'{__name__}.MigrationExampleCommands') + + # ======================================== + # BEFORE: Traditional approach + # ======================================== + + @discord.app_commands.command( + name="teams-old", + description="List teams (old style - basic embed)" + ) + @logged_command("/teams-old") + async def teams_old_style(self, interaction: discord.Interaction, season: Optional[int] = None): + """Old style team listing - basic embed only.""" + await interaction.response.defer() + + season = season or SBA_CURRENT_SEASON + teams = await team_service.get_teams_by_season(season) + + if not teams: + embed = discord.Embed( + title="No Teams Found", + description=f"No teams found for season {season}", + color=0xff6b6b + ) + await interaction.followup.send(embed=embed) + return + + # Sort teams by abbreviation + teams.sort(key=lambda t: t.abbrev) + + # Create basic embed + embed = discord.Embed( + title=f"SBA Teams - Season {season}", + color=0xa6ce39 + ) + + # Simple list - limited functionality + team_list = "\n".join([f"**{team.abbrev}** - {team.lname}" for team in teams[:20]]) + if len(teams) > 20: + team_list += f"\n... and {len(teams) - 20} more teams" + + embed.add_field(name="Teams", value=team_list, inline=False) + embed.set_footer(text=f"Total: {len(teams)} teams") + + await interaction.followup.send(embed=embed) + + # ======================================== + # AFTER: Modern Views v2.0 approach + # ======================================== + + @discord.app_commands.command( + name="teams-new", + description="List teams (new style - interactive with views)" + ) + @logged_command("/teams-new") + async def teams_new_style(self, interaction: discord.Interaction, season: Optional[int] = None): + """New style team listing - interactive with pagination and selection.""" + await interaction.response.defer() + + season = season or SBA_CURRENT_SEASON + teams = await team_service.get_teams_by_season(season) + + if not teams: + embed = EmbedTemplate.warning( + title="No Teams Found", + description=f"No teams found for season {season}" + ) + await interaction.followup.send(embed=embed) + return + + # Sort teams by abbreviation + teams.sort(key=lambda t: t.abbrev) + + # Create paginated view with team selection + await self._create_interactive_team_list(interaction, teams, season) + + async def _create_interactive_team_list( + self, + interaction: discord.Interaction, + teams: List[Team], + season: int + ): + """Create interactive team list with pagination and selection.""" + teams_per_page = 10 + pages = [] + + # Create pages + for i in range(0, len(teams), teams_per_page): + page_teams = teams[i:i + teams_per_page] + + embed = SBAEmbedTemplate.league_status( + season=season, + teams_count=len(teams), + additional_info=f"Showing teams {i + 1}-{min(i + teams_per_page, len(teams))} of {len(teams)}" + ) + embed.title = f"đŸŸī¸ SBA Teams - Season {season}" + + # Group teams by division if available + if any(getattr(team, 'division_id', None) for team in page_teams): + divisions = {} + for team in page_teams: + div_id = getattr(team, 'division_id', 0) or 0 + if div_id not in divisions: + divisions[div_id] = [] + divisions[div_id].append(team) + + for div_id, div_teams in sorted(divisions.items()): + div_name = f"Division {div_id}" if div_id > 0 else "Unassigned" + team_list = "\n".join([ + f"**{team.abbrev}** - {team.lname}" + for team in div_teams + ]) + embed.add_field(name=div_name, value=team_list, inline=True) + else: + # Simple list if no divisions + team_list = "\n".join([ + f"**{team.abbrev}** - {team.lname}" + for team in page_teams + ]) + embed.add_field(name="Teams", value=team_list, inline=False) + + pages.append(embed) + + # Create pagination view + view = PaginationView( + pages=pages, + user_id=interaction.user.id, + show_page_numbers=True + ) + + # Add team selection dropdown to first row + if len(teams) <= 25: # Discord limit for select options + team_select = TeamSelectionView( + teams=teams, + user_id=interaction.user.id, + callback=self._handle_team_selection + ) + + # Combine pagination with selection (would need custom view for this) + # For now, show them separately + + await interaction.followup.send(embed=view.get_current_embed(), view=view) + + # Also provide team selection if reasonable number + if len(teams) <= 25: + await self._add_team_selection_followup(interaction, teams) + + async def _add_team_selection_followup( + self, + interaction: discord.Interaction, + teams: List[Team] + ): + """Add team selection as follow-up message.""" + view = TeamSelectionView( + teams=teams, + user_id=interaction.user.id, + callback=self._handle_team_selection + ) + + embed = EmbedTemplate.info( + title="Team Selection", + description="Select a team below to view detailed information:" + ) + + await interaction.followup.send(embed=embed, view=view, ephemeral=True) + + async def _handle_team_selection( + self, + interaction: discord.Interaction, + team: Team + ): + """Handle team selection from dropdown.""" + # Get additional team data + standings_data = await team_service.get_team_standings_position(team.id, team.season) + + # Create detailed team embed + embed = SBAEmbedTemplate.team_info( + team_abbrev=team.abbrev, + team_name=team.lname, + season=team.season, + short_name=getattr(team, 'sname', None), + stadium=getattr(team, 'stadium', None), + division=f"Division {team.division_id}" if getattr(team, 'division_id', None) else None, + team_color=getattr(team, 'color', None), + team_thumbnail=getattr(team, 'thumbnail', None) + ) + + # Add standings info if available + if standings_data: + try: + wins = standings_data.get('wins', 'N/A') + losses = standings_data.get('losses', 'N/A') + pct = standings_data.get('pct', 'N/A') + gb = standings_data.get('gb', 'N/A') + + record_text = f"{wins}-{losses}" + if pct != 'N/A': + record_text += f" ({pct:.3f})" + if gb != 'N/A' and gb != 0: + record_text += f" â€ĸ {gb} GB" + + embed.add_field(name="Record", value=record_text, inline=False) + except (KeyError, TypeError): + pass + + # Create detailed info view with actions + async def refresh_team_data(interaction: discord.Interaction) -> discord.Embed: + """Refresh team data.""" + updated_standings = await team_service.get_team_standings_position(team.id, team.season) + # Recreate embed with updated data + return embed # Simplified for example + + async def show_roster(interaction: discord.Interaction): + """Show team roster.""" + roster_embed = EmbedTemplate.info( + title=f"{team.abbrev} Roster", + description="Roster functionality would go here..." + ) + await interaction.response.send_message(embed=roster_embed, ephemeral=True) + + view = DetailedInfoView( + embed=embed, + user_id=interaction.user.id, + show_refresh=True, + show_details=True, + refresh_callback=refresh_team_data, + details_callback=show_roster + ) + + await interaction.response.edit_message(embed=embed, view=view) + + # ======================================== + # Additional Examples + # ======================================== + + @discord.app_commands.command( + name="confirmation-example", + description="Example of confirmation dialog" + ) + @logged_command("/confirmation-example") + async def confirmation_example(self, interaction: discord.Interaction): + """Example of modern confirmation dialog.""" + embed = EmbedTemplate.warning( + title="Confirm Action", + description="This is an example confirmation dialog. Do you want to proceed?" + ) + + async def handle_confirm(interaction: discord.Interaction): + """Handle confirmation.""" + success_embed = EmbedTemplate.success( + title="Action Confirmed", + description="The action has been completed successfully!" + ) + await interaction.response.edit_message(embed=success_embed, view=None) + + async def handle_cancel(interaction: discord.Interaction): + """Handle cancellation.""" + cancel_embed = EmbedTemplate.error( + title="Action Cancelled", + description="The action has been cancelled." + ) + await interaction.response.edit_message(embed=cancel_embed, view=None) + + view = ConfirmationView( + user_id=interaction.user.id, + confirm_callback=handle_confirm, + cancel_callback=handle_cancel, + confirm_label="Yes, Proceed", + cancel_label="No, Cancel" + ) + + await interaction.response.send_message(embed=embed, view=view) + + +async def setup(bot: commands.Bot): + """Load the migration example commands cog.""" + await bot.add_cog(MigrationExampleCommands(bot)) \ No newline at end of file diff --git a/config.py b/config.py index 49023c4..14f1bd6 100644 --- a/config.py +++ b/config.py @@ -1,10 +1,7 @@ """ Configuration management for Discord Bot v2.0 """ -import os -from typing import Optional -from pydantic_settings import BaseSettings -from pydantic import ConfigDict +from pydantic_settings import BaseSettings, SettingsConfigDict class BotConfig(BaseSettings): @@ -29,7 +26,7 @@ class BotConfig(BaseSettings): environment: str = "development" testing: bool = False - model_config = ConfigDict( + model_config = SettingsConfigDict( env_file=".env", case_sensitive=False, extra="ignore" # Ignore extra environment variables @@ -53,5 +50,5 @@ def get_config() -> BotConfig: """Get the global configuration instance.""" global _config if _config is None: - _config = BotConfig() + _config = BotConfig() # type: ignore return _config \ No newline at end of file diff --git a/services/__init__.py b/services/__init__.py index c96b4e4..431f014 100644 --- a/services/__init__.py +++ b/services/__init__.py @@ -6,11 +6,13 @@ Service layer providing clean interfaces to data operations. from .team_service import TeamService, team_service from .player_service import PlayerService, player_service +from .league_service import LeagueService, league_service # Wire services together for dependency injection player_service._team_service = team_service __all__ = [ 'TeamService', 'team_service', - 'PlayerService', 'player_service' + 'PlayerService', 'player_service', + 'LeagueService', 'league_service' ] \ No newline at end of file diff --git a/test_real_data.py b/test_real_data.py index de7772c..3383d85 100644 --- a/test_real_data.py +++ b/test_real_data.py @@ -48,6 +48,9 @@ class MockChannel: self.id = 444555666 +import pytest + +@pytest.mark.asyncio async def test_player_search(): """Test player search with real data.""" print("🔍 Testing Player Search...") @@ -125,6 +128,7 @@ async def test_player_search(): return False +@pytest.mark.asyncio async def test_player_service_methods(): """Test various player service methods.""" print("🔧 Testing Player Service Methods...") @@ -176,6 +180,7 @@ async def test_player_service_methods(): return False +@pytest.mark.asyncio async def test_api_connectivity(): """Test basic API connectivity.""" print("🌐 Testing API Connectivity...") diff --git a/tests/test_api_client.py b/tests/test_api_client.py index 4ac2473..c16693b 100644 --- a/tests/test_api_client.py +++ b/tests/test_api_client.py @@ -2,6 +2,8 @@ API client tests using aioresponses for clean HTTP mocking """ import pytest +import asyncio +import aiohttp from unittest.mock import MagicMock, patch from aioresponses import aioresponses @@ -472,4 +474,64 @@ class TestAPIClientCoverageExtras: # Test with no parameters url = client._add_params("https://example.com/api") - assert url == "https://example.com/api" \ No newline at end of file + assert url == "https://example.com/api" + + @pytest.mark.asyncio + async def test_timeout_error_handling(self, mock_config): + """Test timeout error handling using aioresponses.""" + with patch('api.client.get_config', return_value=mock_config): + client = APIClient() + + # Test timeout using aioresponses exception parameter + with aioresponses() as m: + m.get( + "https://api.example.com/v3/players", + exception=asyncio.TimeoutError("Request timed out") + ) + + with pytest.raises(APIException, match="API call failed.*Request timed out"): + await client.get("players") + + await client.close() + + @pytest.mark.asyncio + async def test_generic_exception_handling(self, mock_config): + """Test generic exception handling.""" + with patch('api.client.get_config', return_value=mock_config): + client = APIClient() + + # Test generic exception + with aioresponses() as m: + m.get( + "https://api.example.com/v3/players", + exception=Exception("Generic error") + ) + + with pytest.raises(APIException, match="API call failed.*Generic error"): + await client.get("players") + + await client.close() + + @pytest.mark.asyncio + async def test_session_closed_handling(self, mock_config): + """Test handling of closed session.""" + with patch('api.client.get_config', return_value=mock_config): + # Test that the client recreates session when needed + with aioresponses() as m: + m.get( + "https://api.example.com/v3/players", + payload={"success": True}, + status=200 + ) + + client = APIClient() + + # Close the session manually + await client._ensure_session() + await client._session.close() + + # Client should recreate session and work fine + result = await client.get("players") + assert result == {"success": True} + + await client.close() \ No newline at end of file diff --git a/tests/test_config.py b/tests/test_config.py index e9554af..c36f798 100644 --- a/tests/test_config.py +++ b/tests/test_config.py @@ -35,7 +35,7 @@ class TestBotConfig: 'GUILD_ID': '123456789', 'API_TOKEN': 'test_api_token', 'DB_URL': 'https://api.example.com' - }): + }, clear=True): config = BotConfig() assert config.sba_season == 12 assert config.pd_season == 9 diff --git a/tests/test_utils_logging.py b/tests/test_utils_logging.py new file mode 100644 index 0000000..851bf52 --- /dev/null +++ b/tests/test_utils_logging.py @@ -0,0 +1,361 @@ +""" +Tests for enhanced logging utilities + +Tests contextual logging, operation tracing, and Discord context management. +""" +import pytest +import time +from unittest.mock import Mock, patch +from typing import Dict, Any + +from utils.logging import ( + get_contextual_logger, + set_discord_context, + clear_context, + ContextualLogger, + JSONFormatter, + log_context +) + + +class TestContextualLogger: + """Test contextual logger functionality.""" + + @pytest.fixture + def logger(self) -> ContextualLogger: + """Create a test contextual logger.""" + return get_contextual_logger('test_logger') + + def test_start_operation(self, logger): + """Test operation start tracking.""" + trace_id = logger.start_operation('test_operation') + + assert trace_id is not None + assert len(trace_id) == 8 # UUID truncated to 8 chars + assert logger._start_time is not None + + # Check that context was set + context = log_context.get({}) + assert 'trace_id' in context + assert context['trace_id'] == trace_id + assert context['operation'] == 'test_operation' + + def test_start_operation_no_name(self, logger): + """Test operation start without operation name.""" + # Clear any existing context first + clear_context() + + trace_id = logger.start_operation() + + assert trace_id is not None + assert logger._start_time is not None + + context = log_context.get({}) + assert 'trace_id' in context + assert context['trace_id'] == trace_id + assert 'operation' not in context + + def test_end_operation_success(self, logger): + """Test successful operation end tracking.""" + trace_id = logger.start_operation('test_operation') + time.sleep(0.01) # Small delay to ensure duration > 0 + + with patch.object(logger.logger, 'info') as mock_info: + logger.end_operation(trace_id, 'completed') + + # Verify info was called with correct parameters + mock_info.assert_called_once() + call_args = mock_info.call_args + assert 'Operation completed' in call_args[0][0] + + # Check extra parameters + extra = call_args[1]['extra'] + assert 'trace_id' in extra + assert 'final_duration_ms' in extra + assert extra['final_duration_ms'] > 0 + assert extra['operation_result'] == 'completed' + + # Verify context was cleared + assert logger._start_time is None + + def test_end_operation_without_start(self, logger): + """Test end_operation called without start_operation.""" + with patch.object(logger, 'warning') as mock_warning: + logger.end_operation('fake_trace_id', 'completed') + + mock_warning.assert_called_once_with( + "end_operation called without corresponding start_operation" + ) + + def test_end_operation_clears_context(self, logger): + """Test that end_operation properly clears context.""" + trace_id = logger.start_operation('test_operation') + + # Verify context is set + context_before = log_context.get({}) + assert 'trace_id' in context_before + assert 'operation' in context_before + + logger.end_operation(trace_id, 'completed') + + # Verify context was cleared + context_after = log_context.get({}) + assert 'trace_id' not in context_after or context_after.get('trace_id') != trace_id + assert 'operation' not in context_after + + def test_duration_tracking(self, logger): + """Test that duration is tracked correctly.""" + logger.start_operation('test_operation') + time.sleep(0.01) + + duration_ms = logger._get_duration_ms() + assert duration_ms is not None + assert duration_ms > 0 + assert duration_ms < 1000 # Should be less than 1 second + + def test_logging_methods_with_duration(self, logger): + """Test that logging methods include duration when operation is active.""" + trace_id = logger.start_operation('test_operation') + time.sleep(0.01) + + with patch.object(logger.logger, 'info') as mock_info: + logger.info('test message', extra_param='value') + + mock_info.assert_called_once() + call_args = mock_info.call_args + + assert call_args[0][0] == 'test message' + extra = call_args[1]['extra'] + assert 'duration_ms' in extra + assert extra['duration_ms'] > 0 + assert extra['extra_param'] == 'value' + + def test_error_logging_with_exception(self, logger): + """Test error logging with exception object.""" + logger.start_operation('test_operation') + test_exception = ValueError("Test error") + + with patch.object(logger.logger, 'error') as mock_error: + logger.error('Error occurred', error=test_exception, context='test') + + mock_error.assert_called_once() + call_args = mock_error.call_args + + assert call_args[0][0] == 'Error occurred' + assert call_args[1]['exc_info'] is True + + extra = call_args[1]['extra'] + assert 'error' in extra + assert extra['error']['type'] == 'ValueError' + assert extra['error']['message'] == 'Test error' + assert extra['context'] == 'test' + + def test_error_logging_without_exception(self, logger): + """Test error logging without exception object.""" + with patch.object(logger.logger, 'error') as mock_error: + logger.error('Error occurred', context='test') + + mock_error.assert_called_once() + call_args = mock_error.call_args + + assert call_args[0][0] == 'Error occurred' + assert 'exc_info' not in call_args[1] + + extra = call_args[1]['extra'] + assert 'error' not in extra + assert extra['context'] == 'test' + + +class TestDiscordContext: + """Test Discord context management.""" + + def setUp(self): + """Clear context before each test.""" + clear_context() + + def test_set_discord_context_with_interaction(self): + """Test setting context from Discord interaction.""" + # Mock interaction object + mock_interaction = Mock() + mock_interaction.user.id = 123456789 + mock_interaction.guild.id = 987654321 + mock_interaction.guild.name = "Test Guild" + mock_interaction.channel.id = 555666777 + mock_interaction.command.name = "test" + + set_discord_context(interaction=mock_interaction, command="/test") + + context = log_context.get({}) + assert context['user_id'] == '123456789' + assert context['guild_id'] == '987654321' + assert context['guild_name'] == "Test Guild" + assert context['channel_id'] == '555666777' + assert context['command'] == '/test' + + def test_set_discord_context_explicit_params(self): + """Test setting context with explicit parameters.""" + set_discord_context( + user_id=123456789, + guild_id=987654321, + channel_id=555666777, + command='/explicit', + custom_field='custom_value' + ) + + context = log_context.get({}) + assert context['user_id'] == '123456789' + assert context['guild_id'] == '987654321' + assert context['channel_id'] == '555666777' + assert context['command'] == '/explicit' + assert context['custom_field'] == 'custom_value' + + def test_set_discord_context_override(self): + """Test that explicit parameters override interaction values.""" + mock_interaction = Mock() + mock_interaction.user.id = 111111111 + mock_interaction.guild.id = 222222222 + mock_interaction.channel.id = 333333333 + + set_discord_context( + interaction=mock_interaction, + user_id=999999999, # Override + command='/override' + ) + + context = log_context.get({}) + assert context['user_id'] == '999999999' # Overridden value + assert context['guild_id'] == '222222222' # From interaction + assert context['command'] == '/override' + + def test_clear_context(self): + """Test context clearing.""" + set_discord_context(user_id=123, command='/test') + + # Verify context is set + context_before = log_context.get({}) + assert len(context_before) > 0 + + clear_context() + + # Verify context is cleared + context_after = log_context.get({}) + assert len(context_after) == 0 + + +class TestJSONFormatter: + """Test JSON formatter functionality.""" + + @pytest.fixture + def formatter(self) -> JSONFormatter: + """Create a JSON formatter instance.""" + return JSONFormatter() + + def test_json_formatter_basic(self, formatter): + """Test basic JSON formatting.""" + import logging + record = logging.LogRecord( + name='test_logger', + level=logging.INFO, + pathname='test.py', + lineno=10, + msg='Test message', + args=(), + exc_info=None + ) + + result = formatter.format(record) + + # Should be valid JSON + import json + data = json.loads(result) + + assert data['message'] == 'Test message' + assert data['level'] == 'INFO' + assert data['logger'] == 'test_logger' + assert 'timestamp' in data + + def test_json_formatter_with_extra(self, formatter): + """Test JSON formatting with extra fields.""" + import logging + record = logging.LogRecord( + name='test_logger', + level=logging.ERROR, + pathname='test.py', + lineno=10, + msg='Error message', + args=(), + exc_info=None + ) + + # Add extra fields + record.user_id = '123456789' + record.trace_id = 'abc123' + record.duration_ms = 150 + + result = formatter.format(record) + + import json + data = json.loads(result) + + assert data['message'] == 'Error message' + assert data['level'] == 'ERROR' + # trace_id comes from context, duration_ms goes back to extra + assert 'extra' in data + assert data['extra']['user_id'] == '123456789' + assert data['extra']['trace_id'] == 'abc123' # This will be in extra since not set via context + assert data['extra']['duration_ms'] == 150 + + def test_json_formatter_with_context_trace_id(self, formatter): + """Test JSON formatting with trace_id from context.""" + import logging + from utils.logging import log_context + + # Set trace_id in context + log_context.set({'trace_id': 'context123', 'operation': 'test_op'}) + + record = logging.LogRecord( + name='test_logger', + level=logging.INFO, + pathname='test.py', + lineno=15, + msg='Context message', + args=(), + exc_info=None + ) + + result = formatter.format(record) + + import json + data = json.loads(result) + + assert data['message'] == 'Context message' + assert data['level'] == 'INFO' + # trace_id should be promoted to standard key from context + assert data['trace_id'] == 'context123' + # context should still be present + assert 'context' in data + assert data['context']['trace_id'] == 'context123' + assert data['context']['operation'] == 'test_op' + + # Clean up context + log_context.set({}) + + +class TestLoggerFactory: + """Test logger factory functions.""" + + def test_get_contextual_logger(self): + """Test contextual logger factory.""" + logger = get_contextual_logger('test.module') + + assert isinstance(logger, ContextualLogger) + assert logger.logger.name == 'test.module' + + def test_get_contextual_logger_unique_instances(self): + """Test that each call returns a new instance.""" + logger1 = get_contextual_logger('test1') + logger2 = get_contextual_logger('test2') + + assert logger1 is not logger2 + assert logger1.logger.name == 'test1' + assert logger2.logger.name == 'test2' \ No newline at end of file diff --git a/utils/README.md b/utils/README.md index f0e124a..e3647b5 100644 --- a/utils/README.md +++ b/utils/README.md @@ -47,7 +47,10 @@ class YourCommandCog(commands.Cog): except Exception as e: self.logger.error("Command failed", error=e) + self.logger.end_operation(trace_id, "failed") raise + else: + self.logger.end_operation(trace_id, "completed") ``` ### **Key Features** @@ -59,18 +62,25 @@ Every log entry automatically includes: - **Operation Context**: Trace ID, operation name, execution duration - **Custom Fields**: Additional context via keyword arguments -#### **âąī¸ Automatic Timing** +#### **âąī¸ Automatic Timing & Tracing** ```python trace_id = self.logger.start_operation("complex_operation") # ... do work ... -self.logger.info("Operation completed") # Automatically includes duration_ms +self.logger.info("Operation in progress") # Includes duration_ms in extras +# ... more work ... +self.logger.end_operation(trace_id, "completed") # Final timing log ``` +**Key Behavior:** +- **`trace_id`**: Promoted to **standard JSON key** (root level) for easy filtering +- **`duration_ms`**: Available in **extras** when timing is active (optional field) +- **Context**: All operation context preserved throughout the async operation + #### **🔗 Request Tracing** Track a single request through all log entries using trace IDs: ```bash -# Find all logs for a specific request -jq '.context.trace_id == "abc12345"' logs/discord_bot_v2.json +# Find all logs for a specific request (trace_id is now a standard key) +jq 'select(.trace_id == "abc12345")' logs/discord_bot_v2.json ``` #### **📤 Hybrid Output** @@ -111,6 +121,14 @@ clear_context() trace_id = logger.start_operation("player_search") ``` +**`end_operation(trace_id: str, operation_result: str = "completed")`** +```python +# End operation and log final duration +logger.end_operation(trace_id, "completed") +# or +logger.end_operation(trace_id, "failed") +``` + **`info(message: str, **kwargs)`** ```python logger.info("Player found", player_id=123, team_name="Yankees") @@ -156,12 +174,13 @@ except: #### **JSON Output (Monitoring & Analysis)** ```json { - "timestamp": "2025-08-14T14:32:15.123Z", + "timestamp": "2025-08-15T14:32:15.123Z", "level": "INFO", "logger": "commands.players.info.PlayerInfoCommands", "message": "Player info command started", "function": "player_info", "line": 50, + "trace_id": "abc12345", "context": { "user_id": "123456789", "guild_id": "987654321", @@ -182,12 +201,13 @@ except: #### **Error Output with Exception** ```json { - "timestamp": "2025-08-14T14:32:18.789Z", + "timestamp": "2025-08-15T14:32:18.789Z", "level": "ERROR", - "logger": "commands.players.info.PlayerInfoCommands", + "logger": "commands.players.info.PlayerInfoCommands", "message": "API call failed", "function": "player_info", "line": 125, + "trace_id": "abc12345", "exception": { "type": "APITimeout", "message": "Request timed out after 30s", @@ -198,7 +218,8 @@ except: "guild_id": "987654321", "command": "/player", "player_name": "Mike Trout", - "trace_id": "abc12345" + "trace_id": "abc12345", + "operation": "player_info_command" }, "extra": { "duration_ms": 30000, @@ -315,7 +336,7 @@ jq -r 'select(.level == "ERROR") | .exception.type' logs/discord_bot_v2.json | s **Trace a complete request:** ```bash -jq 'select(.context.trace_id == "abc12345")' logs/discord_bot_v2.json | jq -s 'sort_by(.timestamp)' +jq 'select(.trace_id == "abc12345")' logs/discord_bot_v2.json | jq -s 'sort_by(.timestamp)' ``` #### **Performance Analysis** @@ -340,15 +361,24 @@ jq -r '.context.command' logs/discord_bot_v2.json | sort | uniq -c | sort -nr #### **✅ Do:** 1. **Always set Discord context** at the start of command handlers 2. **Use start_operation()** for timing critical operations -3. **Include relevant context** in log messages via keyword arguments -4. **Log at appropriate levels** (debug for detailed flow, info for milestones, warning for recoverable issues, error for failures) -5. **Include error context** when logging exceptions +3. **Call end_operation()** to complete operation timing +4. **Include relevant context** in log messages via keyword arguments +5. **Log at appropriate levels** (debug for detailed flow, info for milestones, warning for recoverable issues, error for failures) +6. **Include error context** when logging exceptions +7. **Use trace_id for correlation** - it's automatically available as a standard key #### **❌ Don't:** 1. **Don't log sensitive information** (passwords, tokens, personal data) 2. **Don't over-log in tight loops** (use sampling or conditional logging) 3. **Don't use string formatting in log messages** (use keyword arguments instead) 4. **Don't forget to handle exceptions** in logging code itself +5. **Don't manually add trace_id to log messages** - it's handled automatically + +#### **đŸŽ¯ Trace ID & Duration Guidelines:** +- **`trace_id`**: Automatically promoted to standard key when operation is active +- **`duration_ms`**: Appears in extras for logs during timed operations +- **Operation flow**: Always call `start_operation()` → log messages → `end_operation()` +- **Query logs**: Use `jq 'select(.trace_id == "xyz")'` for request tracing #### **Performance Considerations** - JSON serialization adds minimal overhead (~1-2ms per log entry) @@ -524,7 +554,7 @@ utils/ --- -**Last Updated:** Phase 2.1 - Structured Logging Implementation +**Last Updated:** Phase 1.5 - Enhanced Logging with trace_id Promotion and Operation Timing **Next Update:** When additional utility modules are added For questions or improvements to the logging system, check the implementation in `utils/logging.py` or refer to the JSON log outputs in `logs/discord_bot_v2.json`. \ No newline at end of file diff --git a/utils/decorators.py b/utils/decorators.py index fd95df8..29ed2ef 100644 --- a/utils/decorators.py +++ b/utils/decorators.py @@ -87,6 +87,6 @@ def logged_command( raise # Preserve signature for Discord.py command registration - wrapper.__signature__ = inspect.signature(func) + wrapper.__signature__ = inspect.signature(func) # type: ignore return wrapper return decorator \ No newline at end of file diff --git a/utils/logging.py b/utils/logging.py index 8e69ac4..ecb575c 100644 --- a/utils/logging.py +++ b/utils/logging.py @@ -59,6 +59,10 @@ class JSONFormatter(logging.Formatter): context = log_context.get({}) if context: log_obj['context'] = context.copy() + + # Promote trace_id to standard key if available in context + if 'trace_id' in context: + log_obj['trace_id'] = context['trace_id'] # Add custom fields from extra parameter excluded_keys = { @@ -82,7 +86,7 @@ class JSONFormatter(logging.Formatter): if extra_data: log_obj['extra'] = extra_data - return json.dumps(log_obj, ensure_ascii=False) + return json.dumps(log_obj, ensure_ascii=False) + '\n' class ContextualLogger: @@ -124,6 +128,39 @@ class ContextualLogger: return trace_id + def end_operation(self, trace_id: str, operation_result: str = "completed") -> None: + """ + End an operation and log the final duration. + + Args: + trace_id: The trace ID returned by start_operation + operation_result: Result status (e.g., "completed", "failed", "cancelled") + """ + if self._start_time is None: + self.warning("end_operation called without corresponding start_operation") + return + + duration_ms = int((time.time() - self._start_time) * 1000) + + # Get current context + current_context = log_context.get({}) + + # Log operation completion + self.info(f"Operation {operation_result}", + trace_id=trace_id, + final_duration_ms=duration_ms, + operation_result=operation_result) + + # Clear operation-specific context + if 'operation' in current_context: + current_context.pop('operation', None) + if 'trace_id' in current_context and current_context['trace_id'] == trace_id: + current_context.pop('trace_id', None) + log_context.set(current_context) + + # Reset start time + self._start_time = None + def _get_duration_ms(self) -> Optional[int]: """Get operation duration in milliseconds if start_operation was called.""" if self._start_time: diff --git a/views/__init__.py b/views/__init__.py index fa0d2fc..5d66e32 100644 --- a/views/__init__.py +++ b/views/__init__.py @@ -1,5 +1,171 @@ """ Discord UI components for Bot v2.0 -Interactive views, buttons, modals, and select menus. -""" \ No newline at end of file +Interactive views, buttons, modals, and select menus providing a modern, +consistent UI experience for the SBA Discord bot. + +## Core Components + +### Base Views (views.base) +- BaseView: Foundation class with error handling and user authorization +- ConfirmationView: Standard Yes/No confirmation dialogs +- PaginationView: Navigation through multiple pages of content +- SelectMenuView: Base for dropdown selection menus + +### Embed Templates (views.embeds) +- EmbedTemplate: Standard embed creation with consistent styling +- SBAEmbedTemplate: SBA-specific templates for players, teams, league info +- EmbedBuilder: Fluent interface for building complex embeds +- EmbedColors: Standard color palette + +### Common Views (views.common) +- PlayerSelectionView: Select from multiple players +- TeamSelectionView: Select from multiple teams +- DetailedInfoView: Information display with action buttons +- SearchResultsView: Paginated search results with selection +- QuickActionView: Quick action buttons for common operations +- SettingsView: Settings display and modification + +### Modals (views.modals) +- PlayerSearchModal: Detailed player search criteria +- TeamSearchModal: Team search form +- FeedbackModal: User feedback collection +- ConfigurationModal: Settings configuration +- CustomInputModal: Flexible input collection + +## Usage Examples + +### Basic Confirmation +```python +from views.base import ConfirmationView +from views.embeds import EmbedTemplate + +embed = EmbedTemplate.warning("Confirm Action", "Are you sure?") +view = ConfirmationView(user_id=interaction.user.id) +await interaction.response.send_message(embed=embed, view=view) +``` + +### Player Selection +```python +from views.common import PlayerSelectionView + +view = PlayerSelectionView( + players=found_players, + user_id=interaction.user.id, + callback=handle_player_selection +) +``` + +### Paginated Results +```python +from views.base import PaginationView +from views.embeds import SBAEmbedTemplate + +pages = [SBAEmbedTemplate.team_info(...) for team in teams] +view = PaginationView(pages=pages, user_id=interaction.user.id) +``` + +### Custom Modal +```python +from views.modals import PlayerSearchModal + +modal = PlayerSearchModal() +await interaction.response.send_modal(modal) +await modal.wait() +if modal.is_submitted: + search_criteria = modal.result +``` + +## Design Principles + +1. **Consistency**: All views use standard color schemes and layouts +2. **User Authorization**: Views can be restricted to specific users +3. **Error Handling**: Comprehensive error handling with user feedback +4. **Accessibility**: Clear labels, descriptions, and feedback +5. **Performance**: Efficient pagination and lazy loading +6. **Modularity**: Reusable components for common patterns + +## Color Scheme + +- PRIMARY (0xa6ce39): SBA green for standard content +- SUCCESS (0x28a745): Green for successful operations +- WARNING (0xffc107): Yellow for warnings and cautions +- ERROR (0xdc3545): Red for errors and failures +- INFO (0x17a2b8): Blue for informational content +- SECONDARY (0x6c757d): Gray for secondary content + +## Best Practices + +1. Always specify user_id for user-specific views +2. Use appropriate timeouts based on expected interaction time +3. Provide clear feedback for all user interactions +4. Handle edge cases (empty results, errors, timeouts) +5. Use consistent embed styling across related commands +6. Implement proper validation for modal inputs +7. Provide help text and examples in placeholders +""" + +# Import core classes for easy access +from .base import BaseView, ConfirmationView, PaginationView, SelectMenuView +from .embeds import ( + EmbedTemplate, + SBAEmbedTemplate, + EmbedBuilder, + EmbedColors +) +from .common import ( + PlayerSelectionView, + TeamSelectionView, + DetailedInfoView, + SearchResultsView, + QuickActionView, + SettingsView +) +from .modals import ( + PlayerSearchModal, + TeamSearchModal, + FeedbackModal, + ConfigurationModal, + CustomInputModal, + validate_email, + validate_numeric, + validate_integer, + validate_team_abbreviation, + validate_season +) + +__all__ = [ + # Base components + 'BaseView', + 'ConfirmationView', + 'PaginationView', + 'SelectMenuView', + + # Embed templates + 'EmbedTemplate', + 'SBAEmbedTemplate', + 'EmbedBuilder', + 'EmbedColors', + + # Common views + 'PlayerSelectionView', + 'TeamSelectionView', + 'DetailedInfoView', + 'SearchResultsView', + 'QuickActionView', + 'SettingsView', + + # Modals + 'PlayerSearchModal', + 'TeamSearchModal', + 'FeedbackModal', + 'ConfigurationModal', + 'CustomInputModal', + + # Validators + 'validate_email', + 'validate_numeric', + 'validate_integer', + 'validate_team_abbreviation', + 'validate_season' +] \ No newline at end of file diff --git a/views/base.py b/views/base.py new file mode 100644 index 0000000..08ba9c9 --- /dev/null +++ b/views/base.py @@ -0,0 +1,274 @@ +""" +Base View Classes for Discord Bot v2.0 + +Provides foundational view components with consistent styling and behavior. +""" +import logging +from typing import Optional, Any, Callable, Awaitable +from datetime import datetime, timezone + +import discord +from discord.ext import commands + +from utils.logging import get_contextual_logger + + +class BaseView(discord.ui.View): + """Base view class with consistent styling and error handling.""" + + def __init__( + self, + *, + timeout: float = 180.0, + user_id: Optional[int] = None, + logger_name: Optional[str] = None + ): + super().__init__(timeout=timeout) + self.user_id = user_id + self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView') + self.interaction_count = 0 + self.created_at = datetime.now(timezone.utc) + + async def interaction_check(self, interaction: discord.Interaction) -> bool: + """Check if user is authorized to interact with this view.""" + if self.user_id is None: + return True + + if interaction.user.id != self.user_id: + await interaction.response.send_message( + "❌ You cannot interact with this menu.", + ephemeral=True + ) + return False + + return True + + async def on_timeout(self) -> None: + """Handle view timeout.""" + self.logger.info("View timed out", + user_id=self.user_id, + interaction_count=self.interaction_count, + timeout=self.timeout) + + # Disable all items + for item in self.children: + if hasattr(item, 'disabled'): + item.disabled = True # type: ignore + else: + self.logger.info(f'Item {item} has no "disabled" attribute') + + async def on_error( + self, + interaction: discord.Interaction, + error: Exception, + item: discord.ui.Item[Any] + ) -> None: + """Handle view errors.""" + self.logger.error("View error occurred", + user_id=interaction.user.id, + error=error, + item_type=type(item).__name__, + interaction_count=self.interaction_count) + + try: + if not interaction.response.is_done(): + await interaction.response.send_message( + "❌ An error occurred while processing your interaction.", + ephemeral=True + ) + else: + await interaction.followup.send( + "❌ An error occurred while processing your interaction.", + ephemeral=True + ) + except Exception as e: + self.logger.error("Failed to send error message", error=e) + + def increment_interaction_count(self) -> None: + """Increment the interaction counter.""" + self.interaction_count += 1 + + +class ConfirmationView(BaseView): + """Standard confirmation dialog with Yes/No buttons.""" + + def __init__( + self, + *, + user_id: int, + timeout: float = 60.0, + confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None, + cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None, + confirm_label: str = "Confirm", + cancel_label: str = "Cancel" + ): + super().__init__(timeout=timeout, user_id=user_id, logger_name=f'{__name__}.ConfirmationView') + self.confirm_callback = confirm_callback + self.cancel_callback = cancel_callback + self.result: Optional[bool] = None + + # Update button labels + self.confirm_button.label = confirm_label + self.cancel_button.label = cancel_label + + @discord.ui.button( + label="Confirm", + style=discord.ButtonStyle.success, + emoji="✅" + ) + async def confirm_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Handle confirmation.""" + self.increment_interaction_count() + self.result = True + + # Disable all buttons + for item in self.children: + if hasattr(item, 'disabled'): + item.disabled = True # type: ignore + else: + self.logger.info(f'Item {item} has no "disabled" attribute') + + if self.confirm_callback: + await self.confirm_callback(interaction) + else: + await interaction.response.edit_message( + content="✅ Confirmed!", + view=self + ) + + self.stop() + + @discord.ui.button( + label="Cancel", + style=discord.ButtonStyle.secondary, + emoji="❌" + ) + async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Handle cancellation.""" + self.increment_interaction_count() + self.result = False + + # Disable all buttons + for item in self.children: + if hasattr(item, 'disabled'): + item.disabled = True # type: ignore + else: + self.logger.info(f'Item {item} has no "disabled" attribute') + + if self.cancel_callback: + await self.cancel_callback(interaction) + else: + await interaction.response.edit_message( + content="❌ Cancelled.", + view=self + ) + + self.stop() + + +class PaginationView(BaseView): + """Pagination view for navigating through multiple pages.""" + + def __init__( + self, + *, + pages: list[discord.Embed], + user_id: Optional[int] = None, + timeout: float = 300.0, + show_page_numbers: bool = True, + logger_name: Optional[str] = None + ): + super().__init__(timeout=timeout, user_id=user_id, logger_name=logger_name or f'{__name__}.PaginationView') + self.pages = pages + self.current_page = 0 + self.show_page_numbers = show_page_numbers + + # Update button states + self._update_buttons() + + def _update_buttons(self) -> None: + """Update button enabled/disabled states.""" + self.first_page.disabled = self.current_page == 0 + self.previous_page.disabled = self.current_page == 0 + self.next_page.disabled = self.current_page == len(self.pages) - 1 + self.last_page.disabled = self.current_page == len(self.pages) - 1 + + if self.show_page_numbers: + self.page_info.label = f"{self.current_page + 1}/{len(self.pages)}" + + def get_current_embed(self) -> discord.Embed: + """Get the current page embed with footer.""" + embed = self.pages[self.current_page].copy() + + if self.show_page_numbers: + footer_text = f"Page {self.current_page + 1} of {len(self.pages)}" + if embed.footer.text: + footer_text = f"{embed.footer.text} â€ĸ {footer_text}" + embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url) + + return embed + + @discord.ui.button(emoji="âĒ", style=discord.ButtonStyle.secondary, row=0) + async def first_page(self, interaction: discord.Interaction, button: discord.ui.Button): + """Jump to first page.""" + self.increment_interaction_count() + self.current_page = 0 + self._update_buttons() + await interaction.response.edit_message(embed=self.get_current_embed(), view=self) + + @discord.ui.button(emoji="â—€ī¸", style=discord.ButtonStyle.primary, row=0) + async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button): + """Go to previous page.""" + self.increment_interaction_count() + self.current_page = max(0, self.current_page - 1) + self._update_buttons() + await interaction.response.edit_message(embed=self.get_current_embed(), view=self) + + @discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True, row=0) + async def page_info(self, interaction: discord.Interaction, button: discord.ui.Button): + """Page info button (disabled).""" + pass + + @discord.ui.button(emoji="â–ļī¸", style=discord.ButtonStyle.primary, row=0) + async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button): + """Go to next page.""" + self.increment_interaction_count() + self.current_page = min(len(self.pages) - 1, self.current_page + 1) + self._update_buttons() + await interaction.response.edit_message(embed=self.get_current_embed(), view=self) + + @discord.ui.button(emoji="⏊", style=discord.ButtonStyle.secondary, row=0) + async def last_page(self, interaction: discord.Interaction, button: discord.ui.Button): + """Jump to last page.""" + self.increment_interaction_count() + self.current_page = len(self.pages) - 1 + self._update_buttons() + await interaction.response.edit_message(embed=self.get_current_embed(), view=self) + + @discord.ui.button(emoji="đŸ—‘ī¸", style=discord.ButtonStyle.danger, row=1) + async def delete_message(self, interaction: discord.Interaction, button: discord.ui.Button): + """Delete the message.""" + self.increment_interaction_count() + await interaction.response.defer() + await interaction.delete_original_response() + self.stop() + + +class SelectMenuView(BaseView): + """Base class for views with select menus.""" + + def __init__( + self, + *, + user_id: Optional[int] = None, + timeout: float = 180.0, + placeholder: str = "Select an option...", + min_values: int = 1, + max_values: int = 1, + logger_name: Optional[str] = None + ): + super().__init__(timeout=timeout, user_id=user_id, logger_name=logger_name or f'{__name__}.SelectMenuView') + self.placeholder = placeholder + self.min_values = min_values + self.max_values = max_values + self.selected_values: list[str] = [] \ No newline at end of file diff --git a/views/common.py b/views/common.py new file mode 100644 index 0000000..e6d9c8a --- /dev/null +++ b/views/common.py @@ -0,0 +1,528 @@ +""" +Common Discord View Components for Bot v2.0 + +Specialized views for frequent use cases including player/team selection, +detailed information displays, and interactive menus. +""" +from typing import Optional, List, Dict, Any, Callable, Awaitable, Union +import asyncio + +import discord +from discord.ext import commands + +from .base import BaseView, PaginationView, SelectMenuView +from .embeds import SBAEmbedTemplate, EmbedTemplate, EmbedColors +from models.player import Player +from models.team import Team +from utils.logging import get_contextual_logger + + +class PlayerSelectionView(SelectMenuView): + """Select menu for choosing from multiple players.""" + + def __init__( + self, + players: List[Player], + *, + user_id: int, + callback: Optional[Callable[[discord.Interaction, Player], Awaitable[None]]] = None, + timeout: float = 60.0, + max_players: int = 25 + ): + super().__init__( + user_id=user_id, + timeout=timeout, + placeholder="Select a player...", + logger_name=f'{__name__}.PlayerSelectionView' + ) + + self.players = players[:max_players] # Discord limit + self.callback = callback + self.selected_player: Optional[Player] = None + + # Create select menu options + self.add_item(self.player_select) + + @discord.ui.select(placeholder="Choose a player...") + async def player_select(self, interaction: discord.Interaction, select: discord.ui.Select): + """Handle player selection.""" + self.increment_interaction_count() + + # Find selected player + selected_id = int(select.values[0]) + self.selected_player = next( + (p for p in self.players if p.id == selected_id), + None + ) + + if self.selected_player is None: + await interaction.response.send_message( + "❌ Player not found.", + ephemeral=True + ) + return + + # Disable the select menu + select.disabled = True + + if self.callback: + await self.callback(interaction, self.selected_player) + else: + # Default behavior: show player card + embed = SBAEmbedTemplate.player_card( + player_name=self.selected_player.name, + position=self.selected_player.primary_position, + wara=self.selected_player.wara, + season=self.selected_player.season, + player_image=getattr(self.selected_player, 'image', None) + ) + + await interaction.response.edit_message(embed=embed, view=self) + + self.stop() + + def setup_options(self): + """Setup select menu options from players.""" + options = [] + for player in self.players: + # Create option label + label = player.name[:100] # Discord limit + description = f"{player.primary_position}" + + if hasattr(player, 'team') and player.team: + description += f" â€ĸ {player.team.abbrev}" + + # Add WARA if available + if player.wara is not None: + description += f" â€ĸ WARA: {player.wara:.1f}" + + options.append(discord.SelectOption( + label=label, + description=description[:100], # Discord limit + value=str(player.id) + )) + + self.player_select.options = options + + +class TeamSelectionView(SelectMenuView): + """Select menu for choosing from multiple teams.""" + + def __init__( + self, + teams: List[Team], + *, + user_id: int, + callback: Optional[Callable[[discord.Interaction, Team], Awaitable[None]]] = None, + timeout: float = 60.0, + max_teams: int = 25 + ): + super().__init__( + user_id=user_id, + timeout=timeout, + placeholder="Select a team...", + logger_name=f'{__name__}.TeamSelectionView' + ) + + self.teams = teams[:max_teams] # Discord limit + self.callback = callback + self.selected_team: Optional[Team] = None + + # Create select menu options + self.add_item(self.team_select) + self.setup_options() + + @discord.ui.select(placeholder="Choose a team...") + async def team_select(self, interaction: discord.Interaction, select: discord.ui.Select): + """Handle team selection.""" + self.increment_interaction_count() + + # Find selected team + selected_id = int(select.values[0]) + self.selected_team = next( + (t for t in self.teams if t.id == selected_id), + None + ) + + if self.selected_team is None: + await interaction.response.send_message( + "❌ Team not found.", + ephemeral=True + ) + return + + # Disable the select menu + select.disabled = True + + if self.callback: + await self.callback(interaction, self.selected_team) + else: + # Default behavior: show team info + embed = SBAEmbedTemplate.team_info( + team_abbrev=self.selected_team.abbrev, + team_name=self.selected_team.lname, + season=self.selected_team.season, + short_name=getattr(self.selected_team, 'sname', None), + stadium=getattr(self.selected_team, 'stadium', None), + team_color=getattr(self.selected_team, 'color', None), + team_thumbnail=getattr(self.selected_team, 'thumbnail', None) + ) + + await interaction.response.edit_message(embed=embed, view=self) + + self.stop() + + def setup_options(self): + """Setup select menu options from teams.""" + options = [] + for team in self.teams: + # Create option label + label = f"{team.abbrev} - {team.lname}"[:100] # Discord limit + description = f"Season {team.season}" + + if hasattr(team, 'division_id') and team.division_id: + description += f" â€ĸ Division {team.division_id}" + + options.append(discord.SelectOption( + label=label, + description=description[:100], # Discord limit + value=str(team.id) + )) + + self.team_select.options = options + + +class DetailedInfoView(BaseView): + """View for displaying detailed information with action buttons.""" + + def __init__( + self, + embed: discord.Embed, + *, + user_id: Optional[int] = None, + timeout: float = 300.0, + show_refresh: bool = False, + show_details: bool = False, + refresh_callback: Optional[Callable[[discord.Interaction], Awaitable[discord.Embed]]] = None, + details_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None + ): + super().__init__( + timeout=timeout, + user_id=user_id, + logger_name=f'{__name__}.DetailedInfoView' + ) + + self.embed = embed + self.refresh_callback = refresh_callback + self.details_callback = details_callback + + if show_refresh and refresh_callback: + self.add_item(self.refresh_button) + + if show_details and details_callback: + self.add_item(self.details_button) + + @discord.ui.button( + label="Refresh", + emoji="🔄", + style=discord.ButtonStyle.secondary, + row=0 + ) + async def refresh_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Refresh the information.""" + self.increment_interaction_count() + + if self.refresh_callback: + # Show loading state + button.disabled = True + button.label = "Refreshing..." + await interaction.response.edit_message(view=self) + + try: + # Get updated embed + new_embed = await self.refresh_callback(interaction) + self.embed = new_embed + + # Re-enable button + button.disabled = False + button.label = "Refresh" + + await interaction.edit_original_response(embed=new_embed, view=self) + + except Exception as e: + self.logger.error("Failed to refresh data", error=e) + button.disabled = False + button.label = "Refresh" + + error_embed = EmbedTemplate.error( + title="Refresh Failed", + description="Unable to refresh data. Please try again." + ) + + await interaction.edit_original_response(embed=error_embed, view=self) + + @discord.ui.button( + label="More Details", + emoji="📊", + style=discord.ButtonStyle.primary, + row=0 + ) + async def details_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Show more details.""" + self.increment_interaction_count() + + if self.details_callback: + await self.details_callback(interaction) + + +class SearchResultsView(PaginationView): + """Paginated view for search results with selection capability.""" + + def __init__( + self, + results: List[Dict[str, Any]], + search_term: str, + *, + user_id: Optional[int] = None, + timeout: float = 300.0, + results_per_page: int = 10, + selection_callback: Optional[Callable[[discord.Interaction, Dict[str, Any]], Awaitable[None]]] = None + ): + self.results = results + self.search_term = search_term + self.results_per_page = results_per_page + self.selection_callback = selection_callback + + # Create pages + pages = self._create_pages() + + super().__init__( + pages=pages, + user_id=user_id, + timeout=timeout, + logger_name=f'{__name__}.SearchResultsView' + ) + + # Add selection dropdown if callback provided + if selection_callback and results: + self.add_item(self.result_select) + self.setup_selection_options() + + def _create_pages(self) -> List[discord.Embed]: + """Create embed pages from search results.""" + pages = [] + + for i in range(0, len(self.results), self.results_per_page): + page_results = self.results[i:i + self.results_per_page] + + embed = SBAEmbedTemplate.search_results( + search_term=self.search_term, + results=page_results, + max_results=self.results_per_page + ) + + pages.append(embed) + + if not pages: + # No results page + embed = SBAEmbedTemplate.search_results( + search_term=self.search_term, + results=[], + max_results=self.results_per_page + ) + pages.append(embed) + + return pages + + @discord.ui.select(placeholder="Select a result...", row=1) + async def result_select(self, interaction: discord.Interaction, select: discord.ui.Select): + """Handle result selection.""" + self.increment_interaction_count() + + if self.selection_callback: + # Find selected result + selected_index = int(select.values[0]) + if 0 <= selected_index < len(self.results): + selected_result = self.results[selected_index] + await self.selection_callback(interaction, selected_result) + else: + await interaction.response.send_message( + "❌ Invalid selection.", + ephemeral=True + ) + + def setup_selection_options(self): + """Setup selection dropdown options.""" + options = [] + + # Show results for current page + start_idx = self.current_page * self.results_per_page + end_idx = min(start_idx + self.results_per_page, len(self.results)) + + for i in range(start_idx, end_idx): + result = self.results[i] + + label = result.get('name', f'Result {i + 1}')[:100] + description = result.get('detail', '')[:100] + + options.append(discord.SelectOption( + label=label, + description=description, + value=str(i) + )) + + if options: + self.result_select.options = options + self.result_select.disabled = False + else: + self.result_select.disabled = True + + +class QuickActionView(BaseView): + """View with quick action buttons for common operations.""" + + def __init__( + self, + *, + user_id: Optional[int] = None, + timeout: float = 180.0, + actions: Optional[List[Dict[str, Any]]] = None + ): + super().__init__( + timeout=timeout, + user_id=user_id, + logger_name=f'{__name__}.QuickActionView' + ) + + self.actions = actions or [] + self._setup_action_buttons() + + def _setup_action_buttons(self): + """Setup action buttons from actions list.""" + for i, action in enumerate(self.actions[:25]): # Discord limit + button = discord.ui.Button( + label=action.get('label', f'Action {i + 1}'), + emoji=action.get('emoji'), + style=getattr(discord.ButtonStyle, action.get('style', 'secondary')), + custom_id=f'action_{i}', + row=i // 5 # 5 buttons per row + ) + + async def button_callback(interaction: discord.Interaction, btn=button, act=action): + self.increment_interaction_count() + callback = act.get('callback') + if callback: + await callback(interaction) + + button.callback = button_callback + self.add_item(button) + + +class SettingsView(BaseView): + """View for displaying and modifying settings.""" + + def __init__( + self, + settings: Dict[str, Any], + *, + user_id: int, + timeout: float = 300.0, + save_callback: Optional[Callable[[Dict[str, Any]], Awaitable[bool]]] = None + ): + super().__init__( + timeout=timeout, + user_id=user_id, + logger_name=f'{__name__}.SettingsView' + ) + + self.settings = settings.copy() + self.original_settings = settings.copy() + self.save_callback = save_callback + self.has_changes = False + + def create_settings_embed(self) -> discord.Embed: + """Create embed showing current settings.""" + embed = EmbedTemplate.create_base_embed( + title="âš™ī¸ Settings", + color=EmbedColors.SECONDARY + ) + + for key, value in self.settings.items(): + embed.add_field( + name=key.replace('_', ' ').title(), + value=str(value), + inline=True + ) + + if self.has_changes: + embed.set_footer(text="âš ī¸ You have unsaved changes") + + return embed + + @discord.ui.button( + label="Save Changes", + emoji="💾", + style=discord.ButtonStyle.success, + row=0 + ) + async def save_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Save settings changes.""" + self.increment_interaction_count() + + if not self.has_changes: + await interaction.response.send_message( + "â„šī¸ No changes to save.", + ephemeral=True + ) + return + + if self.save_callback: + button.disabled = True + await interaction.response.edit_message(view=self) + + try: + success = await self.save_callback(self.settings) + + if success: + self.has_changes = False + self.original_settings = self.settings.copy() + + embed = EmbedTemplate.success( + title="Settings Saved", + description="Your settings have been saved successfully." + ) + else: + embed = EmbedTemplate.error( + title="Save Failed", + description="Failed to save settings. Please try again." + ) + + button.disabled = False + await interaction.edit_original_response(embed=embed, view=self) + + except Exception as e: + self.logger.error("Failed to save settings", error=e) + button.disabled = False + + embed = EmbedTemplate.error( + title="Save Error", + description="An error occurred while saving settings." + ) + + await interaction.edit_original_response(embed=embed, view=self) + + @discord.ui.button( + label="Reset", + emoji="🔄", + style=discord.ButtonStyle.danger, + row=0 + ) + async def reset_button(self, interaction: discord.Interaction, button: discord.ui.Button): + """Reset settings to original values.""" + self.increment_interaction_count() + + self.settings = self.original_settings.copy() + self.has_changes = False + + embed = self.create_settings_embed() + await interaction.response.edit_message(embed=embed, view=self) \ No newline at end of file diff --git a/views/embeds.py b/views/embeds.py new file mode 100644 index 0000000..c990de2 --- /dev/null +++ b/views/embeds.py @@ -0,0 +1,391 @@ +""" +Embed Templates for Discord Bot v2.0 + +Provides consistent embed styling and templates for common use cases. +""" +from typing import Optional, Union, Any, List +from datetime import datetime + +import discord + +from constants import SBA_CURRENT_SEASON + + +class EmbedColors: + """Standard color palette for embeds.""" + PRIMARY = 0xa6ce39 # SBA green + SUCCESS = 0x28a745 # Green + WARNING = 0xffc107 # Yellow + ERROR = 0xdc3545 # Red + INFO = 0x17a2b8 # Blue + SECONDARY = 0x6c757d # Gray + DARK = 0x343a40 # Dark gray + LIGHT = 0xf8f9fa # Light gray + + +class EmbedTemplate: + """Base embed template with consistent styling.""" + + @staticmethod + def create_base_embed( + title: Optional[str] = None, + description: Optional[str] = None, + color: Union[int, discord.Color] = EmbedColors.PRIMARY, + timestamp: bool = True + ) -> discord.Embed: + """Create a base embed with standard formatting.""" + embed = discord.Embed( + title=title, + description=description, + color=color + ) + + if timestamp: + embed.timestamp = discord.utils.utcnow() + + return embed + + @staticmethod + def success( + title: str = "Success", + description: Optional[str] = None, + **kwargs + ) -> discord.Embed: + """Create a success embed.""" + return EmbedTemplate.create_base_embed( + title=f"✅ {title}", + description=description, + color=EmbedColors.SUCCESS, + **kwargs + ) + + @staticmethod + def error( + title: str = "Error", + description: Optional[str] = None, + **kwargs + ) -> discord.Embed: + """Create an error embed.""" + return EmbedTemplate.create_base_embed( + title=f"❌ {title}", + description=description, + color=EmbedColors.ERROR, + **kwargs + ) + + @staticmethod + def warning( + title: str = "Warning", + description: Optional[str] = None, + **kwargs + ) -> discord.Embed: + """Create a warning embed.""" + return EmbedTemplate.create_base_embed( + title=f"âš ī¸ {title}", + description=description, + color=EmbedColors.WARNING, + **kwargs + ) + + @staticmethod + def info( + title: str = "Information", + description: Optional[str] = None, + **kwargs + ) -> discord.Embed: + """Create an info embed.""" + return EmbedTemplate.create_base_embed( + title=f"â„šī¸ {title}", + description=description, + color=EmbedColors.INFO, + **kwargs + ) + + @staticmethod + def loading( + title: str = "Loading", + description: Optional[str] = None, + **kwargs + ) -> discord.Embed: + """Create a loading embed.""" + return EmbedTemplate.create_base_embed( + title=f"âŗ {title}", + description=description, + color=EmbedColors.SECONDARY, + **kwargs + ) + + +class SBAEmbedTemplate(EmbedTemplate): + """SBA-specific embed templates.""" + + @staticmethod + def player_card( + player_name: str, + position: str, + team_abbrev: Optional[str] = None, + team_name: Optional[str] = None, + wara: Optional[float] = None, + season: Optional[int] = None, + player_image: Optional[str] = None, + team_color: Optional[str] = None, + additional_fields: Optional[List[dict]] = None + ) -> discord.Embed: + """Create a player card embed.""" + color = int(team_color, 16) if team_color else EmbedColors.PRIMARY + + embed = EmbedTemplate.create_base_embed( + title=f"đŸŸī¸ {player_name}", + color=color + ) + + # Basic player info + embed.add_field(name="Position", value=position, inline=True) + + if team_abbrev and team_name: + embed.add_field(name="Team", value=f"{team_abbrev} - {team_name}", inline=True) + elif team_abbrev: + embed.add_field(name="Team", value=team_abbrev, inline=True) + + if wara is not None: + embed.add_field(name="WARA", value=f"{wara:.1f}", inline=True) + + embed.add_field( + name="Season", + value=str(season or SBA_CURRENT_SEASON), + inline=True + ) + + # Add additional fields if provided + if additional_fields: + for field in additional_fields: + embed.add_field( + name=field.get("name", "Field"), + value=field.get("value", "N/A"), + inline=field.get("inline", True) + ) + + # Set player image + if player_image: + embed.set_thumbnail(url=player_image) + + return embed + + @staticmethod + def team_info( + team_abbrev: str, + team_name: str, + season: Optional[int] = None, + short_name: Optional[str] = None, + stadium: Optional[str] = None, + division: Optional[str] = None, + record: Optional[str] = None, + team_color: Optional[str] = None, + team_thumbnail: Optional[str] = None, + additional_fields: Optional[List[dict]] = None + ) -> discord.Embed: + """Create a team information embed.""" + color = int(team_color, 16) if team_color else EmbedColors.PRIMARY + + embed = EmbedTemplate.create_base_embed( + title=f"{team_abbrev} - {team_name}", + description=f"Season {season or SBA_CURRENT_SEASON} Team Information", + color=color + ) + + # Basic team info + if short_name: + embed.add_field(name="Short Name", value=short_name, inline=True) + + embed.add_field(name="Abbreviation", value=team_abbrev, inline=True) + embed.add_field(name="Season", value=str(season or SBA_CURRENT_SEASON), inline=True) + + if stadium: + embed.add_field(name="Stadium", value=stadium, inline=True) + + if division: + embed.add_field(name="Division", value=division, inline=True) + + if record: + embed.add_field(name="Record", value=record, inline=True) + + # Add additional fields if provided + if additional_fields: + for field in additional_fields: + embed.add_field( + name=field.get("name", "Field"), + value=field.get("value", "N/A"), + inline=field.get("inline", True) + ) + + # Set team thumbnail + if team_thumbnail: + embed.set_thumbnail(url=team_thumbnail) + + return embed + + @staticmethod + def league_status( + season: Optional[int] = None, + week: Optional[int] = None, + phase: Optional[str] = None, + additional_info: Optional[str] = None, + teams_count: Optional[int] = None, + active_players: Optional[int] = None + ) -> discord.Embed: + """Create a league status embed.""" + embed = EmbedTemplate.create_base_embed( + title="🏆 SBA League Status", + color=EmbedColors.PRIMARY + ) + + if season: + embed.add_field(name="Season", value=str(season), inline=True) + + if week: + embed.add_field(name="Week", value=str(week), inline=True) + + if phase: + embed.add_field(name="Phase", value=phase, inline=True) + + if teams_count: + embed.add_field(name="Teams", value=str(teams_count), inline=True) + + if active_players: + embed.add_field(name="Active Players", value=str(active_players), inline=True) + + if additional_info: + embed.add_field(name="Additional Info", value=additional_info, inline=False) + + return embed + + @staticmethod + def roster_display( + team_abbrev: str, + team_name: str, + roster_type: str = "Full Roster", + season: Optional[int] = None, + team_color: Optional[str] = None, + player_groups: Optional[dict] = None + ) -> discord.Embed: + """Create a roster display embed.""" + color = int(team_color, 16) if team_color else EmbedColors.PRIMARY + + embed = EmbedTemplate.create_base_embed( + title=f"{team_abbrev} - {roster_type}", + description=f"{team_name} â€ĸ Season {season or SBA_CURRENT_SEASON}", + color=color + ) + + if player_groups: + for group_name, players in player_groups.items(): + if players: + player_list = "\n".join([ + f"â€ĸ {player.get('name', 'Unknown')} ({player.get('position', 'N/A')})" + for player in players[:10] # Limit to 10 players per field + ]) + + if len(players) > 10: + player_list += f"\n... and {len(players) - 10} more" + + embed.add_field( + name=f"{group_name} ({len(players)})", + value=player_list or "No players", + inline=True + ) + + return embed + + @staticmethod + def search_results( + search_term: str, + results: List[dict], + result_type: str = "Results", + max_results: int = 10 + ) -> discord.Embed: + """Create a search results embed.""" + embed = EmbedTemplate.create_base_embed( + title=f"🔍 Search Results for '{search_term}'", + color=EmbedColors.INFO + ) + + if not results: + embed.description = "No results found." + embed.color = EmbedColors.WARNING + return embed + + # Show limited results + displayed_results = results[:max_results] + result_text = "\n".join([ + f"â€ĸ {result.get('name', 'Unknown')} ({result.get('detail', 'N/A')})" + for result in displayed_results + ]) + + if len(results) > max_results: + result_text += f"\n\n... and {len(results) - max_results} more results" + + embed.add_field( + name=f"{result_type} ({len(results)} found)", + value=result_text, + inline=False + ) + + embed.set_footer(text="Please be more specific if you see multiple results.") + + return embed + + +class EmbedBuilder: + """Fluent interface for building complex embeds.""" + + def __init__(self, embed: Optional[discord.Embed] = None): + self._embed = embed or discord.Embed() + + def title(self, title: str) -> 'EmbedBuilder': + """Set embed title.""" + self._embed.title = title + return self + + def description(self, description: str) -> 'EmbedBuilder': + """Set embed description.""" + self._embed.description = description + return self + + def color(self, color: Union[int, discord.Color]) -> 'EmbedBuilder': + """Set embed color.""" + self._embed.color = color + return self + + def field(self, name: str, value: str, inline: bool = True) -> 'EmbedBuilder': + """Add a field to the embed.""" + self._embed.add_field(name=name, value=value, inline=inline) + return self + + def thumbnail(self, url: str) -> 'EmbedBuilder': + """Set embed thumbnail.""" + self._embed.set_thumbnail(url=url) + return self + + def image(self, url: str) -> 'EmbedBuilder': + """Set embed image.""" + self._embed.set_image(url=url) + return self + + def footer(self, text: str, icon_url: Optional[str] = None) -> 'EmbedBuilder': + """Set embed footer.""" + self._embed.set_footer(text=text, icon_url=icon_url) + return self + + def timestamp(self, timestamp: Optional[datetime] = None) -> 'EmbedBuilder': + """Set embed timestamp.""" + self._embed.timestamp = timestamp or discord.utils.utcnow() + return self + + def author(self, name: str, url: Optional[str] = None, icon_url: Optional[str] = None) -> 'EmbedBuilder': + """Set embed author.""" + self._embed.set_author(name=name, url=url, icon_url=icon_url) + return self + + def build(self) -> discord.Embed: + """Build and return the embed.""" + return self._embed \ No newline at end of file diff --git a/views/modals.py b/views/modals.py new file mode 100644 index 0000000..1f90fda --- /dev/null +++ b/views/modals.py @@ -0,0 +1,488 @@ +""" +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 \ No newline at end of file