Major Features Added: • Admin Management System: Complete admin command suite with user moderation, system control, and bot maintenance tools • Enhanced Player Commands: Added batting/pitching statistics with concurrent API calls and improved embed design • League Standings: Full standings system with division grouping, playoff picture, and wild card visualization • Game Schedules: Comprehensive schedule system with team filtering, series organization, and proper home/away indicators New Admin Commands (12 total): • /admin-status, /admin-help, /admin-reload, /admin-sync, /admin-clear • /admin-announce, /admin-maintenance • /admin-timeout, /admin-untimeout, /admin-kick, /admin-ban, /admin-unban, /admin-userinfo Enhanced Player Display: • Team logo positioned beside player name using embed author • Smart thumbnail priority: fancycard → headshot → team logo fallback • Concurrent batting/pitching stats fetching for performance • Rich statistics display with team colors and comprehensive metrics New Models & Services: • BattingStats, PitchingStats, TeamStandings, Division, Game models • StatsService, StandingsService, ScheduleService for data management • CustomCommand system with CRUD operations and cleanup tasks Bot Architecture Improvements: • Admin commands integrated into bot.py with proper loading • Permission checks and safety guards for moderation commands • Enhanced error handling and comprehensive audit logging • All 227 tests passing with new functionality 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
236 lines
8.4 KiB
Python
236 lines
8.4 KiB
Python
"""
|
|
Custom Command models for Discord Bot v2.0
|
|
|
|
Modern Pydantic models for the custom command system with full type safety.
|
|
"""
|
|
from datetime import datetime
|
|
from typing import Optional, Dict, Any
|
|
import re
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from models.base import SBABaseModel
|
|
|
|
|
|
class CustomCommandCreator(SBABaseModel):
|
|
"""Creator of custom commands."""
|
|
id: int = Field(..., description="Database ID") # type: ignore
|
|
discord_id: int = Field(..., description="Discord user ID")
|
|
username: str = Field(..., description="Discord username")
|
|
display_name: Optional[str] = Field(None, description="Discord display name")
|
|
created_at: datetime = Field(..., description="When creator was first recorded") # type: ignore
|
|
total_commands: int = Field(0, description="Total commands created by this user")
|
|
active_commands: int = Field(0, description="Currently active commands")
|
|
|
|
|
|
class CustomCommand(SBABaseModel):
|
|
"""A custom command created by a user."""
|
|
id: int = Field(..., description="Database ID") # type: ignore
|
|
name: str = Field(..., description="Command name (unique)")
|
|
content: str = Field(..., description="Command response content")
|
|
creator_id: int = Field(..., description="ID of the creator")
|
|
creator: Optional[CustomCommandCreator] = Field(None, description="Creator details")
|
|
|
|
# Timestamps
|
|
created_at: datetime = Field(..., description="When command was created") # type: ignore
|
|
updated_at: Optional[datetime] = Field(None, description="When command was last updated") # type: ignore
|
|
last_used: Optional[datetime] = Field(None, description="When command was last executed")
|
|
|
|
# Usage tracking
|
|
use_count: int = Field(0, description="Total times command has been used")
|
|
warning_sent: bool = Field(False, description="Whether cleanup warning was sent")
|
|
|
|
# Metadata
|
|
is_active: bool = Field(True, description="Whether command is currently active")
|
|
tags: Optional[list[str]] = Field(None, description="Optional tags for categorization")
|
|
|
|
@field_validator('name')
|
|
@classmethod
|
|
def validate_name(cls, v):
|
|
"""Validate command name."""
|
|
if not v or len(v.strip()) == 0:
|
|
raise ValueError("Command name cannot be empty")
|
|
|
|
name = v.strip().lower()
|
|
|
|
# Length validation
|
|
if len(name) < 2:
|
|
raise ValueError("Command name must be at least 2 characters")
|
|
if len(name) > 32:
|
|
raise ValueError("Command name cannot exceed 32 characters")
|
|
|
|
# Character validation - only allow alphanumeric, dashes, underscores
|
|
if not re.match(r'^[a-z0-9_-]+$', name):
|
|
raise ValueError("Command name can only contain letters, numbers, dashes, and underscores")
|
|
|
|
# Reserved names
|
|
reserved = {
|
|
'help', 'ping', 'info', 'list', 'create', 'delete', 'edit',
|
|
'admin', 'mod', 'owner', 'bot', 'system', 'config'
|
|
}
|
|
if name in reserved:
|
|
raise ValueError(f"'{name}' is a reserved command name")
|
|
|
|
return name.lower()
|
|
|
|
@field_validator('content')
|
|
@classmethod
|
|
def validate_content(cls, v):
|
|
"""Validate command content."""
|
|
if not v or len(v.strip()) == 0:
|
|
raise ValueError("Command content cannot be empty")
|
|
|
|
content = v.strip()
|
|
|
|
# Length validation
|
|
if len(content) > 2000:
|
|
raise ValueError("Command content cannot exceed 2000 characters")
|
|
|
|
# Basic content filtering
|
|
prohibited = ['@everyone', '@here']
|
|
content_lower = content.lower()
|
|
for term in prohibited:
|
|
if term in content_lower:
|
|
raise ValueError(f"Command content cannot contain '{term}'")
|
|
|
|
return content
|
|
|
|
@property
|
|
def days_since_last_use(self) -> Optional[int]:
|
|
"""Calculate days since last use."""
|
|
if not self.last_used:
|
|
return None
|
|
return (datetime.now() - self.last_used).days
|
|
|
|
@property
|
|
def is_eligible_for_warning(self) -> bool:
|
|
"""Check if command is eligible for deletion warning."""
|
|
if not self.last_used or self.warning_sent:
|
|
return False
|
|
return self.days_since_last_use >= 60 # type: ignore
|
|
|
|
@property
|
|
def is_eligible_for_deletion(self) -> bool:
|
|
"""Check if command is eligible for deletion."""
|
|
if not self.last_used:
|
|
return False
|
|
return self.days_since_last_use >= 90 # type: ignore
|
|
|
|
@property
|
|
def popularity_score(self) -> float:
|
|
"""Calculate popularity score based on usage and recency."""
|
|
if self.use_count == 0:
|
|
return 0.0
|
|
|
|
# Base score from usage
|
|
base_score = min(self.use_count / 10.0, 10.0) # Max 10 points from usage
|
|
|
|
# Recency modifier
|
|
if self.last_used:
|
|
days_ago = self.days_since_last_use
|
|
if days_ago <= 7: # type: ignore
|
|
recency_modifier = 1.5 # Recent use bonus
|
|
elif days_ago <= 30: # type: ignore
|
|
recency_modifier = 1.0 # No modifier
|
|
elif days_ago <= 60: # type: ignore
|
|
recency_modifier = 0.7 # Slight penalty
|
|
else:
|
|
recency_modifier = 0.3 # Old command penalty
|
|
else:
|
|
recency_modifier = 0.1 # Never used
|
|
|
|
return base_score * recency_modifier
|
|
|
|
|
|
class CustomCommandSearchFilters(BaseModel):
|
|
"""Filters for searching custom commands."""
|
|
name_contains: Optional[str] = None
|
|
creator_id: Optional[int] = None
|
|
creator_name: Optional[str] = None
|
|
min_uses: Optional[int] = None
|
|
max_days_unused: Optional[int] = None
|
|
has_tags: Optional[list[str]] = None
|
|
is_active: bool = True
|
|
|
|
# Sorting options
|
|
sort_by: str = Field('name', description="Sort field: name, created_at, last_used, use_count, popularity")
|
|
sort_desc: bool = Field(False, description="Sort in descending order")
|
|
|
|
# Pagination
|
|
page: int = Field(1, description="Page number (1-based)")
|
|
page_size: int = Field(25, description="Items per page")
|
|
|
|
@field_validator('sort_by')
|
|
@classmethod
|
|
def validate_sort_by(cls, v):
|
|
"""Validate sort field."""
|
|
valid_sorts = {'name', 'created_at', 'last_used', 'use_count', 'popularity', 'creator'}
|
|
if v not in valid_sorts:
|
|
raise ValueError(f"sort_by must be one of: {', '.join(valid_sorts)}")
|
|
return v
|
|
|
|
@field_validator('page')
|
|
@classmethod
|
|
def validate_page(cls, v):
|
|
"""Validate page number."""
|
|
if v < 1:
|
|
raise ValueError("Page number must be >= 1")
|
|
return v
|
|
|
|
@field_validator('page_size')
|
|
@classmethod
|
|
def validate_page_size(cls, v):
|
|
"""Validate page size."""
|
|
if v < 1 or v > 100:
|
|
raise ValueError("Page size must be between 1 and 100")
|
|
return v
|
|
|
|
|
|
class CustomCommandSearchResult(BaseModel):
|
|
"""Result of a custom command search."""
|
|
commands: list[CustomCommand]
|
|
total_count: int
|
|
page: int
|
|
page_size: int
|
|
total_pages: int
|
|
has_more: bool
|
|
|
|
@property
|
|
def start_index(self) -> int:
|
|
"""Get the starting index for this page."""
|
|
return (self.page - 1) * self.page_size + 1
|
|
|
|
@property
|
|
def end_index(self) -> int:
|
|
"""Get the ending index for this page."""
|
|
return min(self.page * self.page_size, self.total_count)
|
|
|
|
|
|
class CustomCommandStats(BaseModel):
|
|
"""Statistics about custom commands."""
|
|
total_commands: int
|
|
active_commands: int
|
|
total_creators: int
|
|
total_uses: int
|
|
|
|
# Usage statistics
|
|
most_popular_command: Optional[CustomCommand] = None
|
|
most_active_creator: Optional[CustomCommandCreator] = None
|
|
recent_commands_count: int = 0 # Commands created in last 7 days
|
|
|
|
# Cleanup statistics
|
|
commands_needing_warning: int = 0
|
|
commands_eligible_for_deletion: int = 0
|
|
|
|
@property
|
|
def average_uses_per_command(self) -> float:
|
|
"""Calculate average uses per command."""
|
|
if self.active_commands == 0:
|
|
return 0.0
|
|
return self.total_uses / self.active_commands
|
|
|
|
@property
|
|
def average_commands_per_creator(self) -> float:
|
|
"""Calculate average commands per creator."""
|
|
if self.total_creators == 0:
|
|
return 0.0
|
|
return self.active_commands / self.total_creators |