major-domo-v2/commands/voice/cleanup_service.py
Cal Corum 8515caaf21 CLAUDE: Implement voice channel management system
Add comprehensive voice channel system for Discord gameplay with:

## New Features
- `/voice-channel public` - Create public voice channels with random codenames
- `/voice-channel private` - Create private team vs team channels with role permissions
- Automatic cleanup after configurable empty duration (default: 5 minutes)
- Restart-resilient JSON persistence for channel tracking
- Background monitoring service with graceful error handling

## Technical Implementation
- **Voice Commands Package** (`commands/voice/`)
  - `channels.py` - Main slash command implementation with modern command groups
  - `cleanup_service.py` - Background service for automatic channel deletion
  - `tracker.py` - JSON-based persistent channel tracking
  - `__init__.py` - Package setup with resilient loading
- **Bot Integration** - Voice cleanup service integrated into bot lifecycle
- **Service Dependencies** - Integration with team, league, and schedule services
- **Permission System** - Team-based Discord role permissions for private channels

## Key Features
- **Public Channels**: Random codenames, open speaking permissions
- **Private Channels**: "{Away} vs {Home}" naming, team role restrictions
- **Auto-cleanup**: Configurable intervals with empty duration thresholds
- **Restart Resilience**: JSON file persistence survives bot restarts
- **Error Handling**: Comprehensive validation and graceful degradation
- **Migration Support**: Deprecated old prefix commands with helpful messages

## Documentation & Testing
- Comprehensive README.md following project patterns
- Full test suite with 15+ test methods covering all scenarios
- Updated CLAUDE.md files with voice command documentation
- Clean IDE diagnostics with proper type safety

## Integration Points
- Team service for user validation and role lookup
- League service for current season/week information
- Schedule service for opponent detection in private channels
- Background task management in bot startup/shutdown

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-09-24 23:17:39 -05:00

275 lines
9.9 KiB
Python

"""
Voice Channel Cleanup Service
Provides automatic cleanup of empty voice channels with restart resilience.
"""
import asyncio
import logging
import discord
from discord.ext import commands
from .tracker import VoiceChannelTracker
logger = logging.getLogger(f'{__name__}.VoiceChannelCleanupService')
class VoiceChannelCleanupService:
"""
Manages automatic cleanup of bot-created voice channels.
Features:
- Restart-resilient channel tracking
- Automatic empty channel cleanup
- Configurable cleanup intervals and thresholds
- Stale entry removal and recovery
"""
def __init__(self, data_file: str = "data/voice_channels.json"):
"""
Initialize the cleanup service.
Args:
data_file: Path to the JSON data file for persistence
"""
self.tracker = VoiceChannelTracker(data_file)
self.cleanup_interval = 60 # 5 minutes check interval
self.empty_threshold = 5 # Delete after 15 minutes empty
self._running = False
async def start_monitoring(self, bot: commands.Bot) -> None:
"""
Start the cleanup monitoring loop.
Args:
bot: Discord bot instance
"""
if self._running:
logger.warning("Cleanup service is already running")
return
self._running = True
logger.info("Starting voice channel cleanup service")
# On startup, verify tracked channels still exist and clean up stale entries
await self.verify_tracked_channels(bot)
# Start the monitoring loop
while self._running:
try:
await self.cleanup_cycle(bot)
await asyncio.sleep(self.cleanup_interval)
except Exception as e:
logger.error(f"Cleanup cycle error: {e}", exc_info=True)
# Use shorter retry interval on errors
await asyncio.sleep(60)
logger.info("Voice channel cleanup service stopped")
def stop_monitoring(self) -> None:
"""Stop the cleanup monitoring loop."""
self._running = False
logger.info("Stopping voice channel cleanup service")
async def verify_tracked_channels(self, bot: commands.Bot) -> None:
"""
Verify tracked channels still exist and clean up stale entries.
Args:
bot: Discord bot instance
"""
logger.info("Verifying tracked voice channels on startup")
valid_channel_ids = []
channels_to_remove = []
for channel_data in self.tracker.get_all_tracked_channels():
try:
guild_id = int(channel_data["guild_id"])
channel_id = int(channel_data["channel_id"])
guild = bot.get_guild(guild_id)
if not guild:
logger.warning(f"Guild {guild_id} not found, removing channel {channel_data['name']}")
channels_to_remove.append(channel_id)
continue
channel = guild.get_channel(channel_id)
if not channel:
logger.warning(f"Channel {channel_data['name']} (ID: {channel_id}) no longer exists")
channels_to_remove.append(channel_id)
continue
# Channel exists and is valid
valid_channel_ids.append(channel_id)
except (ValueError, TypeError, KeyError) as e:
logger.warning(f"Invalid channel data: {e}, removing entry")
if "channel_id" in channel_data:
try:
channels_to_remove.append(int(channel_data["channel_id"]))
except (ValueError, TypeError):
pass
# Remove stale entries
for channel_id in channels_to_remove:
self.tracker.remove_channel(channel_id)
# 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
if total_removed > 0:
logger.info(f"Cleaned up {total_removed} stale channel tracking entries")
logger.info(f"Verified {len(valid_channel_ids)} valid tracked channels")
async def cleanup_cycle(self, bot: commands.Bot) -> None:
"""
Check all tracked channels and cleanup empty ones.
Args:
bot: Discord bot instance
"""
logger.debug("Starting cleanup cycle")
# Update status of all tracked channels
await self.update_all_channel_statuses(bot)
# Get channels ready for cleanup
channels_for_cleanup = self.tracker.get_channels_for_cleanup(self.empty_threshold)
if channels_for_cleanup:
logger.info(f"Found {len(channels_for_cleanup)} channels ready for cleanup")
# Delete empty channels
for channel_data in channels_for_cleanup:
await self.cleanup_channel(bot, channel_data)
async def update_all_channel_statuses(self, bot: commands.Bot) -> None:
"""
Update the empty status of all tracked channels.
Args:
bot: Discord bot instance
"""
for channel_data in self.tracker.get_all_tracked_channels():
await self.check_channel_status(bot, channel_data)
async def check_channel_status(self, bot: commands.Bot, channel_data: dict) -> None:
"""
Check if a channel is empty and update tracking.
Args:
bot: Discord bot instance
channel_data: Channel tracking data
"""
try:
guild_id = int(channel_data["guild_id"])
channel_id = int(channel_data["channel_id"])
guild = bot.get_guild(guild_id)
if not guild:
logger.debug(f"Guild {guild_id} not found for channel {channel_data['name']}")
return
channel = guild.get_channel(channel_id)
if not channel:
logger.debug(f"Channel {channel_data['name']} no longer exists, will be cleaned up")
self.tracker.remove_channel(channel_id)
return
# Ensure it's a voice channel before checking members
if not isinstance(channel, discord.VoiceChannel):
logger.warning(f"Channel {channel_data['name']} is not a voice channel, removing from tracking")
self.tracker.remove_channel(channel_id)
return
# Check if channel is empty
is_empty = len(channel.members) == 0
self.tracker.update_channel_status(channel_id, is_empty)
logger.debug(f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
f"({len(channel.members)} members)")
except Exception as e:
logger.error(f"Error checking channel status for {channel_data.get('name', 'unknown')}: {e}")
async def cleanup_channel(self, bot: commands.Bot, channel_data: dict) -> None:
"""
Delete an empty channel and remove from tracking.
Args:
bot: Discord bot instance
channel_data: Channel tracking data
"""
try:
guild_id = int(channel_data["guild_id"])
channel_id = int(channel_data["channel_id"])
channel_name = channel_data["name"]
guild = bot.get_guild(guild_id)
if not guild:
logger.info(f"Guild {guild_id} not found, removing tracking for {channel_name}")
self.tracker.remove_channel(channel_id)
return
channel = guild.get_channel(channel_id)
if not channel:
logger.info(f"Channel {channel_name} already deleted, removing from tracking")
self.tracker.remove_channel(channel_id)
return
# Ensure it's a voice channel before checking members
if not isinstance(channel, discord.VoiceChannel):
logger.warning(f"Channel {channel_name} is not a voice channel, removing from tracking")
self.tracker.remove_channel(channel_id)
return
# Final check: make sure channel is still empty before deleting
if len(channel.members) > 0:
logger.info(f"Channel {channel_name} is no longer empty, skipping cleanup")
self.tracker.update_channel_status(channel_id, False)
return
# Delete the channel
await channel.delete(reason="Automatic cleanup - empty for 15+ minutes")
self.tracker.remove_channel(channel_id)
logger.info(f"✅ Cleaned up empty voice channel: {channel_name} (ID: {channel_id})")
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"]))
except discord.Forbidden:
logger.error(f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}")
except Exception as e:
logger.error(f"Error cleaning up channel {channel_data.get('name', 'unknown')}: {e}")
def get_tracker(self) -> VoiceChannelTracker:
"""
Get the voice channel tracker instance.
Returns:
VoiceChannelTracker instance
"""
return self.tracker
def get_stats(self) -> dict:
"""
Get cleanup service statistics.
Returns:
Dictionary with service statistics
"""
all_channels = self.tracker.get_all_tracked_channels()
empty_channels = [ch for ch in all_channels if ch.get("empty_since")]
return {
"running": self._running,
"total_tracked": len(all_channels),
"empty_channels": len(empty_channels),
"cleanup_interval": self.cleanup_interval,
"empty_threshold": self.empty_threshold
}