major-domo-v2/views/base.py
Cal Corum 62541ac750 Add injury log posting and fix view interaction permissions
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>
2025-12-19 00:08:11 -06:00

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] = []