Add comprehensive admin-managed help system for league documentation, resources, FAQs, and guides. Replaces planned /links command with a more flexible and powerful solution. Features: - Full CRUD operations via Discord commands (/help, /help-create, /help-edit, /help-delete, /help-list) - Permission-based access control (admins + Help Editor role) - Markdown-formatted content with category organization - View tracking and analytics - Soft delete with restore capability - Full audit trail (creator, editor, timestamps) - Autocomplete for topic discovery - Interactive modals and paginated list views Implementation: - New models/help_command.py with Pydantic validation - New services/help_commands_service.py with full CRUD API integration - New views/help_commands.py with interactive modals and views - New commands/help/ package with command handlers - Comprehensive README.md documentation in commands/help/ - Test coverage for models and services Configuration: - Added HELP_EDITOR_ROLE_NAME constant to constants.py - Updated bot.py to load help commands - Updated PRE_LAUNCH_ROADMAP.md to mark system as complete - Updated CLAUDE.md documentation Requires database migration for help_commands table. See .claude/DATABASE_MIGRATION_HELP_COMMANDS.md for details. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
228 lines
7.5 KiB
Python
228 lines
7.5 KiB
Python
"""
|
|
Help Command models for Discord Bot v2.0
|
|
|
|
Modern Pydantic models for the custom help system with full type safety.
|
|
Allows admins and help editors to create custom help topics for league documentation,
|
|
resources, FAQs, links, and guides.
|
|
"""
|
|
from datetime import datetime
|
|
from typing import Optional
|
|
import re
|
|
|
|
from pydantic import BaseModel, Field, field_validator
|
|
from models.base import SBABaseModel
|
|
|
|
|
|
class HelpCommand(SBABaseModel):
|
|
"""A help topic created by an admin or help editor."""
|
|
id: int = Field(..., description="Database ID") # type: ignore
|
|
name: str = Field(..., description="Help topic name (unique)")
|
|
title: str = Field(..., description="Display title")
|
|
content: str = Field(..., description="Help content (markdown supported)")
|
|
category: Optional[str] = Field(None, description="Category for organization")
|
|
|
|
# Audit fields
|
|
created_by_discord_id: str = Field(..., description="Creator Discord ID (stored as text)")
|
|
created_at: datetime = Field(..., description="When help topic was created") # type: ignore
|
|
updated_at: Optional[datetime] = Field(None, description="When help topic was last updated") # type: ignore
|
|
last_modified_by: Optional[str] = Field(None, description="Discord ID of last editor (stored as text)")
|
|
|
|
# Status and metrics
|
|
is_active: bool = Field(True, description="Whether help topic is active (soft delete)")
|
|
view_count: int = Field(0, description="Number of times viewed")
|
|
display_order: int = Field(0, description="Sort order for display")
|
|
|
|
@field_validator('name')
|
|
@classmethod
|
|
def validate_name(cls, v):
|
|
"""Validate help topic name."""
|
|
if not v or len(v.strip()) == 0:
|
|
raise ValueError("Help topic name cannot be empty")
|
|
|
|
name = v.strip().lower()
|
|
|
|
# Length validation
|
|
if len(name) < 2:
|
|
raise ValueError("Help topic name must be at least 2 characters")
|
|
if len(name) > 32:
|
|
raise ValueError("Help topic name cannot exceed 32 characters")
|
|
|
|
# Character validation - only allow alphanumeric, dashes, underscores
|
|
if not re.match(r'^[a-z0-9_-]+$', name):
|
|
raise ValueError("Help topic name can only contain letters, numbers, dashes, and underscores")
|
|
|
|
return name.lower()
|
|
|
|
@field_validator('title')
|
|
@classmethod
|
|
def validate_title(cls, v):
|
|
"""Validate help topic title."""
|
|
if not v or len(v.strip()) == 0:
|
|
raise ValueError("Help topic title cannot be empty")
|
|
|
|
title = v.strip()
|
|
|
|
# Length validation
|
|
if len(title) > 200:
|
|
raise ValueError("Help topic title cannot exceed 200 characters")
|
|
|
|
return title
|
|
|
|
@field_validator('content')
|
|
@classmethod
|
|
def validate_content(cls, v):
|
|
"""Validate help topic content."""
|
|
if not v or len(v.strip()) == 0:
|
|
raise ValueError("Help topic content cannot be empty")
|
|
|
|
content = v.strip()
|
|
|
|
# Length validation
|
|
if len(content) > 4000:
|
|
raise ValueError("Help topic content cannot exceed 4000 characters")
|
|
|
|
# Basic content filtering (still allow @mentions in help content)
|
|
# We allow @everyone and @here in help content since it's admin-controlled
|
|
|
|
return content
|
|
|
|
@field_validator('category')
|
|
@classmethod
|
|
def validate_category(cls, v):
|
|
"""Validate category if provided."""
|
|
if v is None:
|
|
return v
|
|
|
|
category = v.strip().lower()
|
|
|
|
if len(category) == 0:
|
|
return None # Empty string becomes None
|
|
|
|
# Length validation
|
|
if len(category) > 50:
|
|
raise ValueError("Category cannot exceed 50 characters")
|
|
|
|
# Character validation
|
|
if not re.match(r'^[a-z0-9_-]+$', category):
|
|
raise ValueError("Category can only contain letters, numbers, dashes, and underscores")
|
|
|
|
return category
|
|
|
|
@property
|
|
def is_deleted(self) -> bool:
|
|
"""Check if help topic is soft deleted."""
|
|
return not self.is_active
|
|
|
|
@property
|
|
def days_since_update(self) -> Optional[int]:
|
|
"""Calculate days since last update."""
|
|
if not self.updated_at:
|
|
return None
|
|
return (datetime.now() - self.updated_at).days
|
|
|
|
@property
|
|
def days_since_creation(self) -> int:
|
|
"""Calculate days since creation."""
|
|
return (datetime.now() - self.created_at).days
|
|
|
|
@property
|
|
def popularity_score(self) -> float:
|
|
"""
|
|
Calculate popularity score based on view count and recency.
|
|
Higher score = more popular topic.
|
|
"""
|
|
if self.view_count == 0:
|
|
return 0.0
|
|
|
|
# Base score from views
|
|
base_score = min(self.view_count / 10.0, 10.0) # Max 10 points from views
|
|
|
|
# Recency modifier based on creation date
|
|
days_old = self.days_since_creation
|
|
if days_old <= 7:
|
|
recency_modifier = 1.5 # New topic bonus
|
|
elif days_old <= 30:
|
|
recency_modifier = 1.2 # Recent bonus
|
|
elif days_old <= 90:
|
|
recency_modifier = 1.0 # No modifier
|
|
else:
|
|
recency_modifier = 0.8 # Older topic slight penalty
|
|
|
|
return base_score * recency_modifier
|
|
|
|
|
|
class HelpCommandSearchFilters(BaseModel):
|
|
"""Filters for searching help commands."""
|
|
name_contains: Optional[str] = None
|
|
category: Optional[str] = None
|
|
is_active: bool = True
|
|
|
|
# Sorting
|
|
sort_by: str = Field('name', description="Sort field: name, category, created_at, view_count, display_order")
|
|
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', 'title', 'category', 'created_at', 'updated_at', 'view_count', 'display_order'}
|
|
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 HelpCommandSearchResult(BaseModel):
|
|
"""Result of a help command search."""
|
|
help_commands: list[HelpCommand]
|
|
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 HelpCommandStats(BaseModel):
|
|
"""Statistics about help commands."""
|
|
total_commands: int
|
|
active_commands: int
|
|
total_views: int
|
|
most_viewed_command: Optional[HelpCommand] = None
|
|
recent_commands_count: int = 0 # Commands created in last 7 days
|
|
|
|
@property
|
|
def average_views_per_command(self) -> float:
|
|
"""Calculate average views per command."""
|
|
if self.active_commands == 0:
|
|
return 0.0
|
|
return self.total_views / self.active_commands
|