major-domo-v2/commands/injuries/management.py
Cal Corum f4be20afb3 fix: address 7 security issues across the codebase
- Remove hardcoded Giphy API key from config.py, load from env var (#19)
- URL-encode query parameters in APIClient._add_params (#20)
- URL-encode Giphy search phrases before building request URLs (#21)
- Replace internal exception details with generic messages to users (#22)
- Replace all bare except: with except Exception: (#23)
- Guard interaction.guild access in has_player_role (#24)
- Replace MD5 with SHA-256 for command change detection hash (#32)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-20 09:54:53 -06:00

820 lines
29 KiB
Python

"""
Injury management slash commands for Discord Bot v2.0
Modern implementation for player injury tracking with three subcommands:
- /injury roll <player> - Roll for injury using player's injury rating (format: #p## e.g., 1p70, 4p50)
- /injury set-new - Set a new injury for a player
- /injury clear - Clear a player's active injury
The injury rating format (#p##) encodes both games played and rating:
- First character: Games played in series (1-6)
- Remaining: Injury rating (p70, p65, p60, p50, p40, p30, p20)
"""
import math
import random
import discord
from discord import app_commands
from discord.ext import commands
from config import get_config
from models.current import Current
from models.injury import Injury
from models.player import Player
from models.team import RosterType
from services.player_service import player_service
from services.injury_service import injury_service
from services.league_service import league_service
from services.giphy_service import GiphyService
from utils import team_utils
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from utils.autocomplete import player_autocomplete
from utils.permissions import league_only
from views.base import ConfirmationView
from views.embeds import EmbedTemplate
from views.modals import PitcherRestModal, BatterInjuryModal
from exceptions import BotException
class InjuryGroup(app_commands.Group):
"""Injury management command group with roll, set-new, and clear subcommands."""
def __init__(self):
super().__init__(name="injury", description="Injury management commands")
self.logger = get_contextual_logger(f"{__name__}.InjuryGroup")
self.logger.info("InjuryGroup initialized")
def has_player_role(self, interaction: discord.Interaction) -> bool:
"""Check if user has the SBA Players role."""
# Cast to Member to access roles (User doesn't have roles attribute)
if not isinstance(interaction.user, discord.Member):
return False
if interaction.guild is None:
return False
player_role = discord.utils.get(
interaction.guild.roles, name=get_config().sba_players_role_name
)
return player_role in interaction.user.roles if player_role else False
@app_commands.command(
name="roll", description="Roll for injury based on player's injury rating"
)
@app_commands.describe(player_name="Player name")
@app_commands.autocomplete(player_name=player_autocomplete)
@league_only()
@logged_command("/injury roll")
async def injury_roll(self, interaction: discord.Interaction, player_name: str):
"""Roll for injury using 3d6 dice and injury tables."""
await interaction.response.defer()
# Get current season
current = await league_service.get_current_state()
if not current:
raise BotException("Failed to get current season information")
# Search for player using the search endpoint (more reliable than name param)
players = await player_service.search_players(
player_name, limit=10, season=current.season
)
if not players:
embed = EmbedTemplate.error(
title="Player Not Found",
description=f"I did not find anybody named **{player_name}**.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
player = players[0]
# Fetch full team data if team is not populated
if player.team_id and not player.team:
from services.team_service import team_service
player.team = await team_service.get_team(player.team_id)
# Check if player already has an active injury
existing_injury = await injury_service.get_active_injury(
player.id, current.season
)
if existing_injury:
embed = EmbedTemplate.error(
title="Already Injured",
description=f"Hm. It looks like {player.name} is already hurt.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Check for injury_rating field
if not player.injury_rating:
embed = EmbedTemplate.error(
title="No Injury Rating",
description=f"{player.name} does not have an injury rating set.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Parse injury_rating format: "1p70" where first char is games_played, rest is rating
try:
games_played = int(player.injury_rating[0])
injury_rating = player.injury_rating[1:]
# Validate games_played range
if games_played < 1 or games_played > 6:
raise ValueError("Games played must be between 1 and 6")
# Validate rating format (should start with 'p')
if not injury_rating.startswith("p"):
raise ValueError("Invalid rating format")
except (ValueError, IndexError):
embed = EmbedTemplate.error(
title="Invalid Injury Rating Format",
description=f"{player.name} has an invalid injury rating: `{player.injury_rating}`\n\nExpected format: `#p##` (e.g., `1p70`, `4p50`)",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Roll 3d6
d1 = random.randint(1, 6)
d2 = random.randint(1, 6)
d3 = random.randint(1, 6)
roll_total = d1 + d2 + d3
# Get injury result from table
injury_result = self._get_injury_result(injury_rating, games_played, roll_total)
# Create response embed
embed = EmbedTemplate.warning(title=f"Injury roll for {interaction.user.name}")
if player.team and player.team.thumbnail:
embed.set_thumbnail(url=player.team.thumbnail)
embed.add_field(
name="Player",
value=f"{player.name} ({player.primary_position})",
inline=True,
)
embed.add_field(
name="Injury Rating", value=f"{player.injury_rating}", inline=True
)
# embed.add_field(name='', value='', inline=False) # Embed line break
# Format dice roll in markdown (same format as /ab roll)
dice_result = f"```md\n# {roll_total}\nDetails:[3d6 ({d1} {d2} {d3})]```"
embed.add_field(name="Dice Roll", value=dice_result, inline=False)
view = None
# Format result and create callbacks for confirmation
if isinstance(injury_result, int):
result_text = f"**{injury_result} game{'s' if injury_result > 1 else ''}**"
embed.color = discord.Color.orange()
if injury_result > 6:
gif_search_text = ["well shit", "well fuck", "god dammit"]
else:
gif_search_text = ["bummer", "well damn"]
if player.is_pitcher:
result_text += " plus their current rest requirement"
# Pitcher callback shows modal to collect rest games
async def pitcher_confirm_callback(
button_interaction: discord.Interaction,
):
"""Show modal to collect pitcher rest information."""
modal = PitcherRestModal(
player=player, injury_games=injury_result, season=current.season
)
await button_interaction.response.send_modal(modal)
injury_callback = pitcher_confirm_callback
else:
# Batter callback shows modal to collect current week/game
async def batter_confirm_callback(
button_interaction: discord.Interaction,
):
"""Show modal to collect current week/game information for batter injury."""
modal = BatterInjuryModal(
player=player, injury_games=injury_result, season=current.season
)
await button_interaction.response.send_modal(modal)
injury_callback = batter_confirm_callback
# Create confirmation view with appropriate callback
# Only the player's team GM(s) can log the injury
view = ConfirmationView(
timeout=180.0, # 3 minutes for confirmation
responders=(
[player.team.gmid, player.team.gmid2] if player.team else None
),
confirm_callback=injury_callback,
confirm_label="Log Injury",
cancel_label="Ignore Injury",
)
elif injury_result == "REM":
if player.is_pitcher:
result_text = "**FATIGUED**"
else:
result_text = "**REMAINDER OF GAME**"
embed.color = discord.Color.gold()
gif_search_text = ["this is fine", "not even mad", "could be worse"]
else: # 'OK'
result_text = "**No injury!**"
embed.color = discord.Color.green()
gif_search_text = ["we are so back", "all good", "totally fine"]
embed.add_field(name="Injury Length", value=result_text, inline=True)
try:
injury_gif = await GiphyService().get_gif(phrase_options=gif_search_text)
except Exception:
injury_gif = ""
embed.set_image(url=injury_gif)
# Send confirmation (only include view if injury requires logging)
if view is not None:
await interaction.followup.send(embed=embed, view=view)
else:
await interaction.followup.send(embed=embed)
def _get_injury_result(self, rating: str, games_played: int, roll: int):
"""
Get injury result from the injury table.
Args:
rating: Injury rating (e.g., 'p70', 'p65', etc.)
games_played: Number of games played (1-6)
roll: 3d6 roll result (3-18)
Returns:
Injury result: int (games), 'REM', or 'OK'
"""
# Injury table mapping
inj_data = {
"one": {
"p70": [
"OK",
"OK",
"OK",
"OK",
"OK",
"OK",
"REM",
"REM",
1,
1,
2,
2,
3,
3,
4,
4,
],
"p65": [2, 2, "OK", "REM", 1, 2, 3, 3, 4, 4, 4, 4, 5, 6, 8, 12],
"p60": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 4, 5, 5, 6, 8, 12, 16, 16],
"p50": ["OK", "REM", 1, 2, 3, 4, 4, 5, 5, 6, 8, 8, 12, 16, 16, "OK"],
"p40": ["OK", 1, 2, 3, 4, 4, 5, 6, 6, 8, 8, 12, 16, 24, "REM", "OK"],
"p30": ["OK", 4, 1, 3, 4, 5, 6, 8, 8, 12, 16, 24, 4, 2, "REM", "OK"],
"p20": ["OK", 1, 2, 4, 5, 8, 8, 24, 16, 12, 12, 6, 4, 3, "REM", "OK"],
},
"two": {
"p70": [4, 3, 2, 2, 1, 1, "REM", "OK", "REM", "OK", 2, 1, 2, 2, 3, 4],
"p65": [8, 5, 4, 2, 2, "OK", 1, "OK", "REM", 1, "REM", 2, 3, 4, 6, 12],
"p60": [1, 3, 4, 5, 2, 2, "OK", 1, 3, "REM", 4, 4, 6, 8, 12, 3],
"p50": [4, "OK", "OK", "REM", 1, 2, 4, 3, 4, 5, 4, 6, 8, 12, 12, "OK"],
"p40": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 6, 8, 12, 16, 16, "OK"],
"p30": [
"OK",
"REM",
1,
2,
3,
4,
4,
5,
6,
5,
8,
12,
16,
24,
"REM",
"OK",
],
"p20": ["OK", 1, 4, 4, 5, 5, 6, 6, 12, 8, 16, 24, 8, 3, 2, "REM"],
},
"three": {
"p70": [],
"p65": [
"OK",
"OK",
"REM",
1,
3,
"OK",
"REM",
1,
2,
1,
2,
3,
4,
4,
5,
"REM",
],
"p60": ["OK", 5, "OK", "REM", 1, 2, 2, 3, 4, 4, 1, 3, 5, 6, 8, "REM"],
"p50": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, "REM"],
"p40": ["OK", 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, "REM"],
"p30": ["OK", 1, 2, 3, 4, 5, 4, 6, 5, 6, 8, 8, 12, 16, 1, "REM"],
"p20": ["OK", 1, 2, 4, 4, 8, 8, 6, 5, 12, 6, 16, 24, 3, 4, "REM"],
},
"four": {
"p70": [],
"p65": [],
"p60": [
"OK",
"OK",
"REM",
3,
3,
"OK",
"REM",
1,
2,
1,
4,
4,
5,
6,
8,
"REM",
],
"p50": ["OK", 6, 4, "OK", "REM", 1, 2, 4, 4, 3, 5, 3, 6, 8, 12, "REM"],
"p40": ["OK", "OK", "REM", 1, 2, 3, 4, 4, 5, 4, 4, 6, 8, 8, 12, "REM"],
"p30": ["OK", 1, 1, 2, 3, 4, 4, 5, 6, 5, 6, 8, 8, 12, 4, "REM"],
"p20": ["OK", 1, 2, 3, 4, 5, 4, 6, 5, 6, 12, 8, 8, 16, 1, "REM"],
},
"five": {
"p70": [],
"p65": [],
"p60": [
"OK",
"REM",
"REM",
"REM",
3,
"OK",
1,
"REM",
2,
1,
"OK",
4,
5,
2,
6,
8,
],
"p50": [
"OK",
"OK",
"REM",
1,
1,
"OK",
"REM",
3,
2,
4,
4,
5,
5,
6,
8,
12,
],
"p40": ["OK", 6, 6, "OK", 1, 3, 2, 4, 4, 5, "REM", 3, 8, 6, 12, 1],
"p30": ["OK", "OK", "REM", 4, 1, 2, 5, 4, 6, 3, 4, 8, 5, 6, 12, "REM"],
"p20": ["OK", "REM", 2, 3, 4, 4, 5, 4, 6, 5, 8, 6, 8, 1, 12, "REM"],
},
"six": {
"p70": [],
"p65": [],
"p60": [],
"p50": [],
"p40": ["OK", 6, 6, "OK", 1, 3, 2, 4, 4, 5, "REM", 3, 8, 6, 1, 12],
"p30": ["OK", "OK", "REM", 5, 1, 3, 6, 4, 5, 2, 4, 8, 3, 5, 12, "REM"],
"p20": ["OK", "REM", 4, 6, 2, 3, 6, 4, 8, 5, 5, 6, 3, 1, 12, "REM"],
},
}
# Map games_played to key
games_map = {1: "one", 2: "two", 3: "three", 4: "four", 5: "five", 6: "six"}
games_key = games_map.get(games_played)
if not games_key:
return "OK"
# Get the injury table for this rating and games played
injury_table = inj_data.get(games_key, {}).get(rating, [])
# If no table exists (e.g., p70 with 3+ games), no injury
if not injury_table:
return "OK"
# Get result from table (roll 3-18 maps to index 0-15)
table_index = roll - 3
if 0 <= table_index < len(injury_table):
return injury_table[table_index]
return "OK"
@app_commands.command(
name="set-new",
description="Set a new injury for a player (requires SBA Players role)",
)
@app_commands.describe(
player_name="Player name to injure",
this_week="Current week number",
this_game="Current game number (1-4)",
injury_games="Number of games player will be out",
)
@league_only()
@logged_command("/injury set-new")
async def injury_set_new(
self,
interaction: discord.Interaction,
player_name: str,
this_week: int,
this_game: int,
injury_games: int,
):
"""Set a new injury for a player on your team."""
# Check role permissions
if not self.has_player_role(interaction):
embed = EmbedTemplate.error(
title="Permission Denied",
description=f"This command requires the **{get_config().sba_players_role_name}** role.",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
await interaction.response.defer()
# Validate inputs
if this_game < 1 or this_game > 4:
embed = EmbedTemplate.error(
title="Invalid Input",
description="Game number must be between 1 and 4.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
if injury_games < 1:
embed = EmbedTemplate.error(
title="Invalid Input",
description="Injury duration must be at least 1 game.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Get current season
current = await league_service.get_current_state()
if not current:
raise BotException("Failed to get current season information")
# Search for player using the search endpoint (more reliable than name param)
players = await player_service.search_players(
player_name, limit=10, season=current.season
)
if not players:
embed = EmbedTemplate.error(
title="Player Not Found",
description=f"I did not find anybody named **{player_name}**.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
player = players[0]
# Fetch full team data if team is not populated
if player.team_id and not player.team:
from services.team_service import team_service
player.team = await team_service.get_team(player.team_id)
# Check if player is on user's team
# Note: This assumes you have a function to get team by owner
# For now, we'll skip this check - you can add it if needed
# TODO: Add team ownership verification
# Check if player already has an active injury
existing_injury = await injury_service.get_active_injury(
player.id, current.season
)
# Data consistency check: If injury exists but il_return is None, it's stale data
if existing_injury:
if not player.il_return:
# Stale injury record - clear it automatically
self.logger.warning(
f"Found stale injury record for {player.name} (injury {existing_injury.id}): "
f"is_active=True but il_return=None. Auto-clearing stale record."
)
await injury_service.clear_injury(existing_injury.id)
# Notify user but allow them to proceed
self.logger.info(
f"Cleared stale injury {existing_injury.id} for player {player.id}"
)
else:
# Valid active injury - player is actually injured
embed = EmbedTemplate.error(
title="Already Injured",
description=f"Hm. It looks like {player.name} is already hurt (returns {player.il_return}).",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Calculate return date
out_weeks = math.floor(injury_games / 4)
out_games = injury_games % 4
return_week = this_week + out_weeks
return_game = this_game + 1 + out_games
if return_game > 4:
return_week += 1
return_game -= 4
# Adjust start date if injury starts after game 4
start_week = this_week if this_game != 4 else this_week + 1
start_game = this_game + 1 if this_game != 4 else 1
return_date = f"w{return_week:02d}g{return_game}"
# Create injury record
injury = await injury_service.create_injury(
season=current.season,
player_id=player.id,
total_games=injury_games,
start_week=start_week,
start_game=start_game,
end_week=return_week,
end_game=return_game,
)
if not injury:
embed = EmbedTemplate.error(
title="Error",
description="Well that didn't work. Failed to create injury record.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Update player's il_return field
await player_service.update_player(player.id, {"il_return": return_date})
# Success response
embed = EmbedTemplate.success(
title="Injury Recorded",
description=f"{player.name}'s injury has been logged",
)
embed.add_field(
name="Player", value=f"{player.name} ({player.pos_1})", inline=True
)
embed.add_field(
name="Duration",
value=f"{injury_games} game{'s' if injury_games > 1 else ''}",
inline=True,
)
embed.add_field(name="Return Date", value=return_date, inline=True)
if player.team:
embed.add_field(
name="Team",
value=f"{player.team.lname} ({player.team.abbrev})",
inline=False,
)
await interaction.followup.send(embed=embed)
# Log for debugging
self.logger.info(
f"Injury set for {player.name}: {injury_games} games, returns {return_date}",
player_id=player.id,
season=current.season,
injury_id=injury.id,
)
def _calc_injury_dates(
self, start_week: int, start_game: int, injury_games: int
) -> dict:
"""
Calculate injury dates from start week/game and injury duration.
Args:
start_week: Starting week number
start_game: Starting game number (1-4)
injury_games: Number of games player will be out
Returns:
Dictionary with calculated injury date fields
"""
# Calculate return date
out_weeks = math.floor(injury_games / 4)
out_games = injury_games % 4
return_week = start_week + out_weeks
return_game = start_game + 1 + out_games
if return_game > 4:
return_week += 1
return_game -= 4
# Adjust start date if injury starts after game 4
actual_start_week = start_week if start_game != 4 else start_week + 1
actual_start_game = start_game + 1 if start_game != 4 else 1
return {
"total_games": injury_games,
"start_week": actual_start_week,
"start_game": actual_start_game,
"end_week": return_week,
"end_game": return_game,
}
@app_commands.command(
name="clear", description="Clear a player's injury (requires SBA Players role)"
)
@app_commands.describe(player_name="Player name to clear injury")
@app_commands.autocomplete(player_name=player_autocomplete)
@league_only()
@logged_command("/injury clear")
async def injury_clear(self, interaction: discord.Interaction, player_name: str):
"""Clear a player's active injury."""
# Check role permissions
if not self.has_player_role(interaction):
embed = EmbedTemplate.error(
title="Permission Denied",
description=f"This command requires the **{get_config().sba_players_role_name}** role.",
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
await interaction.response.defer()
# Get current season
current = await league_service.get_current_state()
if not current:
raise BotException("Failed to get current season information")
# Search for player using the search endpoint (more reliable than name param)
players = await player_service.search_players(
player_name, limit=10, season=current.season
)
if not players:
embed = EmbedTemplate.error(
title="Player Not Found",
description=f"I did not find anybody named **{player_name}**.",
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
player = players[0]
# Fetch full team data if team is not populated
if player.team_id and not player.team:
from services.team_service import team_service
player.team = await team_service.get_team(player.team_id)
# Get active injury
injury = await injury_service.get_active_injury(player.id, current.season)
if not injury:
embed = EmbedTemplate.error(
title="No Active Injury", description=f"{player.name} isn't injured."
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
# Create confirmation embed
embed = EmbedTemplate.info(
title=f"{player.name}",
description=f"Is **{player.name}** cleared to return?",
)
if player.team and player.team.thumbnail is not None:
embed.set_thumbnail(url=player.team.thumbnail)
embed.add_field(
name="Player",
value=f"{player.name} ({player.primary_position})",
inline=True,
)
if player.team:
embed.add_field(
name="Team",
value=f"{player.team.lname} ({player.team.abbrev})",
inline=True,
)
embed.add_field(name="Expected Return", value=injury.return_date, inline=True)
embed.add_field(name="Games Missed", value=injury.duration_display, inline=True)
# Initialize responder_team to None for major league teams
if player.team.roster_type() == RosterType.MAJOR_LEAGUE:
responder_team = player.team
else:
responder_team = await team_utils.get_user_major_league_team(
interaction.user.id
)
# Create callback for confirmation
async def clear_confirm_callback(button_interaction: discord.Interaction):
"""Handle confirmation to clear injury."""
# Clear the injury
success = await injury_service.clear_injury(injury.id)
if not success:
error_embed = EmbedTemplate.error(
title="Error",
description="Failed to clear the injury. Please try again.",
)
await button_interaction.response.send_message(
embed=error_embed, ephemeral=True
)
return
# Clear player's il_return field
await player_service.update_player(player.id, {"il_return": ""})
# Success response
success_embed = EmbedTemplate.success(
title="Injury Cleared",
description=f"{player.name} has been cleared and is eligible to play again.",
)
success_embed.add_field(
name="Injury Return Date", value=injury.return_date, inline=True
)
success_embed.add_field(
name="Total Games Missed", value=injury.duration_display, inline=True
)
if player.team:
success_embed.add_field(
name="Team", value=f"{player.team.lname}", inline=False
)
if player.team.thumbnail is not None:
success_embed.set_thumbnail(url=player.team.thumbnail)
await button_interaction.response.send_message(embed=success_embed)
# Log for debugging
self.logger.info(
f"Injury cleared for {player.name}",
player_id=player.id,
season=current.season,
injury_id=injury.id,
)
# Create confirmation view
view = ConfirmationView(
user_id=interaction.user.id,
timeout=180.0, # 3 minutes for confirmation
responders=(
[responder_team.gmid, responder_team.gmid2] if responder_team else None
),
confirm_callback=clear_confirm_callback,
confirm_label="Clear Injury",
cancel_label="Cancel",
)
# Send confirmation embed with view
await interaction.followup.send(embed=embed, view=view)
async def setup(bot: commands.Bot):
"""Setup function for loading the injury commands."""
bot.tree.add_command(InjuryGroup())