From 62541ac75040db2e7aae983a05ebc7cd463f7bd9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Fri, 19 Dec 2025 00:08:11 -0600 Subject: [PATCH] Add injury log posting and fix view interaction permissions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Features: - Post injury announcements to #sba-network-news when injuries are logged - Update #injury-log channel with two embeds: - All injuries grouped by Major League team with return dates - All injuries grouped by return week, sorted ascending - Auto-purge old messages before posting updated injury log Bug Fixes: - Fix BaseView interaction_check logic that incorrectly rejected command users - Old: Rejected if (not user_id match) OR (not in responders) - New: Allow if (user_id match) OR (in responders) - Filter None values from responders list (handles missing gmid2) Changes: - services/injury_service.py: Add get_all_active_injuries_raw() method - utils/injury_log.py: New utility for injury channel posting - views/modals.py: Call injury posting after successful injury logging - views/base.py: Fix interaction authorization logic - config.py: Update to Season 13 Players role 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- CLAUDE.md | 52 +++++- VERSION | 2 +- config.py | 2 +- services/injury_service.py | 34 ++++ utils/injury_log.py | 357 +++++++++++++++++++++++++++++++++++++ views/base.py | 35 ++-- views/modals.py | 32 ++++ 7 files changed, 501 insertions(+), 13 deletions(-) create mode 100644 utils/injury_log.py diff --git a/CLAUDE.md b/CLAUDE.md index 4ce381a..634c5f4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -477,6 +477,56 @@ REDIS_URL=redis://localhost:6379 # Empty disables caching REDIS_CACHE_TTL=300 # Default TTL in seconds ``` +## 🐳 Docker Hub Configuration + +### 🚨 CRITICAL: Correct Repository Name + +**Discord Bot v2.0 Docker Hub Repository:** +``` +manticorum67/major-domo-discordapp +``` + +**⚠️ IMPORTANT: There is NO DASH between "discord" and "app"** + +### Common Mistakes to Avoid +```bash +# ❌ WRONG - Has extra dash +manticorum67/major-domo-discord-app + +# ❌ WRONG - Uses v2 suffix +manticorum67/major-domo-discordapp-v2 + +# ❌ WRONG - Different project name pattern +manticorum67/major-domo-discord-app-v2 + +# ✅ CORRECT - Use this exact name +manticorum67/major-domo-discordapp +``` + +### Build and Push Commands +```bash +# Build with version tag +docker build -t manticorum67/major-domo-discordapp:X.Y.Z . + +# Tag as latest +docker tag manticorum67/major-domo-discordapp:X.Y.Z manticorum67/major-domo-discordapp:latest + +# Push both tags +docker push manticorum67/major-domo-discordapp:X.Y.Z +docker push manticorum67/major-domo-discordapp:latest +``` + +### Version Tagging Convention +- **Version tags**: `manticorum67/major-domo-discordapp:2.24.0` +- **Latest tag**: `manticorum67/major-domo-discordapp:latest` +- **Development tag**: `manticorum67/major-domo-discordapp:dev` + +### Related Repositories +| Component | Docker Hub Repository | +|-----------|----------------------| +| Discord Bot v2 | `manticorum67/major-domo-discordapp` | +| Database API | `manticorum67/major-domo-database` | + ## 📊 Monitoring and Logs ### Log Files @@ -687,7 +737,7 @@ creator_id: Optional[int] = Field(None, description="ID of the creator (may be m --- -**Last Updated:** October 2025 +**Last Updated:** December 2025 **Maintenance:** Keep this file synchronized with CLAUDE.md files when making significant architectural changes **Next Review:** When major new features or patterns are added diff --git a/VERSION b/VERSION index 358c8e6..5c18f91 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -2.24.9 +2.25.0 diff --git a/config.py b/config.py index 2bd898f..6cce743 100644 --- a/config.py +++ b/config.py @@ -70,7 +70,7 @@ class BotConfig(BaseSettings): # Role Names help_editor_role_name: str = "Help Editor" - sba_players_role_name: str = "Season 12 Players" + sba_players_role_name: str = "Season 13 Players" # Channel Names sba_network_news_channel: str = "sba-network-news" diff --git a/services/injury_service.py b/services/injury_service.py index bb07bfe..1b379aa 100644 --- a/services/injury_service.py +++ b/services/injury_service.py @@ -201,6 +201,40 @@ class InjuryService(BaseService[Injury]): logger.error(f"Error clearing injury {injury_id}: {e}") return False + async def get_all_active_injuries_raw(self, season: int) -> list[dict]: + """ + Get all active injuries for a season with raw API response data. + + This method returns the raw API response which includes nested player + objects with team information, needed for injury log displays. + + Args: + season: Season number + + Returns: + List of raw injury dictionaries from API with nested player/team data + """ + try: + client = await self.get_client() + params = [ + ('season', str(season)), + ('is_active', 'true'), + ('sort', 'return-asc') + ] + + response = await client.get(self.endpoint, params=params) + + if response and 'injuries' in response: + logger.debug(f"Retrieved {len(response['injuries'])} active injuries for season {season}") + return response['injuries'] + + logger.debug(f"No active injuries found for season {season}") + return [] + + except Exception as e: + logger.error(f"Error getting all active injuries for season {season}: {e}") + return [] + # Global service instance injury_service = InjuryService() diff --git a/utils/injury_log.py b/utils/injury_log.py new file mode 100644 index 0000000..b671d45 --- /dev/null +++ b/utils/injury_log.py @@ -0,0 +1,357 @@ +""" +Injury Log Posting Utility + +Provides functions for posting injury information to Discord channels: +- #injury-log: Two embeds showing current injuries by team and by return week +- #sba-network-news: Individual injury announcements +""" +from typing import Optional, Dict, List, Any +from collections import defaultdict + +import discord + +from config import get_config +from models.player import Player +from models.team import Team +from services.injury_service import injury_service +from services.team_service import team_service +from views.embeds import EmbedTemplate, EmbedColors +from utils.logging import get_contextual_logger + +logger = get_contextual_logger(f'{__name__}') + + +async def get_major_league_team_name(team_data: dict, season: int) -> str: + """ + Get the Major League team name from player's team data. + + Args: + team_data: Team dictionary from API response + season: Current season number + + Returns: + Major League team short name (sname) + """ + if not team_data: + return "Unknown" + + abbrev = team_data.get('abbrev', '') + + # If abbreviation is 3 chars or less, it's already ML + if len(abbrev) <= 3: + return team_data.get('sname', abbrev) + + # Extract base abbreviation for MiL/IL teams + abbrev_lower = abbrev.lower() + if abbrev_lower.endswith('mil'): + base_abbrev = abbrev[:-3] + elif abbrev_lower.endswith('il'): + base_abbrev = abbrev[:-2] + else: + return team_data.get('sname', abbrev) + + # Look up the ML team + try: + ml_team = await team_service.get_team_by_abbrev(base_abbrev, season) + if ml_team: + return ml_team.sname + except Exception as e: + logger.warning(f"Could not get ML team for {abbrev}: {e}") + + return team_data.get('sname', abbrev) + + +async def update_injury_log_channel( + bot: discord.Client, + season: int +) -> bool: + """ + Update the #injury-log channel with current injuries. + + Creates two embeds: + 1. Current injuries grouped by Major League team + 2. Current injuries grouped by return week + + Args: + bot: Discord bot instance + season: Current season number + + Returns: + True if successful, False otherwise + """ + try: + config = get_config() + guild = bot.get_guild(config.guild_id) + + if not guild: + logger.warning(f"Could not find guild {config.guild_id}") + return False + + channel = discord.utils.get(guild.text_channels, name='injury-log') + if not channel: + logger.warning("Could not find #injury-log channel") + return False + + # Get all active injuries + injuries_raw = await injury_service.get_all_active_injuries_raw(season) + + if not injuries_raw: + logger.info("No active injuries found for injury log update") + # Still update the channel with "no injuries" message + await _clear_and_post_no_injuries(channel, season) + return True + + # Group injuries by team and by week + injuries_by_team: Dict[str, List[dict]] = defaultdict(list) + injuries_by_week: Dict[int, List[dict]] = defaultdict(list) + + for injury in injuries_raw: + player = injury.get('player', {}) + team_data = player.get('team', {}) + + # Get ML team name for grouping + ml_team_name = await get_major_league_team_name(team_data, season) + + injuries_by_team[ml_team_name].append({ + 'name': player.get('name', 'Unknown'), + 'il_return': player.get('il_return', 'TBD'), + 'end_week': injury.get('end_week', 0) + }) + + end_week = injury.get('end_week', 0) + injuries_by_week[end_week].append({ + 'name': player.get('name', 'Unknown'), + 'il_return': player.get('il_return', 'TBD') + }) + + # Create team embed + team_embed = EmbedTemplate.create_base_embed( + title="🏥 Current Injuries by Team", + description="Player Name (Return Date)", + color=EmbedColors.WARNING, + timestamp=True + ) + team_embed.set_thumbnail(url=config.sba_logo_url) + + # Sort teams alphabetically and add fields + for team_name in sorted(injuries_by_team.keys()): + players = injuries_by_team[team_name] + team_string = '\n'.join( + f"{p['name']} ({p['il_return']})" + for p in players + ) + + # Discord field value limit is 1024 chars + if len(team_string) > 1024: + team_string = team_string[:1020] + "..." + + team_embed.add_field( + name=f"{team_name} ({len(players)})", + value=team_string, + inline=True + ) + + team_embed.set_footer( + text=f"SBa Season {season} • {len(injuries_raw)} active injuries", + icon_url=config.sba_logo_url + ) + + # Create week embed + week_embed = EmbedTemplate.create_base_embed( + title="📅 Current Injuries by Return Week", + description="Player Name (Return Date)", + color=EmbedColors.INFO, + timestamp=True + ) + week_embed.set_thumbnail(url=config.sba_logo_url) + + # Sort weeks numerically and add fields + for week_num in sorted(injuries_by_week.keys()): + players = injuries_by_week[week_num] + week_string = '\n'.join( + f"{p['name']} ({p['il_return']})" + for p in players + ) + + # Discord field value limit is 1024 chars + if len(week_string) > 1024: + week_string = week_string[:1020] + "..." + + week_embed.add_field( + name=f"Week {week_num} ({len(players)})", + value=week_string, + inline=True + ) + + week_embed.set_footer( + text=f"SBa Season {season} • Sorted by earliest return", + icon_url=config.sba_logo_url + ) + + # Clear old messages and post new ones + try: + await channel.purge(limit=25) + except discord.errors.Forbidden: + logger.warning("Could not purge messages in #injury-log (missing permissions)") + except Exception as e: + logger.warning(f"Error purging messages in #injury-log: {e}") + + await channel.send(embed=team_embed) + await channel.send(embed=week_embed) + + logger.info(f"Updated injury log: {len(injuries_raw)} injuries across {len(injuries_by_team)} teams") + return True + + except Exception as e: + logger.error(f"Error updating injury log channel: {e}") + return False + + +async def _clear_and_post_no_injuries(channel: discord.TextChannel, season: int) -> None: + """Post a 'no injuries' message when there are no active injuries.""" + config = get_config() + + try: + await channel.purge(limit=25) + except Exception: + pass + + embed = EmbedTemplate.create_base_embed( + title="🏥 Current Injuries", + description="No active injuries at this time.", + color=EmbedColors.SUCCESS, + timestamp=True + ) + embed.set_thumbnail(url=config.sba_logo_url) + embed.set_footer( + text=f"SBa Season {season}", + icon_url=config.sba_logo_url + ) + + await channel.send(embed=embed) + + +async def post_injury_news( + bot: discord.Client, + player: Player, + injury_games: int, + return_date: str, + season: int +) -> bool: + """ + Post an injury announcement to #sba-network-news. + + Args: + bot: Discord bot instance + player: Player object who was injured + injury_games: Number of games player will miss + return_date: Return date in w##g# format + season: Current season number + + Returns: + True if successful, False otherwise + """ + try: + config = get_config() + guild = bot.get_guild(config.guild_id) + + if not guild: + logger.warning(f"Could not find guild {config.guild_id}") + return False + + channel = discord.utils.get( + guild.text_channels, + name=config.sba_network_news_channel + ) + if not channel: + logger.warning(f"Could not find #{config.sba_network_news_channel} channel") + return False + + # Determine team info + team_name = "Unknown Team" + team_color = EmbedColors.WARNING + team_thumbnail = None + + if player.team: + team_name = player.team.sname or player.team.lname + if player.team.color: + try: + team_color = int(player.team.color, 16) + except ValueError: + pass + team_thumbnail = player.team.thumbnail + + # Create news embed + embed = EmbedTemplate.create_base_embed( + title="🚑 Injury Report", + description=f"**{player.name}** has been placed on the injured list.", + color=team_color, + timestamp=True + ) + + embed.add_field( + name="Player", + value=f"{player.name} ({player.primary_position})", + inline=True + ) + + embed.add_field( + name="Team", + value=team_name, + inline=True + ) + + embed.add_field( + name="Duration", + value=f"{injury_games} game{'s' if injury_games != 1 else ''}", + inline=True + ) + + embed.add_field( + name="Expected Return", + value=return_date, + inline=True + ) + + if team_thumbnail: + embed.set_thumbnail(url=team_thumbnail) + + embed.set_footer( + text=f"SBa Season {season}", + icon_url=config.sba_logo_url + ) + + await channel.send(embed=embed) + logger.info(f"Posted injury news for {player.name}: {injury_games} games") + return True + + except Exception as e: + logger.error(f"Error posting injury news: {e}") + return False + + +async def post_injury_and_update_log( + bot: discord.Client, + player: Player, + injury_games: int, + return_date: str, + season: int +) -> None: + """ + Convenience function to post injury news and update injury log. + + This is the main entry point for injury logging after an injury is recorded. + It handles both the news announcement and the full injury log update. + + Args: + bot: Discord bot instance + player: Player object who was injured + injury_games: Number of games player will miss + return_date: Return date in w##g# format + season: Current season number + """ + # Post to sba-network-news + await post_injury_news(bot, player, injury_games, return_date, season) + + # Update injury-log channel with all current injuries + await update_injury_log_channel(bot, season) diff --git a/views/base.py b/views/base.py index a39493b..dab3ef1 100644 --- a/views/base.py +++ b/views/base.py @@ -32,18 +32,33 @@ class BaseView(discord.ui.View): 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.""" + """Check if user is authorized to interact with this view. + + Authorization logic: + - If no restrictions set (user_id and responders both None), allow all + - If user_id is set, the original command user can interact + - If responders is set, anyone in the responders list can interact + - User only needs to match ONE condition to be authorized + """ + # No restrictions - allow everyone if self.user_id is None and self.responders is None: return True - - if (self.user_id is not None and interaction.user.id != self.user_id) or (self.responders is not None and interaction.user.id not in self.responders): - await interaction.response.send_message( - "❌ You cannot interact with this menu.", - ephemeral=True - ) - return False - - return True + + # Check if user is authorized by either condition + is_command_user = self.user_id is not None and interaction.user.id == self.user_id + is_authorized_responder = ( + self.responders is not None and + interaction.user.id in [r for r in self.responders if r is not None] + ) + + if is_command_user or is_authorized_responder: + return True + + await interaction.response.send_message( + "❌ You cannot interact with this menu.", + ephemeral=True + ) + return False async def on_timeout(self) -> None: """Handle view timeout.""" diff --git a/views/modals.py b/views/modals.py index 8cd5c55..f64b815 100644 --- a/views/modals.py +++ b/views/modals.py @@ -652,6 +652,22 @@ class BatterInjuryModal(BaseModal): await interaction.response.send_message(embed=embed) + # Post injury news and update injury log channel + try: + from utils.injury_log import post_injury_and_update_log + await post_injury_and_update_log( + bot=interaction.client, + player=self.player, + injury_games=self.injury_games, + return_date=return_date, + season=self.season + ) + except Exception as log_error: + self.logger.warning( + f"Failed to post injury to channels (injury was still logged): {log_error}", + player_id=self.player.id + ) + except Exception as e: self.logger.error("Failed to create batter injury", error=e, player_id=self.player.id) embed = EmbedTemplate.error( @@ -864,6 +880,22 @@ class PitcherRestModal(BaseModal): await interaction.response.send_message(embed=embed) + # Post injury news and update injury log channel + try: + from utils.injury_log import post_injury_and_update_log + await post_injury_and_update_log( + bot=interaction.client, + player=self.player, + injury_games=total_injury_games, + return_date=return_date, + season=self.season + ) + except Exception as log_error: + self.logger.warning( + f"Failed to post injury to channels (injury was still logged): {log_error}", + player_id=self.player.id + ) + except Exception as e: self.logger.error("Failed to create pitcher injury", error=e, player_id=self.player.id) embed = EmbedTemplate.error(