major-domo-v2/tasks/live_scorebug_tracker.py
Cal Corum 6f3339a42e perf: parallelize independent API calls (#90)
Closes #90

Replace sequential awaits with asyncio.gather() in all locations identified
in the issue:

- commands/gameplay/scorebug.py: parallel team lookups in publish_scorecard
  and scorebug commands; also fix missing await on async scorecard_tracker calls
- commands/league/submit_scorecard.py: parallel away/home team lookups
- tasks/live_scorebug_tracker.py: parallel team lookups inside per-scorecard
  loop (compounds across multiple active games); fix missing await on
  get_all_scorecards
- commands/injuries/management.py: parallel get_current_state() +
  search_players() in injury_roll, injury_set_new, and injury_clear
- services/trade_builder.py: parallel per-participant roster validation in
  validate_trade()

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-20 17:48:10 +00:00

305 lines
11 KiB
Python

"""
Live Scorebug Tracker
Background task that monitors published scorecards and updates live score displays.
"""
import asyncio
from typing import List
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 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 = await 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 = []
read_failures = 0
confirmed_final = 0
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, home_team = await asyncio.gather(
team_service.get_team(scorebug_data.away_team_id),
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
)
else:
confirmed_final += 1
await asyncio.sleep(1) # Rate limit between reads
except SheetsException as e:
read_failures += 1
self.logger.warning(f"Could not read scorecard {sheet_url}: {e}")
except Exception as e:
read_failures += 1
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
)
elif read_failures > 0 and confirmed_final < len(all_scorecards):
# Some reads failed — don't hide the channel, preserve last state
self.logger.warning(
f"Skipping channel hide: {read_failures} scorecard read(s) failed, "
f"only {confirmed_final}/{len(all_scorecards)} confirmed final"
)
else:
# All games confirmed final — safe to clear and hide
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 using bulk delete
await channel.purge(limit=25)
# 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 using bulk delete
await channel.purge(limit=25)
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)