Merge pull request #7 from calcorum/bug-live-scoreboard

Bug live scoreboard
This commit is contained in:
Cal Corum 2025-10-22 19:27:09 -05:00 committed by GitHub
commit 268ac9a547
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 603 additions and 247 deletions

View File

@ -12,6 +12,7 @@ from discord import app_commands
from config import get_config from config import get_config
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from utils.decorators import logged_command from utils.decorators import logged_command
from utils.discord_helpers import set_channel_visibility
from views.embeds import EmbedColors, EmbedTemplate from views.embeds import EmbedColors, EmbedTemplate
@ -115,7 +116,8 @@ class AdminCommands(commands.Cog):
value="**`/admin-status`** - Display bot status and information\n" value="**`/admin-status`** - Display bot status and information\n"
"**`/admin-reload <cog>`** - Reload a specific cog\n" "**`/admin-reload <cog>`** - Reload a specific cog\n"
"**`/admin-sync`** - Sync application commands\n" "**`/admin-sync`** - Sync application commands\n"
"**`/admin-clear <count>`** - Clear messages from channel", "**`/admin-clear <count>`** - Clear messages from channel\n"
"**`/admin-clear-scorecards`** - Clear live scorebug channel and hide it",
inline=False inline=False
) )
@ -498,6 +500,104 @@ class AdminCommands(commands.Cog):
await interaction.followup.send(embed=embed) 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): async def setup(bot: commands.Bot):
"""Load the admin commands cog.""" """Load the admin commands cog."""

View File

@ -7,10 +7,11 @@ import discord
from discord.ext import commands from discord.ext import commands
from discord import app_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 services.team_service import team_service
from utils.logging import get_contextual_logger from utils.logging import get_contextual_logger
from utils.decorators import logged_command from utils.decorators import logged_command
from utils.scorebug_helpers import create_scorebug_embed
from views.embeds import EmbedTemplate, EmbedColors from views.embeds import EmbedTemplate, EmbedColors
from exceptions import SheetsException from exceptions import SheetsException
from .scorecard_tracker import ScorecardTracker from .scorecard_tracker import ScorecardTracker
@ -190,8 +191,8 @@ class ScorebugCommands(commands.Cog):
if scorebug_data.home_team_id: if scorebug_data.home_team_id:
home_team = await team_service.get_team(scorebug_data.home_team_id) home_team = await team_service.get_team(scorebug_data.home_team_id)
# Create scorebug embed # Create scorebug embed using shared utility
embed = await self._create_scorebug_embed( embed = create_scorebug_embed(
scorebug_data, scorebug_data,
away_team, away_team,
home_team, home_team,
@ -224,134 +225,6 @@ class ScorebugCommands(commands.Cog):
) )
await interaction.edit_original_response(content=None, embed=embed) 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): async def setup(bot: commands.Bot):
"""Load the scorebug commands cog.""" """Load the scorebug commands cog."""

View File

@ -645,7 +645,7 @@ class InjuryGroup(app_commands.Group):
return return
# Clear player's il_return field # 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 response
success_embed = EmbedTemplate.success( success_embed = EmbedTemplate.success(

View File

@ -187,7 +187,8 @@ class InjuryService(BaseService[Injury]):
True if successfully cleared, False otherwise True if successfully cleared, False otherwise
""" """
try: 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: if updated_injury:
logger.info(f"Cleared injury {injury_id}") logger.info(f"Cleared injury {injury_id}")

View File

@ -22,10 +22,24 @@ class ScorebugData:
self.away_score = data.get('away_score', 0) self.away_score = data.get('away_score', 0)
self.home_score = data.get('home_score', 0) self.home_score = data.get('home_score', 0)
self.which_half = data.get('which_half', '') self.which_half = data.get('which_half', '')
self.inning = data.get('inning', 1)
self.is_final = data.get('is_final', False) self.is_final = data.get('is_final', False)
self.runners = data.get('runners', []) self.outs = data.get('outs', 0)
self.matchups = data.get('matchups', []) self.win_percentage = data.get('win_percentage', 50.0)
self.summary = data.get('summary', [])
# 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 @property
def score_line(self) -> str: def score_line(self) -> str:
@ -37,6 +51,22 @@ class ScorebugData:
"""Check if game is currently active (not final).""" """Check if game is currently active (not final)."""
return not self.is_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): class ScorebugService(SheetsService):
"""Google Sheets integration for reading live scorebug data.""" """Google Sheets integration for reading live scorebug data."""
@ -69,9 +99,13 @@ class ScorebugService(SheetsService):
Raises: Raises:
SheetsException: If scorecard cannot be read 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: try:
# Open scorecard # Open scorecard
scorecard = await self.open_scorecard(sheet_url_or_key) scorecard = await self.open_scorecard(sheet_url_or_key)
self.logger.debug(f" ✅ Scorecard opened successfully")
loop = asyncio.get_event_loop() loop = asyncio.get_event_loop()
@ -88,101 +122,226 @@ class ScorebugService(SheetsService):
lambda: scorebug_tab.get_values('B2', 'S20', include_tailing_empty_rows=True) 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) # 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 = [ game_state = [
all_data[0][:6], all_data[1][:6], all_data[2][:6], all_data[3][:6], 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] 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) # 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 # 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 # First column (index 0) contains the team ID - this is column B in the sheet
try: self.logger.debug(f"🏟️ Extracting team IDs from game_state:")
away_team_id = int(game_state[3][0]) if len(game_state) > 3 and len(game_state[3]) > 0 else None self.logger.debug(f" Away team row: game_state[3] = Sheet row 5, column B (index 0)")
home_team_id = int(game_state[4][0]) if len(game_state) > 4 and len(game_state[4]) > 0 else None 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: 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})') raise ValueError(f'Team IDs not found in scorebug (away: {away_team_id}, home: {home_team_id})')
except (ValueError, IndexError) as e: 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') raise ValueError(f'Could not extract team IDs from scorecard')
# Parse game state # 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 '' header = game_state[0][0] if game_state[0] else ''
is_final = header[-5:] == 'FINAL' if header else False is_final = header[-5:] == 'FINAL' if header else False
self.logger.debug(f" Header value: '{header}'")
self.logger.debug(f"Header: '{header}', Is Final: {is_final}") self.logger.debug(f" 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'}")
# Parse scores with validation # 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: try:
away_score_raw = game_state[3][2] if len(game_state) > 3 and len(game_state[3]) > 2 else '0' 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}'") self.logger.debug(f" Raw away score value: '{away_score_raw}' (type: {type(away_score_raw).__name__})")
away_score = int(away_score_raw) 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: 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 away_score = 0
try: try:
home_score_raw = game_state[4][2] if len(game_state) > 4 and len(game_state[4]) > 2 else '0' 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}'") self.logger.debug(f" Raw home score value: '{home_score_raw}' (type: {type(home_score_raw).__name__})")
home_score = int(home_score_raw) 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: 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 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 '' 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) # Extract matchup information - K3:O6 (rows 3-6, columns K-O)
runners = [ # In all_data: rows 1-4 (sheet rows 3-6), columns 9-13 (sheet columns K-O)
all_data[9][9:11] if len(all_data) > 9 else [], self.logger.debug(f"⚔️ Extracting matchups from K3:O6:")
all_data[10][9:11] if len(all_data) > 10 else [], matchups = [
all_data[11][9:11] if len(all_data) > 11 else [], all_data[1][9:14] if len(all_data) > 1 else [], # Pitcher (row 3)
all_data[12][9:11] if len(all_data) > 12 else [] 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) # Pitcher: matchups[0][0]=name, [1]=URL, [2]=stats
matchups = [] pitcher_name = matchups[0][0] if len(matchups[0]) > 0 else ''
if full_length: pitcher_url = matchups[0][1] if len(matchups[0]) > 1 else ''
matchups = [ pitcher_stats = matchups[0][2] if len(matchups[0]) > 2 else ''
all_data[9][11:13] if len(all_data) > 9 else [], self.logger.debug(f" Pitcher: {pitcher_name} | {pitcher_stats} | {pitcher_url}")
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 []
]
# 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 = [] summary = []
if full_length: if full_length:
summary = [ self.logger.debug(f"📋 Extracting summary from R3:S20:")
all_data[9][15:17] if len(all_data) > 9 else [], # R3:S20 is columns 16-17 (R-S), rows 3-20 (indices 1-18)
all_data[10][15:17] if len(all_data) > 10 else [], for row_idx in range(1, min(19, len(all_data))):
all_data[11][15:17] if len(all_data) > 11 else [], if len(all_data[row_idx]) > 17:
all_data[12][15:17] if len(all_data) > 12 else [] 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, 'away_team_id': away_team_id,
'home_team_id': home_team_id, 'home_team_id': home_team_id,
'header': header, 'header': header,
'away_score': away_score, 'away_score': away_score,
'home_score': home_score, 'home_score': home_score,
'which_half': which_half, 'which_half': which_half,
'inning': inning,
'is_final': is_final, 'is_final': is_final,
'runners': runners, 'outs': outs,
'matchups': matchups, 'win_percentage': win_percentage,
'summary': summary '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: except pygsheets.WorksheetNotFound:
self.logger.error(f"Scorebug tab not found in scorecard") self.logger.error(f"Scorebug tab not found in scorecard")
raise SheetsException("Scorebug tab not found. Is this a valid scorecard?") raise SheetsException("Scorebug tab not found. Is this a valid scorecard?")

View File

@ -10,6 +10,8 @@ from discord.ext import tasks, commands
from models.team import Team from models.team import Team
from utils.logging import get_contextual_logger 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.scorebug_service import ScorebugData, ScorebugService
from services.team_service import team_service from services.team_service import team_service
from commands.gameplay.scorecard_tracker import ScorecardTracker from commands.gameplay.scorecard_tracker import ScorecardTracker
@ -92,8 +94,13 @@ class LiveScorebugTracker:
all_scorecards = self.scorecard_tracker.get_all_scorecards() all_scorecards = self.scorecard_tracker.get_all_scorecards()
if not 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 self._clear_live_scores_channel(live_scores_channel)
await set_channel_visibility(
live_scores_channel,
visible=False,
reason="No active games"
)
return return
# Read all scorebugs and create embeds # Read all scorebugs and create embeds
@ -114,11 +121,12 @@ class LiveScorebugTracker:
if away_team is None or home_team is None: 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}') raise ValueError(f'Error looking up teams in scorecard; IDs provided: {scorebug_data.away_team_id} & {scorebug_data.home_team_id}')
# Create compact embed # Create compact embed using shared utility
embed = await self._create_compact_scorebug_embed( embed = create_scorebug_embed(
scorebug_data, scorebug_data,
away_team, away_team,
home_team home_team,
full_length=False # Compact view for live channel
) )
active_scorebugs.append(embed) active_scorebugs.append(embed)
@ -140,66 +148,20 @@ class LiveScorebugTracker:
# Update live scores channel # Update live scores channel
if active_scorebugs: 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) await self._post_scorebugs_to_channel(live_scores_channel, active_scorebugs)
else: 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) await self._clear_live_scores_channel(live_scores_channel)
await set_channel_visibility(
async def _create_compact_scorebug_embed( live_scores_channel,
self, visible=False,
scorebug_data, reason="No active games"
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
async def _post_scorebugs_to_channel( async def _post_scorebugs_to_channel(
self, self,

View File

@ -245,12 +245,13 @@ class TestInjuryService:
"""Test clearing an injury.""" """Test clearing an injury."""
with patch('api.client.get_config', return_value=mock_config): with patch('api.client.get_config', return_value=mock_config):
with aioresponses() as m: 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 = sample_injury_data.copy()
cleared_data['is_active'] = False cleared_data['is_active'] = False
m.patch( m.patch(
'https://api.example.com/v3/injuries/1', 'https://api.example.com/v3/injuries/1?is_active=False',
payload=cleared_data payload=cleared_data
) )
@ -263,8 +264,9 @@ class TestInjuryService:
"""Test clearing injury when it fails.""" """Test clearing injury when it fails."""
with patch('api.client.get_config', return_value=mock_config): with patch('api.client.get_config', return_value=mock_config):
with aioresponses() as m: with aioresponses() as m:
# Note: Python's str(False) converts to "False" (capital F)
m.patch( m.patch(
'https://api.example.com/v3/injuries/1', 'https://api.example.com/v3/injuries/1?is_active=False',
status=500 status=500
) )

View File

@ -119,3 +119,55 @@ def format_key_plays(
key_plays_text += f"{play_description}\n" key_plays_text += f"{play_description}\n"
return key_plays_text 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

207
utils/scorebug_helpers.py Normal file
View File

@ -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}%'