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:
parent
8897b7fa5e
commit
e6a30af604
83
bot.py
83
bot.py
@ -30,27 +30,15 @@ def setup_logging():
|
|||||||
logger = logging.getLogger('discord_bot_v2')
|
logger = logging.getLogger('discord_bot_v2')
|
||||||
logger.setLevel(getattr(logging, config.log_level.upper()))
|
logger.setLevel(getattr(logging, config.log_level.upper()))
|
||||||
|
|
||||||
# Console handler - human readable for development
|
# Console handler - detailed format for development debugging
|
||||||
console_handler = logging.StreamHandler()
|
console_handler = logging.StreamHandler()
|
||||||
console_formatter = logging.Formatter(
|
console_formatter = logging.Formatter(
|
||||||
'%(asctime)s - %(name)s - %(levelname)s - %(message)s'
|
'%(asctime)s - %(name)s - %(levelname)s - %(funcName)s:%(lineno)d - %(message)s'
|
||||||
)
|
)
|
||||||
console_handler.setFormatter(console_formatter)
|
console_handler.setFormatter(console_formatter)
|
||||||
logger.addHandler(console_handler)
|
logger.addHandler(console_handler)
|
||||||
|
|
||||||
# Traditional file handler - human readable with debug info
|
# JSON file handler - structured logging for monitoring and analysis
|
||||||
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_handler = RotatingFileHandler(
|
json_handler = RotatingFileHandler(
|
||||||
'logs/discord_bot_v2.json',
|
'logs/discord_bot_v2.json',
|
||||||
maxBytes=5 * 1024 * 1024, # 5MB
|
maxBytes=5 * 1024 * 1024, # 5MB
|
||||||
@ -59,16 +47,20 @@ def setup_logging():
|
|||||||
json_handler.setFormatter(JSONFormatter())
|
json_handler.setFormatter(JSONFormatter())
|
||||||
logger.addHandler(json_handler)
|
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 = logging.getLogger()
|
||||||
root_logger.setLevel(getattr(logging, config.log_level.upper()))
|
root_logger.setLevel(getattr(logging, config.log_level.upper()))
|
||||||
|
|
||||||
# Add handlers to root logger so all child loggers inherit them
|
# Add handlers to root logger so third-party loggers inherit them
|
||||||
if not root_logger.handlers: # Avoid duplicate handlers
|
if not root_logger.handlers: # Avoid duplicate handlers
|
||||||
root_logger.addHandler(console_handler)
|
root_logger.addHandler(console_handler)
|
||||||
root_logger.addHandler(file_handler)
|
|
||||||
root_logger.addHandler(json_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
|
return logger
|
||||||
|
|
||||||
|
|
||||||
@ -84,7 +76,7 @@ class SBABot(commands.Bot):
|
|||||||
super().__init__(
|
super().__init__(
|
||||||
command_prefix='!', # Legacy prefix, primarily using slash commands
|
command_prefix='!', # Legacy prefix, primarily using slash commands
|
||||||
intents=intents,
|
intents=intents,
|
||||||
description="SBA League Management Bot v2.0"
|
description="Major Domo v2.0"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.logger = logging.getLogger('discord_bot_v2')
|
self.logger = logging.getLogger('discord_bot_v2')
|
||||||
@ -112,13 +104,15 @@ class SBABot(commands.Bot):
|
|||||||
async def _load_command_packages(self):
|
async def _load_command_packages(self):
|
||||||
"""Load all command packages with resilient error handling."""
|
"""Load all command packages with resilient error handling."""
|
||||||
from commands.players import setup_players
|
from commands.players import setup_players
|
||||||
|
from commands.teams import setup_teams
|
||||||
|
from commands.league import setup_league
|
||||||
|
|
||||||
# Define command packages to load
|
# Define command packages to load
|
||||||
command_packages = [
|
command_packages = [
|
||||||
("players", setup_players),
|
("players", setup_players),
|
||||||
|
("teams", setup_teams),
|
||||||
|
("league", setup_league),
|
||||||
# Future packages:
|
# Future packages:
|
||||||
# ("teams", setup_teams),
|
|
||||||
# ("league", setup_league),
|
|
||||||
# ("admin", setup_admin),
|
# ("admin", setup_admin),
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -153,19 +147,29 @@ class SBABot(commands.Bot):
|
|||||||
# Create hash of current command tree
|
# Create hash of current command tree
|
||||||
commands_data = []
|
commands_data = []
|
||||||
for cmd in self.tree.get_commands():
|
for cmd in self.tree.get_commands():
|
||||||
# Include relevant command data for comparison
|
# Handle different command types properly
|
||||||
cmd_dict = {
|
cmd_dict = {}
|
||||||
'name': cmd.name,
|
cmd_dict['name'] = cmd.name
|
||||||
'description': cmd.description,
|
cmd_dict['type'] = type(cmd).__name__
|
||||||
'parameters': [
|
|
||||||
|
# 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,
|
'name': param.name,
|
||||||
'description': param.description,
|
'description': param.description,
|
||||||
'required': param.required,
|
'required': param.required,
|
||||||
'type': str(param.type)
|
'type': str(param.type)
|
||||||
} for param in cmd.parameters
|
} for param in cmd.parameters
|
||||||
] 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.append(cmd_dict)
|
||||||
|
|
||||||
# Sort for consistent hashing
|
# Sort for consistent hashing
|
||||||
@ -195,18 +199,29 @@ class SBABot(commands.Bot):
|
|||||||
# Create hash of current command tree (same logic as _should_sync_commands)
|
# Create hash of current command tree (same logic as _should_sync_commands)
|
||||||
commands_data = []
|
commands_data = []
|
||||||
for cmd in self.tree.get_commands():
|
for cmd in self.tree.get_commands():
|
||||||
cmd_dict = {
|
# Handle different command types properly
|
||||||
'name': cmd.name,
|
cmd_dict = {}
|
||||||
'description': cmd.description,
|
cmd_dict['name'] = cmd.name
|
||||||
'parameters': [
|
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,
|
'name': param.name,
|
||||||
'description': param.description,
|
'description': param.description,
|
||||||
'required': param.required,
|
'required': param.required,
|
||||||
'type': str(param.type)
|
'type': str(param.type)
|
||||||
} for param in cmd.parameters
|
} for param in cmd.parameters
|
||||||
] 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.append(cmd_dict)
|
||||||
|
|
||||||
commands_data.sort(key=lambda x: x['name'])
|
commands_data.sort(key=lambda x: x['name'])
|
||||||
|
|||||||
6
commands/examples/__init__.py
Normal file
6
commands/examples/__init__.py
Normal 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.
|
||||||
|
"""
|
||||||
372
commands/examples/enhanced_player.py
Normal file
372
commands/examples/enhanced_player.py
Normal 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))
|
||||||
311
commands/examples/migration_example.py
Normal file
311
commands/examples/migration_example.py
Normal 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))
|
||||||
@ -1,10 +1,7 @@
|
|||||||
"""
|
"""
|
||||||
Configuration management for Discord Bot v2.0
|
Configuration management for Discord Bot v2.0
|
||||||
"""
|
"""
|
||||||
import os
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
from typing import Optional
|
|
||||||
from pydantic_settings import BaseSettings
|
|
||||||
from pydantic import ConfigDict
|
|
||||||
|
|
||||||
|
|
||||||
class BotConfig(BaseSettings):
|
class BotConfig(BaseSettings):
|
||||||
@ -29,7 +26,7 @@ class BotConfig(BaseSettings):
|
|||||||
environment: str = "development"
|
environment: str = "development"
|
||||||
testing: bool = False
|
testing: bool = False
|
||||||
|
|
||||||
model_config = ConfigDict(
|
model_config = SettingsConfigDict(
|
||||||
env_file=".env",
|
env_file=".env",
|
||||||
case_sensitive=False,
|
case_sensitive=False,
|
||||||
extra="ignore" # Ignore extra environment variables
|
extra="ignore" # Ignore extra environment variables
|
||||||
@ -53,5 +50,5 @@ def get_config() -> BotConfig:
|
|||||||
"""Get the global configuration instance."""
|
"""Get the global configuration instance."""
|
||||||
global _config
|
global _config
|
||||||
if _config is None:
|
if _config is None:
|
||||||
_config = BotConfig()
|
_config = BotConfig() # type: ignore
|
||||||
return _config
|
return _config
|
||||||
@ -6,11 +6,13 @@ Service layer providing clean interfaces to data operations.
|
|||||||
|
|
||||||
from .team_service import TeamService, team_service
|
from .team_service import TeamService, team_service
|
||||||
from .player_service import PlayerService, player_service
|
from .player_service import PlayerService, player_service
|
||||||
|
from .league_service import LeagueService, league_service
|
||||||
|
|
||||||
# Wire services together for dependency injection
|
# Wire services together for dependency injection
|
||||||
player_service._team_service = team_service
|
player_service._team_service = team_service
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
'TeamService', 'team_service',
|
'TeamService', 'team_service',
|
||||||
'PlayerService', 'player_service'
|
'PlayerService', 'player_service',
|
||||||
|
'LeagueService', 'league_service'
|
||||||
]
|
]
|
||||||
@ -48,6 +48,9 @@ class MockChannel:
|
|||||||
self.id = 444555666
|
self.id = 444555666
|
||||||
|
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
async def test_player_search():
|
async def test_player_search():
|
||||||
"""Test player search with real data."""
|
"""Test player search with real data."""
|
||||||
print("🔍 Testing Player Search...")
|
print("🔍 Testing Player Search...")
|
||||||
@ -125,6 +128,7 @@ async def test_player_search():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
async def test_player_service_methods():
|
async def test_player_service_methods():
|
||||||
"""Test various player service methods."""
|
"""Test various player service methods."""
|
||||||
print("🔧 Testing Player Service Methods...")
|
print("🔧 Testing Player Service Methods...")
|
||||||
@ -176,6 +180,7 @@ async def test_player_service_methods():
|
|||||||
return False
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
async def test_api_connectivity():
|
async def test_api_connectivity():
|
||||||
"""Test basic API connectivity."""
|
"""Test basic API connectivity."""
|
||||||
print("🌐 Testing API Connectivity...")
|
print("🌐 Testing API Connectivity...")
|
||||||
|
|||||||
@ -2,6 +2,8 @@
|
|||||||
API client tests using aioresponses for clean HTTP mocking
|
API client tests using aioresponses for clean HTTP mocking
|
||||||
"""
|
"""
|
||||||
import pytest
|
import pytest
|
||||||
|
import asyncio
|
||||||
|
import aiohttp
|
||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
from aioresponses import aioresponses
|
from aioresponses import aioresponses
|
||||||
|
|
||||||
@ -473,3 +475,63 @@ class TestAPIClientCoverageExtras:
|
|||||||
# Test with no parameters
|
# Test with no parameters
|
||||||
url = client._add_params("https://example.com/api")
|
url = client._add_params("https://example.com/api")
|
||||||
assert url == "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()
|
||||||
@ -35,7 +35,7 @@ class TestBotConfig:
|
|||||||
'GUILD_ID': '123456789',
|
'GUILD_ID': '123456789',
|
||||||
'API_TOKEN': 'test_api_token',
|
'API_TOKEN': 'test_api_token',
|
||||||
'DB_URL': 'https://api.example.com'
|
'DB_URL': 'https://api.example.com'
|
||||||
}):
|
}, clear=True):
|
||||||
config = BotConfig()
|
config = BotConfig()
|
||||||
assert config.sba_season == 12
|
assert config.sba_season == 12
|
||||||
assert config.pd_season == 9
|
assert config.pd_season == 9
|
||||||
|
|||||||
361
tests/test_utils_logging.py
Normal file
361
tests/test_utils_logging.py
Normal 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'
|
||||||
@ -47,7 +47,10 @@ class YourCommandCog(commands.Cog):
|
|||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
self.logger.error("Command failed", error=e)
|
self.logger.error("Command failed", error=e)
|
||||||
|
self.logger.end_operation(trace_id, "failed")
|
||||||
raise
|
raise
|
||||||
|
else:
|
||||||
|
self.logger.end_operation(trace_id, "completed")
|
||||||
```
|
```
|
||||||
|
|
||||||
### **Key Features**
|
### **Key Features**
|
||||||
@ -59,18 +62,25 @@ Every log entry automatically includes:
|
|||||||
- **Operation Context**: Trace ID, operation name, execution duration
|
- **Operation Context**: Trace ID, operation name, execution duration
|
||||||
- **Custom Fields**: Additional context via keyword arguments
|
- **Custom Fields**: Additional context via keyword arguments
|
||||||
|
|
||||||
#### **⏱️ Automatic Timing**
|
#### **⏱️ Automatic Timing & Tracing**
|
||||||
```python
|
```python
|
||||||
trace_id = self.logger.start_operation("complex_operation")
|
trace_id = self.logger.start_operation("complex_operation")
|
||||||
# ... do work ...
|
# ... 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**
|
#### **🔗 Request Tracing**
|
||||||
Track a single request through all log entries using trace IDs:
|
Track a single request through all log entries using trace IDs:
|
||||||
```bash
|
```bash
|
||||||
# Find all logs for a specific request
|
# Find all logs for a specific request (trace_id is now a standard key)
|
||||||
jq '.context.trace_id == "abc12345"' logs/discord_bot_v2.json
|
jq 'select(.trace_id == "abc12345")' logs/discord_bot_v2.json
|
||||||
```
|
```
|
||||||
|
|
||||||
#### **📤 Hybrid Output**
|
#### **📤 Hybrid Output**
|
||||||
@ -111,6 +121,14 @@ clear_context()
|
|||||||
trace_id = logger.start_operation("player_search")
|
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)`**
|
**`info(message: str, **kwargs)`**
|
||||||
```python
|
```python
|
||||||
logger.info("Player found", player_id=123, team_name="Yankees")
|
logger.info("Player found", player_id=123, team_name="Yankees")
|
||||||
@ -156,12 +174,13 @@ except:
|
|||||||
#### **JSON Output (Monitoring & Analysis)**
|
#### **JSON Output (Monitoring & Analysis)**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2025-08-14T14:32:15.123Z",
|
"timestamp": "2025-08-15T14:32:15.123Z",
|
||||||
"level": "INFO",
|
"level": "INFO",
|
||||||
"logger": "commands.players.info.PlayerInfoCommands",
|
"logger": "commands.players.info.PlayerInfoCommands",
|
||||||
"message": "Player info command started",
|
"message": "Player info command started",
|
||||||
"function": "player_info",
|
"function": "player_info",
|
||||||
"line": 50,
|
"line": 50,
|
||||||
|
"trace_id": "abc12345",
|
||||||
"context": {
|
"context": {
|
||||||
"user_id": "123456789",
|
"user_id": "123456789",
|
||||||
"guild_id": "987654321",
|
"guild_id": "987654321",
|
||||||
@ -182,12 +201,13 @@ except:
|
|||||||
#### **Error Output with Exception**
|
#### **Error Output with Exception**
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"timestamp": "2025-08-14T14:32:18.789Z",
|
"timestamp": "2025-08-15T14:32:18.789Z",
|
||||||
"level": "ERROR",
|
"level": "ERROR",
|
||||||
"logger": "commands.players.info.PlayerInfoCommands",
|
"logger": "commands.players.info.PlayerInfoCommands",
|
||||||
"message": "API call failed",
|
"message": "API call failed",
|
||||||
"function": "player_info",
|
"function": "player_info",
|
||||||
"line": 125,
|
"line": 125,
|
||||||
|
"trace_id": "abc12345",
|
||||||
"exception": {
|
"exception": {
|
||||||
"type": "APITimeout",
|
"type": "APITimeout",
|
||||||
"message": "Request timed out after 30s",
|
"message": "Request timed out after 30s",
|
||||||
@ -198,7 +218,8 @@ except:
|
|||||||
"guild_id": "987654321",
|
"guild_id": "987654321",
|
||||||
"command": "/player",
|
"command": "/player",
|
||||||
"player_name": "Mike Trout",
|
"player_name": "Mike Trout",
|
||||||
"trace_id": "abc12345"
|
"trace_id": "abc12345",
|
||||||
|
"operation": "player_info_command"
|
||||||
},
|
},
|
||||||
"extra": {
|
"extra": {
|
||||||
"duration_ms": 30000,
|
"duration_ms": 30000,
|
||||||
@ -315,7 +336,7 @@ jq -r 'select(.level == "ERROR") | .exception.type' logs/discord_bot_v2.json | s
|
|||||||
|
|
||||||
**Trace a complete request:**
|
**Trace a complete request:**
|
||||||
```bash
|
```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**
|
#### **Performance Analysis**
|
||||||
@ -340,15 +361,24 @@ jq -r '.context.command' logs/discord_bot_v2.json | sort | uniq -c | sort -nr
|
|||||||
#### **✅ Do:**
|
#### **✅ Do:**
|
||||||
1. **Always set Discord context** at the start of command handlers
|
1. **Always set Discord context** at the start of command handlers
|
||||||
2. **Use start_operation()** for timing critical operations
|
2. **Use start_operation()** for timing critical operations
|
||||||
3. **Include relevant context** in log messages via keyword arguments
|
3. **Call end_operation()** to complete operation timing
|
||||||
4. **Log at appropriate levels** (debug for detailed flow, info for milestones, warning for recoverable issues, error for failures)
|
4. **Include relevant context** in log messages via keyword arguments
|
||||||
5. **Include error context** when logging exceptions
|
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:**
|
#### **❌ Don't:**
|
||||||
1. **Don't log sensitive information** (passwords, tokens, personal data)
|
1. **Don't log sensitive information** (passwords, tokens, personal data)
|
||||||
2. **Don't over-log in tight loops** (use sampling or conditional logging)
|
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)
|
3. **Don't use string formatting in log messages** (use keyword arguments instead)
|
||||||
4. **Don't forget to handle exceptions** in logging code itself
|
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**
|
#### **Performance Considerations**
|
||||||
- JSON serialization adds minimal overhead (~1-2ms per log entry)
|
- 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
|
**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`.
|
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`.
|
||||||
@ -87,6 +87,6 @@ def logged_command(
|
|||||||
raise
|
raise
|
||||||
|
|
||||||
# Preserve signature for Discord.py command registration
|
# Preserve signature for Discord.py command registration
|
||||||
wrapper.__signature__ = inspect.signature(func)
|
wrapper.__signature__ = inspect.signature(func) # type: ignore
|
||||||
return wrapper
|
return wrapper
|
||||||
return decorator
|
return decorator
|
||||||
@ -60,6 +60,10 @@ class JSONFormatter(logging.Formatter):
|
|||||||
if context:
|
if context:
|
||||||
log_obj['context'] = context.copy()
|
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
|
# Add custom fields from extra parameter
|
||||||
excluded_keys = {
|
excluded_keys = {
|
||||||
'name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
|
'name', 'msg', 'args', 'levelname', 'levelno', 'pathname',
|
||||||
@ -82,7 +86,7 @@ class JSONFormatter(logging.Formatter):
|
|||||||
if extra_data:
|
if extra_data:
|
||||||
log_obj['extra'] = 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:
|
class ContextualLogger:
|
||||||
@ -124,6 +128,39 @@ class ContextualLogger:
|
|||||||
|
|
||||||
return trace_id
|
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]:
|
def _get_duration_ms(self) -> Optional[int]:
|
||||||
"""Get operation duration in milliseconds if start_operation was called."""
|
"""Get operation duration in milliseconds if start_operation was called."""
|
||||||
if self._start_time:
|
if self._start_time:
|
||||||
|
|||||||
@ -1,5 +1,171 @@
|
|||||||
"""
|
"""
|
||||||
Discord UI components for Bot v2.0
|
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
274
views/base.py
Normal 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
528
views/common.py
Normal 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
391
views/embeds.py
Normal 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
488
views/modals.py
Normal 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
|
||||||
Loading…
Reference in New Issue
Block a user