major-domo-v2/commands/players/info.py
Cal Corum 858663cd27 refactor: move 42 unnecessary lazy imports to top-level across codebase
Codebase audit identified ~50 lazy imports. Moved 42 unnecessary ones to
top-level imports — only keeping those justified by circular imports,
init-order dependencies, or optional dependency guards. Updated test mock
patch targets where needed. See #57 for remaining DI candidates.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-02 13:35:23 -06:00

263 lines
9.4 KiB
Python

"""
Player Information Commands
Implements slash commands for displaying player information and statistics.
"""
import asyncio
from typing import Optional, List
import discord
from discord.ext import commands
from config import get_config
from services.player_service import player_service
from services.stats_service import stats_service
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from views.players import PlayerStatsView
async def player_name_autocomplete(
interaction: discord.Interaction,
current: str,
) -> List[discord.app_commands.Choice[str]]:
"""Autocomplete for player names across all seasons, deduplicated by name.
Returns unique player names only (most recent season's data for display).
Users can specify the `season` parameter to get historical data.
"""
if len(current) < 2:
return []
try:
# Use the dedicated search endpoint to get matching players
# Search current season only for performance (all_seasons=True takes 15+ seconds)
# Results are ordered by most recent season first
players = await player_service.search_players(
current,
limit=30,
all_seasons=False, # Current season only to stay under 3-second timeout
)
# Deduplicate by player name, keeping only the first (most recent) occurrence
seen_names: set[str] = set()
choices = []
for player in players:
# Skip if we've already seen this player name
name_lower = player.name.lower()
if name_lower in seen_names:
continue
seen_names.add(name_lower)
# Format: "Player Name (Position) - Team"
# No season indicator needed since we're showing unique players
display_name = f"{player.name} ({player.primary_position})"
if hasattr(player, "team") and player.team:
display_name += f" - {player.team.abbrev}"
choices.append(
discord.app_commands.Choice(
name=display_name[:100], # Discord limit is 100 chars
value=player.name,
)
)
# Stop once we have 25 unique players (Discord's max)
if len(choices) >= 25:
break
return choices
except Exception:
# Return empty list on error to avoid breaking autocomplete
return []
class PlayerInfoCommands(commands.Cog):
"""Player information and statistics command handlers."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f"{__name__}.PlayerInfoCommands")
@discord.app_commands.command(
name="player",
description="Display player information and statistics",
)
@discord.app_commands.describe(
name="Player name to search for",
season="Season number for historical stats (defaults to most recent)",
)
@discord.app_commands.autocomplete(name=player_name_autocomplete)
@logged_command("/player")
async def player_info(
self, interaction: discord.Interaction, name: str, season: Optional[int] = None
):
"""Display player card with statistics."""
# Defer response for potentially slow API calls
await interaction.response.defer()
self.logger.debug("Response deferred")
# Determine search strategy based on whether season was explicitly provided
explicit_season = season is not None
if explicit_season:
# User specified a season - search only that season
search_season = season
self.logger.debug(
"Starting player search (explicit season)",
api_call="get_players_by_name",
season=search_season,
)
players = await player_service.get_players_by_name(name, search_season)
else:
# No season specified - search ALL seasons, prioritize most recent
self.logger.debug(
"Starting player search (all seasons)",
api_call="search_players",
all_seasons=True,
)
players = await player_service.search_players(
name, limit=25, all_seasons=True
)
# Set search_season to the season of the first matching player (most recent)
search_season = players[0].season if players else get_config().sba_season
self.logger.info(
"Player search completed",
players_found=len(players),
season=search_season,
all_seasons=not explicit_season,
)
if not players:
# Try fuzzy search as fallback (search all seasons)
self.logger.info(
"No exact matches found, attempting fuzzy search", search_term=name
)
fuzzy_players = await player_service.search_players_fuzzy(
name, limit=10, all_seasons=True
)
if not fuzzy_players:
self.logger.warning(
"No players found even with fuzzy search", search_term=name
)
await interaction.followup.send(
f"❌ No players found matching '{name}'.", ephemeral=True
)
return
# Show fuzzy search results for user selection with season info
self.logger.info(
"Fuzzy search results found", fuzzy_results_count=len(fuzzy_players)
)
fuzzy_list = "\n".join(
[
f"{p.name} ({p.primary_position}) [Season {p.season}]"
for p in fuzzy_players[:10]
]
)
await interaction.followup.send(
f"🔍 No exact match found for '{name}'. Did you mean one of these?\n{fuzzy_list}\n\nPlease try again with the exact name.",
ephemeral=True,
)
return
# If multiple players, try exact match first
player = None
if len(players) == 1:
player = players[0]
self.logger.debug(
"Single player found", player_id=player.id, player_name=player.name
)
else:
self.logger.debug(
"Multiple players found, attempting exact match",
candidate_count=len(players),
)
# Try exact match
for p in players:
if p.name.lower() == name.lower():
player = p
self.logger.debug(
"Exact match found",
player_id=player.id,
player_name=player.name,
)
break
if player is None:
# Show multiple options with season info
candidate_names = [f"{p.name} (S{p.season})" for p in players[:10]]
self.logger.info(
"Multiple candidates found, requiring user clarification",
candidates=candidate_names,
)
player_list = "\n".join(
[
f"{p.name} ({p.primary_position}) [Season {p.season}]"
for p in players[:10]
]
)
await interaction.followup.send(
f"🔍 Multiple players found for '{name}':\n{player_list}\n\nPlease specify a season with the `season` parameter.",
ephemeral=True,
)
return
# Get player data and statistics concurrently
self.logger.debug(
"Fetching player data and statistics",
player_id=player.id,
season=search_season,
)
# Fetch player data and stats concurrently for better performance
player_with_team, (batting_stats, pitching_stats) = await asyncio.gather(
player_service.get_player(player.id),
stats_service.get_player_stats(player.id, search_season),
)
if player_with_team is None:
self.logger.warning("Failed to get player data, using search result")
player_with_team = player # Fallback to search result
else:
team_info = (
f"{player_with_team.team.abbrev}"
if hasattr(player_with_team, "team") and player_with_team.team
else "No team"
)
self.logger.debug(
"Player data retrieved",
team=team_info,
batting_stats=bool(batting_stats),
pitching_stats=bool(pitching_stats),
)
# Create interactive player view with toggleable statistics
self.logger.debug("Creating PlayerStatsView with toggleable statistics")
view = PlayerStatsView(
player=player_with_team,
season=search_season,
batting_stats=batting_stats,
pitching_stats=pitching_stats,
user_id=None, # setting to None so any GM can toggle the stats views
)
# Get initial embed with stats hidden
embed = await view.get_initial_embed()
# Send with interactive view
await interaction.followup.send(embed=embed, view=view)
async def setup(bot: commands.Bot):
"""Load the player info commands cog."""
await bot.add_cog(PlayerInfoCommands(bot))