CLAUDE: Add automatic scorecard unpublishing when voice channels are cleaned up

This enhancement automatically unpublishes scorecards when their associated
voice channels are deleted by the cleanup service, ensuring data synchronization
and reducing unnecessary API calls to Google Sheets for inactive games.

Implementation:
- Added gameplay commands package with scorebug/scorecard functionality
- Created ScorebugService for reading live game data from Google Sheets
- VoiceChannelTracker now stores text_channel_id for voice-to-text association
- VoiceChannelCleanupService integrates ScorecardTracker for automatic cleanup
- LiveScorebugTracker monitors published scorecards and updates displays
- Bot initialization includes gameplay commands and live scorebug tracker

Key Features:
- Voice channels track associated text channel IDs
- cleanup_channel() unpublishes scorecards during normal cleanup
- verify_tracked_channels() unpublishes scorecards for stale entries on startup
- get_voice_channel_for_text_channel() enables reverse lookup
- LiveScorebugTracker logging improved (debug level for missing channels)

Testing:
- Added comprehensive test coverage (2 new tests, 19 total pass)
- Tests verify scorecard unpublishing in cleanup and verification scenarios

Documentation:
- Updated commands/voice/CLAUDE.md with scorecard cleanup integration
- Updated commands/gameplay/CLAUDE.md with background task integration
- Updated tasks/CLAUDE.md with automatic cleanup details

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Cal Corum 2025-10-21 07:08:36 -05:00
parent 77d6ca2bb5
commit 5616cfec3a
13 changed files with 1749 additions and 8 deletions

14
bot.py
View File

@ -121,6 +121,7 @@ class SBABot(commands.Bot):
from commands.profile import setup_profile_commands
from commands.soak import setup_soak
from commands.injuries import setup_injuries
from commands.gameplay import setup_gameplay
# Define command packages to load
command_packages = [
@ -137,6 +138,7 @@ class SBABot(commands.Bot):
("profile", setup_profile_commands),
("soak", setup_soak),
("injuries", setup_injuries),
("gameplay", setup_gameplay),
]
total_successful = 0
@ -187,6 +189,11 @@ class SBABot(commands.Bot):
asyncio.create_task(self.voice_cleanup_service.start_monitoring(self))
self.logger.info("✅ Voice channel cleanup service started")
# Initialize live scorebug tracker
from tasks.live_scorebug_tracker import setup_scorebug_tracker
self.live_scorebug_tracker = setup_scorebug_tracker(self)
self.logger.info("✅ Live scorebug tracker started")
self.logger.info("✅ Background tasks initialized successfully")
except Exception as e:
@ -341,6 +348,13 @@ class SBABot(commands.Bot):
except Exception as e:
self.logger.error(f"Error stopping voice cleanup service: {e}")
if hasattr(self, 'live_scorebug_tracker'):
try:
self.live_scorebug_tracker.update_loop.cancel()
self.logger.info("Live scorebug tracker stopped")
except Exception as e:
self.logger.error(f"Error stopping live scorebug tracker: {e}")
# Call parent close method
await super().close()
self.logger.info("Bot shutdown complete")

317
commands/gameplay/CLAUDE.md Normal file
View File

@ -0,0 +1,317 @@
# Gameplay Commands
This directory contains Discord slash commands for live game tracking and scorecard management during gameplay.
## Files
### `scorebug.py`
- **Commands**:
- `/publish-scorecard <url>` - Link a Google Sheets scorecard to a channel for live tracking
- `/scorebug [full_length]` - Display the current scorebug from the published scorecard
- **Description**: Main command implementation for scorebug display and management
- **Service Dependencies**:
- `ScorebugService` - Reading live game data from Google Sheets
- `team_service.get_team()` - Team information lookup
- **Tracker Dependencies**:
- `ScorecardTracker` - JSON-based persistent storage of scorecard-channel mappings
### `scorecard_tracker.py`
- **Class**: `ScorecardTracker`
- **Description**: JSON-based persistent tracking of published scorecards
- **Features**:
- Maps Discord text channels to Google Sheets URLs
- Persistent storage across bot restarts
- Automatic stale entry cleanup
- Timestamp tracking for monitoring
### `__init__.py`
- **Function**: `setup_gameplay(bot)`
- **Description**: Package initialization with resilient cog loading
- **Integration**: Follows established bot architecture patterns
## Background Integration
### Live Scorebug Tracker Task
**File**: `tasks/live_scorebug_tracker.py`
**Schedule**: Every 3 minutes
**Operations**:
1. **Update `#live-sba-scores` Channel**
- Reads all published scorecards from tracker
- Generates compact scorebug embeds for active games
- Clears old messages and posts fresh scorebugs
- Filters out final games (only shows active/in-progress)
2. **Update Voice Channel Descriptions**
- For each active scorecard, checks for associated voice channel
- Updates voice channel topic with live score: `"BOS 4 @ 3 NYY"`
- Adds "FINAL" suffix when game completes: `"BOS 5 @ 3 NYY - FINAL"`
- Gracefully handles missing or deleted voice channels
## Key Features
### `/publish-scorecard <url>`
**URL/Key Support**:
- Full URL: `https://docs.google.com/spreadsheets/d/[KEY]/edit...`
- Just the key: `[SHEET_KEY]`
**Validation**:
- Checks scorecard accessibility (public read permissions)
- Verifies scorecard has required 'Scorebug' tab
- Tests data reading to ensure valid scorecard structure
**Storage**:
- Saves mapping in `data/scorecards.json`
- Persists across bot restarts
- Associates scorecard with text channel ID
**User Feedback**:
- Confirmation message with sheet title
- Usage instructions for `/scorebug` command
- Clear error messages for access issues
### `/scorebug [full_length]`
**Display Modes**:
- `full_length=True` (default): Complete scorebug with runners, matchups, and summary
- `full_length=False`: Compact view with just score and status
**Data Display**:
- Game header and inning information
- Score formatted in table
- Current game status (inning/half)
- Runners on base with positions
- Current matchups (optional)
- Game summary (optional)
- Team colors and thumbnails
**Error Handling**:
- Clear message if no scorecard published in channel
- Helpful errors for access or read failures
- Graceful handling of missing team data
### Live Score Updates (Background Task)
**Channel Updates**:
- Clears `#live-sba-scores` channel before each update
- Posts up to 10 scorebugs per message (Discord limit)
- Splits into multiple messages if needed
- Shows only active games (filters out finals)
**Voice Channel Integration**:
- Looks up voice channel associated with scorecard's text channel
- Updates voice channel `topic` with formatted score
- Format: `"{AWAY_ABBREV} {AWAY_SCORE} @ {HOME_SCORE} {HOME_ABBREV}"`
- Adds "- FINAL" when game completes
- Rate limits to avoid Discord API issues
## Architecture
### Service Layer Integration
**ScorebugService** (`services/scorebug_service.py`):
- Extends `SheetsService` for Google Sheets access
- Returns `ScorebugData` objects with parsed game information
- Supports both URL and key-based scorecard access
- Reads from 'Scorebug' tab (B2:S20) for game state
- Reads team IDs from 'Setup' tab (B5:B6)
**ScorebugData Fields**:
```python
{
'away_team_id': int,
'home_team_id': int,
'header': str, # Game header with inning info
'away_score': int,
'home_score': int,
'which_half': str, # Top/Bottom inning indicator
'is_final': bool,
'runners': list, # Runner info [position, name] pairs
'matchups': list, # Current batter/pitcher matchups
'summary': list # Game summary data
}
```
### Tracker Integration
**ScorecardTracker** stores:
```json
{
"scorecards": {
"123456789": {
"text_channel_id": "123456789",
"sheet_url": "https://docs.google.com/...",
"published_at": "2025-01-15T10:30:00",
"last_updated": "2025-01-15T10:35:00",
"publisher_id": "111222333"
}
}
}
```
**Voice Channel Association**:
- Voice tracker updated to store `text_channel_id` when voice channels created
- New method: `get_voice_channel_for_text_channel(text_channel_id)`
- Enables background task to update voice channel descriptions
### Command Flow
**Publishing Flow**:
1. User runs `/publish-scorecard <url>`
2. Bot validates access to Google Sheet
3. Bot verifies Scorebug tab exists
4. Bot reads sample data to ensure valid structure
5. Tracker stores text_channel_id → sheet_url mapping
6. User receives confirmation message
**Display Flow**:
1. User runs `/scorebug` in channel
2. Bot looks up scorecard URL from tracker
3. Bot reads current scorebug data from sheet
4. Bot fetches team information from API
5. Bot creates rich embed with game state
6. Bot updates tracker timestamp
**Background Update Flow**:
1. Task runs every 3 minutes
2. Reads all published scorecards from tracker
3. For each scorecard:
- Reads current scorebug data
- Checks if game is active (not final)
- Creates compact embed for live channel
- Checks for associated voice channel
- Updates voice channel description if found
4. Posts all active scorebugs to `#live-sba-scores`
5. Clears channel if no active games
## Configuration
### Channel Requirements
- **`#live-sba-scores`** - Live scorebug display channel (auto-updated every 3 minutes)
### Data Storage
- **`data/scorecards.json`** - Published scorecard mappings
- **`data/voice_channels.json`** - Voice channel tracking (includes text_channel_id)
### Google Sheets Requirements
Scorecards must have:
- **Scorebug tab**: Live game data (B2:S20)
- **Setup tab**: Team IDs (B5:B6)
- **Public read access**: "Anyone with the link can view"
## Error Handling
### Common Scenarios
- **Sheet not accessible**: Clear message about public permissions
- **Missing Scorebug tab**: Error indicating invalid scorecard structure
- **No scorecard published**: Helpful message to use `/publish-scorecard`
- **Sheet read failures**: Graceful degradation with retry suggestions
- **Voice channel deleted**: Silent skip (no errors to users)
- **Missing permissions**: Clear permission error messages
### Service Dependencies
- **Graceful degradation**: Commands work without background task
- **Rate limiting**: 1-second delay between scorecard reads
- **API failures**: Comprehensive error handling for external service calls
- **Discord errors**: Specific handling for Forbidden, NotFound, etc.
## Voice Channel Enhancement
### Text Channel Association
When voice channels are created via `/voice-channel`:
- Text channel ID stored in voice channel tracking data
- Enables scorebug → voice channel lookup
- Persistent across bot restarts
### Description Update Format
**Active Game**:
```
BOS 4 @ 3 NYY
```
**Final Game**:
```
BOS 5 @ 3 NYY - FINAL
```
**Implementation**:
- Uses voice channel `topic` field (description)
- Updates every 3 minutes with live scores
- Automatic cleanup when game ends
- No manual user interaction required
## Integration Points
### Bot Integration
- **Package Loading**: Integrated into `bot.py` command package loading sequence
- **Background Tasks**: Live scorebug tracker started in `_setup_background_tasks()`
- **Shutdown Handling**: Tracker stopped in `bot.close()`
### Service Layer
- **ScorebugService**: Google Sheets data extraction
- **TeamService**: Team information and logo lookups
- **ScorecardTracker**: Persistent scorecard-channel mapping
### Discord Integration
- **Application Commands**: Modern slash command interface
- **Embed Templates**: Consistent styling using `EmbedTemplate`
- **Error Handling**: Integration with global application command error handler
- **Voice Channels**: Bi-directional integration with voice channel system
## Usage Examples
### Publishing a Scorecard
```
/publish-scorecard https://docs.google.com/spreadsheets/d/ABC123/edit
```
**Result**: Scorecard linked to current channel for live tracking
### Using Just Sheet Key
```
/publish-scorecard ABC123DEF456
```
**Result**: Same functionality with cleaner input
### Displaying Scorebug
```
/scorebug
```
**Result**: Full scorebug display with all details
### Compact Scorebug
```
/scorebug full_length:False
```
**Result**: Just score and status, no runners/matchups/summary
## Monitoring and Logs
### Structured Logging
```python
self.logger.info(f"Published scorecard to channel {text_channel_id}: {sheet_url}")
self.logger.debug(f"Updated voice channel {voice_channel.name} description to: {description}")
self.logger.warning(f"Could not read scorecard {sheet_url}: {e}")
```
### Performance Tracking
- Background task execution timing
- Google Sheets read latency
- Voice channel update success rates
- Scorecard access failure rates
## Future Enhancements
### Potential Features
- **Scorecard rotation**: Multiple scorecard support per channel
- **Custom refresh intervals**: User-configurable update frequency
- **Notification system**: Alerts for game events (runs, innings, etc.)
- **Statistics tracking**: Historical scorebug access patterns
- **Mobile optimization**: Compact embeds for mobile viewing
### Configuration Options
- **Per-channel settings**: Different update intervals per channel
- **Role permissions**: Restrict scorecard publishing to specific roles
- **Format customization**: User-selectable scorebug styles
- **Alert thresholds**: Configurable notification triggers
---
**Last Updated**: January 2025
**Architecture**: Modern async Discord.py with Google Sheets integration
**Dependencies**: discord.py, pygsheets, ScorebugService, ScorecardTracker, VoiceChannelTracker

View File

@ -0,0 +1,50 @@
"""
Gameplay Commands Package
This package contains commands for live game tracking and scorecard management.
"""
import logging
from discord.ext import commands
from .scorebug import ScorebugCommands
logger = logging.getLogger(__name__)
async def setup_gameplay(bot: commands.Bot):
"""
Setup all gameplay command modules.
Returns:
tuple: (successful_count, failed_count, failed_modules)
"""
# Define all gameplay command cogs to load
gameplay_cogs = [
("ScorebugCommands", ScorebugCommands),
]
successful = 0
failed = 0
failed_modules = []
for cog_name, cog_class in gameplay_cogs:
try:
await bot.add_cog(cog_class(bot))
logger.info(f"✅ Loaded {cog_name}")
successful += 1
except Exception as e:
logger.error(f"❌ Failed to load {cog_name}: {e}", exc_info=True)
failed += 1
failed_modules.append(cog_name)
# Log summary
if failed == 0:
logger.info(f"🎉 All {successful} gameplay command modules loaded successfully")
else:
logger.warning(f"⚠️ Gameplay commands loaded with issues: {successful} successful, {failed} failed")
return successful, failed, failed_modules
# Export the setup function for easy importing
__all__ = ['setup_gameplay', 'ScorebugCommands']

View File

@ -0,0 +1,358 @@
"""
Scorebug Commands
Implements commands for publishing and displaying live game scorebugs from Google Sheets scorecards.
"""
import discord
from discord.ext import commands
from discord import app_commands
from services.scorebug_service import ScorebugService
from services.team_service import team_service
from utils.logging import get_contextual_logger
from utils.decorators import logged_command
from views.embeds import EmbedTemplate, EmbedColors
from exceptions import SheetsException
from .scorecard_tracker import ScorecardTracker
class ScorebugCommands(commands.Cog):
"""Scorebug command handlers for live game tracking."""
def __init__(self, bot: commands.Bot):
self.bot = bot
self.logger = get_contextual_logger(f'{__name__}.ScorebugCommands')
self.scorebug_service = ScorebugService()
self.scorecard_tracker = ScorecardTracker()
self.logger.info("ScorebugCommands cog initialized")
@app_commands.command(
name="publish-scorecard",
description="Publish a Google Sheets scorecard to this channel for live tracking"
)
@app_commands.describe(
url="Full URL to the Google Sheets scorecard or just the sheet key"
)
@logged_command("/publish-scorecard")
async def publish_scorecard(
self,
interaction: discord.Interaction,
url: str
):
"""
Link a Google Sheets scorecard to the current channel for live scorebug tracking.
The scorecard will be monitored for live score updates which will be displayed
in the live scores channel and optionally in associated voice channels.
"""
await interaction.response.defer()
try:
# Validate access to the scorecard
await interaction.edit_original_response(
content="📋 Accessing scorecard..."
)
# Try to open the scorecard to validate it
scorecard = await self.scorebug_service.open_scorecard(url)
# Verify it has a Scorebug tab
try:
scorebug_data = await self.scorebug_service.read_scorebug_data(url, full_length=False)
except SheetsException:
embed = EmbedTemplate.error(
title="Invalid Scorecard",
description=(
"This doesn't appear to be a valid scorecard.\n\n"
"Make sure the sheet has a 'Scorebug' tab and is properly set up."
)
)
await interaction.edit_original_response(content=None, embed=embed)
return
# Get team data for display
away_team = None
home_team = None
if scorebug_data.away_team_id:
away_team = await team_service.get_team(scorebug_data.away_team_id)
if scorebug_data.home_team_id:
home_team = await team_service.get_team(scorebug_data.home_team_id)
# Format scorecard link
away_abbrev = away_team.abbrev if away_team else "AWAY"
home_abbrev = home_team.abbrev if home_team else "HOME"
scorecard_link = f"[{away_abbrev} @ {home_abbrev}]({url})"
# Store the scorecard in the tracker
self.scorecard_tracker.publish_scorecard(
text_channel_id=interaction.channel_id,
sheet_url=url,
publisher_id=interaction.user.id
)
# Create success embed
embed = EmbedTemplate.success(
title="Scorecard Published",
description=(
f"Your scorecard has been published to {interaction.channel.mention}\n\n"
f"**Sheet:** {scorecard.title}\n"
f"**Status:** Live tracking enabled\n"
f"**Scorecard:** {scorecard_link}\n\n"
f"Anyone can now run `/scorebug` in this channel to see the current score.\n"
f"The scorebug will also update in the live scores channel every 3 minutes."
)
)
embed.add_field(
name="Commands",
value=(
"`/scorebug` - Display full scorebug with details\n"
"`/scorebug full_length:False` - Display compact scorebug"
),
inline=False
)
await interaction.edit_original_response(content=None, embed=embed)
except SheetsException as e:
embed = EmbedTemplate.error(
title="Cannot Access Scorecard",
description=(
f"{str(e)}\n\n"
f"**Common issues:**\n"
f"• Sheet is not publicly accessible\n"
f"• Invalid sheet URL or key\n"
f"• Sheet doesn't exist\n\n"
f"Make sure your sheet is shared with 'Anyone with the link can view'."
)
)
await interaction.edit_original_response(content=None, embed=embed)
except Exception as e:
self.logger.error(f"Error publishing scorecard: {e}", exc_info=True)
embed = EmbedTemplate.error(
title="Publication Failed",
description=(
"❌ An unexpected error occurred while publishing the scorecard.\n\n"
"Please try again or contact support if the issue persists."
)
)
await interaction.edit_original_response(content=None, embed=embed)
@app_commands.command(
name="scorebug",
description="Display the scorebug for the game in this channel"
)
@app_commands.describe(
full_length="Include full game details (defaults to True)"
)
@logged_command("/scorebug")
async def scorebug(
self,
interaction: discord.Interaction,
full_length: bool = True
):
"""
Display the current scorebug from the scorecard published in this channel.
"""
await interaction.response.defer()
# Check if a scorecard is published in this channel
sheet_url = self.scorecard_tracker.get_scorecard(interaction.channel_id)
if not sheet_url:
embed = EmbedTemplate.error(
title="No Scorecard Published",
description=(
"❌ No scorecard has been published in this channel.\n\n"
"Use `/publish-scorecard <url>` to publish a scorecard first."
)
)
await interaction.followup.send(embed=embed, ephemeral=True)
return
try:
# Read scorebug data
await interaction.edit_original_response(
content="📊 Reading scorebug..."
)
scorebug_data = await self.scorebug_service.read_scorebug_data(
sheet_url,
full_length=full_length
)
# Get team data
away_team = None
home_team = None
if scorebug_data.away_team_id:
away_team = await team_service.get_team(scorebug_data.away_team_id)
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(
scorebug_data,
away_team,
home_team,
full_length
)
await interaction.edit_original_response(content=None, embed=embed)
# Update timestamp in tracker
self.scorecard_tracker.update_timestamp(interaction.channel_id)
except SheetsException as e:
embed = EmbedTemplate.error(
title="Cannot Read Scorebug",
description=(
f"{str(e)}\n\n"
f"The scorecard may have been deleted or the sheet structure changed."
)
)
await interaction.edit_original_response(content=None, embed=embed)
except Exception as e:
self.logger.error(f"Error displaying scorebug: {e}", exc_info=True)
embed = EmbedTemplate.error(
title="Display Failed",
description=(
"❌ An error occurred while reading the scorebug.\n\n"
"Please try again or republish the scorecard."
)
)
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."""
await bot.add_cog(ScorebugCommands(bot))

View File

@ -0,0 +1,177 @@
"""
Scorecard Tracker
Provides persistent tracking of published scorecards per Discord text channel using JSON file storage.
"""
import json
import logging
from datetime import datetime, UTC
from pathlib import Path
from typing import Dict, List, Optional, Tuple
logger = logging.getLogger(f'{__name__}.ScorecardTracker')
class ScorecardTracker:
"""
Tracks published Google Sheets scorecards linked to Discord text channels.
Features:
- Persistent storage across bot restarts
- Channel-to-scorecard URL mapping
- Automatic stale entry cleanup
- Timestamp tracking for monitoring
"""
def __init__(self, data_file: str = "data/scorecards.json"):
"""
Initialize the scorecard tracker.
Args:
data_file: Path to the JSON data file
"""
self.data_file = Path(data_file)
self.data_file.parent.mkdir(exist_ok=True)
self._data: Dict[str, any] = {}
self.load_data()
def load_data(self) -> None:
"""Load scorecard data from JSON file."""
try:
if self.data_file.exists():
with open(self.data_file, 'r') as f:
self._data = json.load(f)
logger.debug(f"Loaded {len(self._data.get('scorecards', {}))} tracked scorecards")
else:
self._data = {"scorecards": {}}
logger.info("No existing scorecard data found, starting fresh")
except Exception as e:
logger.error(f"Failed to load scorecard data: {e}")
self._data = {"scorecards": {}}
def save_data(self) -> None:
"""Save scorecard data to JSON file."""
try:
with open(self.data_file, 'w') as f:
json.dump(self._data, f, indent=2, default=str)
logger.debug("Scorecard data saved successfully")
except Exception as e:
logger.error(f"Failed to save scorecard data: {e}")
def publish_scorecard(
self,
text_channel_id: int,
sheet_url: str,
publisher_id: int
) -> None:
"""
Link a scorecard to a text channel.
Args:
text_channel_id: Discord text channel ID
sheet_url: Google Sheets URL or key
publisher_id: Discord user ID who published the scorecard
"""
self._data.setdefault("scorecards", {})[str(text_channel_id)] = {
"text_channel_id": str(text_channel_id),
"sheet_url": sheet_url,
"published_at": datetime.now(UTC).isoformat(),
"last_updated": datetime.now(UTC).isoformat(),
"publisher_id": str(publisher_id)
}
self.save_data()
logger.info(f"Published scorecard to channel {text_channel_id}: {sheet_url}")
def unpublish_scorecard(self, text_channel_id: int) -> bool:
"""
Remove scorecard from a text channel.
Args:
text_channel_id: Discord text channel ID
Returns:
True if scorecard was removed, False if not found
"""
scorecards = self._data.get("scorecards", {})
channel_key = str(text_channel_id)
if channel_key in scorecards:
del scorecards[channel_key]
self.save_data()
logger.info(f"Unpublished scorecard from channel {text_channel_id}")
return True
return False
def get_scorecard(self, text_channel_id: int) -> Optional[str]:
"""
Get scorecard URL for a text channel.
Args:
text_channel_id: Discord text channel ID
Returns:
Sheet URL if published, None otherwise
"""
scorecards = self._data.get("scorecards", {})
scorecard_data = scorecards.get(str(text_channel_id))
return scorecard_data["sheet_url"] if scorecard_data else None
def get_all_scorecards(self) -> List[Tuple[int, str]]:
"""
Get all published scorecards.
Returns:
List of (text_channel_id, sheet_url) tuples
"""
scorecards = self._data.get("scorecards", {})
return [
(int(channel_id), data["sheet_url"])
for channel_id, data in scorecards.items()
]
def update_timestamp(self, text_channel_id: int) -> None:
"""
Update the last_updated timestamp for a scorecard.
Args:
text_channel_id: Discord text channel ID
"""
scorecards = self._data.get("scorecards", {})
channel_key = str(text_channel_id)
if channel_key in scorecards:
scorecards[channel_key]["last_updated"] = datetime.now(UTC).isoformat()
self.save_data()
def cleanup_stale_entries(self, valid_channel_ids: List[int]) -> int:
"""
Remove tracking entries for text channels that no longer exist.
Args:
valid_channel_ids: List of channel IDs that still exist in Discord
Returns:
Number of stale entries removed
"""
scorecards = self._data.get("scorecards", {})
stale_entries = []
for channel_id_str in scorecards.keys():
try:
channel_id = int(channel_id_str)
if channel_id not in valid_channel_ids:
stale_entries.append(channel_id_str)
except (ValueError, TypeError):
logger.warning(f"Invalid channel ID in scorecard data: {channel_id_str}")
stale_entries.append(channel_id_str)
# Remove stale entries
for channel_id_str in stale_entries:
del scorecards[channel_id_str]
logger.info(f"Removed stale scorecard entry for channel ID: {channel_id_str}")
if stale_entries:
self.save_data()
return len(stale_entries)

View File

@ -65,6 +65,7 @@ This directory contains Discord slash commands for creating and managing voice c
- **Restart Resilience**: JSON file persistence survives bot restarts
- **Startup Verification**: Validates tracked channels still exist on bot startup
- **Graceful Error Handling**: Continues operation even if individual operations fail
- **Scorecard Cleanup**: Automatically unpublishes scorecards when associated voice channels are deleted
## Architecture
@ -123,6 +124,29 @@ if hasattr(self.bot, 'voice_cleanup_service'):
cleanup_service.tracker.add_channel(channel, channel_type, interaction.user.id)
```
### Scorecard Cleanup Integration
When a voice channel is cleaned up (deleted after being empty for the configured threshold), the cleanup service automatically unpublishes any scorecard associated with that voice channel's text channel. This prevents the live scorebug tracker from continuing to update scores for games that no longer have active voice channels.
**Cleanup Flow**:
1. Voice channel becomes empty and exceeds empty threshold
2. Cleanup service deletes the voice channel
3. Service checks if voice channel has associated `text_channel_id`
4. If found, unpublishes scorecard from that text channel
5. Live scorebug tracker stops updating that scorecard
**Integration Points**:
- `cleanup_service.py` imports `ScorecardTracker` from `commands.gameplay.scorecard_tracker`
- Scorecard unpublishing happens in three scenarios:
- Normal cleanup (channel deleted after being empty)
- Stale channel cleanup (channel already deleted externally)
- Startup verification (channel no longer exists when bot starts)
**Logging**:
```
✅ Cleaned up empty voice channel: Gameplay Phoenix (ID: 123456789)
📋 Unpublished scorecard from text channel 987654321 (voice channel cleanup)
```
### JSON Data Structure
```json
{
@ -135,12 +159,15 @@ if hasattr(self.bot, 'voice_cleanup_service'):
"created_at": "2025-01-15T10:30:00",
"last_checked": "2025-01-15T10:35:00",
"empty_since": "2025-01-15T10:32:00",
"creator_id": "111222333"
"creator_id": "111222333",
"text_channel_id": "555666777"
}
}
}
```
**Note**: The `text_channel_id` field links the voice channel to its associated text channel, enabling automatic scorecard cleanup when the voice channel is deleted.
## Configuration
### Cleanup Service Settings

View File

@ -112,10 +112,17 @@ class VoiceChannelCommands(commands.Cog):
category=voice_category
)
# Add to cleanup service tracking
# Add to cleanup service tracking with text channel association
if hasattr(self.bot, 'voice_cleanup_service'):
cleanup_service = self.bot.voice_cleanup_service # type: ignore[attr-defined]
cleanup_service.tracker.add_channel(channel, channel_type, interaction.user.id)
self.logger.info(f"Adding voice channel {channel.name} (ID: {channel.id}) to tracking with text channel {interaction.channel_id}")
cleanup_service.tracker.add_channel(
channel,
channel_type,
interaction.user.id,
text_channel_id=interaction.channel_id # Associate with text channel
)
self.logger.info(f"Successfully added voice channel to tracking")
else:
self.logger.warning("Voice cleanup service not available, channel won't be tracked")

View File

@ -10,6 +10,7 @@ import discord
from discord.ext import commands
from .tracker import VoiceChannelTracker
from commands.gameplay.scorecard_tracker import ScorecardTracker
logger = logging.getLogger(f'{__name__}.VoiceChannelCleanupService')
@ -23,6 +24,7 @@ class VoiceChannelCleanupService:
- Automatic empty channel cleanup
- Configurable cleanup intervals and thresholds
- Stale entry removal and recovery
- Automatic scorecard unpublishing when voice channel is cleaned up
"""
def __init__(self, data_file: str = "data/voice_channels.json"):
@ -33,6 +35,7 @@ class VoiceChannelCleanupService:
data_file: Path to the JSON data file for persistence
"""
self.tracker = VoiceChannelTracker(data_file)
self.scorecard_tracker = ScorecardTracker()
self.cleanup_interval = 60 # 5 minutes check interval
self.empty_threshold = 5 # Delete after 15 minutes empty
self._running = False
@ -111,10 +114,22 @@ class VoiceChannelCleanupService:
except (ValueError, TypeError):
pass
# Remove stale entries
# Remove stale entries and unpublish associated scorecards
for channel_id in channels_to_remove:
# Get channel data before removing to access text_channel_id
channel_data = self.tracker.get_tracked_channel(channel_id)
self.tracker.remove_channel(channel_id)
# Unpublish associated scorecard if it exists
if channel_data and channel_data.get("text_channel_id"):
try:
text_channel_id_int = int(channel_data["text_channel_id"])
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
if was_unpublished:
logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)")
except (ValueError, TypeError) as e:
logger.warning(f"Invalid text_channel_id in stale voice channel data: {e}")
# Also clean up any additional stale entries
stale_removed = self.tracker.cleanup_stale_entries(valid_channel_ids)
total_removed = len(channels_to_remove) + stale_removed
@ -238,10 +253,34 @@ class VoiceChannelCleanupService:
logger.info(f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})")
# Unpublish associated scorecard if it exists
text_channel_id = channel_data.get("text_channel_id")
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
if was_unpublished:
logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)")
else:
logger.debug(f"No scorecard found for text channel {text_channel_id_int}")
except (ValueError, TypeError) as e:
logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
except discord.NotFound:
# Channel was already deleted
logger.info(f"Channel {channel_data.get('name', 'unknown')} was already deleted")
self.tracker.remove_channel(int(channel_data["channel_id"]))
# Still try to unpublish associated scorecard
text_channel_id = channel_data.get("text_channel_id")
if text_channel_id:
try:
text_channel_id_int = int(text_channel_id)
was_unpublished = self.scorecard_tracker.unpublish_scorecard(text_channel_id_int)
if was_unpublished:
logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)")
except (ValueError, TypeError) as e:
logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
except discord.Forbidden:
logger.error(f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}")
except Exception as e:

View File

@ -64,7 +64,8 @@ class VoiceChannelTracker:
self,
channel: discord.VoiceChannel,
channel_type: str,
creator_id: int
creator_id: int,
text_channel_id: Optional[int] = None
) -> None:
"""
Add a new channel to tracking.
@ -73,6 +74,7 @@ class VoiceChannelTracker:
channel: Discord voice channel object
channel_type: Type of channel ('public' or 'private')
creator_id: Discord user ID who created the channel
text_channel_id: Optional Discord text channel ID associated with this voice channel
"""
self._data.setdefault("voice_channels", {})[str(channel.id)] = {
"channel_id": str(channel.id),
@ -82,7 +84,8 @@ class VoiceChannelTracker:
"created_at": datetime.now(UTC).isoformat(),
"last_checked": datetime.now(UTC).isoformat(),
"empty_since": None,
"creator_id": str(creator_id)
"creator_id": str(creator_id),
"text_channel_id": str(text_channel_id) if text_channel_id else None
}
self.save_data()
logger.info(f"Added channel to tracking: {channel.name} (ID: {channel.id})")
@ -190,6 +193,29 @@ class VoiceChannelTracker:
channels = self._data.get("voice_channels", {})
return channels.get(str(channel_id))
def get_voice_channel_for_text_channel(self, text_channel_id: int) -> Optional[int]:
"""
Get voice channel ID associated with a text channel.
Args:
text_channel_id: Discord text channel ID
Returns:
Voice channel ID if found, None otherwise
"""
channels = self._data.get("voice_channels", {})
for voice_channel_id_str, channel_data in channels.items():
stored_text_channel_id = channel_data.get("text_channel_id")
if stored_text_channel_id:
try:
if int(stored_text_channel_id) == text_channel_id:
return int(voice_channel_id_str)
except (ValueError, TypeError):
continue
return None
def cleanup_stale_entries(self, valid_channel_ids: List[int]) -> int:
"""
Remove tracking entries for channels that no longer exist.

View File

@ -0,0 +1,191 @@
"""
Scorebug Service
Handles reading live game data from Google Sheets scorecards for real-time score displays.
"""
import asyncio
from typing import Dict, List, Any, Optional
import pygsheets
from utils.logging import get_contextual_logger
from exceptions import SheetsException
from services.sheets_service import SheetsService
class ScorebugData:
"""Data class for scorebug information."""
def __init__(self, data: Dict[str, Any]):
self.away_team_id = data.get('away_team_id', 1)
self.home_team_id = data.get('home_team_id', 1)
self.header = data.get('header', '')
self.away_score = data.get('away_score', 0)
self.home_score = data.get('home_score', 0)
self.which_half = data.get('which_half', '')
self.is_final = data.get('is_final', False)
self.runners = data.get('runners', [])
self.matchups = data.get('matchups', [])
self.summary = data.get('summary', [])
@property
def score_line(self) -> str:
"""Get formatted score line for display."""
return f"{self.away_score} @ {self.home_score}"
@property
def is_active(self) -> bool:
"""Check if game is currently active (not final)."""
return not self.is_final
class ScorebugService(SheetsService):
"""Google Sheets integration for reading live scorebug data."""
def __init__(self, credentials_path: Optional[str] = None):
"""
Initialize scorebug service.
Args:
credentials_path: Path to service account credentials JSON
"""
super().__init__(credentials_path)
self.logger = get_contextual_logger(f'{__name__}.ScorebugService')
async def read_scorebug_data(
self,
sheet_url_or_key: str,
full_length: bool = True
) -> ScorebugData:
"""
Read live scorebug data from Google Sheets scorecard.
Args:
sheet_url_or_key: Full URL or Google Sheets key
full_length: If True, includes summary data; if False, compact view
Returns:
ScorebugData object with game state
Raises:
SheetsException: If scorecard cannot be read
"""
try:
# Open scorecard
scorecard = await self.open_scorecard(sheet_url_or_key)
loop = asyncio.get_event_loop()
# Get Scorebug tab
scorebug_tab = await loop.run_in_executor(
None,
scorecard.worksheet_by_title,
'Scorebug'
)
# Read all data from B2:S20 for efficiency
all_data = await loop.run_in_executor(
None,
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]}")
# Extract game state (B2:G8)
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}")
# 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
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}")
raise ValueError(f'Could not extract team IDs from scorecard')
# Parse game state
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'}")
# Parse scores with validation
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)
except (ValueError, IndexError) as 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)
except (ValueError, IndexError) as e:
self.logger.warning(f"Failed to parse home score: {e}")
home_score = 0
which_half = game_state[3][4] if len(game_state) > 3 and len(game_state[3]) > 4 else ''
self.logger.debug(f"Parsed values - Away: {away_score}, Home: {home_score}, Which Half: '{which_half}'")
# 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 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 []
]
# Extract summary if full_length (Q11:R14 → offset in all_data)
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 []
]
return 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,
'is_final': is_final,
'runners': runners,
'matchups': matchups,
'summary': summary
})
except pygsheets.WorksheetNotFound:
self.logger.error(f"Scorebug tab not found in scorecard")
raise SheetsException("Scorebug tab not found. Is this a valid scorecard?")
except Exception as e:
self.logger.error(f"Failed to read scorebug data: {e}")
raise SheetsException(f"Unable to read scorebug data: {str(e)}")

View File

@ -78,7 +78,7 @@ async def _begin_freeze(self, current: Current):
See `services/CLAUDE.md` for complete service layer best practices.
### Base Task Pattern
All tasks follow a consistent structure:
All tasks follow a consistent structure with **MANDATORY** safe startup:
```python
from discord.ext import tasks
@ -105,12 +105,132 @@ class ExampleTask:
@task_loop.before_loop
async def before_task(self):
"""Wait for bot to be ready before starting."""
"""Wait for bot to be ready before starting - REQUIRED FOR SAFE STARTUP."""
await self.bot.wait_until_ready()
self.logger.info("Bot is ready, task starting")
```
### 🚨 CRITICAL: Safe Startup Pattern
**EVERY background task MUST use the `@task.before_loop` decorator with `await self.bot.wait_until_ready()`.**
This pattern prevents tasks from executing before:
- Discord connection is established
- Bot guilds are fully loaded
- Bot cache is populated
- Service dependencies are available
#### ✅ CORRECT Pattern (Always Use This)
```python
@tasks.loop(minutes=3)
async def my_task_loop(self):
"""Main task logic."""
# Your task code here
pass
@my_task_loop.before_loop
async def before_my_task(self):
"""Wait for bot to be ready before starting - REQUIRED."""
await self.bot.wait_until_ready()
self.logger.info("Bot is ready, my_task starting")
```
#### ❌ WRONG Pattern (Will Cause Errors)
```python
@tasks.loop(minutes=3)
async def my_task_loop(self):
"""Main task logic."""
# Task starts immediately - bot may not be ready!
# This will cause AttributeError, NoneType errors, etc.
pass
# Missing @before_loop - BAD!
```
#### Why This Is Critical
Without the `before_loop` pattern:
- **Guild lookup fails** - `bot.get_guild()` returns `None`
- **Channel lookup fails** - `guild.text_channels` is empty or incomplete
- **Cache errors** - Discord objects not fully populated
- **Service failures** - Dependencies may not be initialized
- **Race conditions** - Task runs before bot state is stable
#### Implementation Checklist
When creating a new task, ensure:
- [ ] `@tasks.loop()` decorator on main loop method
- [ ] `@task.before_loop` decorator on before method
- [ ] `await self.bot.wait_until_ready()` in before method
- [ ] Log message confirming task is ready to start
- [ ] Task started in `__init__()` with `self.task_loop.start()`
- [ ] Task cancelled in `cog_unload()` with `self.task_loop.cancel()`
## Current Tasks
### Live Scorebug Tracker (`live_scorebug_tracker.py`)
**Purpose:** Automated live game score updates for active games
**Schedule:** Every 3 minutes
**Operations:**
- **Live Scores Channel Update:**
- Reads all published scorecards from ScorecardTracker
- Generates compact scorebug embeds for active games
- Clears and updates `#live-sba-scores` channel
- Filters out final games (only shows active/in-progress)
- **Voice Channel Description Update:**
- For each active scorecard, checks for associated voice channel
- Updates voice channel topic with live score (e.g., "BOS 4 @ 3 NYY")
- Adds "- FINAL" suffix when game completes
- Gracefully handles missing or deleted voice channels
#### Key Features
- **Restart Resilience:** Uses JSON-based scorecard tracking
- **Voice Integration:** Bi-directional integration with voice channel system
- **Rate Limiting:** 1-second delay between scorecard reads
- **Error Resilience:** Continues operation despite individual failures
- **Safe Startup:** Uses `@before_loop` pattern with `await bot.wait_until_ready()`
#### Configuration
The tracker respects configuration settings:
```python
# config.py settings
guild_id: int # Target guild for operations
```
**Environment Variables:**
- `GUILD_ID` - Discord server ID
#### Scorecard Publishing
Users publish scorecards via `/publish-scorecard <url>`:
- Validates Google Sheets access and structure
- Stores text_channel_id → sheet_url mapping in JSON
- Persists across bot restarts
#### Voice Channel Association
When voice channels are created:
- Text channel ID stored in voice channel tracking data
- Enables scorebug → voice channel lookup
- Voice channel topic updated every 3 minutes with live scores
**Automatic Cleanup Integration:**
When voice channels are cleaned up (deleted after being empty):
- Voice cleanup service automatically unpublishes the associated scorecard
- Prevents live scorebug tracker from updating scores for games without active voice channels
- Ensures scorecard tracking stays synchronized with voice channel state
- Reduces unnecessary API calls to Google Sheets for inactive games
#### Channel Requirements
- **#live-sba-scores** - Live scorebug display channel
#### Error Handling
- Comprehensive try/catch blocks with structured logging
- Graceful degradation if channels not found
- Silent skip for deleted voice channels
- Prevents duplicate error messages
- Continues operation despite individual scorecard failures
### Transaction Freeze/Thaw (`transaction_freeze.py`)
**Purpose:** Automated weekly system for freezing transactions and processing contested player acquisitions

View File

@ -0,0 +1,323 @@
"""
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 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
await self._clear_live_scores_channel(live_scores_channel)
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
embed = await self._create_compact_scorebug_embed(
scorebug_data,
away_team,
home_team
)
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 self._post_scorebugs_to_channel(live_scores_channel, active_scorebugs)
else:
# All games finished - clear the channel
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
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)

View File

@ -292,6 +292,98 @@ class TestVoiceChannelCleanupService:
# Should have removed from tracking
assert "123" not in cleanup_service.tracker._data["voice_channels"]
@pytest.mark.asyncio
async def test_cleanup_channel_with_scorecard(self, cleanup_service, mock_bot):
"""Test that cleaning up a channel also unpublishes associated scorecard."""
# Mock guild and channel
mock_guild = MagicMock()
mock_guild.id = 999
mock_channel = AsyncMock(spec=discord.VoiceChannel)
mock_channel.id = 123
mock_channel.members = [] # Empty channel
mock_bot.get_guild.return_value = mock_guild
mock_guild.get_channel.return_value = mock_channel
# Add channel to tracking with associated text channel
cleanup_service.tracker._data["voice_channels"]["123"] = {
"channel_id": "123",
"guild_id": "999",
"name": "Test Channel",
"text_channel_id": "555"
}
# Add a scorecard for the text channel
cleanup_service.scorecard_tracker._data = {
"scorecards": {
"555": {
"text_channel_id": "555",
"sheet_url": "https://example.com/sheet",
"published_at": "2025-01-15T10:30:00",
"publisher_id": "12345"
}
}
}
channel_data = {
"channel_id": "123",
"guild_id": "999",
"name": "Test Channel",
"text_channel_id": "555"
}
await cleanup_service.cleanup_channel(mock_bot, channel_data)
# Should have deleted the channel
mock_channel.delete.assert_called_once_with(reason="Automatic cleanup - empty for 15+ minutes")
# Should have removed from voice channel tracking
assert "123" not in cleanup_service.tracker._data["voice_channels"]
# Should have unpublished the scorecard
assert "555" not in cleanup_service.scorecard_tracker._data["scorecards"]
@pytest.mark.asyncio
async def test_verify_tracked_channels_unpublishes_scorecards(self, cleanup_service, mock_bot):
"""Test that verifying tracked channels also unpublishes associated scorecards."""
# Add test data with a stale voice channel that has an associated scorecard
cleanup_service.tracker._data = {
"voice_channels": {
"123": {
"channel_id": "123",
"guild_id": "999",
"name": "Stale Channel",
"text_channel_id": "555"
}
}
}
# Add a scorecard for the text channel
cleanup_service.scorecard_tracker._data = {
"scorecards": {
"555": {
"text_channel_id": "555",
"sheet_url": "https://example.com/sheet",
"published_at": "2025-01-15T10:30:00",
"publisher_id": "12345"
}
}
}
# Mock guild but not the channel (simulating deleted channel)
mock_guild = MagicMock()
mock_guild.id = 999
mock_bot.get_guild.return_value = mock_guild
mock_guild.get_channel.return_value = None # Channel no longer exists
await cleanup_service.verify_tracked_channels(mock_bot)
# Voice channel should be removed from tracking
assert "123" not in cleanup_service.tracker._data["voice_channels"]
# Scorecard should be unpublished
assert "555" not in cleanup_service.scorecard_tracker._data["scorecards"]
class TestVoiceChannelCommands:
"""Test voice channel command functionality."""