major-domo-v2/models/custom_command.py
Cal Corum 1575d4f096 CLAUDE: Add /jump command and improve dice rolling with team colors, plus admin and type safety fixes
Added /jump command for baserunner stealing mechanics with pickoff/balk detection.
Enhanced dice rolling commands with team color support in embeds.
Improved /admin-sync with local/global options and prefix command fallback.
Fixed type safety issues in admin commands and injury management.
Updated config for expanded draft rounds and testing mode.

Key changes:
- commands/dice/rolls.py: New /jump and !j commands with special cases for pickoff (d20=1) and balk (d20=2)
- commands/dice/rolls.py: Added team/channel color support to /ab and dice embeds
- commands/dice/rolls.py: Added pitcher position to /fielding command with proper range/error charts
- commands/admin/management.py: Enhanced /admin-sync with local/clear options and !admin-sync prefix fallback
- commands/admin/management.py: Fixed Member type checking and channel type validation
- commands/injuries/management.py: Fixed responder team detection for injury clearing
- models/custom_command.py: Made creator_id optional for execute endpoint compatibility
- config.py: Updated draft_rounds to 32 and enabled testing mode
- services/transaction_builder.py: Adjusted ML roster limit to 26

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-20 15:10:48 -05:00

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: Optional[int] = Field(None, description="ID of the creator (may be missing from execute endpoint)")
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