major-domo-v2/tasks/live_scorebug_tracker.py
Cal Corum 8907841ec6 CLAUDE: Refactor scorebug display and add dynamic channel visibility
- 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 <noreply@anthropic.com>
2025-10-22 16:58:21 -05:00

286 lines
10 KiB
Python

"""
Live Scorebug Tracker
Background task that monitors published scorecards and updates live score displays.
"""
import asyncio
from typing import List, Optional
import discord
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
from commands.voice.tracker import VoiceChannelTracker
from views.embeds import EmbedTemplate, EmbedColors
from config import get_config
from exceptions import SheetsException
class LiveScorebugTracker:
"""
Manages live scorebug updates for active games.
Features:
- Updates live scores channel every 3 minutes
- Updates voice channel descriptions with live scores
- Clears displays when no active games
- Error resilient with graceful degradation
"""
def __init__(self, bot: commands.Bot):
"""
Initialize the live scorebug tracker.
Args:
bot: Discord bot instance
"""
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.LiveScorebugTracker')
self.scorebug_service = ScorebugService()
self.scorecard_tracker = ScorecardTracker()
self.voice_tracker = VoiceChannelTracker()
# Start the monitoring loop
self.update_loop.start()
self.logger.info("Live scorebug tracker initialized")
def cog_unload(self):
"""Stop the task when service is unloaded."""
self.update_loop.cancel()
self.logger.info("Live scorebug tracker stopped")
@tasks.loop(minutes=3)
async def update_loop(self):
"""
Main update loop - runs every 3 minutes.
Updates:
- Live scores channel with all active scorebugs
- Voice channel descriptions with live scores
"""
try:
await self._update_scorebugs()
except Exception as e:
self.logger.error(f"Error in scorebug update loop: {e}", exc_info=True)
@update_loop.before_loop
async def before_update_loop(self):
"""Wait for bot to be ready before starting."""
await self.bot.wait_until_ready()
self.logger.info("Live scorebug tracker ready to start monitoring")
async def _update_scorebugs(self):
"""Update all scorebug displays."""
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
self.logger.warning(f"Guild {config.guild_id} not found, skipping update")
return
# Get live scores channel
live_scores_channel = discord.utils.get(guild.text_channels, name='live-sba-scores')
if not live_scores_channel:
self.logger.warning("live-sba-scores channel not found, skipping channel update")
# Don't return - still update voice channels
else:
# Get all published scorecards
all_scorecards = self.scorecard_tracker.get_all_scorecards()
if not all_scorecards:
# 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
active_scorebugs = []
for text_channel_id, sheet_url in all_scorecards:
try:
scorebug_data = await self.scorebug_service.read_scorebug_data(
sheet_url,
full_length=False # Compact view for live channel
)
# Only include active (non-final) games
if scorebug_data.is_active:
# Get team data
away_team = await team_service.get_team(scorebug_data.away_team_id)
home_team = await team_service.get_team(scorebug_data.home_team_id)
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 using shared utility
embed = create_scorebug_embed(
scorebug_data,
away_team,
home_team,
full_length=False # Compact view for live channel
)
active_scorebugs.append(embed)
# Update associated voice channel if it exists
await self._update_voice_channel_description(
text_channel_id,
scorebug_data,
away_team,
home_team
)
await asyncio.sleep(1) # Rate limit between reads
except SheetsException as e:
self.logger.warning(f"Could not read scorecard {sheet_url}: {e}")
except Exception as e:
self.logger.error(f"Error processing scorecard {sheet_url}: {e}")
# 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 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"
)
async def _post_scorebugs_to_channel(
self,
channel: discord.TextChannel,
embeds: List[discord.Embed]
):
"""
Post scorebugs to the live scores channel.
Args:
channel: Discord text channel
embeds: List of scorebug embeds
"""
try:
# Clear old messages
async for message in channel.history(limit=25):
await message.delete()
# Post new scorebugs (Discord allows up to 10 embeds per message)
if len(embeds) <= 10:
await channel.send(embeds=embeds)
else:
# Split into multiple messages if more than 10 embeds
for i in range(0, len(embeds), 10):
batch = embeds[i:i+10]
await channel.send(embeds=batch)
self.logger.info(f"Posted {len(embeds)} scorebugs to live-sba-scores")
except discord.Forbidden:
self.logger.error("Missing permissions to update live-sba-scores channel")
except Exception as e:
self.logger.error(f"Error posting scorebugs: {e}")
async def _clear_live_scores_channel(self, channel: discord.TextChannel):
"""
Clear the live scores channel when no active games.
Args:
channel: Discord text channel
"""
try:
# Clear all messages
async for message in channel.history(limit=25):
await message.delete()
self.logger.info("Cleared live-sba-scores channel (no active games)")
except discord.Forbidden:
self.logger.error("Missing permissions to clear live-sba-scores channel")
except Exception as e:
self.logger.error(f"Error clearing channel: {e}")
async def _update_voice_channel_description(
self,
text_channel_id: int,
scorebug_data: ScorebugData,
away_team: Team,
home_team: Team
):
"""
Update voice channel description with live score.
Args:
text_channel_id: Text channel ID where scorecard was published
scorebug_data: ScorebugData object
away_team: Away team object (optional)
home_team: Home team object (optional)
"""
try:
# Check if there's an associated voice channel
voice_channel_id = self.voice_tracker.get_voice_channel_for_text_channel(text_channel_id)
if not voice_channel_id:
self.logger.debug(f'No voice channel associated with text channel ID {text_channel_id} (may have been cleaned up)')
return # No associated voice channel
# Get the voice channel
config = get_config()
guild = self.bot.get_guild(config.guild_id)
if not guild:
return
voice_channel = guild.get_channel(voice_channel_id)
if not voice_channel or not isinstance(voice_channel, discord.VoiceChannel):
self.logger.debug(f"Voice channel {voice_channel_id} not found or wrong type")
return
# Format description: "BOS 4 @ 3 NYY" or "BOS 5 @ 3 NYY - FINAL"
away_abbrev = away_team.abbrev if away_team else "AWAY"
home_abbrev = home_team.abbrev if home_team else "HOME"
if scorebug_data.is_final:
description = f"{away_abbrev} {scorebug_data.away_score} @ {scorebug_data.home_score} {home_abbrev} - FINAL"
else:
description = f"{away_abbrev} {scorebug_data.away_score} @ {scorebug_data.home_score} {home_abbrev}"
# Update voice channel description (topic)
await voice_channel.edit(status=description)
self.logger.debug(f"Updated voice channel {voice_channel.name} description to: {description}")
except discord.Forbidden:
self.logger.warning(f"Missing permissions to update voice channel {voice_channel_id}")
except Exception as e:
self.logger.error(f"Error updating voice channel description: {e}")
def setup_scorebug_tracker(bot: commands.Bot) -> LiveScorebugTracker:
"""
Setup function to initialize the live scorebug tracker.
Args:
bot: Discord bot instance
Returns:
LiveScorebugTracker instance
"""
return LiveScorebugTracker(bot)