Add interactive PlayerStatsView with toggle buttons to show/hide batting and pitching statistics independently in the /player command. Stats are hidden by default with clean, user-friendly buttons (💥 batting, ⚾ pitching) that update the embed in-place. Only the command caller can toggle stats, and buttons timeout after 5 minutes. Player Stats Toggle Feature: - Add views/players.py with PlayerStatsView class - Update /player command to use interactive view - Stats hidden by default, shown on button click - Independent batting/pitching toggles - User-restricted interactions with timeout handling Injury System Enhancements: - Add BatterInjuryModal and PitcherRestModal for injury logging - Add player_id extraction validator to Injury model - Fix injury creation to merge API request/response data - Add responders parameter to BaseView for multi-user interactions API Client Improvements: - Handle None values correctly in PATCH query parameters - Convert None to empty string for nullable fields in database 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
277 lines
10 KiB
Python
277 lines
10 KiB
Python
"""
|
|
Base View Classes for Discord Bot v2.0
|
|
|
|
Provides foundational view components with consistent styling and behavior.
|
|
"""
|
|
import logging
|
|
from typing import List, Optional, Any, Callable, Awaitable, Union
|
|
from datetime import datetime, timezone
|
|
|
|
import discord
|
|
from discord.ext import commands
|
|
|
|
from utils.logging import get_contextual_logger
|
|
|
|
|
|
class BaseView(discord.ui.View):
|
|
"""Base view class with consistent styling and error handling."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
timeout: float = 180.0,
|
|
user_id: Optional[int] = None,
|
|
responders: Optional[List[int | None]] = None,
|
|
logger_name: Optional[str] = None
|
|
):
|
|
super().__init__(timeout=timeout)
|
|
self.user_id = user_id
|
|
self.responders = responders
|
|
self.logger = get_contextual_logger(logger_name or f'{__name__}.BaseView')
|
|
self.interaction_count = 0
|
|
self.created_at = datetime.now(timezone.utc)
|
|
|
|
async def interaction_check(self, interaction: discord.Interaction) -> bool:
|
|
"""Check if user is authorized to interact with this view."""
|
|
if self.user_id is None and self.responders is None:
|
|
return True
|
|
|
|
if (self.user_id is not None and interaction.user.id != self.user_id) or (self.responders is not None and interaction.user.id not in self.responders):
|
|
await interaction.response.send_message(
|
|
"❌ You cannot interact with this menu.",
|
|
ephemeral=True
|
|
)
|
|
return False
|
|
|
|
return True
|
|
|
|
async def on_timeout(self) -> None:
|
|
"""Handle view timeout."""
|
|
self.logger.info("View timed out",
|
|
user_id=self.user_id,
|
|
interaction_count=self.interaction_count,
|
|
timeout=self.timeout)
|
|
|
|
# Disable all items
|
|
for item in self.children:
|
|
if hasattr(item, 'disabled'):
|
|
item.disabled = True # type: ignore
|
|
else:
|
|
self.logger.info(f'Item {item} has no "disabled" attribute')
|
|
|
|
async def on_error(
|
|
self,
|
|
interaction: discord.Interaction,
|
|
error: Exception,
|
|
item: discord.ui.Item[Any]
|
|
) -> None:
|
|
"""Handle view errors."""
|
|
self.logger.error("View error occurred",
|
|
user_id=interaction.user.id,
|
|
error=error,
|
|
item_type=type(item).__name__,
|
|
interaction_count=self.interaction_count)
|
|
|
|
try:
|
|
if not interaction.response.is_done():
|
|
await interaction.response.send_message(
|
|
"❌ An error occurred while processing your interaction.",
|
|
ephemeral=True
|
|
)
|
|
else:
|
|
await interaction.followup.send(
|
|
"❌ An error occurred while processing your interaction.",
|
|
ephemeral=True
|
|
)
|
|
except Exception as e:
|
|
self.logger.error("Failed to send error message", error=e)
|
|
|
|
def increment_interaction_count(self) -> None:
|
|
"""Increment the interaction counter."""
|
|
self.interaction_count += 1
|
|
|
|
|
|
class ConfirmationView(BaseView):
|
|
"""Standard confirmation dialog with Yes/No buttons."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
user_id: Optional[int] = None,
|
|
responders: Optional[List[int | None]] = None,
|
|
timeout: float = 60.0,
|
|
confirm_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
|
|
cancel_callback: Optional[Callable[[discord.Interaction], Awaitable[None]]] = None,
|
|
confirm_label: str = "Confirm",
|
|
cancel_label: str = "Cancel"
|
|
):
|
|
super().__init__(timeout=timeout, user_id=user_id, responders=responders, logger_name=f'{__name__}.ConfirmationView')
|
|
self.confirm_callback = confirm_callback
|
|
self.cancel_callback = cancel_callback
|
|
self.result: Optional[bool] = None
|
|
|
|
# Update button labels
|
|
self.confirm_button.label = confirm_label
|
|
self.cancel_button.label = cancel_label
|
|
|
|
@discord.ui.button(
|
|
label="Confirm",
|
|
style=discord.ButtonStyle.success,
|
|
emoji="✅"
|
|
)
|
|
async def confirm_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Handle confirmation."""
|
|
self.increment_interaction_count()
|
|
self.result = True
|
|
|
|
# Disable all buttons
|
|
for item in self.children:
|
|
if hasattr(item, 'disabled'):
|
|
item.disabled = True # type: ignore
|
|
else:
|
|
self.logger.info(f'Item {item} has no "disabled" attribute')
|
|
|
|
if self.confirm_callback:
|
|
await self.confirm_callback(interaction)
|
|
else:
|
|
await interaction.response.edit_message(
|
|
content="✅ Confirmed!",
|
|
view=self
|
|
)
|
|
|
|
self.stop()
|
|
|
|
@discord.ui.button(
|
|
label="Cancel",
|
|
style=discord.ButtonStyle.secondary,
|
|
emoji="❌"
|
|
)
|
|
async def cancel_button(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Handle cancellation."""
|
|
self.increment_interaction_count()
|
|
self.result = False
|
|
|
|
# Disable all buttons
|
|
for item in self.children:
|
|
if hasattr(item, 'disabled'):
|
|
item.disabled = True # type: ignore
|
|
else:
|
|
self.logger.info(f'Item {item} has no "disabled" attribute')
|
|
|
|
if self.cancel_callback:
|
|
await self.cancel_callback(interaction)
|
|
else:
|
|
await interaction.response.edit_message(
|
|
content="❌ Cancelled.",
|
|
view=self
|
|
)
|
|
|
|
self.stop()
|
|
|
|
|
|
class PaginationView(BaseView):
|
|
"""Pagination view for navigating through multiple pages."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
pages: list[discord.Embed],
|
|
user_id: Optional[int] = None,
|
|
timeout: float = 300.0,
|
|
show_page_numbers: bool = True,
|
|
logger_name: Optional[str] = None
|
|
):
|
|
super().__init__(timeout=timeout, user_id=user_id, logger_name=logger_name or f'{__name__}.PaginationView')
|
|
self.pages = pages
|
|
self.current_page = 0
|
|
self.show_page_numbers = show_page_numbers
|
|
|
|
# Update button states
|
|
self._update_buttons()
|
|
|
|
def _update_buttons(self) -> None:
|
|
"""Update button enabled/disabled states."""
|
|
self.first_page.disabled = self.current_page == 0
|
|
self.previous_page.disabled = self.current_page == 0
|
|
self.next_page.disabled = self.current_page == len(self.pages) - 1
|
|
self.last_page.disabled = self.current_page == len(self.pages) - 1
|
|
|
|
if self.show_page_numbers:
|
|
self.page_info.label = f"{self.current_page + 1}/{len(self.pages)}"
|
|
|
|
def get_current_embed(self) -> discord.Embed:
|
|
"""Get the current page embed with footer."""
|
|
embed = self.pages[self.current_page].copy()
|
|
|
|
if self.show_page_numbers:
|
|
footer_text = f"Page {self.current_page + 1} of {len(self.pages)}"
|
|
if embed.footer.text:
|
|
footer_text = f"{embed.footer.text} • {footer_text}"
|
|
embed.set_footer(text=footer_text, icon_url=embed.footer.icon_url)
|
|
|
|
return embed
|
|
|
|
@discord.ui.button(emoji="⏪", style=discord.ButtonStyle.secondary, row=0)
|
|
async def first_page(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Jump to first page."""
|
|
self.increment_interaction_count()
|
|
self.current_page = 0
|
|
self._update_buttons()
|
|
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
|
|
|
|
@discord.ui.button(emoji="◀️", style=discord.ButtonStyle.primary, row=0)
|
|
async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Go to previous page."""
|
|
self.increment_interaction_count()
|
|
self.current_page = max(0, self.current_page - 1)
|
|
self._update_buttons()
|
|
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
|
|
|
|
@discord.ui.button(label="1/1", style=discord.ButtonStyle.secondary, disabled=True, row=0)
|
|
async def page_info(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Page info button (disabled)."""
|
|
pass
|
|
|
|
@discord.ui.button(emoji="▶️", style=discord.ButtonStyle.primary, row=0)
|
|
async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Go to next page."""
|
|
self.increment_interaction_count()
|
|
self.current_page = min(len(self.pages) - 1, self.current_page + 1)
|
|
self._update_buttons()
|
|
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
|
|
|
|
@discord.ui.button(emoji="⏩", style=discord.ButtonStyle.secondary, row=0)
|
|
async def last_page(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Jump to last page."""
|
|
self.increment_interaction_count()
|
|
self.current_page = len(self.pages) - 1
|
|
self._update_buttons()
|
|
await interaction.response.edit_message(embed=self.get_current_embed(), view=self)
|
|
|
|
@discord.ui.button(emoji="🗑️", style=discord.ButtonStyle.danger, row=1)
|
|
async def delete_message(self, interaction: discord.Interaction, button: discord.ui.Button):
|
|
"""Delete the message."""
|
|
self.increment_interaction_count()
|
|
await interaction.response.defer()
|
|
await interaction.delete_original_response()
|
|
self.stop()
|
|
|
|
|
|
class SelectMenuView(BaseView):
|
|
"""Base class for views with select menus."""
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
user_id: Optional[int] = None,
|
|
timeout: float = 180.0,
|
|
placeholder: str = "Select an option...",
|
|
min_values: int = 1,
|
|
max_values: int = 1,
|
|
logger_name: Optional[str] = None
|
|
):
|
|
super().__init__(timeout=timeout, user_id=user_id, logger_name=logger_name or f'{__name__}.SelectMenuView')
|
|
self.placeholder = placeholder
|
|
self.min_values = min_values
|
|
self.max_values = max_values
|
|
self.selected_values: list[str] = [] |