CLAUDE: SUCCESSFUL STARTUP - Discord Bot v2.0 fully operational

 **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 <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-08-16 07:36:47 -05:00
parent 8897b7fa5e
commit e6a30af604
18 changed files with 3105 additions and 60 deletions

83
bot.py
View File

@ -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'])

View File

@ -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.
"""

View File

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

View File

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

View File

@ -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

View File

@ -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'
]

View File

@ -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...")

View File

@ -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
@ -473,3 +475,63 @@ class TestAPIClientCoverageExtras:
# Test with no parameters
url = client._add_params("https://example.com/api")
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()

View File

@ -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

361
tests/test_utils_logging.py Normal file
View File

@ -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'

View File

@ -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",
"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`.

View File

@ -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

View File

@ -60,6 +60,10 @@ class JSONFormatter(logging.Formatter):
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 = {
'name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
@ -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:

View File

@ -1,5 +1,171 @@
"""
Discord UI components for Bot v2.0
Interactive views, buttons, modals, and select menus.
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'
]

274
views/base.py Normal file
View File

@ -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] = []

528
views/common.py Normal file
View File

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

391
views/embeds.py Normal file
View File

@ -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

488
views/modals.py Normal file
View File

@ -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