Problem: - Voice cleanup service used manual while loop instead of @tasks.loop - Did not wait for bot readiness before starting - Startup verification could miss stale entries - Manual channel deletions did not unpublish associated scorecards Changes: - Refactored VoiceChannelCleanupService to use @tasks.loop(minutes=1) - Added @before_loop decorator with await bot.wait_until_ready() - Updated bot.py to use setup_voice_cleanup() pattern - Fixed scorecard unpublishing for manually deleted channels - Fixed scorecard unpublishing for wrong channel type scenarios - Updated cleanup interval from 60 seconds to 1 minute (same behavior) - Changed cleanup reason message from "15+ minutes" to "5+ minutes" (matches actual threshold) Benefits: - Safe startup: cleanup waits for bot to be fully ready - Reliable stale cleanup: startup verification guaranteed to run - Complete cleanup: scorecards unpublished in all scenarios - Consistent pattern: follows same pattern as other background tasks - Better error handling: integrated with discord.py task lifecycle Testing: - All 19 voice command tests passing - Updated test fixtures to handle new task pattern - Fixed test assertions for new cleanup reason message Documentation: - Updated commands/voice/CLAUDE.md with new architecture - Documented all four cleanup scenarios for scorecards - Added task lifecycle information - Updated configuration section 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
347 lines
14 KiB
Python
347 lines
14 KiB
Python
"""
|
|
Voice Channel Cleanup Service
|
|
|
|
Provides automatic cleanup of empty voice channels with restart resilience.
|
|
"""
|
|
import logging
|
|
|
|
import discord
|
|
from discord.ext import commands, tasks
|
|
|
|
from .tracker import VoiceChannelTracker
|
|
from commands.gameplay.scorecard_tracker import ScorecardTracker
|
|
from utils.logging import get_contextual_logger
|
|
|
|
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
|
|
- Automatic scorecard unpublishing when voice channel is cleaned up
|
|
"""
|
|
|
|
def __init__(self, bot: commands.Bot, data_file: str = "data/voice_channels.json"):
|
|
"""
|
|
Initialize the cleanup service.
|
|
|
|
Args:
|
|
bot: Discord bot instance
|
|
data_file: Path to the JSON data file for persistence
|
|
"""
|
|
self.bot = bot
|
|
self.logger = get_contextual_logger(f'{__name__}.VoiceChannelCleanupService')
|
|
self.tracker = VoiceChannelTracker(data_file)
|
|
self.scorecard_tracker = ScorecardTracker()
|
|
self.empty_threshold = 5 # Delete after 5 minutes empty
|
|
|
|
# Start the cleanup task - @before_loop will wait for bot readiness
|
|
self.cleanup_loop.start()
|
|
self.logger.info("Voice channel cleanup service initialized")
|
|
|
|
def cog_unload(self):
|
|
"""Stop the task when service is unloaded."""
|
|
self.cleanup_loop.cancel()
|
|
self.logger.info("Voice channel cleanup service stopped")
|
|
|
|
@tasks.loop(minutes=1)
|
|
async def cleanup_loop(self):
|
|
"""
|
|
Main cleanup loop - runs every minute.
|
|
|
|
Checks all tracked channels and cleans up empty ones.
|
|
"""
|
|
try:
|
|
await self.cleanup_cycle(self.bot)
|
|
except Exception as e:
|
|
self.logger.error(f"Cleanup cycle error: {e}", exc_info=True)
|
|
|
|
@cleanup_loop.before_loop
|
|
async def before_cleanup_loop(self):
|
|
"""Wait for bot to be ready before starting - REQUIRED FOR SAFE STARTUP."""
|
|
await self.bot.wait_until_ready()
|
|
self.logger.info("Bot is ready, voice cleanup service starting")
|
|
|
|
# On startup, verify tracked channels still exist and clean up stale entries
|
|
await self.verify_tracked_channels(self.bot)
|
|
|
|
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
|
|
"""
|
|
self.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:
|
|
self.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:
|
|
self.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:
|
|
self.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 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:
|
|
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel)")
|
|
except (ValueError, TypeError) as e:
|
|
self.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
|
|
|
|
if total_removed > 0:
|
|
self.logger.info(f"Cleaned up {total_removed} stale channel tracking entries")
|
|
|
|
self.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
|
|
"""
|
|
self.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:
|
|
self.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:
|
|
self.logger.debug(f"Guild {guild_id} not found for channel {channel_data['name']}")
|
|
return
|
|
|
|
channel = guild.get_channel(channel_id)
|
|
if not channel:
|
|
self.logger.debug(f"Channel {channel_data['name']} no longer exists, removing from tracking")
|
|
self.tracker.remove_channel(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:
|
|
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (manually deleted voice channel)")
|
|
except (ValueError, TypeError) as e:
|
|
self.logger.warning(f"Invalid text_channel_id in manually deleted voice channel data: {e}")
|
|
|
|
return
|
|
|
|
# Ensure it's a voice channel before checking members
|
|
if not isinstance(channel, discord.VoiceChannel):
|
|
self.logger.warning(f"Channel {channel_data['name']} is not a voice channel, removing from tracking")
|
|
self.tracker.remove_channel(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:
|
|
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (wrong channel type)")
|
|
except (ValueError, TypeError) as e:
|
|
self.logger.warning(f"Invalid text_channel_id in wrong channel type data: {e}")
|
|
|
|
return
|
|
|
|
# Check if channel is empty
|
|
is_empty = len(channel.members) == 0
|
|
self.tracker.update_channel_status(channel_id, is_empty)
|
|
|
|
self.logger.debug(f"Channel {channel_data['name']}: {'empty' if is_empty else 'occupied'} "
|
|
f"({len(channel.members)} members)")
|
|
|
|
except Exception as e:
|
|
self.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:
|
|
self.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:
|
|
self.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):
|
|
self.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:
|
|
self.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 5+ minutes")
|
|
self.tracker.remove_channel(channel_id)
|
|
|
|
self.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:
|
|
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (voice channel cleanup)")
|
|
else:
|
|
self.logger.debug(f"No scorecard found for text channel {text_channel_id_int}")
|
|
except (ValueError, TypeError) as e:
|
|
self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
|
|
|
|
except discord.NotFound:
|
|
# Channel was already deleted
|
|
self.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:
|
|
self.logger.info(f"📋 Unpublished scorecard from text channel {text_channel_id_int} (stale voice channel cleanup)")
|
|
except (ValueError, TypeError) as e:
|
|
self.logger.warning(f"Invalid text_channel_id in voice channel data: {e}")
|
|
except discord.Forbidden:
|
|
self.logger.error(f"Missing permissions to delete channel {channel_data.get('name', 'unknown')}")
|
|
except Exception as e:
|
|
self.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.cleanup_loop.is_running(),
|
|
"total_tracked": len(all_channels),
|
|
"empty_channels": len(empty_channels),
|
|
"empty_threshold": self.empty_threshold
|
|
}
|
|
|
|
|
|
def setup_voice_cleanup(bot: commands.Bot) -> VoiceChannelCleanupService:
|
|
"""
|
|
Setup function to initialize the voice channel cleanup service.
|
|
|
|
Args:
|
|
bot: Discord bot instance
|
|
|
|
Returns:
|
|
VoiceChannelCleanupService instance
|
|
"""
|
|
return VoiceChannelCleanupService(bot) |