Features: - Post injury announcements to #sba-network-news when injuries are logged - Update #injury-log channel with two embeds: - All injuries grouped by Major League team with return dates - All injuries grouped by return week, sorted ascending - Auto-purge old messages before posting updated injury log Bug Fixes: - Fix BaseView interaction_check logic that incorrectly rejected command users - Old: Rejected if (not user_id match) OR (not in responders) - New: Allow if (user_id match) OR (in responders) - Filter None values from responders list (handles missing gmid2) Changes: - services/injury_service.py: Add get_all_active_injuries_raw() method - utils/injury_log.py: New utility for injury channel posting - views/modals.py: Call injury posting after successful injury logging - views/base.py: Fix interaction authorization logic - config.py: Update to Season 13 Players role 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
292 lines
11 KiB
Python
292 lines
11 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.
|
|
|
|
Authorization logic:
|
|
- If no restrictions set (user_id and responders both None), allow all
|
|
- If user_id is set, the original command user can interact
|
|
- If responders is set, anyone in the responders list can interact
|
|
- User only needs to match ONE condition to be authorized
|
|
"""
|
|
# No restrictions - allow everyone
|
|
if self.user_id is None and self.responders is None:
|
|
return True
|
|
|
|
# Check if user is authorized by either condition
|
|
is_command_user = self.user_id is not None and interaction.user.id == self.user_id
|
|
is_authorized_responder = (
|
|
self.responders is not None and
|
|
interaction.user.id in [r for r in self.responders if r is not None]
|
|
)
|
|
|
|
if is_command_user or is_authorized_responder:
|
|
return True
|
|
|
|
await interaction.response.send_message(
|
|
"❌ You cannot interact with this menu.",
|
|
ephemeral=True
|
|
)
|
|
return False
|
|
|
|
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] = [] |