From 8907841ec61b822fd563398eb28d8bfdc8aa4bdd Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 22 Oct 2025 16:58:21 -0500 Subject: [PATCH 1/3] CLAUDE: Refactor scorebug display and add dynamic channel visibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Created utils/scorebug_helpers.py with shared scorebug functions - create_scorebug_embed(): Unified embed creation for command and background task - create_team_progress_bar(): Win probability visualization - Fixed win probability bar to show dark blocks weighted toward winning team - Arrow extends from the side with advantage - Home winning: "POR ░▓▓▓▓▓▓▓▓▓► WV 95.0%" - Away winning: "POR ◄▓▓▓▓▓▓▓░░░ WV 30.0%" - Changed embed color from score-based to win probability-based - Embed shows color of team favored to win, not necessarily winning - Creates fun psychological element showing momentum/advantage - Added dynamic channel visibility for #live-sba-scores - Channel visible to @everyone when active games exist - Channel hidden when no games are active - Bot retains access via role permissions - Added set_channel_visibility() to utils/discord_helpers.py - Eliminated ~220 lines of duplicate code across files - Removed duplicate embed creation from commands/gameplay/scorebug.py - Removed duplicate embed creation from tasks/live_scorebug_tracker.py 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- commands/gameplay/scorebug.py | 135 +---------------- services/scorebug_service.py | 257 ++++++++++++++++++++++++++------- tasks/live_scorebug_tracker.py | 84 +++-------- utils/discord_helpers.py | 52 +++++++ utils/scorebug_helpers.py | 207 ++++++++++++++++++++++++++ 5 files changed, 494 insertions(+), 241 deletions(-) create mode 100644 utils/scorebug_helpers.py diff --git a/commands/gameplay/scorebug.py b/commands/gameplay/scorebug.py index 3d2bb9d..4f9acd1 100644 --- a/commands/gameplay/scorebug.py +++ b/commands/gameplay/scorebug.py @@ -7,10 +7,11 @@ import discord from discord.ext import commands from discord import app_commands -from services.scorebug_service import ScorebugService +from services.scorebug_service import ScorebugData, ScorebugService from services.team_service import team_service from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.scorebug_helpers import create_scorebug_embed from views.embeds import EmbedTemplate, EmbedColors from exceptions import SheetsException from .scorecard_tracker import ScorecardTracker @@ -190,8 +191,8 @@ class ScorebugCommands(commands.Cog): if scorebug_data.home_team_id: home_team = await team_service.get_team(scorebug_data.home_team_id) - # Create scorebug embed - embed = await self._create_scorebug_embed( + # Create scorebug embed using shared utility + embed = create_scorebug_embed( scorebug_data, away_team, home_team, @@ -224,134 +225,6 @@ class ScorebugCommands(commands.Cog): ) await interaction.edit_original_response(content=None, embed=embed) - async def _create_scorebug_embed( - self, - scorebug_data, - away_team, - home_team, - full_length: bool - ) -> discord.Embed: - """ - Create a rich embed from scorebug data. - - Args: - scorebug_data: ScorebugData object - away_team: Away team object (optional) - home_team: Home team object (optional) - full_length: Include full details - - Returns: - Discord embed with scorebug information - """ - # Determine winning team for embed color - if scorebug_data.away_score > scorebug_data.home_score and away_team: - embed_color = away_team.get_color_int() - thumbnail_url = away_team.thumbnail if away_team.thumbnail else None - elif scorebug_data.home_score > scorebug_data.away_score and home_team: - embed_color = home_team.get_color_int() - thumbnail_url = home_team.thumbnail if home_team.thumbnail else None - else: - embed_color = EmbedColors.INFO - thumbnail_url = None - - # Create embed with header as title - embed = discord.Embed( - title=scorebug_data.header, - color=embed_color - ) - - if thumbnail_url: - embed.set_thumbnail(url=thumbnail_url) - - # Add score information - away_abbrev = away_team.abbrev if away_team else "AWAY" - home_abbrev = home_team.abbrev if home_team else "HOME" - - score_text = ( - f"```\n" - f"{away_abbrev:<6} {scorebug_data.away_score:>3}\n" - f"{home_abbrev:<6} {scorebug_data.home_score:>3}\n" - f"```" - ) - - embed.add_field( - name="Score", - value=score_text, - inline=True - ) - - # Add game state - if not scorebug_data.is_final: - embed.add_field( - name="Status", - value=f"**{scorebug_data.which_half}**", - inline=True - ) - - # Add runners on base if present - if scorebug_data.runners and any(scorebug_data.runners): - runners_text = self._format_runners(scorebug_data.runners) - if runners_text: - embed.add_field( - name="Runners", - value=runners_text, - inline=False - ) - - # Add matchups if full length - if full_length and scorebug_data.matchups and any(scorebug_data.matchups): - matchups_text = self._format_matchups(scorebug_data.matchups) - if matchups_text: - embed.add_field( - name="Matchups", - value=matchups_text, - inline=False - ) - - # Add summary if full length - if full_length and scorebug_data.summary and any(scorebug_data.summary): - summary_text = self._format_summary(scorebug_data.summary) - if summary_text: - embed.add_field( - name="Summary", - value=summary_text, - inline=False - ) - - return embed - - def _format_runners(self, runners) -> str: - """Format runners on base for display.""" - # runners is a list of [runner_name, runner_position] pairs - runner_lines = [] - for runner_data in runners: - if runner_data and len(runner_data) >= 2 and runner_data[0]: - runner_lines.append(f"**{runner_data[1]}:** {runner_data[0]}") - - return "\n".join(runner_lines) if runner_lines else "" - - def _format_matchups(self, matchups) -> str: - """Format current matchups for display.""" - # matchups is a list of [batter, pitcher] pairs - matchup_lines = [] - for matchup_data in matchups: - if matchup_data and len(matchup_data) >= 2 and matchup_data[0]: - matchup_lines.append(f"{matchup_data[0]} vs {matchup_data[1]}") - - return "\n".join(matchup_lines) if matchup_lines else "" - - def _format_summary(self, summary) -> str: - """Format game summary for display.""" - # summary is a list of summary line pairs - summary_lines = [] - for summary_data in summary: - if summary_data and len(summary_data) >= 1 and summary_data[0]: - # Join both columns if present - line = " - ".join([str(x) for x in summary_data if x]) - summary_lines.append(line) - - return "\n".join(summary_lines) if summary_lines else "" - async def setup(bot: commands.Bot): """Load the scorebug commands cog.""" diff --git a/services/scorebug_service.py b/services/scorebug_service.py index b2f3c53..0c6f3f5 100644 --- a/services/scorebug_service.py +++ b/services/scorebug_service.py @@ -22,10 +22,24 @@ class ScorebugData: self.away_score = data.get('away_score', 0) self.home_score = data.get('home_score', 0) self.which_half = data.get('which_half', '') + self.inning = data.get('inning', 1) self.is_final = data.get('is_final', False) - self.runners = data.get('runners', []) - self.matchups = data.get('matchups', []) - self.summary = data.get('summary', []) + self.outs = data.get('outs', 0) + self.win_percentage = data.get('win_percentage', 50.0) + + # Current matchup information + self.pitcher_name = data.get('pitcher_name', '') + self.pitcher_url = data.get('pitcher_url', '') + self.pitcher_stats = data.get('pitcher_stats', '') + self.batter_name = data.get('batter_name', '') + self.batter_url = data.get('batter_url', '') + self.batter_stats = data.get('batter_stats', '') + self.on_deck_name = data.get('on_deck_name', '') + self.in_hole_name = data.get('in_hole_name', '') + + # Additional data + self.runners = data.get('runners', []) # [Catcher, On First, On Second, On Third] + self.summary = data.get('summary', []) # Play-by-play summary lines @property def score_line(self) -> str: @@ -37,6 +51,22 @@ class ScorebugData: """Check if game is currently active (not final).""" return not self.is_final + @property + def current_matchup(self) -> str: + """Get formatted current matchup string.""" + if self.batter_name and self.pitcher_name: + return f"{self.batter_name} vs {self.pitcher_name}" + return "" + + @property + def situation(self) -> str: + """Get game situation (outs and runners).""" + parts = [] + if self.outs is not None: + outs_text = "out" if self.outs == 1 else "outs" + parts.append(f"{self.outs} {outs_text}") + return ", ".join(parts) if parts else "" + class ScorebugService(SheetsService): """Google Sheets integration for reading live scorebug data.""" @@ -69,9 +99,13 @@ class ScorebugService(SheetsService): Raises: SheetsException: If scorecard cannot be read """ + self.logger.info(f"📖 Reading scorebug data from sheet: {sheet_url_or_key}") + self.logger.debug(f" Full length mode: {full_length}") + try: # Open scorecard scorecard = await self.open_scorecard(sheet_url_or_key) + self.logger.debug(f" ✅ Scorecard opened successfully") loop = asyncio.get_event_loop() @@ -88,101 +122,226 @@ class ScorebugService(SheetsService): lambda: scorebug_tab.get_values('B2', 'S20', include_tailing_empty_rows=True) ) - self.logger.debug(f"Raw scorebug data (first 10 rows): {all_data[:10]}") + self.logger.debug(f"📊 Raw scorebug data dimensions: {len(all_data)} rows") + self.logger.debug(f"📊 First row length: {len(all_data[0]) if all_data else 0} columns") + self.logger.debug(f"📊 Reading from range B2:S20 (columns B-S = indices 0-17 in data)") + self.logger.debug(f"📊 Raw data structure (all rows):") + for idx, row in enumerate(all_data): + self.logger.debug(f" Row {idx} (Sheet row {idx + 2}): {row}") # Extract game state (B2:G8) + # This corresponds to columns B-G (indices 0-5 in all_data) + # Rows 2-8 in sheet (indices 0-6 in all_data) game_state = [ all_data[0][:6], all_data[1][:6], all_data[2][:6], all_data[3][:6], all_data[4][:6], all_data[5][:6], all_data[6][:6] ] - self.logger.debug(f"Extracted game_state: {game_state}") + self.logger.debug(f"🎮 Extracted game_state (B2:G8):") + for idx, row in enumerate(game_state): + self.logger.debug(f" game_state[{idx}] (Sheet row {idx + 2}): {row}") # Extract team IDs from game_state (already read from Scorebug tab) - # game_state[3] is away team row, game_state[4] is home team row - # First column (index 0) contains the team ID - try: - away_team_id = int(game_state[3][0]) if len(game_state) > 3 and len(game_state[3]) > 0 else None - home_team_id = int(game_state[4][0]) if len(game_state) > 4 and len(game_state[4]) > 0 else None + # game_state[3] is away team row (Sheet row 5), game_state[4] is home team row (Sheet row 6) + # First column (index 0) contains the team ID - this is column B in the sheet + self.logger.debug(f"🏟️ Extracting team IDs from game_state:") + self.logger.debug(f" Away team row: game_state[3] = Sheet row 5, column B (index 0)") + self.logger.debug(f" Home team row: game_state[4] = Sheet row 6, column B (index 0)") - self.logger.debug(f"Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}") + try: + away_team_id_raw = game_state[3][0] if len(game_state) > 3 and len(game_state[3]) > 0 else None + home_team_id_raw = game_state[4][0] if len(game_state) > 4 and len(game_state[4]) > 0 else None + + self.logger.debug(f" Raw away team ID value: '{away_team_id_raw}'") + self.logger.debug(f" Raw home team ID value: '{home_team_id_raw}'") + + away_team_id = int(away_team_id_raw) if away_team_id_raw else None + home_team_id = int(home_team_id_raw) if home_team_id_raw else None + + self.logger.debug(f" ✅ Parsed team IDs - Away: {away_team_id}, Home: {home_team_id}") if away_team_id is None or home_team_id is None: raise ValueError(f'Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})') except (ValueError, IndexError) as e: - self.logger.error(f"Failed to parse team IDs from scorebug: {e}") + self.logger.error(f"❌ Failed to parse team IDs from scorebug: {e}") raise ValueError(f'Could not extract team IDs from scorecard') # Parse game state + self.logger.debug(f"📝 Parsing header from game_state[0][0] (Sheet B2):") header = game_state[0][0] if game_state[0] else '' is_final = header[-5:] == 'FINAL' if header else False - - self.logger.debug(f"Header: '{header}', Is Final: {is_final}") - self.logger.debug(f"Away team row (game_state[3]): {game_state[3] if len(game_state) > 3 else 'N/A'}") - self.logger.debug(f"Home team row (game_state[4]): {game_state[4] if len(game_state) > 4 else 'N/A'}") + self.logger.debug(f" Header value: '{header}'") + self.logger.debug(f" Is Final: {is_final}") # Parse scores with validation + self.logger.debug(f"⚾ Parsing scores:") + self.logger.debug(f" Away score: game_state[3][2] (Sheet row 5, column D)") + self.logger.debug(f" Home score: game_state[4][2] (Sheet row 6, column D)") + try: away_score_raw = game_state[3][2] if len(game_state) > 3 and len(game_state[3]) > 2 else '0' - self.logger.debug(f"Raw away score value: '{away_score_raw}'") - away_score = int(away_score_raw) + self.logger.debug(f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})") + away_score = int(away_score_raw) if away_score_raw != '' else 0 + self.logger.debug(f" ✅ Parsed away score: {away_score}") except (ValueError, IndexError) as e: - self.logger.warning(f"Failed to parse away score: {e}") + self.logger.warning(f" ⚠️ Failed to parse away score: {e}") away_score = 0 try: home_score_raw = game_state[4][2] if len(game_state) > 4 and len(game_state[4]) > 2 else '0' - self.logger.debug(f"Raw home score value: '{home_score_raw}'") - home_score = int(home_score_raw) + self.logger.debug(f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})") + home_score = int(home_score_raw) if home_score_raw != '' else 0 + self.logger.debug(f" ✅ Parsed home score: {home_score}") except (ValueError, IndexError) as e: - self.logger.warning(f"Failed to parse home score: {e}") + self.logger.warning(f" ⚠️ Failed to parse home score: {e}") home_score = 0 + try: + inning_raw = game_state[3][5] if len(game_state) > 3 and len(game_state[3]) > 5 else '0' + self.logger.debug(f" Raw inning value: '{inning_raw}' (type: {type(inning_raw).__name__})") + inning = int(inning_raw) if inning_raw != '' else 1 + self.logger.debug(f" ✅ Parsed inning: {inning}") + except (ValueError, IndexError) as e: + self.logger.warning(f" ⚠️ Failed to parse home score: {e}") + inning = 1 + + self.logger.debug(f"⏱️ Parsing game state from game_state[3][4] (Sheet row 5, column F):") which_half = game_state[3][4] if len(game_state) > 3 and len(game_state[3]) > 4 else '' + self.logger.debug(f" Which half value: '{which_half}'") - self.logger.debug(f"Parsed values - Away: {away_score}, Home: {home_score}, Which Half: '{which_half}'") + # Parse outs from all_data[4][4] (Sheet F6 - columns start at B, so F=index 4) + self.logger.debug(f"🔢 Parsing outs from F6 (all_data[4][4]):") + try: + outs_raw = all_data[4][4] if len(all_data) > 4 and len(all_data[4]) > 4 else '0' + self.logger.debug(f" Raw outs value: '{outs_raw}'") + # Handle "2" or any number + outs = int(outs_raw) if outs_raw and str(outs_raw).strip() else 0 + self.logger.debug(f" ✅ Parsed outs: {outs}") + except (ValueError, IndexError, AttributeError) as e: + self.logger.warning(f" ⚠️ Failed to parse outs: {e}") + outs = 0 - # Extract runners (K11:L14 → offset in all_data) - runners = [ - all_data[9][9:11] if len(all_data) > 9 else [], - all_data[10][9:11] if len(all_data) > 10 else [], - all_data[11][9:11] if len(all_data) > 11 else [], - all_data[12][9:11] if len(all_data) > 12 else [] + # Extract matchup information - K3:O6 (rows 3-6, columns K-O) + # In all_data: rows 1-4 (sheet rows 3-6), columns 9-13 (sheet columns K-O) + self.logger.debug(f"⚔️ Extracting matchups from K3:O6:") + matchups = [ + all_data[1][9:14] if len(all_data) > 1 else [], # Pitcher (row 3) + all_data[2][9:14] if len(all_data) > 2 else [], # Batter (row 4) + all_data[3][9:14] if len(all_data) > 3 else [], # On Deck (row 5) + all_data[4][9:14] if len(all_data) > 4 else [], # In Hole (row 6) ] - # Extract matchups if full_length (M11:N14 → offset in all_data) - matchups = [] - if full_length: - matchups = [ - all_data[9][11:13] if len(all_data) > 9 else [], - all_data[10][11:13] if len(all_data) > 10 else [], - all_data[11][11:13] if len(all_data) > 11 else [], - all_data[12][11:13] if len(all_data) > 12 else [] - ] + # Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats + pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else '' + pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else '' + pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else '' + self.logger.debug(f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}") - # Extract summary if full_length (Q11:R14 → offset in all_data) + # Batter: matchups[1][0]=name, [1]=URL, [2]=stats, [3]=order, [4]=position + batter_name = matchups[1][0] if len(matchups[1]) > 0 else '' + batter_url = matchups[1][1] if len(matchups[1]) > 1 else '' + batter_stats = matchups[1][2] if len(matchups[1]) > 2 else '' + self.logger.debug(f" Batter: {batter_name} | {batter_stats} | {batter_url}") + + # On Deck: matchups[2][0]=name + on_deck_name = matchups[2][0] if len(matchups[2]) > 0 else '' + on_deck_url = matchups[2][1] if len(matchups[2]) > 1 else '' + self.logger.debug(f" On Deck: {on_deck_name}") + + # In Hole: matchups[3][0]=name + in_hole_name = matchups[3][0] if len(matchups[3]) > 0 else '' + in_hole_url = matchups[3][1] if len(matchups[3]) > 1 else '' + self.logger.debug(f" In Hole: {in_hole_name}") + + # Parse win percentage from all_data[6][2] (Sheet D8 - row 8, column D) + self.logger.debug(f"📈 Parsing win percentage from D8 (all_data[6][2]):") + try: + win_pct_raw = all_data[6][2] if len(all_data) > 6 and len(all_data[6]) > 2 else '50%' + self.logger.debug(f" Raw win percentage value: '{win_pct_raw}'") + # Remove % sign if present and convert to float + win_pct_str = str(win_pct_raw).replace('%', '').strip() + win_percentage = float(win_pct_str) if win_pct_str else 50.0 + self.logger.debug(f" ✅ Parsed win percentage: {win_percentage}%") + except (ValueError, IndexError, AttributeError) as e: + self.logger.warning(f" ⚠️ Failed to parse win percentage: {e}") + win_percentage = 50.0 + + self.logger.debug(f"📊 Final parsed values:") + self.logger.debug(f" Away team {away_team_id}: {away_score}") + self.logger.debug(f" Home team {home_team_id}: {home_score}") + self.logger.debug(f" Game state: '{which_half}'") + self.logger.debug(f" Outs: {outs}") + self.logger.debug(f" Win percentage: {win_percentage}%") + self.logger.debug(f" Current matchup: {batter_name} vs {pitcher_name}") + self.logger.debug(f" Status: {'FINAL' if is_final else 'IN PROGRESS'}") + + # Extract runners - K11:L14 (rows 11-14, columns K-L) + # In all_data: rows 9-12 (sheet rows 11-14), columns 9-10 (sheet columns K-L) + # runners[0] = Catcher, runners[1] = On First, runners[2] = On Second, runners[3] = On Third + # Each runner is [name, URL] + self.logger.debug(f"🏃 Extracting runners from K11:L14:") + runners = [ + all_data[9][9:11] if len(all_data) > 9 else [], # Catcher (row 11) + all_data[10][9:11] if len(all_data) > 10 else [], # On First (row 12) + all_data[11][9:11] if len(all_data) > 11 else [], # On Second (row 13) + all_data[12][9:11] if len(all_data) > 12 else [] # On Third (row 14) + ] + self.logger.debug(f" Catcher: {runners[0]}") + self.logger.debug(f" On First: {runners[1]}") + self.logger.debug(f" On Second: {runners[2]}") + self.logger.debug(f" On Third: {runners[3]}") + + # Extract summary if full_length (R3:S20 for inning-by-inning plays) + # This is the "Play by Play" summary section summary = [] if full_length: - summary = [ - all_data[9][15:17] if len(all_data) > 9 else [], - all_data[10][15:17] if len(all_data) > 10 else [], - all_data[11][15:17] if len(all_data) > 11 else [], - all_data[12][15:17] if len(all_data) > 12 else [] - ] + self.logger.debug(f"📋 Extracting summary from R3:S20:") + # R3:S20 is columns 16-17 (R-S), rows 3-20 (indices 1-18) + for row_idx in range(1, min(19, len(all_data))): + if len(all_data[row_idx]) > 17: + play_line = [all_data[row_idx][16], all_data[row_idx][17]] + if play_line[0] or play_line[1]: # Only add if not empty + summary.append(play_line) + self.logger.debug(f" Found {len(summary)} summary lines") + else: + self.logger.debug(f"📝 Skipping summary (compact view)") - return ScorebugData({ + self.logger.debug(f"✅ Scorebug data extraction complete!") + + scorebug_data = ScorebugData({ 'away_team_id': away_team_id, 'home_team_id': home_team_id, 'header': header, 'away_score': away_score, 'home_score': home_score, 'which_half': which_half, + 'inning': inning, 'is_final': is_final, - 'runners': runners, - 'matchups': matchups, - 'summary': summary + 'outs': outs, + 'win_percentage': win_percentage, + 'pitcher_name': pitcher_name, + 'pitcher_url': pitcher_url, + 'pitcher_stats': pitcher_stats, + 'batter_name': batter_name, + 'batter_url': batter_url, + 'batter_stats': batter_stats, + 'on_deck_name': on_deck_name, + 'in_hole_name': in_hole_name, + 'runners': runners, # [Catcher, On First, On Second, On Third], each is [name, URL] + 'summary': summary # Play-by-play lines from R3:S20 }) + self.logger.debug(f"🎯 Created ScorebugData object:") + self.logger.debug(f" Away Team ID: {scorebug_data.away_team_id}") + self.logger.debug(f" Home Team ID: {scorebug_data.home_team_id}") + self.logger.debug(f" Header: '{scorebug_data.header}'") + self.logger.debug(f" Score Line: {scorebug_data.score_line}") + self.logger.debug(f" Which Half: '{scorebug_data.which_half}'") + self.logger.debug(f" Is Final: {scorebug_data.is_final}") + self.logger.debug(f" Is Active: {scorebug_data.is_active}") + + return scorebug_data + except pygsheets.WorksheetNotFound: self.logger.error(f"Scorebug tab not found in scorecard") raise SheetsException("Scorebug tab not found. Is this a valid scorecard?") diff --git a/tasks/live_scorebug_tracker.py b/tasks/live_scorebug_tracker.py index 50ab0d2..2885434 100644 --- a/tasks/live_scorebug_tracker.py +++ b/tasks/live_scorebug_tracker.py @@ -10,6 +10,8 @@ from discord.ext import tasks, commands from models.team import Team from utils.logging import get_contextual_logger +from utils.scorebug_helpers import create_scorebug_embed +from utils.discord_helpers import set_channel_visibility from services.scorebug_service import ScorebugData, ScorebugService from services.team_service import team_service from commands.gameplay.scorecard_tracker import ScorecardTracker @@ -92,8 +94,13 @@ class LiveScorebugTracker: all_scorecards = self.scorecard_tracker.get_all_scorecards() if not all_scorecards: - # No active scorebugs - clear the channel + # No active scorebugs - clear the channel and hide it await self._clear_live_scores_channel(live_scores_channel) + await set_channel_visibility( + live_scores_channel, + visible=False, + reason="No active games" + ) return # Read all scorebugs and create embeds @@ -114,11 +121,12 @@ class LiveScorebugTracker: if away_team is None or home_team is None: raise ValueError(f'Error looking up teams in scorecard; IDs provided: {scorebug_data.away_team_id} & {scorebug_data.home_team_id}') - # Create compact embed - embed = await self._create_compact_scorebug_embed( + # Create compact embed using shared utility + embed = create_scorebug_embed( scorebug_data, away_team, - home_team + home_team, + full_length=False # Compact view for live channel ) active_scorebugs.append(embed) @@ -140,66 +148,20 @@ class LiveScorebugTracker: # Update live scores channel if active_scorebugs: + await set_channel_visibility( + live_scores_channel, + visible=True, + reason="Active games in progress" + ) await self._post_scorebugs_to_channel(live_scores_channel, active_scorebugs) else: - # All games finished - clear the channel + # All games finished - clear the channel and hide it await self._clear_live_scores_channel(live_scores_channel) - - async def _create_compact_scorebug_embed( - self, - scorebug_data, - away_team: Team, - home_team: Team - ) -> discord.Embed: - """ - Create a compact scorebug embed for the live channel. - - Args: - scorebug_data: ScorebugData object - away_team: Away team object (optional) - home_team: Home team object (optional) - - Returns: - Discord embed with compact scorebug - """ - # Determine winning team for embed color - if scorebug_data.away_score > scorebug_data.home_score and away_team: - embed_color = away_team.get_color_int() - elif scorebug_data.home_score > scorebug_data.away_score and home_team: - embed_color = home_team.get_color_int() - else: - embed_color = EmbedColors.INFO - - # Create compact embed - embed = discord.Embed( - title=scorebug_data.header, - color=embed_color - ) - - # Add score - away_abbrev = away_team.abbrev if away_team else "AWAY" - home_abbrev = home_team.abbrev if home_team else "HOME" - - score_text = ( - f"```\n" - f"{away_abbrev:<4} {scorebug_data.away_score:>2}\n" - f"{home_abbrev:<4} {scorebug_data.home_score:>2}\n" - f"```" - ) - - embed.add_field( - name="Score", - value=score_text, - inline=True - ) - - embed.add_field( - name="Status", - value=f"**{scorebug_data.which_half}**", - inline=True - ) - - return embed + await set_channel_visibility( + live_scores_channel, + visible=False, + reason="No active games" + ) async def _post_scorebugs_to_channel( self, diff --git a/utils/discord_helpers.py b/utils/discord_helpers.py index 793cf1d..ec59fd5 100644 --- a/utils/discord_helpers.py +++ b/utils/discord_helpers.py @@ -119,3 +119,55 @@ def format_key_plays( key_plays_text += f"{play_description}\n" return key_plays_text + + +async def set_channel_visibility( + channel: discord.TextChannel, + visible: bool, + reason: Optional[str] = None +) -> bool: + """ + Set channel visibility for @everyone. + + The bot's permissions are based on its role, not @everyone, so the bot + will retain access even when @everyone view permission is removed. + + Args: + channel: Discord text channel to modify + visible: If True, grant @everyone view permission; if False, deny it + reason: Optional reason for audit log + + Returns: + True if permissions updated successfully, False otherwise + """ + try: + guild = channel.guild + everyone_role = guild.default_role + + if visible: + # Grant @everyone permission to view channel + default_reason = "Channel made visible to all members" + await channel.set_permissions( + everyone_role, + view_channel=True, + reason=reason or default_reason + ) + logger.info(f"Set #{channel.name} to VISIBLE for @everyone") + else: + # Remove @everyone view permission + default_reason = "Channel hidden from members" + await channel.set_permissions( + everyone_role, + view_channel=False, + reason=reason or default_reason + ) + logger.info(f"Set #{channel.name} to HIDDEN for @everyone") + + return True + + except discord.Forbidden: + logger.error(f"Missing permissions to modify #{channel.name} permissions") + return False + except Exception as e: + logger.error(f"Error setting channel visibility for #{channel.name}: {e}") + return False diff --git a/utils/scorebug_helpers.py b/utils/scorebug_helpers.py new file mode 100644 index 0000000..f544bb7 --- /dev/null +++ b/utils/scorebug_helpers.py @@ -0,0 +1,207 @@ +""" +Scorebug Display Helpers + +Utility functions for formatting and displaying scorebug information. +""" +import discord +from typing import Optional + +from views.embeds import EmbedColors + + +def create_scorebug_embed( + scorebug_data, + away_team, + home_team, + full_length: bool = True +) -> discord.Embed: + """ + Create a rich embed from scorebug data. + + Args: + scorebug_data: ScorebugData object with game information + away_team: Away team object (optional) + home_team: Home team object (optional) + full_length: If True, includes pitcher/batter/runners/summary; if False, compact view + + Returns: + Discord embed with scorebug information + """ + # Determine embed color based on win probability (not score!) + # This creates a fun twist where the favored team's color shows, + # even if they're currently losing + if scorebug_data.win_percentage > 50 and home_team: + embed_color = home_team.get_color_int() # Home team favored + elif scorebug_data.win_percentage < 50 and away_team: + embed_color = away_team.get_color_int() # Away team favored + else: + embed_color = EmbedColors.INFO # Even game (50/50) + + # Create embed with header as title + embed = discord.Embed( + title=scorebug_data.header, + color=embed_color + ) + + # Get team abbreviations for use throughout + away_abbrev = away_team.abbrev if away_team else "AWAY" + home_abbrev = home_team.abbrev if home_team else "HOME" + + # Create ASCII scorebug with bases visualization + occupied = '●' + unoccupied = '○' + + # runners[0]=Catcher, [1]=On First, [2]=On Second, [3]=On Third + first_base = unoccupied if not scorebug_data.runners[1] or not scorebug_data.runners[1][0] else occupied + second_base = unoccupied if not scorebug_data.runners[2] or not scorebug_data.runners[2][0] else occupied + third_base = unoccupied if not scorebug_data.runners[3] or not scorebug_data.runners[3][0] else occupied + half = '▲' if scorebug_data.which_half == 'Top' else '▼' + + if not scorebug_data.is_final: + inning = f'{half} {scorebug_data.inning}' + outs = f'{scorebug_data.outs} Out{"s" if scorebug_data.outs != 1 else ""}' + else: + # Final inning display + final_inning = scorebug_data.inning if scorebug_data.which_half == "Bot" else scorebug_data.inning - 1 + inning = f'F/{final_inning}' + outs = '' + + game_state_text = ( + f'```\n' + f'{away_abbrev: ^4}{scorebug_data.away_score: ^3} {second_base}{inning: >10}\n' + f'{home_abbrev: ^4}{scorebug_data.home_score: ^3} {third_base} {first_base}{outs: >8}\n' + f'```' + ) + + # Add win probability bar + embed.add_field( + name='Win Probability', + value=create_team_progress_bar( + scorebug_data.win_percentage, + away_abbrev, + home_abbrev + ), + inline=False + ) + + # Add game state + embed.add_field( + name='Game State', + value=game_state_text, + inline=False + ) + + # If not full_length, return compact version + if not full_length: + return embed + + # Full length - add pitcher and batter info + if scorebug_data.pitcher_name: + embed.add_field( + name='Pitcher', + value=f'[{scorebug_data.pitcher_name}]({scorebug_data.pitcher_url})\n{scorebug_data.pitcher_stats}', + inline=True + ) + + if scorebug_data.batter_name: + embed.add_field( + name='Batter', + value=f'[{scorebug_data.batter_name}]({scorebug_data.batter_url})\n{scorebug_data.batter_stats}', + inline=True + ) + + # Add baserunners if present + on_first = scorebug_data.runners[1] if scorebug_data.runners[1] else '' + on_second = scorebug_data.runners[2] if scorebug_data.runners[2] else '' + on_third = scorebug_data.runners[3] if scorebug_data.runners[3] else '' + have_baserunners = len(on_first[0]) + len(on_second[0]) + len(on_third[0]) > 0 + + if have_baserunners > 0: + br_string = '' + if len(on_first) > 0: + br_string += f'On First: [{on_first[0]}]({on_first[1]})\n' + if len(on_second) > 0: + br_string += f'On Second: [{on_second[0]}]({on_second[1]})\n' + if len(on_third) > 0: + br_string += f'On Third: [{on_third[0]}]({on_third[1]})\n' + + embed.add_field(name=' ', value=' ', inline=False) # Spacer + embed.add_field( + name='Baserunners', + value=br_string, + inline=True + ) + + # Add catcher + if scorebug_data.runners[0] and scorebug_data.runners[0][0]: + embed.add_field( + name='Catcher', + value=f'[{scorebug_data.runners[0][0]}]({scorebug_data.runners[0][1]})', + inline=True + ) + + # Add inning summary if not final + if not scorebug_data.is_final and scorebug_data.summary: + i_string = '' + for line in scorebug_data.summary: + if line and len(line) >= 2 and line[0]: + i_string += f'- Play {line[0]}: {line[1]}\n' + + if i_string and "IFERROR" not in i_string: + embed.add_field( + name='Inning Summary', + value=i_string, + inline=False + ) + + return embed + + +def create_team_progress_bar( + win_percentage: float, + away_abbrev: str, + home_abbrev: str, + length: int = 10 +) -> str: + """ + Create a proportional progress bar showing each team's win probability. + + Args: + win_percentage: Home team's win percentage (0-100) + away_abbrev: Away team abbreviation (e.g., "POR") + home_abbrev: Home team abbreviation (e.g., "WV") + length: Total bar length in blocks (default 10) + + Returns: + Formatted bar with dark blocks (▓) weighted toward winning team. + Arrow extends from the side with the advantage. + Examples: + Home winning: "POR ░▓▓▓▓▓▓▓▓▓► WV 95.0%" + Away winning: "POR ◄▓▓▓▓▓▓▓░░░ WV 30.0%" + Even game: "POR =▓▓▓▓▓▓▓▓▓▓= WV 50.0%" + """ + # Calculate blocks for each team (home team's percentage) + home_blocks = int((win_percentage / 100) * length) + away_blocks = length - home_blocks + + if win_percentage > 50: + # Home team (right side) is winning + away_char = '░' # Light blocks for losing team + home_char = '▓' # Dark blocks for winning team + bar = away_char * away_blocks + home_char * home_blocks + # Arrow extends from right side + return f'{away_abbrev} {bar}► {home_abbrev} {win_percentage:.1f}%' + elif win_percentage < 50: + # Away team (left side) is winning + away_char = '▓' # Dark blocks for winning team + home_char = '░' # Light blocks for losing team + bar = away_char * away_blocks + home_char * home_blocks + # Arrow extends from left side + return f'{away_abbrev} ◄{bar} {home_abbrev} {win_percentage:.1f}%' + else: + # Even game (50/50) + away_char = '▓' + home_char = '▓' + bar = away_char * away_blocks + home_char * home_blocks + # Arrows on both sides for balanced display + return f'{away_abbrev} ={bar}= {home_abbrev} {win_percentage:.1f}%' From a80b84ae252fc8eb2d5d464ae52cc57fe5dd5fa9 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 22 Oct 2025 17:03:43 -0500 Subject: [PATCH 2/3] CLAUDE: Add /admin-clear-scorecards command for manual channel management MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Added new admin-only command to manually clear #live-sba-scores - Clears all messages from the channel (up to 100) - Sets @everyone view permission to False (hides channel) - Bot retains access via role permissions - Provides detailed feedback on operation success - Updated /admin-help to include new command Use cases: - Manual cleanup of stale scorebug displays - Testing channel visibility functionality - Emergency channel management - Forcing channel to hidden state 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- commands/admin/management.py | 102 ++++++++++++++++++++++++++++++++++- 1 file changed, 101 insertions(+), 1 deletion(-) diff --git a/commands/admin/management.py b/commands/admin/management.py index eff9029..f4defc8 100644 --- a/commands/admin/management.py +++ b/commands/admin/management.py @@ -12,6 +12,7 @@ from discord import app_commands from config import get_config from utils.logging import get_contextual_logger from utils.decorators import logged_command +from utils.discord_helpers import set_channel_visibility from views.embeds import EmbedColors, EmbedTemplate @@ -115,7 +116,8 @@ class AdminCommands(commands.Cog): value="**`/admin-status`** - Display bot status and information\n" "**`/admin-reload `** - Reload a specific cog\n" "**`/admin-sync`** - Sync application commands\n" - "**`/admin-clear `** - Clear messages from channel", + "**`/admin-clear `** - Clear messages from channel\n" + "**`/admin-clear-scorecards`** - Clear live scorebug channel and hide it", inline=False ) @@ -498,6 +500,104 @@ class AdminCommands(commands.Cog): await interaction.followup.send(embed=embed) + @app_commands.command( + name="admin-clear-scorecards", + description="Manually clear the live scorebug channel and hide it from members" + ) + @logged_command("/admin-clear-scorecards") + async def admin_clear_scorecards(self, interaction: discord.Interaction): + """ + Manually clear #live-sba-scores channel and set @everyone view permission to off. + + This is useful for: + - Cleaning up stale scorebug displays + - Manually hiding the channel when games finish + - Testing channel visibility functionality + """ + await interaction.response.defer() + + # Get the live-sba-scores channel + config = get_config() + guild = self.bot.get_guild(config.guild_id) + + if not guild: + await interaction.followup.send( + "❌ Could not find guild. Check configuration.", + ephemeral=True + ) + return + + live_scores_channel = discord.utils.get(guild.text_channels, name='live-sba-scores') + + if not live_scores_channel: + await interaction.followup.send( + "❌ Could not find #live-sba-scores channel.", + ephemeral=True + ) + return + + try: + # Clear all messages from the channel + deleted_count = 0 + async for message in live_scores_channel.history(limit=100): + try: + await message.delete() + deleted_count += 1 + except discord.NotFound: + pass # Message already deleted + + self.logger.info(f"Cleared {deleted_count} messages from #live-sba-scores") + + # Hide channel from @everyone + visibility_success = await set_channel_visibility( + live_scores_channel, + visible=False, + reason="Admin manual clear via /admin-clear-scorecards" + ) + + if visibility_success: + visibility_status = "✅ Hidden from @everyone" + else: + visibility_status = "⚠️ Could not change visibility (check permissions)" + + # Create success embed + embed = EmbedTemplate.success( + title="Live Scorebug Channel Cleared", + description="Successfully cleared the #live-sba-scores channel" + ) + + embed.add_field( + name="Clear Details", + value=f"**Channel:** {live_scores_channel.mention}\n" + f"**Messages Deleted:** {deleted_count}\n" + f"**Visibility:** {visibility_status}\n" + f"**Time:** {discord.utils.utcnow().strftime('%H:%M:%S UTC')}", + inline=False + ) + + embed.add_field( + name="Next Steps", + value="• Channel is now hidden from @everyone\n" + "• Bot retains full access to the channel\n" + "• Channel will auto-show when games are published\n" + "• Live scorebug tracker runs every 3 minutes", + inline=False + ) + + await interaction.followup.send(embed=embed) + + except discord.Forbidden: + await interaction.followup.send( + "❌ Missing permissions to clear messages or modify channel permissions.", + ephemeral=True + ) + except Exception as e: + self.logger.error(f"Error clearing scorecards: {e}", exc_info=True) + await interaction.followup.send( + f"❌ Failed to clear channel: {str(e)}", + ephemeral=True + ) + async def setup(bot: commands.Bot): """Load the admin commands cog.""" From 08434eb3407d95102068224b2743f636c6f6bc50 Mon Sep 17 00:00:00 2001 From: Cal Corum Date: Wed, 22 Oct 2025 19:20:43 -0500 Subject: [PATCH 3/3] Clear Injury bug fix --- commands/injuries/management.py | 2 +- services/injury_service.py | 3 ++- tests/test_services_injury.py | 8 +++++--- 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/commands/injuries/management.py b/commands/injuries/management.py index 3459e95..90ac45a 100644 --- a/commands/injuries/management.py +++ b/commands/injuries/management.py @@ -645,7 +645,7 @@ class InjuryGroup(app_commands.Group): return # Clear player's il_return field - await player_service.update_player(player.id, {'il_return': None}) + await player_service.update_player(player.id, {'il_return': ''}) # Success response success_embed = EmbedTemplate.success( diff --git a/services/injury_service.py b/services/injury_service.py index 9f85d3d..bb07bfe 100644 --- a/services/injury_service.py +++ b/services/injury_service.py @@ -187,7 +187,8 @@ class InjuryService(BaseService[Injury]): True if successfully cleared, False otherwise """ try: - updated_injury = await self.patch(injury_id, {'is_active': False}) + # Note: API expects is_active as query parameter, not JSON body + updated_injury = await self.patch(injury_id, {'is_active': False}, use_query_params=True) if updated_injury: logger.info(f"Cleared injury {injury_id}") diff --git a/tests/test_services_injury.py b/tests/test_services_injury.py index a40de60..f9f4402 100644 --- a/tests/test_services_injury.py +++ b/tests/test_services_injury.py @@ -245,12 +245,13 @@ class TestInjuryService: """Test clearing an injury.""" with patch('api.client.get_config', return_value=mock_config): with aioresponses() as m: - # Mock the PATCH request (note: patch sends data in body, not URL) + # Mock the PATCH request - API expects is_active as query parameter + # Note: Python's str(False) converts to "False" (capital F) cleared_data = sample_injury_data.copy() cleared_data['is_active'] = False m.patch( - 'https://api.example.com/v3/injuries/1', + 'https://api.example.com/v3/injuries/1?is_active=False', payload=cleared_data ) @@ -263,8 +264,9 @@ class TestInjuryService: """Test clearing injury when it fails.""" with patch('api.client.get_config', return_value=mock_config): with aioresponses() as m: + # Note: Python's str(False) converts to "False" (capital F) m.patch( - 'https://api.example.com/v3/injuries/1', + 'https://api.example.com/v3/injuries/1?is_active=False', status=500 )