✅ **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>
274 lines
10 KiB
Python
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] = [] |