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:
parent
77d6ca2bb5
commit
5616cfec3a
14
bot.py
14
bot.py
@ -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
317
commands/gameplay/CLAUDE.md
Normal 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
|
||||
50
commands/gameplay/__init__.py
Normal file
50
commands/gameplay/__init__.py
Normal 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']
|
||||
358
commands/gameplay/scorebug.py
Normal file
358
commands/gameplay/scorebug.py
Normal 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))
|
||||
177
commands/gameplay/scorecard_tracker.py
Normal file
177
commands/gameplay/scorecard_tracker.py
Normal 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)
|
||||
@ -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
|
||||
|
||||
@ -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")
|
||||
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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.
|
||||
|
||||
191
services/scorebug_service.py
Normal file
191
services/scorebug_service.py
Normal 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)}")
|
||||
124
tasks/CLAUDE.md
124
tasks/CLAUDE.md
@ -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
|
||||
|
||||
|
||||
323
tasks/live_scorebug_tracker.py
Normal file
323
tasks/live_scorebug_tracker.py
Normal 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)
|
||||
@ -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."""
|
||||
|
||||
Loading…
Reference in New Issue
Block a user