major-domo-v2/services/help_commands_service.py
Cal Corum bcd6a10aef CLAUDE: Implement custom help commands system
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>
2025-10-10 13:14:13 -05:00

512 lines
16 KiB
Python

"""
Help Commands Service for Discord Bot v2.0
Modern async service layer for managing help commands with full type safety.
Allows admins and help editors to create custom help topics for league documentation,
resources, FAQs, links, and guides.
"""
import math
from typing import Optional, List
from utils.logging import get_contextual_logger
from models.help_command import (
HelpCommand,
HelpCommandSearchFilters,
HelpCommandSearchResult,
HelpCommandStats
)
from services.base_service import BaseService
from exceptions import BotException
class HelpCommandNotFoundError(BotException):
"""Raised when a help command is not found."""
pass
class HelpCommandExistsError(BotException):
"""Raised when trying to create a help command that already exists."""
pass
class HelpCommandPermissionError(BotException):
"""Raised when user lacks permission for help command operation."""
pass
class HelpCommandsService(BaseService[HelpCommand]):
"""Service for managing help commands."""
def __init__(self):
super().__init__(HelpCommand, 'help_commands')
self.logger = get_contextual_logger(f'{__name__}.HelpCommandsService')
self.logger.info("HelpCommandsService initialized")
# === Command CRUD Operations ===
async def create_help(
self,
name: str,
title: str,
content: str,
creator_discord_id: str,
category: Optional[str] = None,
display_order: int = 0
) -> HelpCommand:
"""
Create a new help command.
Args:
name: Help topic name (will be validated and normalized)
title: Display title
content: Help content (markdown supported)
creator_discord_id: Discord ID of the creator
category: Optional category for organization
display_order: Sort order for display (default: 0)
Returns:
The created HelpCommand
Raises:
HelpCommandExistsError: If help topic name already exists
ValidationError: If name, title, or content fails validation
"""
# Check if help topic already exists
try:
await self.get_help_by_name(name)
raise HelpCommandExistsError(f"Help topic '{name}' already exists")
except HelpCommandNotFoundError:
# Help topic doesn't exist, which is what we want
pass
# Create help command data
help_data = {
'name': name.lower().strip(),
'title': title.strip(),
'content': content.strip(),
'category': category.lower().strip() if category else None,
'created_by_discord_id': str(creator_discord_id), # Convert to string for safe storage
'display_order': display_order,
'is_active': True,
'view_count': 0
}
# Create via API
result = await self.create(help_data)
if not result:
raise BotException("Failed to create help command")
self.logger.info("Help command created",
help_name=name,
creator_id=creator_discord_id,
category=category)
# Return full help command
return await self.get_help_by_name(name)
async def get_help_by_name(
self,
name: str,
include_inactive: bool = False
) -> HelpCommand:
"""
Get a help command by name.
Args:
name: Help topic name to search for
include_inactive: Whether to include soft-deleted topics
Returns:
HelpCommand if found
Raises:
HelpCommandNotFoundError: If help command not found
"""
normalized_name = name.lower().strip()
try:
# Use the dedicated by_name endpoint for exact lookup
client = await self.get_client()
params = [('include_inactive', include_inactive)] if include_inactive else []
data = await client.get(f'help_commands/by_name/{normalized_name}', params=params)
if not data:
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
# Convert API data to HelpCommand
return self.model_class.from_api_data(data)
except Exception as e:
if "404" in str(e) or "not found" in str(e).lower():
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
else:
self.logger.error("Failed to get help command by name",
help_name=name,
error=e)
raise BotException(f"Failed to retrieve help topic '{name}': {e}")
async def update_help(
self,
name: str,
new_title: Optional[str] = None,
new_content: Optional[str] = None,
updater_discord_id: Optional[str] = None,
new_category: Optional[str] = None,
new_display_order: Optional[int] = None
) -> HelpCommand:
"""
Update an existing help command.
Args:
name: Help topic name to update
new_title: New title (optional)
new_content: New content (optional)
updater_discord_id: Discord ID of user making the update
new_category: New category (optional)
new_display_order: New display order (optional)
Returns:
Updated HelpCommand
Raises:
HelpCommandNotFoundError: If help command doesn't exist
"""
help_cmd = await self.get_help_by_name(name)
# Prepare update data
update_data = {}
if new_title is not None:
update_data['title'] = new_title.strip()
if new_content is not None:
update_data['content'] = new_content.strip()
if new_category is not None:
update_data['category'] = new_category.lower().strip() if new_category else None
if new_display_order is not None:
update_data['display_order'] = new_display_order
if updater_discord_id is not None:
update_data['last_modified_by'] = str(updater_discord_id) # Convert to string for safe storage
if not update_data:
raise BotException("No fields to update")
# Update via API
client = await self.get_client()
result = await client.put(f'help_commands/{help_cmd.id}', update_data)
if not result:
raise BotException("Failed to update help command")
self.logger.info("Help command updated",
help_name=name,
updater_id=updater_discord_id,
fields_updated=list(update_data.keys()))
return await self.get_help_by_name(name)
async def delete_help(self, name: str) -> bool:
"""
Soft delete a help command (sets is_active = FALSE).
Args:
name: Help topic name to delete
Returns:
True if successfully deleted
Raises:
HelpCommandNotFoundError: If help command doesn't exist
"""
help_cmd = await self.get_help_by_name(name)
# Soft delete via API
client = await self.get_client()
await client.delete(f'help_commands/{help_cmd.id}')
self.logger.info("Help command soft deleted",
help_name=name,
help_id=help_cmd.id)
return True
async def restore_help(self, name: str) -> HelpCommand:
"""
Restore a soft-deleted help command.
Args:
name: Help topic name to restore
Returns:
Restored HelpCommand
Raises:
HelpCommandNotFoundError: If help command doesn't exist
"""
# Get help command including inactive ones
help_cmd = await self.get_help_by_name(name, include_inactive=True)
if help_cmd.is_active:
raise BotException(f"Help topic '{name}' is already active")
# Restore via API
client = await self.get_client()
result = await client.patch(f'help_commands/{help_cmd.id}/restore')
if not result:
raise BotException("Failed to restore help command")
self.logger.info("Help command restored",
help_name=name,
help_id=help_cmd.id)
return self.model_class.from_api_data(result)
async def increment_view_count(self, name: str) -> HelpCommand:
"""
Increment view count for a help command.
Args:
name: Help topic name
Returns:
Updated HelpCommand
Raises:
HelpCommandNotFoundError: If help command doesn't exist
"""
normalized_name = name.lower().strip()
try:
client = await self.get_client()
await client.patch(f'help_commands/by_name/{normalized_name}/view')
self.logger.debug("Help command view count incremented",
help_name=name)
# Return updated command
return await self.get_help_by_name(name)
except Exception as e:
if "404" in str(e) or "not found" in str(e).lower():
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
else:
self.logger.error("Failed to increment view count",
help_name=name,
error=e)
raise BotException(f"Failed to increment view count for '{name}': {e}")
# === Search and Listing ===
async def search_help_commands(
self,
filters: HelpCommandSearchFilters
) -> HelpCommandSearchResult:
"""
Search for help commands with filtering and pagination.
Args:
filters: Search filters and pagination options
Returns:
HelpCommandSearchResult with matching commands
"""
# Build search parameters
params = []
# Apply filters
if filters.name_contains:
params.append(('name', filters.name_contains)) # API will do ILIKE search
if filters.category:
params.append(('category', filters.category))
params.append(('is_active', filters.is_active))
# Add sorting
params.append(('sort', filters.sort_by))
# Add pagination
params.append(('page', filters.page))
params.append(('page_size', filters.page_size))
# Execute search via API
client = await self.get_client()
data = await client.get('help_commands', params=params)
if not data:
return HelpCommandSearchResult(
help_commands=[],
total_count=0,
page=filters.page,
page_size=filters.page_size,
total_pages=0,
has_more=False
)
# Extract response data
help_commands_data = data.get('help_commands', [])
total_count = data.get('total_count', 0)
total_pages = data.get('total_pages', 0)
has_more = data.get('has_more', False)
# Convert to HelpCommand objects
help_commands = []
for cmd_data in help_commands_data:
try:
help_commands.append(self.model_class.from_api_data(cmd_data))
except Exception as e:
self.logger.warning("Failed to create HelpCommand from API data",
help_id=cmd_data.get('id'),
error=e)
continue
self.logger.debug("Help commands search completed",
total_results=total_count,
page=filters.page,
filters_applied=len([p for p in params if p[0] not in ['sort', 'page', 'page_size']]))
return HelpCommandSearchResult(
help_commands=help_commands,
total_count=total_count,
page=filters.page,
page_size=filters.page_size,
total_pages=total_pages,
has_more=has_more
)
async def get_all_help_topics(
self,
category: Optional[str] = None,
include_inactive: bool = False
) -> List[HelpCommand]:
"""
Get all help topics, optionally filtered by category.
Args:
category: Optional category filter
include_inactive: Whether to include soft-deleted topics
Returns:
List of HelpCommand objects
"""
params = []
if category:
params.append(('category', category))
params.append(('is_active', not include_inactive))
params.append(('sort', 'display_order'))
params.append(('page_size', 100)) # Get all
client = await self.get_client()
data = await client.get('help_commands', params=params)
if not data:
return []
help_commands_data = data.get('help_commands', [])
help_commands = []
for cmd_data in help_commands_data:
try:
help_commands.append(self.model_class.from_api_data(cmd_data))
except Exception as e:
self.logger.warning("Failed to create HelpCommand from API data",
help_id=cmd_data.get('id'),
error=e)
continue
return help_commands
async def get_help_names_for_autocomplete(
self,
partial_name: str = "",
limit: int = 25,
include_inactive: bool = False
) -> List[str]:
"""
Get help command names for Discord autocomplete.
Args:
partial_name: Partial help topic name to match
limit: Maximum number of suggestions
include_inactive: Whether to include soft-deleted topics
Returns:
List of help topic names matching the partial input
"""
try:
# Use the dedicated autocomplete endpoint
client = await self.get_client()
params = [('limit', limit)]
if partial_name:
params.append(('q', partial_name.lower()))
result = await client.get('help_commands/autocomplete', params=params)
# The autocomplete endpoint returns results with name, title, category
if isinstance(result, dict) and 'results' in result:
return [item['name'] for item in result['results']]
else:
self.logger.warning("Unexpected autocomplete response format",
response=result)
return []
except Exception as e:
self.logger.error("Failed to get help names for autocomplete",
partial_name=partial_name,
error=e)
# Return empty list on error to not break Discord autocomplete
return []
# === Statistics ===
async def get_statistics(self) -> HelpCommandStats:
"""Get comprehensive statistics about help commands."""
try:
client = await self.get_client()
data = await client.get('help_commands/stats')
if not data:
return HelpCommandStats(
total_commands=0,
active_commands=0,
total_views=0,
most_viewed_command=None,
recent_commands_count=0
)
# Convert most_viewed_command if present
most_viewed = None
if data.get('most_viewed_command'):
try:
most_viewed = self.model_class.from_api_data(data['most_viewed_command'])
except Exception as e:
self.logger.warning("Failed to parse most viewed command", error=e)
return HelpCommandStats(
total_commands=data.get('total_commands', 0),
active_commands=data.get('active_commands', 0),
total_views=data.get('total_views', 0),
most_viewed_command=most_viewed,
recent_commands_count=data.get('recent_commands_count', 0)
)
except Exception as e:
self.logger.error("Failed to get help command statistics", error=e)
# Return empty stats on error
return HelpCommandStats(
total_commands=0,
active_commands=0,
total_views=0,
most_viewed_command=None,
recent_commands_count=0
)
# Global service instance
help_commands_service = HelpCommandsService()