major-domo-v2/views/help_commands.py
Cal Corum 213e754673 Add input validation for help command name and category
The help command creation modal was accepting names with spaces and
special characters (e.g., "scorecard links"), which passed to the API
but caused Pydantic validation errors when reading the records back.

Changes:
- Add regex validation in modal on_submit for topic name and category
- Only allow lowercase letters, numbers, dashes, and underscores
- Show clear error messages with valid examples when validation fails
- Normalize name/category to lowercase before storing

This prevents invalid records from being created in the database.

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

Co-Authored-By: Claude <noreply@anthropic.com>
2025-12-11 19:11:42 -06:00

432 lines
15 KiB
Python

"""
Help Command Views for Discord Bot v2.0
Interactive views and modals for the custom help system.
"""
from typing import Optional, List
import discord
from views.base import BaseView, ConfirmationView, PaginationView
from views.embeds import EmbedTemplate, EmbedColors
from views.modals import BaseModal
from models.help_command import HelpCommand, HelpCommandSearchResult
from utils.logging import get_contextual_logger
from exceptions import BotException
class HelpCommandCreateModal(BaseModal):
"""Modal for creating a new help topic."""
def __init__(self, *, timeout: Optional[float] = 300.0):
super().__init__(title="Create Help Topic", timeout=timeout)
self.topic_name = discord.ui.TextInput(
label="Topic Name",
placeholder="e.g., trading-rules (2-32 chars, letters/numbers/dashes)",
required=True,
min_length=2,
max_length=32
)
self.topic_title = discord.ui.TextInput(
label="Display Title",
placeholder="e.g., Trading Rules & Guidelines",
required=True,
min_length=1,
max_length=200
)
self.topic_category = discord.ui.TextInput(
label="Category (Optional)",
placeholder="e.g., rules, guides, resources, info, faq",
required=False,
max_length=50
)
self.topic_content = discord.ui.TextInput(
label="Content",
placeholder="Help content (markdown supported, max 4000 chars)",
style=discord.TextStyle.paragraph,
required=True,
min_length=1,
max_length=4000
)
self.add_item(self.topic_name)
self.add_item(self.topic_title)
self.add_item(self.topic_category)
self.add_item(self.topic_content)
async def on_submit(self, interaction: discord.Interaction):
"""Handle form submission."""
import re
# Validate topic name format
name = self.topic_name.value.strip().lower()
if not re.match(r'^[a-z0-9_-]+$', name):
embed = EmbedTemplate.error(
title="Invalid Topic Name",
description=(
f"Topic name `{self.topic_name.value}` contains invalid characters.\n\n"
"**Allowed:** lowercase letters, numbers, dashes, and underscores only.\n"
"**Examples:** `trading-rules`, `how_to_draft`, `faq1`\n\n"
"Please try again with a valid name."
)
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Validate category format if provided
category = self.topic_category.value.strip().lower() if self.topic_category.value else None
if category and not re.match(r'^[a-z0-9_-]+$', category):
embed = EmbedTemplate.error(
title="Invalid Category",
description=(
f"Category `{self.topic_category.value}` contains invalid characters.\n\n"
"**Allowed:** lowercase letters, numbers, dashes, and underscores only.\n"
"**Examples:** `rules`, `guides`, `faq`\n\n"
"Please try again with a valid category."
)
)
await interaction.response.send_message(embed=embed, ephemeral=True)
return
# Store results
self.result = {
'name': name,
'title': self.topic_title.value.strip(),
'content': self.topic_content.value.strip(),
'category': category
}
self.is_submitted = True
# Create preview embed
embed = EmbedTemplate.info(
title="Help Topic Preview",
description="Here's how your help topic will look:"
)
embed.add_field(
name="Name",
value=f"`/help {self.result['name']}`",
inline=True
)
embed.add_field(
name="Category",
value=self.result['category'] or "None",
inline=True
)
embed.add_field(
name="Title",
value=self.result['title'],
inline=False
)
# Show content preview (truncated if too long)
content_preview = self.result['content'][:500] + ('...' if len(self.result['content']) > 500 else '')
embed.add_field(
name="Content",
value=content_preview,
inline=False
)
embed.set_footer(text="Creating this help topic will make it available to all server members")
await interaction.response.send_message(embed=embed, ephemeral=True)
class HelpCommandEditModal(BaseModal):
"""Modal for editing an existing help topic."""
def __init__(self, help_command: HelpCommand, *, timeout: Optional[float] = 300.0):
super().__init__(title=f"Edit: {help_command.name}", timeout=timeout)
self.original_help = help_command
self.topic_title = discord.ui.TextInput(
label="Display Title",
placeholder="e.g., Trading Rules & Guidelines",
default=help_command.title,
required=True,
min_length=1,
max_length=200
)
self.topic_category = discord.ui.TextInput(
label="Category (Optional)",
placeholder="e.g., rules, guides, resources, info, faq",
default=help_command.category or '',
required=False,
max_length=50
)
self.topic_content = discord.ui.TextInput(
label="Content",
placeholder="Help content (markdown supported, max 4000 chars)",
style=discord.TextStyle.paragraph,
default=help_command.content,
required=True,
min_length=1,
max_length=4000
)
self.add_item(self.topic_title)
self.add_item(self.topic_category)
self.add_item(self.topic_content)
async def on_submit(self, interaction: discord.Interaction):
"""Handle form submission."""
# Store results
self.result = {
'name': self.original_help.name,
'title': self.topic_title.value.strip(),
'content': self.topic_content.value.strip(),
'category': self.topic_category.value.strip() if self.topic_category.value else None
}
self.is_submitted = True
# Create preview embed showing changes
embed = EmbedTemplate.info(
title="Help Topic Edit Preview",
description=f"Changes to `/help {self.original_help.name}`:"
)
# Show title changes if different
if self.original_help.title != self.result['title']:
embed.add_field(name="Old Title", value=self.original_help.title, inline=True)
embed.add_field(name="New Title", value=self.result['title'], inline=True)
embed.add_field(name="\u200b", value="\u200b", inline=True) # Spacer
# Show category changes
old_cat = self.original_help.category or "None"
new_cat = self.result['category'] or "None"
if old_cat != new_cat:
embed.add_field(name="Old Category", value=old_cat, inline=True)
embed.add_field(name="New Category", value=new_cat, inline=True)
embed.add_field(name="\u200b", value="\u200b", inline=True) # Spacer
# Show content preview (always show since it's the main field)
old_content = self.original_help.content[:300] + ('...' if len(self.original_help.content) > 300 else '')
new_content = self.result['content'][:300] + ('...' if len(self.result['content']) > 300 else '')
embed.add_field(
name="Old Content",
value=old_content,
inline=False
)
embed.add_field(
name="New Content",
value=new_content,
inline=False
)
embed.set_footer(text="Changes will be visible to all server members")
await interaction.response.send_message(embed=embed, ephemeral=True)
class HelpCommandDeleteConfirmView(BaseView):
"""Confirmation view for deleting a help topic."""
def __init__(self, help_command: HelpCommand, *, user_id: int, timeout: float = 180.0):
super().__init__(timeout=timeout, user_id=user_id)
self.help_command = help_command
self.result = None
@discord.ui.button(label="Delete Topic", emoji="🗑️", style=discord.ButtonStyle.danger, row=0)
async def confirm_delete(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Confirm the topic deletion."""
self.result = True
embed = EmbedTemplate.success(
title="Help Topic Deleted",
description=f"The help topic `/help {self.help_command.name}` has been deleted (soft delete)."
)
embed.add_field(
name="Note",
value="This topic can be restored later if needed using admin commands.",
inline=False
)
# Disable all buttons
for item in self.children:
if hasattr(item, 'disabled'):
item.disabled = True # type: ignore
await interaction.response.edit_message(embed=embed, view=self)
self.stop()
@discord.ui.button(label="Cancel", emoji="", style=discord.ButtonStyle.secondary, row=0)
async def cancel_delete(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Cancel the topic deletion."""
self.result = False
embed = EmbedTemplate.info(
title="Deletion Cancelled",
description=f"The help topic `/help {self.help_command.name}` was not deleted."
)
# Disable all buttons
for item in self.children:
if hasattr(item, 'disabled'):
item.disabled = True # type: ignore
await interaction.response.edit_message(embed=embed, view=self)
self.stop()
class HelpCommandListView(BaseView):
"""Paginated view for browsing help topics."""
def __init__(
self,
help_commands: List[HelpCommand],
user_id: Optional[int] = None,
category_filter: Optional[str] = None,
*,
timeout: float = 300.0
):
super().__init__(timeout=timeout, user_id=user_id)
self.help_commands = help_commands
self.category_filter = category_filter
self.current_page = 0
self.topics_per_page = 10
self._update_buttons()
def _update_buttons(self):
"""Update button states based on current page."""
total_pages = max(1, (len(self.help_commands) + self.topics_per_page - 1) // self.topics_per_page)
self.previous_page.disabled = self.current_page == 0
self.next_page.disabled = self.current_page >= total_pages - 1
# Update page info
self.page_info.label = f"Page {self.current_page + 1}/{total_pages}"
def _get_current_topics(self) -> List[HelpCommand]:
"""Get help topics for current page."""
start_idx = self.current_page * self.topics_per_page
end_idx = start_idx + self.topics_per_page
return self.help_commands[start_idx:end_idx]
def _create_embed(self) -> discord.Embed:
"""Create embed for current page."""
current_topics = self._get_current_topics()
title = "📚 Help Topics"
if self.category_filter:
title += f" - {self.category_filter.title()}"
description = f"Found {len(self.help_commands)} help topic{'s' if len(self.help_commands) != 1 else ''}"
embed = EmbedTemplate.create_base_embed(
title=title,
description=description,
color=EmbedColors.INFO
)
if not current_topics:
embed.add_field(
name="No Topics",
value="No help topics found. Admins can create topics using `/help-create`.",
inline=False
)
else:
# Group by category for better organization
by_category = {}
for topic in current_topics:
cat = topic.category or "Uncategorized"
if cat not in by_category:
by_category[cat] = []
by_category[cat].append(topic)
for category, topics in sorted(by_category.items()):
topic_list = []
for topic in topics:
views_text = f"{topic.view_count} views" if topic.view_count > 0 else ""
topic_list.append(f"• `/help {topic.name}` - {topic.title}{views_text}")
embed.add_field(
name=f"📂 {category}",
value='\n'.join(topic_list),
inline=False
)
embed.set_footer(text="Use /help <topic-name> to view a specific topic")
return embed
@discord.ui.button(emoji="◀️", style=discord.ButtonStyle.secondary, row=0)
async def previous_page(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Go to previous page."""
self.current_page = max(0, self.current_page - 1)
self._update_buttons()
embed = self._create_embed()
await interaction.response.edit_message(embed=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 (disabled button)."""
pass
@discord.ui.button(emoji="▶️", style=discord.ButtonStyle.secondary, row=0)
async def next_page(self, interaction: discord.Interaction, button: discord.ui.Button):
"""Go to next page."""
total_pages = max(1, (len(self.help_commands) + self.topics_per_page - 1) // self.topics_per_page)
self.current_page = min(total_pages - 1, self.current_page + 1)
self._update_buttons()
embed = self._create_embed()
await interaction.response.edit_message(embed=embed, view=self)
async def on_timeout(self):
"""Handle view timeout."""
for item in self.children:
if hasattr(item, 'disabled'):
item.disabled = True # type: ignore
def get_embed(self) -> discord.Embed:
"""Get the embed for this view."""
return self._create_embed()
def create_help_topic_embed(help_command: HelpCommand) -> discord.Embed:
"""
Create a formatted embed for displaying a help topic.
Args:
help_command: The help command to display
Returns:
Formatted discord.Embed
"""
embed = EmbedTemplate.create_base_embed(
title=help_command.title,
description=help_command.content,
color=EmbedColors.INFO
)
# Add metadata footer
footer_text = f"Topic: {help_command.name}"
if help_command.category:
footer_text += f" • Category: {help_command.category}"
if help_command.view_count > 0:
footer_text += f" • Viewed {help_command.view_count} times"
embed.set_footer(text=footer_text)
# Add timestamps if available
if help_command.updated_at:
embed.timestamp = help_command.updated_at
else:
embed.timestamp = help_command.created_at
return embed