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>
381 lines
16 KiB
Python
381 lines
16 KiB
Python
"""
|
|
Voice Channel Commands
|
|
|
|
Implements slash commands for creating and managing voice channels for gameplay.
|
|
"""
|
|
import logging
|
|
import random
|
|
from typing import Optional
|
|
|
|
import discord
|
|
from discord.ext import commands
|
|
|
|
from config import get_config
|
|
from services.team_service import team_service
|
|
from services.schedule_service import ScheduleService
|
|
from services.league_service import league_service
|
|
from utils.logging import get_contextual_logger
|
|
from utils.decorators import logged_command
|
|
from views.embeds import EmbedTemplate
|
|
from models.team import RosterType
|
|
|
|
logger = logging.getLogger(f'{__name__}.VoiceChannelCommands')
|
|
|
|
# Random codenames for public channels
|
|
CODENAMES = [
|
|
"Phoenix", "Thunder", "Lightning", "Storm", "Blaze", "Frost", "Shadow", "Nova",
|
|
"Viper", "Falcon", "Wolf", "Eagle", "Tiger", "Shark", "Bear", "Dragon",
|
|
"Alpha", "Beta", "Gamma", "Delta", "Echo", "Foxtrot", "Golf", "Hotel",
|
|
"Crimson", "Azure", "Emerald", "Golden", "Silver", "Bronze", "Platinum", "Diamond"
|
|
]
|
|
|
|
|
|
def random_codename() -> str:
|
|
"""Generate a random codename for public channels."""
|
|
return random.choice(CODENAMES)
|
|
|
|
|
|
class VoiceChannelCommands(commands.Cog):
|
|
"""Voice channel management commands for gameplay."""
|
|
|
|
def __init__(self, bot: commands.Bot):
|
|
self.bot = bot
|
|
self.logger = get_contextual_logger(f'{__name__}.VoiceChannelCommands')
|
|
self.schedule_service = ScheduleService()
|
|
|
|
# Modern slash command group
|
|
voice_group = discord.app_commands.Group(
|
|
name="voice-channel",
|
|
description="Create voice channels for gameplay"
|
|
)
|
|
|
|
async def _get_user_team(self, user_id: int, season: Optional[int] = None):
|
|
"""
|
|
Get the user's current team.
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
season: Season to check (defaults to current)
|
|
|
|
Returns:
|
|
Team object or None if not found
|
|
"""
|
|
season = season or get_config().sba_current_season
|
|
teams = await team_service.get_teams_by_owner(user_id, season)
|
|
return teams[0] if teams else None
|
|
|
|
async def _get_user_major_league_team(self, user_id: int, season: Optional[int] = None):
|
|
"""
|
|
Get the user's Major League team for schedule/game purposes.
|
|
|
|
Args:
|
|
user_id: Discord user ID
|
|
season: Season to check (defaults to current)
|
|
|
|
Returns:
|
|
Major League Team object or None if not found
|
|
"""
|
|
season = season or get_config().sba_current_season
|
|
teams = await team_service.get_teams_by_owner(user_id, season)
|
|
|
|
# Filter to only Major League teams (3-character abbreviations)
|
|
major_league_teams = [team for team in teams if team.roster_type() == RosterType.MAJOR_LEAGUE]
|
|
|
|
return major_league_teams[0] if major_league_teams else None
|
|
|
|
async def _create_tracked_channel(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
channel_name: str,
|
|
channel_type: str,
|
|
overwrites: dict
|
|
) -> discord.VoiceChannel:
|
|
"""
|
|
Create a voice channel and add it to tracking.
|
|
|
|
Args:
|
|
interaction: Discord interaction
|
|
channel_name: Name for the voice channel
|
|
channel_type: Type of channel ('public' or 'private')
|
|
overwrites: Permission overwrites for the channel
|
|
|
|
Returns:
|
|
Created Discord voice channel
|
|
"""
|
|
guild = interaction.guild
|
|
voice_category = discord.utils.get(guild.categories, name="Voice Channels")
|
|
|
|
# Create the voice channel
|
|
channel = await guild.create_voice_channel(
|
|
name=channel_name,
|
|
overwrites=overwrites,
|
|
category=voice_category
|
|
)
|
|
|
|
# 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]
|
|
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")
|
|
|
|
return channel
|
|
|
|
@voice_group.command(name="public", description="Create a public voice channel")
|
|
@logged_command("/voice-channel public")
|
|
async def create_public_channel(self, interaction: discord.Interaction):
|
|
"""Create a public voice channel for gameplay."""
|
|
await interaction.response.defer()
|
|
|
|
# Verify user has a Major League team
|
|
user_team = await self._get_user_major_league_team(interaction.user.id)
|
|
if not user_team:
|
|
embed = EmbedTemplate.error(
|
|
title="No Major League Team Found",
|
|
description="❌ You must own a Major League team to create voice channels.\n\n"
|
|
"Contact a league administrator if you believe this is an error."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
# Create channel with public permissions
|
|
overwrites = {
|
|
interaction.guild.default_role: discord.PermissionOverwrite(speak=True, connect=True)
|
|
}
|
|
|
|
channel_name = f"Gameplay {random_codename()}"
|
|
|
|
try:
|
|
channel = await self._create_tracked_channel(
|
|
interaction, channel_name, "public", overwrites
|
|
)
|
|
|
|
# Get actual cleanup time from service
|
|
cleanup_minutes = getattr(self.bot, 'voice_cleanup_service', None)
|
|
cleanup_time = cleanup_minutes.empty_threshold if cleanup_minutes else 15
|
|
|
|
embed = EmbedTemplate.success(
|
|
title="Voice Channel Created",
|
|
description=f"✅ Created public voice channel {channel.mention}\n\n"
|
|
f"**Channel:** {channel.name}\n"
|
|
f"**Type:** Public (everyone can speak)\n"
|
|
f"**Auto-cleanup:** {cleanup_time} minutes after becoming empty"
|
|
)
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
except discord.Forbidden:
|
|
embed = EmbedTemplate.error(
|
|
title="Permission Error",
|
|
description="❌ I don't have permission to create voice channels.\n\n"
|
|
"Please contact a server administrator."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
self.logger.error(f"Error creating public voice channel: {e}")
|
|
embed = EmbedTemplate.error(
|
|
title="Creation Failed",
|
|
description="❌ An error occurred while creating the voice channel.\n\n"
|
|
"Please try again or contact support."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
@voice_group.command(name="private", description="Create a private team vs team voice channel")
|
|
@logged_command("/voice-channel private")
|
|
async def create_private_channel(self, interaction: discord.Interaction):
|
|
"""Create a private voice channel for team matchup."""
|
|
await interaction.response.defer()
|
|
|
|
# Verify user has a Major League team
|
|
user_team = await self._get_user_major_league_team(interaction.user.id)
|
|
if not user_team:
|
|
embed = EmbedTemplate.error(
|
|
title="No Major League Team Found",
|
|
description="❌ You must own a Major League team to create private voice channels.\n\n"
|
|
"Private channels are for scheduled games between Major League teams.\n"
|
|
"Contact a league administrator if you believe this is an error."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
# Get current league info
|
|
try:
|
|
current_info = await league_service.get_current_state()
|
|
if current_info is None:
|
|
embed = EmbedTemplate.error(
|
|
title="League Info Error",
|
|
description="❌ Unable to retrieve current league information.\n\n"
|
|
"Please try again later."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
current_season = current_info.season
|
|
current_week = current_info.week
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting current league info: {e}")
|
|
embed = EmbedTemplate.error(
|
|
title="League Info Error",
|
|
description="❌ Unable to retrieve current league information.\n\n"
|
|
"Please try again later."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
# Find opponent from current week's schedule
|
|
try:
|
|
# Get all games for the current week
|
|
week_games = await self.schedule_service.get_week_schedule(current_season, current_week)
|
|
|
|
# Filter for games involving this team that haven't been completed
|
|
team_abbrev_upper = user_team.abbrev.upper()
|
|
current_week_games = [
|
|
g for g in week_games
|
|
if (g.away_team.abbrev.upper() == team_abbrev_upper or
|
|
g.home_team.abbrev.upper() == team_abbrev_upper)
|
|
and not g.is_completed
|
|
]
|
|
|
|
if not current_week_games:
|
|
embed = EmbedTemplate.warning(
|
|
title="No Games Found",
|
|
description=f"❌ No upcoming games found for {user_team.abbrev} in week {current_week}.\n\n"
|
|
f"You may be between series or all games for this week are complete."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
game = current_week_games[0] # Use first upcoming game
|
|
opponent_team = game.away_team if game.home_team.id == user_team.id else game.home_team
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Error getting team schedule: {e}")
|
|
embed = EmbedTemplate.error(
|
|
title="Schedule Error",
|
|
description="❌ Unable to retrieve your team's schedule.\n\n"
|
|
"Please try again later."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
return
|
|
|
|
# Setup permissions for team roles
|
|
user_team_role = discord.utils.get(interaction.guild.roles, name=user_team.lname)
|
|
opponent_team_role = discord.utils.get(interaction.guild.roles, name=opponent_team.lname)
|
|
|
|
# Start with default permissions (everyone can connect but not speak)
|
|
overwrites = {
|
|
interaction.guild.default_role: discord.PermissionOverwrite(speak=False, connect=True)
|
|
}
|
|
|
|
# Add speaking permissions for team roles
|
|
team_roles_found = []
|
|
if user_team_role:
|
|
overwrites[user_team_role] = discord.PermissionOverwrite(speak=True, connect=True)
|
|
team_roles_found.append(user_team.lname)
|
|
|
|
if opponent_team_role:
|
|
overwrites[opponent_team_role] = discord.PermissionOverwrite(speak=True, connect=True)
|
|
team_roles_found.append(opponent_team.lname)
|
|
|
|
# Create private channel with team names
|
|
away_name = game.away_team.sname
|
|
home_name = game.home_team.sname
|
|
channel_name = f"{away_name} vs {home_name}"
|
|
|
|
try:
|
|
channel = await self._create_tracked_channel(
|
|
interaction, channel_name, "private", overwrites
|
|
)
|
|
|
|
# Get actual cleanup time from service
|
|
cleanup_minutes = getattr(self.bot, 'voice_cleanup_service', None)
|
|
cleanup_time = cleanup_minutes.empty_threshold if cleanup_minutes else 15
|
|
|
|
embed = EmbedTemplate.success(
|
|
title="Private Voice Channel Created",
|
|
description=f"✅ Created private voice channel {channel.mention}\n\n"
|
|
f"**Matchup:** {away_name} vs {home_name}\n"
|
|
f"**Type:** Private (team members only can speak)\n"
|
|
f"**Auto-cleanup:** {cleanup_time} minutes after becoming empty"
|
|
)
|
|
|
|
embed.add_field(
|
|
name="Speaking Permissions",
|
|
value=f"🎤 **{user_team.abbrev}** - {user_team.lname}\n"
|
|
f"🎤 **{opponent_team.abbrev}** - {opponent_team.lname}\n"
|
|
f"👂 Everyone else can listen",
|
|
inline=False
|
|
)
|
|
|
|
if len(team_roles_found) < 2:
|
|
missing_roles = []
|
|
if not user_team_role:
|
|
missing_roles.append(user_team.lname)
|
|
if not opponent_team_role:
|
|
missing_roles.append(opponent_team.lname)
|
|
|
|
embed.add_field(
|
|
name="⚠️ Missing Roles",
|
|
value=f"Could not find Discord roles for: {', '.join(missing_roles)}\n"
|
|
f"These teams may not have speaking permissions.",
|
|
inline=False
|
|
)
|
|
|
|
await interaction.followup.send(embed=embed)
|
|
|
|
except discord.Forbidden:
|
|
embed = EmbedTemplate.error(
|
|
title="Permission Error",
|
|
description="❌ I don't have permission to create voice channels.\n\n"
|
|
"Please contact a server administrator."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
except Exception as e:
|
|
self.logger.error(f"Error creating private voice channel: {e}")
|
|
embed = EmbedTemplate.error(
|
|
title="Creation Failed",
|
|
description="❌ An error occurred while creating the voice channel.\n\n"
|
|
"Please try again or contact support."
|
|
)
|
|
await interaction.followup.send(embed=embed, ephemeral=True)
|
|
|
|
# Deprecated prefix commands with migration messages
|
|
@commands.command(name="vc", aliases=["voice", "gameplay"])
|
|
async def deprecated_public_voice(self, ctx: commands.Context):
|
|
"""Deprecated command - redirect to new slash command."""
|
|
embed = EmbedTemplate.info(
|
|
title="Command Deprecated",
|
|
description=(
|
|
"The `!vc` command has been deprecated.\n\n"
|
|
"**Please use:** `/voice-channel public` for your voice channel needs.\n\n"
|
|
"The new slash commands provide better functionality and organization!"
|
|
)
|
|
)
|
|
embed.set_footer(text="💡 Tip: Type /voice-channel and see the available options!")
|
|
await ctx.send(embed=embed)
|
|
|
|
@commands.command(name="private")
|
|
async def deprecated_private_voice(self, ctx: commands.Context):
|
|
"""Deprecated command - redirect to new slash command."""
|
|
embed = EmbedTemplate.info(
|
|
title="Command Deprecated",
|
|
description=(
|
|
"The `!private` command has been deprecated.\n\n"
|
|
"**Please use:** `/voice-channel private` for your private team channel needs.\n\n"
|
|
"The new slash commands provide better functionality and organization!"
|
|
)
|
|
)
|
|
embed.set_footer(text="💡 Tip: Type /voice-channel and see the available options!")
|
|
await ctx.send(embed=embed)
|
|
|
|
|
|
async def setup(bot: commands.Bot):
|
|
"""Load the voice channel commands cog."""
|
|
await bot.add_cog(VoiceChannelCommands(bot)) |