major-domo-v2/views/base.py
Cal Corum e6a30af604 CLAUDE: SUCCESSFUL STARTUP - Discord Bot v2.0 fully operational
 **MAJOR MILESTONE**: Bot successfully starts and loads all commands

🔧 **Key Fixes Applied**:
- Fixed Pydantic configuration (SettingsConfigDict vs ConfigDict)
- Resolved duplicate logging with hybrid propagation approach
- Enhanced console logging with detailed format (function:line)
- Eliminated redundant .log file handler (kept console + JSON)
- Fixed Pylance type errors across views and modals
- Added newline termination to JSON logs for better tool compatibility
- Enabled league commands package in bot.py
- Enhanced command tree hashing for proper type support

📦 **New Components Added**:
- Complete views package (base.py, common.py, embeds.py, modals.py)
- League service and commands integration
- Comprehensive test coverage improvements
- Enhanced decorator functionality with proper signature preservation

🎯 **Architecture Improvements**:
- Hybrid logging: detailed console for dev + structured JSON for monitoring
- Type-safe command tree handling for future extensibility
- Proper optional parameter handling in Pydantic models
- Eliminated duplicate log messages while preserving third-party library logs

🚀 **Ready for Production**: Bot loads all command packages successfully with no errors

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-08-16 07:36:47 -05:00

274 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 Optional, Any, Callable, Awaitable
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,
logger_name: Optional[str] = None
):
super().__init__(timeout=timeout)
self.user_id = user_id
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:
return True
if interaction.user.id != self.user_id:
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: int,
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, 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] = []