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>
This commit is contained in:
parent
7d422f50c5
commit
bcd6a10aef
@ -59,16 +59,25 @@ This document outlines the remaining functionality required before the Discord B
|
||||
- **Data Storage**: `storage/charts.json` with JSON persistence
|
||||
- **Completed**: January 2025
|
||||
|
||||
#### 4. League Resources Links
|
||||
- **Command**: `/links <resource-name>`
|
||||
- **Description**: Quick access to league resources and external links
|
||||
- **Features**:
|
||||
- Categorized resource library (rules, schedules, tools)
|
||||
- URL validation and testing
|
||||
- Admin management interface
|
||||
- Search/autocomplete for resource names
|
||||
- **Data Storage**: JSON config file or database entries
|
||||
- **Estimated Effort**: 2-3 hours
|
||||
#### 4. Custom Help System **✅ COMPLETED**
|
||||
- **Commands**: `/help [topic]`, `/help-create`, `/help-edit`, `/help-delete`, `/help-list`
|
||||
- **Status**: Complete and ready for deployment (requires database migration)
|
||||
- **Description**: Comprehensive help system for league documentation, resources, FAQs, and guides
|
||||
- **Features Implemented**:
|
||||
- Create/edit/delete help topics (admin + "Help Editor" role)
|
||||
- Categorized help library (rules, guides, resources, info, faq)
|
||||
- Autocomplete for topic discovery
|
||||
- Markdown-formatted content
|
||||
- View tracking and analytics
|
||||
- Soft delete with restore capability
|
||||
- Full audit trail (who created, who modified)
|
||||
- Interactive modals for creation/editing
|
||||
- Paginated list views
|
||||
- Permission-based access control
|
||||
- **Data Storage**: PostgreSQL table `help_commands` via API
|
||||
- **Replaces**: Planned `/links` command (more flexible solution)
|
||||
- **Documentation**: See `commands/help/README.md` and `.claude/DATABASE_MIGRATION_HELP_COMMANDS.md`
|
||||
- **Completed**: January 2025
|
||||
|
||||
### 🖼️ User Profile Management
|
||||
|
||||
@ -131,7 +140,7 @@ This document outlines the remaining functionality required before the Discord B
|
||||
### Phase 2: Core Utilities
|
||||
2. ✅ **Weather Command** - Complete with smart team resolution (January 2025)
|
||||
3. ✅ **Charts System** - Complete with admin management and 12 charts (January 2025)
|
||||
4. **Links System** - Administrative convenience
|
||||
4. 🚧 **Help System** - In progress - comprehensive help topics with CRUD capabilities
|
||||
|
||||
### Phase 3: User Features (Week 2)
|
||||
5. **Image Management** - User profile customization
|
||||
@ -270,7 +279,7 @@ commands/
|
||||
- ✅ Trading System: Complete (saved 6-8 hours)
|
||||
- ✅ Weather Command: Complete (saved 3-4 hours)
|
||||
- ✅ Charts System: Complete (saved 2-3 hours)
|
||||
- Remaining: Links (2-3h), Images (2-3h), Memes (1-2h), Scout (3-4h)
|
||||
- Remaining: Help System (2-3h), Images (2-3h), Memes (1-2h), Scout (3-4h)
|
||||
- **API Costs**: None required (weather is gameplay dice rolling, not real weather)
|
||||
- **Database Storage**: Minimal increase for new features
|
||||
- **Hosting Resources**: Current infrastructure sufficient
|
||||
@ -278,4 +287,4 @@ commands/
|
||||
---
|
||||
|
||||
**Target Timeline: 1 week for complete pre-launch readiness**
|
||||
**Next Steps: Proceed with Links system, then user features (image management, meme commands)**
|
||||
**Next Steps: Implement Help system (replaces /links with more flexible solution), then user features (image management, meme commands)**
|
||||
2
bot.py
2
bot.py
@ -117,6 +117,7 @@ class SBABot(commands.Bot):
|
||||
from commands.dice import setup_dice
|
||||
from commands.voice import setup_voice
|
||||
from commands.utilities import setup_utilities
|
||||
from commands.help import setup_help_commands
|
||||
|
||||
# Define command packages to load
|
||||
command_packages = [
|
||||
@ -129,6 +130,7 @@ class SBABot(commands.Bot):
|
||||
("dice", setup_dice),
|
||||
("voice", setup_voice),
|
||||
("utilities", setup_utilities),
|
||||
("help", setup_help_commands),
|
||||
]
|
||||
|
||||
total_successful = 0
|
||||
|
||||
433
commands/help/README.md
Normal file
433
commands/help/README.md
Normal file
@ -0,0 +1,433 @@
|
||||
# Help Commands System
|
||||
|
||||
**Last Updated:** January 2025
|
||||
**Status:** ✅ Fully Implemented
|
||||
**Location:** `commands/help/`
|
||||
|
||||
## Overview
|
||||
|
||||
The Help Commands System provides a comprehensive, admin-managed help system for the Discord server. Administrators and designated "Help Editors" can create, edit, and manage custom help topics covering league documentation, resources, FAQs, links, and guides. This system replaces the originally planned `/links` command with a more flexible and powerful solution.
|
||||
|
||||
## Commands
|
||||
|
||||
### `/help [topic]`
|
||||
**Description:** View a help topic or list all available topics
|
||||
**Parameters:**
|
||||
- `topic` (optional): Name of the help topic to view
|
||||
|
||||
**Behavior:**
|
||||
- **With topic**: Displays the specified help topic with formatted content
|
||||
- **Without topic**: Shows a paginated list of all available help topics organized by category
|
||||
- Automatically increments view count when a topic is viewed
|
||||
|
||||
**Permissions:** Available to all server members
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
/help trading-rules
|
||||
/help
|
||||
```
|
||||
|
||||
### `/help-create`
|
||||
**Description:** Create a new help topic
|
||||
**Permissions:** Administrators + "Help Editor" role
|
||||
|
||||
**Modal Fields:**
|
||||
- **Topic Name**: URL-safe name (2-32 chars, letters/numbers/dashes only)
|
||||
- **Display Title**: Human-readable title (1-200 chars)
|
||||
- **Category**: Optional category (rules/guides/resources/info/faq)
|
||||
- **Content**: Help content with markdown support (1-4000 chars)
|
||||
|
||||
**Features:**
|
||||
- Real-time validation of all fields
|
||||
- Preview before final creation
|
||||
- Automatic duplicate detection
|
||||
|
||||
**Example:**
|
||||
```
|
||||
Topic Name: trading-rules
|
||||
Display Title: Trading Rules & Guidelines
|
||||
Category: rules
|
||||
Content: [Detailed trading rules with markdown formatting]
|
||||
```
|
||||
|
||||
### `/help-edit <topic>`
|
||||
**Description:** Edit an existing help topic
|
||||
**Parameters:**
|
||||
- `topic` (required): Name of the help topic to edit
|
||||
|
||||
**Permissions:** Administrators + "Help Editor" role
|
||||
|
||||
**Features:**
|
||||
- Pre-populated modal with current values
|
||||
- Shows preview of changes before saving
|
||||
- Tracks last editor and update timestamp
|
||||
- Autocomplete for topic names
|
||||
|
||||
**Example:**
|
||||
```
|
||||
/help-edit trading-rules
|
||||
```
|
||||
|
||||
### `/help-delete <topic>`
|
||||
**Description:** Delete a help topic (soft delete)
|
||||
**Parameters:**
|
||||
- `topic` (required): Name of the help topic to delete
|
||||
|
||||
**Permissions:** Administrators + "Help Editor" role
|
||||
|
||||
**Features:**
|
||||
- Confirmation dialog before deletion
|
||||
- Shows topic statistics (view count)
|
||||
- Soft delete (can be restored later)
|
||||
- Autocomplete for topic names
|
||||
|
||||
**Example:**
|
||||
```
|
||||
/help-delete trading-rules
|
||||
```
|
||||
|
||||
### `/help-list [category] [show_deleted]`
|
||||
**Description:** Browse all help topics
|
||||
**Parameters:**
|
||||
- `category` (optional): Filter by category
|
||||
- `show_deleted` (optional): Show soft-deleted topics (admin only)
|
||||
|
||||
**Permissions:** Available to all (show_deleted requires admin/help editor)
|
||||
|
||||
**Features:**
|
||||
- Organized display by category
|
||||
- Shows view counts
|
||||
- Paginated interface for many topics
|
||||
- Filtered views by category
|
||||
|
||||
**Examples:**
|
||||
```
|
||||
/help-list
|
||||
/help-list category:rules
|
||||
/help-list show_deleted:true
|
||||
```
|
||||
|
||||
## Permission System
|
||||
|
||||
### Roles with Help Edit Permissions
|
||||
1. **Server Administrators** - Full access to all help commands
|
||||
2. **Help Editor Role** - Designated role with editing permissions
|
||||
- Role name: "Help Editor" (configurable in `constants.py`)
|
||||
- Can create, edit, and delete help topics
|
||||
- Cannot view deleted topics unless also admin
|
||||
|
||||
### Permission Checks
|
||||
```python
|
||||
def has_help_edit_permission(interaction: discord.Interaction) -> bool:
|
||||
"""Check if user can edit help commands."""
|
||||
# Admin check
|
||||
if interaction.user.guild_permissions.administrator:
|
||||
return True
|
||||
|
||||
# Help Editor role check
|
||||
role = discord.utils.get(interaction.guild.roles, name=HELP_EDITOR_ROLE_NAME)
|
||||
if role and role in interaction.user.roles:
|
||||
return True
|
||||
|
||||
return False
|
||||
```
|
||||
|
||||
## Architecture
|
||||
|
||||
### Components
|
||||
|
||||
**Models** (`models/help_command.py`):
|
||||
- `HelpCommand`: Main data model with validation
|
||||
- `HelpCommandSearchFilters`: Search/filtering parameters
|
||||
- `HelpCommandSearchResult`: Paginated search results
|
||||
- `HelpCommandStats`: Statistics and analytics
|
||||
|
||||
**Service Layer** (`services/help_commands_service.py`):
|
||||
- `HelpCommandsService`: CRUD operations and business logic
|
||||
- `help_commands_service`: Global service instance
|
||||
- Integrates with BaseService for API calls
|
||||
|
||||
**Views** (`views/help_commands.py`):
|
||||
- `HelpCommandCreateModal`: Interactive creation modal
|
||||
- `HelpCommandEditModal`: Interactive editing modal
|
||||
- `HelpCommandDeleteConfirmView`: Deletion confirmation
|
||||
- `HelpCommandListView`: Paginated topic browser
|
||||
- `create_help_topic_embed()`: Formatted topic display
|
||||
|
||||
**Commands** (`commands/help/main.py`):
|
||||
- `HelpCommands`: Cog with all command handlers
|
||||
- Permission checking integration
|
||||
- Autocomplete for topic names
|
||||
- Error handling and user feedback
|
||||
|
||||
### Data Flow
|
||||
|
||||
1. **User Interaction** → Discord slash command
|
||||
2. **Permission Check** → Validate user permissions
|
||||
3. **Modal Display** → Interactive data input (for create/edit)
|
||||
4. **Service Call** → Business logic and validation
|
||||
5. **API Request** → Database operations via API
|
||||
6. **Response** → Formatted embed with success/error message
|
||||
|
||||
### Database Integration
|
||||
|
||||
**API Endpoints** (via `../database/app/routers_v3/help_commands.py`):
|
||||
- `GET /api/v3/help_commands` - List with filters
|
||||
- `GET /api/v3/help_commands/{id}` - Get by ID
|
||||
- `GET /api/v3/help_commands/by_name/{name}` - Get by name
|
||||
- `POST /api/v3/help_commands` - Create
|
||||
- `PUT /api/v3/help_commands/{id}` - Update
|
||||
- `DELETE /api/v3/help_commands/{id}` - Soft delete
|
||||
- `PATCH /api/v3/help_commands/{id}/restore` - Restore
|
||||
- `PATCH /api/v3/help_commands/by_name/{name}/view` - Increment views
|
||||
- `GET /api/v3/help_commands/autocomplete` - Autocomplete
|
||||
- `GET /api/v3/help_commands/stats` - Statistics
|
||||
|
||||
**Database Table** (`help_commands`):
|
||||
```sql
|
||||
CREATE TABLE help_commands (
|
||||
id SERIAL PRIMARY KEY,
|
||||
name TEXT UNIQUE NOT NULL,
|
||||
title TEXT NOT NULL,
|
||||
content TEXT NOT NULL,
|
||||
category TEXT,
|
||||
created_by_discord_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMP,
|
||||
last_modified_by BIGINT,
|
||||
is_active BOOLEAN DEFAULT TRUE,
|
||||
view_count INTEGER DEFAULT 0,
|
||||
display_order INTEGER DEFAULT 0
|
||||
);
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Soft Delete
|
||||
- Topics are never permanently deleted from the database
|
||||
- `is_active` flag controls visibility
|
||||
- Admins can restore deleted topics (future enhancement)
|
||||
- Full audit trail preserved
|
||||
|
||||
### View Tracking
|
||||
- Automatic view count increment when topics are accessed
|
||||
- Statistics available via API
|
||||
- Most viewed topics tracked
|
||||
|
||||
### Category Organization
|
||||
- Optional categorization of topics
|
||||
- Suggested categories:
|
||||
- `rules` - League rules and regulations
|
||||
- `guides` - How-to guides and tutorials
|
||||
- `resources` - Links to external resources
|
||||
- `info` - General league information
|
||||
- `faq` - Frequently asked questions
|
||||
|
||||
### Markdown Support
|
||||
- Full markdown formatting in content
|
||||
- Support for:
|
||||
- Headers
|
||||
- Bold/italic text
|
||||
- Lists (ordered and unordered)
|
||||
- Links
|
||||
- Code blocks
|
||||
- Blockquotes
|
||||
|
||||
### Autocomplete
|
||||
- Fast topic name suggestions
|
||||
- Searches across names and titles
|
||||
- Limited to 25 suggestions for performance
|
||||
|
||||
## Use Cases
|
||||
|
||||
### Example Help Topics
|
||||
|
||||
**Trading Rules** (`/help trading-rules`):
|
||||
```markdown
|
||||
# Trading Rules & Guidelines
|
||||
|
||||
## Trade Deadline
|
||||
All trades must be completed by Week 15 of the regular season.
|
||||
|
||||
## Restrictions
|
||||
- Maximum 3 trades per team per season
|
||||
- All trades must be approved by league commissioner
|
||||
- No trading draft picks beyond 2 seasons ahead
|
||||
|
||||
## How to Propose a Trade
|
||||
Use the `/trade` command to propose a trade. Both teams must accept before the trade is processed.
|
||||
```
|
||||
|
||||
**Discord Links** (`/help links`):
|
||||
```markdown
|
||||
# Important League Links
|
||||
|
||||
## Website
|
||||
https://sba-league.com
|
||||
|
||||
## Google Sheet
|
||||
https://docs.google.com/spreadsheets/...
|
||||
|
||||
## Discord Invite
|
||||
https://discord.gg/...
|
||||
|
||||
## Rules Document
|
||||
https://docs.google.com/document/...
|
||||
```
|
||||
|
||||
**How to Trade** (`/help how-to-trade`):
|
||||
```markdown
|
||||
# How to Use the Trade System
|
||||
|
||||
1. Type `/trade` to start a new trade proposal
|
||||
2. Select the team you want to trade with
|
||||
3. Add players/picks to the trade
|
||||
4. Submit for review
|
||||
5. Both teams must accept
|
||||
6. Commissioner approves
|
||||
7. Trade is processed!
|
||||
|
||||
For more information, see `/help trading-rules`
|
||||
```
|
||||
|
||||
## Error Handling
|
||||
|
||||
### Common Errors
|
||||
|
||||
**Topic Not Found**:
|
||||
```
|
||||
❌ Topic Not Found
|
||||
No help topic named 'xyz' exists.
|
||||
Use /help to see available topics.
|
||||
```
|
||||
|
||||
**Permission Denied**:
|
||||
```
|
||||
❌ Permission Denied
|
||||
Only administrators and users with the Help Editor role can create help topics.
|
||||
```
|
||||
|
||||
**Topic Already Exists**:
|
||||
```
|
||||
❌ Topic Already Exists
|
||||
A help topic named 'trading-rules' already exists.
|
||||
Try a different name.
|
||||
```
|
||||
|
||||
**Validation Errors**:
|
||||
- Topic name too short/long
|
||||
- Invalid characters in topic name
|
||||
- Content too long (>4000 chars)
|
||||
- Title too long (>200 chars)
|
||||
|
||||
## Best Practices
|
||||
|
||||
### For Administrators
|
||||
|
||||
1. **Use Clear Topic Names**
|
||||
- Use lowercase with hyphens: `trading-rules`, `how-to-draft`
|
||||
- Keep names short but descriptive
|
||||
- Avoid special characters
|
||||
|
||||
2. **Organize by Category**
|
||||
- Consistent category naming
|
||||
- Group related topics together
|
||||
- Use standard categories (rules, guides, resources, info, faq)
|
||||
|
||||
3. **Write Clear Content**
|
||||
- Use markdown formatting for readability
|
||||
- Keep content concise and focused
|
||||
- Link to related topics when appropriate
|
||||
- Update regularly to keep information current
|
||||
|
||||
4. **Monitor Usage**
|
||||
- Check view counts to see popular topics
|
||||
- Update frequently accessed topics
|
||||
- Archive outdated information
|
||||
|
||||
### For Users
|
||||
|
||||
1. **Browse Topics**
|
||||
- Use `/help` to see all available topics
|
||||
- Use `/help-list` to browse by category
|
||||
- Use autocomplete to find topics quickly
|
||||
|
||||
2. **Request New Topics**
|
||||
- Contact admins or help editors
|
||||
- Suggest topics that would be useful
|
||||
- Provide draft content if possible
|
||||
|
||||
## Testing
|
||||
|
||||
### Test Coverage
|
||||
- ✅ Model validation tests
|
||||
- ✅ Service layer CRUD operations
|
||||
- ✅ Permission checking
|
||||
- ✅ Autocomplete functionality
|
||||
- ✅ Soft delete behavior
|
||||
- ✅ View count incrementing
|
||||
|
||||
### Test Files
|
||||
- `tests/test_models_help_command.py`
|
||||
- `tests/test_services_help_commands.py`
|
||||
- `tests/test_commands_help.py`
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
### Planned Features (Post-Launch)
|
||||
- Restore command for deleted topics (`/help-restore <topic>`)
|
||||
- Statistics dashboard (`/help-stats`)
|
||||
- Search functionality across all content
|
||||
- Topic versioning and change history
|
||||
- Attachments support (images, files)
|
||||
- Related topics linking
|
||||
- User feedback and ratings
|
||||
- Full-text search in content
|
||||
|
||||
### Potential Improvements
|
||||
- Rich embed support with custom colors
|
||||
- Topic aliases (multiple names for same topic)
|
||||
- Scheduled topic updates
|
||||
- Topic templates for common formats
|
||||
- Import/export functionality
|
||||
- Bulk operations for admins
|
||||
|
||||
## Migration from Legacy System
|
||||
|
||||
If migrating from an older help/links system:
|
||||
|
||||
1. **Export existing content** from old system
|
||||
2. **Create help topics** using `/help-create`
|
||||
3. **Test all topics** for formatting and accuracy
|
||||
4. **Update documentation** to reference new commands
|
||||
5. **Train help editors** on new system
|
||||
6. **Announce to users** with usage instructions
|
||||
|
||||
## Support
|
||||
|
||||
### For Users
|
||||
- Use `/help` to browse available topics
|
||||
- Contact server admins for topic requests
|
||||
- Report broken links or outdated information
|
||||
|
||||
### For Administrators
|
||||
- Review the implementation plan in `.claude/HELP_COMMANDS_PLAN.md`
|
||||
- Check database migration docs in `.claude/DATABASE_MIGRATION_HELP_COMMANDS.md`
|
||||
- See main project documentation in `CLAUDE.md`
|
||||
|
||||
---
|
||||
|
||||
**Implementation Details:**
|
||||
- **Models:** `models/help_command.py`
|
||||
- **Service:** `services/help_commands_service.py`
|
||||
- **Views:** `views/help_commands.py`
|
||||
- **Commands:** `commands/help/main.py`
|
||||
- **Constants:** `constants.py` (HELP_EDITOR_ROLE_NAME)
|
||||
- **Tests:** `tests/test_*_help*.py`
|
||||
|
||||
**Related Documentation:**
|
||||
- Implementation Plan: `.claude/HELP_COMMANDS_PLAN.md`
|
||||
- Database Migration: `.claude/DATABASE_MIGRATION_HELP_COMMANDS.md`
|
||||
- Project Overview: `CLAUDE.md`
|
||||
- Roadmap: `PRE_LAUNCH_ROADMAP.md`
|
||||
49
commands/help/__init__.py
Normal file
49
commands/help/__init__.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""
|
||||
Help Commands package for Discord Bot v2.0
|
||||
|
||||
Modern slash command system for admin-created help topics.
|
||||
"""
|
||||
import logging
|
||||
from typing import List, Tuple, Type
|
||||
|
||||
from discord.ext import commands
|
||||
|
||||
from .main import HelpCommands
|
||||
|
||||
logger = logging.getLogger(f'{__name__}.setup_help_commands')
|
||||
|
||||
|
||||
async def setup_help_commands(bot: commands.Bot) -> Tuple[int, int, List[str]]:
|
||||
"""
|
||||
Set up help commands command modules.
|
||||
|
||||
Returns:
|
||||
Tuple of (successful_loads, failed_loads, failed_modules)
|
||||
"""
|
||||
help_command_cogs: List[Tuple[str, Type[commands.Cog]]] = [
|
||||
("HelpCommands", HelpCommands),
|
||||
]
|
||||
|
||||
successful = 0
|
||||
failed = 0
|
||||
failed_modules = []
|
||||
|
||||
for cog_name, cog_class in help_command_cogs:
|
||||
try:
|
||||
await bot.add_cog(cog_class(bot))
|
||||
logger.info(f"✅ Loaded help commands module: {cog_name}")
|
||||
successful += 1
|
||||
except Exception as e:
|
||||
logger.error(f"❌ Failed to load help commands module {cog_name}: {e}")
|
||||
failed += 1
|
||||
failed_modules.append(cog_name)
|
||||
|
||||
# Log summary
|
||||
if failed == 0:
|
||||
logger.info(f"🎉 All {successful} help commands modules loaded successfully")
|
||||
else:
|
||||
logger.warning(f"⚠️ Help commands loaded with issues: {successful} successful, {failed} failed")
|
||||
if failed_modules:
|
||||
logger.warning(f"Failed modules: {', '.join(failed_modules)}")
|
||||
|
||||
return successful, failed, failed_modules
|
||||
432
commands/help/main.py
Normal file
432
commands/help/main.py
Normal file
@ -0,0 +1,432 @@
|
||||
"""
|
||||
Help Commands slash commands for Discord Bot v2.0
|
||||
|
||||
Modern implementation for admin-created help topics.
|
||||
"""
|
||||
from typing import Optional, List
|
||||
import discord
|
||||
from discord import app_commands
|
||||
from discord.ext import commands
|
||||
|
||||
from services.help_commands_service import (
|
||||
help_commands_service,
|
||||
HelpCommandNotFoundError,
|
||||
HelpCommandExistsError
|
||||
)
|
||||
from utils.logging import get_contextual_logger
|
||||
from utils.decorators import logged_command
|
||||
from views.embeds import EmbedTemplate, EmbedColors
|
||||
from views.help_commands import (
|
||||
HelpCommandCreateModal,
|
||||
HelpCommandEditModal,
|
||||
HelpCommandDeleteConfirmView,
|
||||
HelpCommandListView,
|
||||
create_help_topic_embed
|
||||
)
|
||||
from constants import HELP_EDITOR_ROLE_NAME
|
||||
from exceptions import BotException
|
||||
|
||||
|
||||
async def help_topic_autocomplete(
|
||||
interaction: discord.Interaction,
|
||||
current: str,
|
||||
) -> List[app_commands.Choice[str]]:
|
||||
"""Autocomplete for help topic names."""
|
||||
try:
|
||||
# Get topic names matching the current input
|
||||
topic_names = await help_commands_service.get_help_names_for_autocomplete(
|
||||
partial_name=current,
|
||||
limit=25
|
||||
)
|
||||
|
||||
return [
|
||||
app_commands.Choice(name=name, value=name)
|
||||
for name in topic_names
|
||||
]
|
||||
except Exception:
|
||||
# Return empty list on error to avoid breaking autocomplete
|
||||
return []
|
||||
|
||||
|
||||
class HelpCommands(commands.Cog):
|
||||
"""Help system slash command handlers."""
|
||||
|
||||
def __init__(self, bot: commands.Bot):
|
||||
self.bot = bot
|
||||
self.logger = get_contextual_logger(f'{__name__}.HelpCommands')
|
||||
self.logger.info("HelpCommands cog initialized")
|
||||
|
||||
def has_help_edit_permission(self, interaction: discord.Interaction) -> bool:
|
||||
"""Check if user can edit help commands."""
|
||||
# Check if user is admin
|
||||
if interaction.user.guild_permissions.administrator:
|
||||
return True
|
||||
|
||||
# Check if user has the Help Editor role
|
||||
role = discord.utils.get(interaction.guild.roles, name=HELP_EDITOR_ROLE_NAME)
|
||||
if role and role in interaction.user.roles:
|
||||
return True
|
||||
|
||||
return False
|
||||
|
||||
@app_commands.command(name="help", description="View help topics or list all available help")
|
||||
@app_commands.describe(topic="Help topic to view (optional - leave blank to see all topics)")
|
||||
@app_commands.autocomplete(topic=help_topic_autocomplete)
|
||||
@logged_command("/help")
|
||||
async def help_command(self, interaction: discord.Interaction, topic: Optional[str] = None):
|
||||
"""View a help topic or list all available help topics."""
|
||||
await interaction.response.defer()
|
||||
|
||||
try:
|
||||
if topic:
|
||||
# Get specific help topic
|
||||
help_cmd = await help_commands_service.get_help_by_name(topic)
|
||||
|
||||
# Increment view count
|
||||
await help_commands_service.increment_view_count(topic)
|
||||
|
||||
# Create and send embed
|
||||
embed = create_help_topic_embed(help_cmd)
|
||||
await interaction.followup.send(embed=embed)
|
||||
|
||||
else:
|
||||
# List all help topics
|
||||
all_topics = await help_commands_service.get_all_help_topics()
|
||||
|
||||
if not all_topics:
|
||||
embed = EmbedTemplate.info(
|
||||
title="📚 Help Topics",
|
||||
description="No help topics are currently available.\nAdmins can create topics using `/help-create`."
|
||||
)
|
||||
await interaction.followup.send(embed=embed)
|
||||
return
|
||||
|
||||
# Create list view
|
||||
list_view = HelpCommandListView(
|
||||
help_commands=all_topics,
|
||||
user_id=interaction.user.id
|
||||
)
|
||||
|
||||
embed = list_view.get_embed()
|
||||
await interaction.followup.send(embed=embed, view=list_view)
|
||||
|
||||
except HelpCommandNotFoundError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Topic Not Found",
|
||||
description=f"No help topic named `{topic}` exists.\nUse `/help` to see available topics."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to show help",
|
||||
topic=topic,
|
||||
user_id=interaction.user.id,
|
||||
error=e)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Error",
|
||||
description="An error occurred while loading help. Please try again."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(name="help-create", description="Create a new help topic (admin/help editor only)")
|
||||
@logged_command("/help-create")
|
||||
async def help_create(self, interaction: discord.Interaction):
|
||||
"""Create a new help topic using an interactive modal."""
|
||||
# Check permissions
|
||||
if not self.has_help_edit_permission(interaction):
|
||||
embed = EmbedTemplate.error(
|
||||
title="Permission Denied",
|
||||
description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can create help topics."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Show the creation modal
|
||||
modal = HelpCommandCreateModal()
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
# Wait for modal completion
|
||||
await modal.wait()
|
||||
|
||||
if not modal.is_submitted:
|
||||
return
|
||||
|
||||
try:
|
||||
# Create the help topic
|
||||
help_cmd = await help_commands_service.create_help(
|
||||
name=modal.result['name'], # type: ignore
|
||||
title=modal.result['title'], # type: ignore
|
||||
content=modal.result['content'], # type: ignore
|
||||
creator_discord_id=interaction.user.id,
|
||||
category=modal.result.get('category') # type: ignore
|
||||
)
|
||||
|
||||
# Success embed
|
||||
embed = EmbedTemplate.success(
|
||||
title="✅ Help Topic Created!",
|
||||
description=f"The help topic `/help {help_cmd.name}` has been created successfully."
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="How users can access it",
|
||||
value=f"Type `/help {help_cmd.name}` to view this topic.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Management",
|
||||
value=f"Use `/help-edit {help_cmd.name}` to edit or `/help-delete {help_cmd.name}` to delete.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Try to send as followup
|
||||
try:
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
except (discord.NotFound, discord.HTTPException):
|
||||
# If followup fails, try editing original
|
||||
try:
|
||||
await interaction.edit_original_response(embed=embed)
|
||||
except (discord.NotFound, discord.HTTPException):
|
||||
pass # Silently fail if we can't send the confirmation
|
||||
|
||||
except HelpCommandExistsError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Topic Already Exists",
|
||||
description=f"A help topic named `{modal.result['name']}` already exists.\nTry a different name." # type: ignore
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to create help topic",
|
||||
topic_name=modal.result.get('name'), # type: ignore
|
||||
user_id=interaction.user.id,
|
||||
error=e)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Creation Failed",
|
||||
description="An error occurred while creating the help topic. Please try again."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(name="help-edit", description="Edit an existing help topic (admin/help editor only)")
|
||||
@app_commands.describe(topic="Help topic to edit")
|
||||
@app_commands.autocomplete(topic=help_topic_autocomplete)
|
||||
@logged_command("/help-edit")
|
||||
async def help_edit(self, interaction: discord.Interaction, topic: str):
|
||||
"""Edit an existing help topic."""
|
||||
# Check permissions
|
||||
if not self.has_help_edit_permission(interaction):
|
||||
embed = EmbedTemplate.error(
|
||||
title="Permission Denied",
|
||||
description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can edit help topics."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
# Get the help topic
|
||||
help_cmd = await help_commands_service.get_help_by_name(topic)
|
||||
|
||||
# Show edit modal
|
||||
modal = HelpCommandEditModal(help_cmd)
|
||||
await interaction.response.send_modal(modal)
|
||||
|
||||
# Wait for modal completion
|
||||
await modal.wait()
|
||||
|
||||
if not modal.is_submitted:
|
||||
return
|
||||
|
||||
# Update the help topic
|
||||
updated_help = await help_commands_service.update_help(
|
||||
name=help_cmd.name,
|
||||
new_title=modal.result['title'], # type: ignore
|
||||
new_content=modal.result['content'], # type: ignore
|
||||
updater_discord_id=interaction.user.id,
|
||||
new_category=modal.result.get('category') # type: ignore
|
||||
)
|
||||
|
||||
# Success embed
|
||||
embed = EmbedTemplate.success(
|
||||
title="✅ Help Topic Updated!",
|
||||
description=f"The help topic `/help {updated_help.name}` has been updated successfully."
|
||||
)
|
||||
|
||||
# Try to send as followup
|
||||
try:
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
except (discord.NotFound, discord.HTTPException):
|
||||
# If followup fails, try editing original
|
||||
try:
|
||||
await interaction.edit_original_response(embed=embed)
|
||||
except (discord.NotFound, discord.HTTPException):
|
||||
pass # Silently fail if we can't send the confirmation
|
||||
|
||||
except HelpCommandNotFoundError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Topic Not Found",
|
||||
description=f"No help topic named `{topic}` exists."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to edit help topic",
|
||||
topic=topic,
|
||||
user_id=interaction.user.id,
|
||||
error=e)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Edit Failed",
|
||||
description="An error occurred while editing the help topic. Please try again."
|
||||
)
|
||||
try:
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(name="help-delete", description="Delete a help topic (admin/help editor only)")
|
||||
@app_commands.describe(topic="Help topic to delete")
|
||||
@app_commands.autocomplete(topic=help_topic_autocomplete)
|
||||
@logged_command("/help-delete")
|
||||
async def help_delete(self, interaction: discord.Interaction, topic: str):
|
||||
"""Delete a help topic with confirmation."""
|
||||
# Check permissions
|
||||
if not self.has_help_edit_permission(interaction):
|
||||
embed = EmbedTemplate.error(
|
||||
title="Permission Denied",
|
||||
description=f"Only administrators and users with the **{HELP_EDITOR_ROLE_NAME}** role can delete help topics."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
try:
|
||||
# Get the help topic
|
||||
help_cmd = await help_commands_service.get_help_by_name(topic)
|
||||
|
||||
# Show deletion confirmation
|
||||
embed = EmbedTemplate.warning(
|
||||
title="⚠️ Delete Help Topic",
|
||||
description=f"Are you sure you want to delete `/help {help_cmd.name}`?"
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Title",
|
||||
value=help_cmd.title,
|
||||
inline=False
|
||||
)
|
||||
|
||||
embed.add_field(
|
||||
name="Note",
|
||||
value=f"This topic has been viewed **{help_cmd.view_count}** times.\nThis is a soft delete - the topic can be restored later if needed.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
# Create confirmation view
|
||||
confirmation_view = HelpCommandDeleteConfirmView(
|
||||
help_cmd,
|
||||
user_id=interaction.user.id
|
||||
)
|
||||
|
||||
await interaction.response.send_message(embed=embed, view=confirmation_view, ephemeral=True)
|
||||
await confirmation_view.wait()
|
||||
|
||||
if confirmation_view.result:
|
||||
# User confirmed deletion - actually delete it
|
||||
await help_commands_service.delete_help(topic)
|
||||
self.logger.info("Help topic deleted",
|
||||
topic=topic,
|
||||
user_id=interaction.user.id)
|
||||
|
||||
except HelpCommandNotFoundError:
|
||||
embed = EmbedTemplate.error(
|
||||
title="Topic Not Found",
|
||||
description=f"No help topic named `{topic}` exists."
|
||||
)
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to delete help topic",
|
||||
topic=topic,
|
||||
user_id=interaction.user.id,
|
||||
error=e)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Error",
|
||||
description="An error occurred while trying to delete the help topic."
|
||||
)
|
||||
try:
|
||||
await interaction.response.send_message(embed=embed, ephemeral=True)
|
||||
except discord.InteractionResponded:
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
|
||||
@app_commands.command(name="help-list", description="Browse all help topics")
|
||||
@app_commands.describe(
|
||||
category="Filter by category (optional)",
|
||||
show_deleted="Show deleted topics (admin only, default: false)"
|
||||
)
|
||||
@logged_command("/help-list")
|
||||
async def help_list(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
category: Optional[str] = None,
|
||||
show_deleted: bool = False
|
||||
):
|
||||
"""Browse all help topics with optional category filter."""
|
||||
await interaction.response.defer()
|
||||
|
||||
try:
|
||||
# Check permissions for show_deleted
|
||||
if show_deleted and not self.has_help_edit_permission(interaction):
|
||||
embed = EmbedTemplate.error(
|
||||
title="Permission Denied",
|
||||
description="Only administrators and help editors can view deleted topics."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
return
|
||||
|
||||
# Get help topics
|
||||
all_topics = await help_commands_service.get_all_help_topics(
|
||||
category=category,
|
||||
include_inactive=show_deleted
|
||||
)
|
||||
|
||||
if not all_topics:
|
||||
embed = EmbedTemplate.info(
|
||||
title="📚 Help Topics",
|
||||
description="No help topics found matching your criteria."
|
||||
)
|
||||
|
||||
if category:
|
||||
embed.add_field(
|
||||
name="Tip",
|
||||
value=f"Try viewing all categories by using `/help-list` without filters.",
|
||||
inline=False
|
||||
)
|
||||
else:
|
||||
embed.add_field(
|
||||
name="Get Started",
|
||||
value="Admins can create topics using `/help-create`.",
|
||||
inline=False
|
||||
)
|
||||
|
||||
await interaction.followup.send(embed=embed)
|
||||
return
|
||||
|
||||
# Create list view
|
||||
list_view = HelpCommandListView(
|
||||
help_commands=all_topics,
|
||||
user_id=interaction.user.id,
|
||||
category_filter=category
|
||||
)
|
||||
|
||||
embed = list_view.get_embed()
|
||||
await interaction.followup.send(embed=embed, view=list_view)
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error("Failed to list help topics",
|
||||
category=category,
|
||||
show_deleted=show_deleted,
|
||||
user_id=interaction.user.id,
|
||||
error=e)
|
||||
embed = EmbedTemplate.error(
|
||||
title="Error",
|
||||
description="An error occurred while loading help topics. Please try again."
|
||||
)
|
||||
await interaction.followup.send(embed=embed, ephemeral=True)
|
||||
@ -31,4 +31,7 @@ DEFAULT_PICK_MINUTES = 10
|
||||
DRAFT_ROUNDS = 25
|
||||
|
||||
# Special Team IDs
|
||||
FREE_AGENT_TEAM_ID = 31 # Generic free agent team ID (same per season)
|
||||
FREE_AGENT_TEAM_ID = 31 # Generic free agent team ID (same per season)
|
||||
|
||||
# Role Names
|
||||
HELP_EDITOR_ROLE_NAME = "Help Editor" # Users with this role can edit help commands
|
||||
227
models/help_command.py
Normal file
227
models/help_command.py
Normal file
@ -0,0 +1,227 @@
|
||||
"""
|
||||
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
|
||||
511
services/help_commands_service.py
Normal file
511
services/help_commands_service.py
Normal file
@ -0,0 +1,511 @@
|
||||
"""
|
||||
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()
|
||||
535
tests/test_models_help_command.py
Normal file
535
tests/test_models_help_command.py
Normal file
@ -0,0 +1,535 @@
|
||||
"""
|
||||
Tests for Help Command models
|
||||
|
||||
Validates model creation, validation, and business logic.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timedelta
|
||||
from pydantic import ValidationError
|
||||
|
||||
from models.help_command import (
|
||||
HelpCommand,
|
||||
HelpCommandSearchFilters,
|
||||
HelpCommandSearchResult,
|
||||
HelpCommandStats
|
||||
)
|
||||
|
||||
|
||||
class TestHelpCommandModel:
|
||||
"""Test HelpCommand model functionality."""
|
||||
|
||||
def test_help_command_creation_minimal(self):
|
||||
"""Test help command creation with minimal required fields."""
|
||||
help_cmd = HelpCommand(
|
||||
id=1,
|
||||
name='test-topic',
|
||||
title='Test Topic',
|
||||
content='This is test content',
|
||||
created_by_discord_id=123456789,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
|
||||
assert help_cmd.id == 1
|
||||
assert help_cmd.name == 'test-topic'
|
||||
assert help_cmd.title == 'Test Topic'
|
||||
assert help_cmd.content == 'This is test content'
|
||||
assert help_cmd.created_by_discord_id == 123456789
|
||||
assert help_cmd.is_active is True
|
||||
assert help_cmd.view_count == 0
|
||||
|
||||
def test_help_command_creation_with_optional_fields(self):
|
||||
"""Test help command creation with all optional fields."""
|
||||
now = datetime.now()
|
||||
help_cmd = HelpCommand(
|
||||
id=2,
|
||||
name='trading-rules',
|
||||
title='Trading Rules & Guidelines',
|
||||
content='Complete trading rules...',
|
||||
category='rules',
|
||||
created_by_discord_id=123456789,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
last_modified_by=987654321,
|
||||
is_active=True,
|
||||
view_count=100,
|
||||
display_order=10
|
||||
)
|
||||
|
||||
assert help_cmd.category == 'rules'
|
||||
assert help_cmd.updated_at == now
|
||||
assert help_cmd.last_modified_by == 987654321
|
||||
assert help_cmd.view_count == 100
|
||||
assert help_cmd.display_order == 10
|
||||
|
||||
def test_help_command_name_validation(self):
|
||||
"""Test help command name validation."""
|
||||
base_data = {
|
||||
'id': 3,
|
||||
'title': 'Test',
|
||||
'content': 'Content',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now()
|
||||
}
|
||||
|
||||
# Valid names
|
||||
valid_names = ['test', 'test-topic', 'test_topic', 'test123', 'abc']
|
||||
for name in valid_names:
|
||||
help_cmd = HelpCommand(name=name, **base_data)
|
||||
assert help_cmd.name == name.lower()
|
||||
|
||||
# Invalid names - too short
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(name='a', **base_data)
|
||||
|
||||
# Invalid names - too long
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(name='a' * 33, **base_data)
|
||||
|
||||
# Invalid names - special characters
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(name='test@topic', **base_data)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(name='test topic', **base_data)
|
||||
|
||||
def test_help_command_title_validation(self):
|
||||
"""Test help command title validation."""
|
||||
base_data = {
|
||||
'id': 4,
|
||||
'name': 'test',
|
||||
'content': 'Content',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now()
|
||||
}
|
||||
|
||||
# Valid title
|
||||
help_cmd = HelpCommand(title='Test Topic', **base_data)
|
||||
assert help_cmd.title == 'Test Topic'
|
||||
|
||||
# Empty title
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(title='', **base_data)
|
||||
|
||||
# Title too long
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(title='a' * 201, **base_data)
|
||||
|
||||
def test_help_command_content_validation(self):
|
||||
"""Test help command content validation."""
|
||||
base_data = {
|
||||
'id': 5,
|
||||
'name': 'test',
|
||||
'title': 'Test',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now()
|
||||
}
|
||||
|
||||
# Valid content
|
||||
help_cmd = HelpCommand(content='Test content', **base_data)
|
||||
assert help_cmd.content == 'Test content'
|
||||
|
||||
# Empty content
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(content='', **base_data)
|
||||
|
||||
# Content too long
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(content='a' * 4001, **base_data)
|
||||
|
||||
def test_help_command_category_validation(self):
|
||||
"""Test help command category validation."""
|
||||
base_data = {
|
||||
'id': 6,
|
||||
'name': 'test',
|
||||
'title': 'Test',
|
||||
'content': 'Content',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now()
|
||||
}
|
||||
|
||||
# Valid categories
|
||||
valid_categories = ['rules', 'guides', 'resources', 'info', 'faq']
|
||||
for category in valid_categories:
|
||||
help_cmd = HelpCommand(category=category, **base_data)
|
||||
assert help_cmd.category == category.lower()
|
||||
|
||||
# None category
|
||||
help_cmd = HelpCommand(category=None, **base_data)
|
||||
assert help_cmd.category is None
|
||||
|
||||
# Invalid category - special characters
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommand(category='test@category', **base_data)
|
||||
|
||||
def test_help_command_is_deleted_property(self):
|
||||
"""Test is_deleted property."""
|
||||
active = HelpCommand(
|
||||
id=7,
|
||||
name='active',
|
||||
title='Active Topic',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now(),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
deleted = HelpCommand(
|
||||
id=8,
|
||||
name='deleted',
|
||||
title='Deleted Topic',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now(),
|
||||
is_active=False
|
||||
)
|
||||
|
||||
assert active.is_deleted is False
|
||||
assert deleted.is_deleted is True
|
||||
|
||||
def test_help_command_days_since_update(self):
|
||||
"""Test days_since_update property."""
|
||||
# No updates
|
||||
no_update = HelpCommand(
|
||||
id=9,
|
||||
name='test',
|
||||
title='Test',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now(),
|
||||
updated_at=None
|
||||
)
|
||||
assert no_update.days_since_update is None
|
||||
|
||||
# Recent update
|
||||
recent = HelpCommand(
|
||||
id=10,
|
||||
name='test',
|
||||
title='Test',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now() - timedelta(days=5)
|
||||
)
|
||||
assert recent.days_since_update == 5
|
||||
|
||||
def test_help_command_days_since_creation(self):
|
||||
"""Test days_since_creation property."""
|
||||
old = HelpCommand(
|
||||
id=11,
|
||||
name='test',
|
||||
title='Test',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now() - timedelta(days=30)
|
||||
)
|
||||
assert old.days_since_creation == 30
|
||||
|
||||
def test_help_command_popularity_score(self):
|
||||
"""Test popularity_score property."""
|
||||
# No views
|
||||
no_views = HelpCommand(
|
||||
id=12,
|
||||
name='test',
|
||||
title='Test',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now(),
|
||||
view_count=0
|
||||
)
|
||||
assert no_views.popularity_score == 0.0
|
||||
|
||||
# New topic with views
|
||||
new_popular = HelpCommand(
|
||||
id=13,
|
||||
name='test',
|
||||
title='Test',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now() - timedelta(days=5),
|
||||
view_count=50
|
||||
)
|
||||
score = new_popular.popularity_score
|
||||
assert score > 5.0 # Base score (5.0) with new topic bonus (1.5x)
|
||||
|
||||
# Old topic with views
|
||||
old_popular = HelpCommand(
|
||||
id=14,
|
||||
name='test',
|
||||
title='Test',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now() - timedelta(days=100),
|
||||
view_count=50
|
||||
)
|
||||
old_score = old_popular.popularity_score
|
||||
assert old_score < new_popular.popularity_score # Older topics get penalty
|
||||
|
||||
|
||||
class TestHelpCommandSearchFilters:
|
||||
"""Test HelpCommandSearchFilters model."""
|
||||
|
||||
def test_search_filters_defaults(self):
|
||||
"""Test search filters with default values."""
|
||||
filters = HelpCommandSearchFilters()
|
||||
|
||||
assert filters.name_contains is None
|
||||
assert filters.category is None
|
||||
assert filters.is_active is True
|
||||
assert filters.sort_by == 'name'
|
||||
assert filters.sort_desc is False
|
||||
assert filters.page == 1
|
||||
assert filters.page_size == 25
|
||||
|
||||
def test_search_filters_custom_values(self):
|
||||
"""Test search filters with custom values."""
|
||||
filters = HelpCommandSearchFilters(
|
||||
name_contains='trading',
|
||||
category='rules',
|
||||
is_active=False,
|
||||
sort_by='view_count',
|
||||
sort_desc=True,
|
||||
page=2,
|
||||
page_size=50
|
||||
)
|
||||
|
||||
assert filters.name_contains == 'trading'
|
||||
assert filters.category == 'rules'
|
||||
assert filters.is_active is False
|
||||
assert filters.sort_by == 'view_count'
|
||||
assert filters.sort_desc is True
|
||||
assert filters.page == 2
|
||||
assert filters.page_size == 50
|
||||
|
||||
def test_search_filters_sort_by_validation(self):
|
||||
"""Test sort_by field validation."""
|
||||
# Valid sort fields
|
||||
valid_sorts = ['name', 'title', 'category', 'created_at', 'updated_at', 'view_count', 'display_order']
|
||||
for sort_field in valid_sorts:
|
||||
filters = HelpCommandSearchFilters(sort_by=sort_field)
|
||||
assert filters.sort_by == sort_field
|
||||
|
||||
# Invalid sort field
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommandSearchFilters(sort_by='invalid_field')
|
||||
|
||||
def test_search_filters_page_validation(self):
|
||||
"""Test page number validation."""
|
||||
# Valid page numbers
|
||||
filters = HelpCommandSearchFilters(page=1)
|
||||
assert filters.page == 1
|
||||
|
||||
filters = HelpCommandSearchFilters(page=100)
|
||||
assert filters.page == 100
|
||||
|
||||
# Invalid page numbers
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommandSearchFilters(page=0)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommandSearchFilters(page=-1)
|
||||
|
||||
def test_search_filters_page_size_validation(self):
|
||||
"""Test page size validation."""
|
||||
# Valid page sizes
|
||||
filters = HelpCommandSearchFilters(page_size=1)
|
||||
assert filters.page_size == 1
|
||||
|
||||
filters = HelpCommandSearchFilters(page_size=100)
|
||||
assert filters.page_size == 100
|
||||
|
||||
# Invalid page sizes
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommandSearchFilters(page_size=0)
|
||||
|
||||
with pytest.raises(ValidationError):
|
||||
HelpCommandSearchFilters(page_size=101)
|
||||
|
||||
|
||||
class TestHelpCommandSearchResult:
|
||||
"""Test HelpCommandSearchResult model."""
|
||||
|
||||
def test_search_result_creation(self):
|
||||
"""Test search result creation."""
|
||||
help_commands = [
|
||||
HelpCommand(
|
||||
id=i,
|
||||
name=f'topic-{i}',
|
||||
title=f'Topic {i}',
|
||||
content=f'Content {i}',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now()
|
||||
)
|
||||
for i in range(1, 11)
|
||||
]
|
||||
|
||||
result = HelpCommandSearchResult(
|
||||
help_commands=help_commands,
|
||||
total_count=50,
|
||||
page=1,
|
||||
page_size=10,
|
||||
total_pages=5,
|
||||
has_more=True
|
||||
)
|
||||
|
||||
assert len(result.help_commands) == 10
|
||||
assert result.total_count == 50
|
||||
assert result.page == 1
|
||||
assert result.page_size == 10
|
||||
assert result.total_pages == 5
|
||||
assert result.has_more is True
|
||||
|
||||
def test_search_result_start_index(self):
|
||||
"""Test start_index property."""
|
||||
result = HelpCommandSearchResult(
|
||||
help_commands=[],
|
||||
total_count=100,
|
||||
page=3,
|
||||
page_size=25,
|
||||
total_pages=4,
|
||||
has_more=True
|
||||
)
|
||||
|
||||
assert result.start_index == 51 # (3-1) * 25 + 1
|
||||
|
||||
def test_search_result_end_index(self):
|
||||
"""Test end_index property."""
|
||||
# Last page with remaining items
|
||||
result = HelpCommandSearchResult(
|
||||
help_commands=[],
|
||||
total_count=55,
|
||||
page=3,
|
||||
page_size=25,
|
||||
total_pages=3,
|
||||
has_more=False
|
||||
)
|
||||
|
||||
assert result.end_index == 55 # min(3 * 25, 55)
|
||||
|
||||
# Full page
|
||||
result = HelpCommandSearchResult(
|
||||
help_commands=[],
|
||||
total_count=100,
|
||||
page=2,
|
||||
page_size=25,
|
||||
total_pages=4,
|
||||
has_more=True
|
||||
)
|
||||
|
||||
assert result.end_index == 50 # min(2 * 25, 100)
|
||||
|
||||
|
||||
class TestHelpCommandStats:
|
||||
"""Test HelpCommandStats model."""
|
||||
|
||||
def test_stats_creation(self):
|
||||
"""Test stats creation."""
|
||||
stats = HelpCommandStats(
|
||||
total_commands=50,
|
||||
active_commands=45,
|
||||
total_views=1000,
|
||||
most_viewed_command=None,
|
||||
recent_commands_count=5
|
||||
)
|
||||
|
||||
assert stats.total_commands == 50
|
||||
assert stats.active_commands == 45
|
||||
assert stats.total_views == 1000
|
||||
assert stats.most_viewed_command is None
|
||||
assert stats.recent_commands_count == 5
|
||||
|
||||
def test_stats_with_most_viewed(self):
|
||||
"""Test stats with most viewed command."""
|
||||
most_viewed = HelpCommand(
|
||||
id=1,
|
||||
name='popular-topic',
|
||||
title='Popular Topic',
|
||||
content='Content',
|
||||
created_by_discord_id=123,
|
||||
created_at=datetime.now(),
|
||||
view_count=500
|
||||
)
|
||||
|
||||
stats = HelpCommandStats(
|
||||
total_commands=50,
|
||||
active_commands=45,
|
||||
total_views=1000,
|
||||
most_viewed_command=most_viewed,
|
||||
recent_commands_count=5
|
||||
)
|
||||
|
||||
assert stats.most_viewed_command is not None
|
||||
assert stats.most_viewed_command.name == 'popular-topic'
|
||||
assert stats.most_viewed_command.view_count == 500
|
||||
|
||||
def test_stats_average_views_per_command(self):
|
||||
"""Test average_views_per_command property."""
|
||||
# Normal case
|
||||
stats = HelpCommandStats(
|
||||
total_commands=50,
|
||||
active_commands=40,
|
||||
total_views=800,
|
||||
most_viewed_command=None,
|
||||
recent_commands_count=5
|
||||
)
|
||||
|
||||
assert stats.average_views_per_command == 20.0 # 800 / 40
|
||||
|
||||
# No active commands
|
||||
stats = HelpCommandStats(
|
||||
total_commands=10,
|
||||
active_commands=0,
|
||||
total_views=0,
|
||||
most_viewed_command=None,
|
||||
recent_commands_count=0
|
||||
)
|
||||
|
||||
assert stats.average_views_per_command == 0.0
|
||||
|
||||
|
||||
class TestHelpCommandFromAPIData:
|
||||
"""Test creating HelpCommand from API data."""
|
||||
|
||||
def test_from_api_data_complete(self):
|
||||
"""Test from_api_data with complete data."""
|
||||
api_data = {
|
||||
'id': 1,
|
||||
'name': 'trading-rules',
|
||||
'title': 'Trading Rules & Guidelines',
|
||||
'content': 'Complete trading rules...',
|
||||
'category': 'rules',
|
||||
'created_by_discord_id': 123456789,
|
||||
'created_at': '2025-01-01T12:00:00',
|
||||
'updated_at': '2025-01-10T15:30:00',
|
||||
'last_modified_by': 987654321,
|
||||
'is_active': True,
|
||||
'view_count': 100,
|
||||
'display_order': 10
|
||||
}
|
||||
|
||||
help_cmd = HelpCommand.from_api_data(api_data)
|
||||
|
||||
assert help_cmd.id == 1
|
||||
assert help_cmd.name == 'trading-rules'
|
||||
assert help_cmd.title == 'Trading Rules & Guidelines'
|
||||
assert help_cmd.content == 'Complete trading rules...'
|
||||
assert help_cmd.category == 'rules'
|
||||
assert help_cmd.view_count == 100
|
||||
|
||||
def test_from_api_data_minimal(self):
|
||||
"""Test from_api_data with minimal required data."""
|
||||
api_data = {
|
||||
'id': 2,
|
||||
'name': 'simple-topic',
|
||||
'title': 'Simple Topic',
|
||||
'content': 'Simple content',
|
||||
'created_by_discord_id': 123456789,
|
||||
'created_at': '2025-01-01T12:00:00'
|
||||
}
|
||||
|
||||
help_cmd = HelpCommand.from_api_data(api_data)
|
||||
|
||||
assert help_cmd.id == 2
|
||||
assert help_cmd.name == 'simple-topic'
|
||||
assert help_cmd.category is None
|
||||
assert help_cmd.updated_at is None
|
||||
assert help_cmd.view_count == 0
|
||||
518
tests/test_services_help_commands.py
Normal file
518
tests/test_services_help_commands.py
Normal file
@ -0,0 +1,518 @@
|
||||
"""
|
||||
Tests for Help Commands Service in Discord Bot v2.0
|
||||
|
||||
Comprehensive tests for help commands CRUD operations and business logic.
|
||||
"""
|
||||
import pytest
|
||||
from datetime import datetime, timezone, timedelta
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
from services.help_commands_service import (
|
||||
HelpCommandsService,
|
||||
HelpCommandNotFoundError,
|
||||
HelpCommandExistsError
|
||||
)
|
||||
from models.help_command import (
|
||||
HelpCommand,
|
||||
HelpCommandSearchFilters,
|
||||
HelpCommandSearchResult,
|
||||
HelpCommandStats
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_help_command() -> HelpCommand:
|
||||
"""Fixture providing a sample help command."""
|
||||
now = datetime.now(timezone.utc)
|
||||
return HelpCommand(
|
||||
id=1,
|
||||
name='trading-rules',
|
||||
title='Trading Rules & Guidelines',
|
||||
content='Complete trading rules for the league...',
|
||||
category='rules',
|
||||
created_by_discord_id=123456789,
|
||||
created_at=now,
|
||||
updated_at=None,
|
||||
last_modified_by=None,
|
||||
is_active=True,
|
||||
view_count=100,
|
||||
display_order=10
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client():
|
||||
"""Mock API client."""
|
||||
client = AsyncMock()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def help_commands_service_instance(mock_client):
|
||||
"""Create HelpCommandsService instance with mocked client."""
|
||||
service = HelpCommandsService()
|
||||
service._client = mock_client
|
||||
return service
|
||||
|
||||
|
||||
class TestHelpCommandsServiceInit:
|
||||
"""Test service initialization and basic functionality."""
|
||||
|
||||
def test_service_singleton_pattern(self):
|
||||
"""Test that the service follows singleton pattern."""
|
||||
from services.help_commands_service import help_commands_service
|
||||
|
||||
# Multiple imports should return the same instance
|
||||
from services.help_commands_service import help_commands_service as service2
|
||||
assert help_commands_service is service2
|
||||
|
||||
def test_service_has_required_methods(self):
|
||||
"""Test that service has all required methods."""
|
||||
from services.help_commands_service import help_commands_service
|
||||
|
||||
# Core CRUD operations
|
||||
assert hasattr(help_commands_service, 'create_help')
|
||||
assert hasattr(help_commands_service, 'get_help_by_name')
|
||||
assert hasattr(help_commands_service, 'update_help')
|
||||
assert hasattr(help_commands_service, 'delete_help')
|
||||
assert hasattr(help_commands_service, 'restore_help')
|
||||
|
||||
# Search and listing
|
||||
assert hasattr(help_commands_service, 'search_help_commands')
|
||||
assert hasattr(help_commands_service, 'get_all_help_topics')
|
||||
assert hasattr(help_commands_service, 'get_help_names_for_autocomplete')
|
||||
|
||||
# View tracking
|
||||
assert hasattr(help_commands_service, 'increment_view_count')
|
||||
|
||||
# Statistics
|
||||
assert hasattr(help_commands_service, 'get_statistics')
|
||||
|
||||
|
||||
class TestHelpCommandsServiceCRUD:
|
||||
"""Test CRUD operations of the help commands service."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_help_success(self, help_commands_service_instance):
|
||||
"""Test successful help command creation."""
|
||||
created_help = None
|
||||
|
||||
async def mock_get_help_by_name(name, *args, **kwargs):
|
||||
if created_help and name == "test-topic":
|
||||
return created_help
|
||||
# Command doesn't exist initially - raise exception
|
||||
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
||||
|
||||
async def mock_create(data):
|
||||
nonlocal created_help
|
||||
# Create the help command model directly from the data
|
||||
created_help = HelpCommand(
|
||||
id=1,
|
||||
name=data["name"],
|
||||
title=data["title"],
|
||||
content=data["content"],
|
||||
category=data.get("category"),
|
||||
created_by_discord_id=data["created_by_discord_id"],
|
||||
created_at=datetime.now(timezone.utc),
|
||||
updated_at=None,
|
||||
last_modified_by=None,
|
||||
is_active=True,
|
||||
view_count=0,
|
||||
display_order=data.get("display_order", 0)
|
||||
)
|
||||
return created_help
|
||||
|
||||
# Patch the service methods
|
||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||
help_commands_service_instance.create = mock_create
|
||||
|
||||
result = await help_commands_service_instance.create_help(
|
||||
name="test-topic",
|
||||
title="Test Topic",
|
||||
content="This is test content for the help topic.",
|
||||
creator_discord_id=123456789,
|
||||
category="info"
|
||||
)
|
||||
|
||||
assert isinstance(result, HelpCommand)
|
||||
assert result.name == "test-topic"
|
||||
assert result.title == "Test Topic"
|
||||
assert result.category == "info"
|
||||
assert result.view_count == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_help_already_exists(self, help_commands_service_instance, sample_help_command):
|
||||
"""Test help command creation when topic already exists."""
|
||||
# Mock topic already exists
|
||||
async def mock_get_help_by_name(*args, **kwargs):
|
||||
return sample_help_command
|
||||
|
||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||
|
||||
with pytest.raises(HelpCommandExistsError, match="Help topic 'trading-rules' already exists"):
|
||||
await help_commands_service_instance.create_help(
|
||||
name="trading-rules",
|
||||
title="Trading Rules",
|
||||
content="Rules content",
|
||||
creator_discord_id=123456789
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_help_by_name_success(self, help_commands_service_instance, sample_help_command):
|
||||
"""Test successful help command retrieval."""
|
||||
# Mock the API client to return proper data structure
|
||||
help_data = {
|
||||
'id': sample_help_command.id,
|
||||
'name': sample_help_command.name,
|
||||
'title': sample_help_command.title,
|
||||
'content': sample_help_command.content,
|
||||
'category': sample_help_command.category,
|
||||
'created_by_discord_id': sample_help_command.created_by_discord_id,
|
||||
'created_at': sample_help_command.created_at.isoformat(),
|
||||
'updated_at': sample_help_command.updated_at.isoformat() if sample_help_command.updated_at else None,
|
||||
'last_modified_by': sample_help_command.last_modified_by,
|
||||
'is_active': sample_help_command.is_active,
|
||||
'view_count': sample_help_command.view_count,
|
||||
'display_order': sample_help_command.display_order
|
||||
}
|
||||
|
||||
help_commands_service_instance._client.get.return_value = help_data
|
||||
|
||||
result = await help_commands_service_instance.get_help_by_name("trading-rules")
|
||||
|
||||
assert isinstance(result, HelpCommand)
|
||||
assert result.name == "trading-rules"
|
||||
assert result.title == "Trading Rules & Guidelines"
|
||||
assert result.view_count == 100
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_help_by_name_not_found(self, help_commands_service_instance):
|
||||
"""Test help command retrieval when topic doesn't exist."""
|
||||
# Mock the API client to return None (not found)
|
||||
help_commands_service_instance._client.get.return_value = None
|
||||
|
||||
with pytest.raises(HelpCommandNotFoundError, match="Help topic 'nonexistent' not found"):
|
||||
await help_commands_service_instance.get_help_by_name("nonexistent")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_help_success(self, help_commands_service_instance, sample_help_command):
|
||||
"""Test successful help command update."""
|
||||
# Mock getting the existing help command
|
||||
async def mock_get_help_by_name(name, include_inactive=False):
|
||||
if name == "trading-rules":
|
||||
return sample_help_command
|
||||
raise HelpCommandNotFoundError(f"Help topic '{name}' not found")
|
||||
|
||||
# Mock the API update call
|
||||
async def mock_put(*args, **kwargs):
|
||||
return True
|
||||
|
||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||
help_commands_service_instance._client.put = mock_put
|
||||
|
||||
# Update should call get_help_by_name again at the end, so mock it to return updated version
|
||||
updated_help = HelpCommand(
|
||||
id=sample_help_command.id,
|
||||
name=sample_help_command.name,
|
||||
title="Updated Trading Rules",
|
||||
content="Updated content",
|
||||
category=sample_help_command.category,
|
||||
created_by_discord_id=sample_help_command.created_by_discord_id,
|
||||
created_at=sample_help_command.created_at,
|
||||
updated_at=datetime.now(timezone.utc),
|
||||
last_modified_by=987654321,
|
||||
is_active=sample_help_command.is_active,
|
||||
view_count=sample_help_command.view_count,
|
||||
display_order=sample_help_command.display_order
|
||||
)
|
||||
|
||||
call_count = 0
|
||||
|
||||
async def mock_get_with_counter(name, include_inactive=False):
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
if call_count == 1:
|
||||
return sample_help_command
|
||||
else:
|
||||
return updated_help
|
||||
|
||||
help_commands_service_instance.get_help_by_name = mock_get_with_counter
|
||||
|
||||
result = await help_commands_service_instance.update_help(
|
||||
name="trading-rules",
|
||||
new_title="Updated Trading Rules",
|
||||
new_content="Updated content",
|
||||
updater_discord_id=987654321
|
||||
)
|
||||
|
||||
assert isinstance(result, HelpCommand)
|
||||
assert result.title == "Updated Trading Rules"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_help_success(self, help_commands_service_instance, sample_help_command):
|
||||
"""Test successful help command deletion (soft delete)."""
|
||||
# Mock getting the help command
|
||||
async def mock_get_help_by_name(name, include_inactive=False):
|
||||
return sample_help_command
|
||||
|
||||
# Mock the API delete call
|
||||
async def mock_delete(*args, **kwargs):
|
||||
return None
|
||||
|
||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||
help_commands_service_instance._client.delete = mock_delete
|
||||
|
||||
result = await help_commands_service_instance.delete_help("trading-rules")
|
||||
|
||||
assert result is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restore_help_success(self, help_commands_service_instance):
|
||||
"""Test successful help command restoration."""
|
||||
# Mock getting a deleted help command
|
||||
deleted_help = HelpCommand(
|
||||
id=1,
|
||||
name='deleted-topic',
|
||||
title='Deleted Topic',
|
||||
content='Content',
|
||||
created_by_discord_id=123456789,
|
||||
created_at=datetime.now(timezone.utc),
|
||||
is_active=False
|
||||
)
|
||||
|
||||
async def mock_get_help_by_name(name, include_inactive=False):
|
||||
return deleted_help
|
||||
|
||||
# Mock the API restore call
|
||||
restored_data = {
|
||||
'id': deleted_help.id,
|
||||
'name': deleted_help.name,
|
||||
'title': deleted_help.title,
|
||||
'content': deleted_help.content,
|
||||
'created_by_discord_id': deleted_help.created_by_discord_id,
|
||||
'created_at': deleted_help.created_at.isoformat(),
|
||||
'is_active': True,
|
||||
'view_count': 0,
|
||||
'display_order': 0
|
||||
}
|
||||
|
||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||
help_commands_service_instance._client.patch.return_value = restored_data
|
||||
|
||||
result = await help_commands_service_instance.restore_help("deleted-topic")
|
||||
|
||||
assert isinstance(result, HelpCommand)
|
||||
assert result.is_active is True
|
||||
|
||||
|
||||
class TestHelpCommandsServiceSearch:
|
||||
"""Test search and listing operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_search_help_commands(self, help_commands_service_instance):
|
||||
"""Test searching for help commands with filters."""
|
||||
filters = HelpCommandSearchFilters(
|
||||
name_contains='trading',
|
||||
category='rules',
|
||||
page=1,
|
||||
page_size=10
|
||||
)
|
||||
|
||||
# Mock API response
|
||||
api_response = {
|
||||
'help_commands': [
|
||||
{
|
||||
'id': 1,
|
||||
'name': 'trading-rules',
|
||||
'title': 'Trading Rules',
|
||||
'content': 'Content',
|
||||
'category': 'rules',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
'is_active': True,
|
||||
'view_count': 100,
|
||||
'display_order': 0
|
||||
}
|
||||
],
|
||||
'total_count': 1,
|
||||
'page': 1,
|
||||
'page_size': 10,
|
||||
'total_pages': 1,
|
||||
'has_more': False
|
||||
}
|
||||
|
||||
help_commands_service_instance._client.get.return_value = api_response
|
||||
|
||||
result = await help_commands_service_instance.search_help_commands(filters)
|
||||
|
||||
assert isinstance(result, HelpCommandSearchResult)
|
||||
assert len(result.help_commands) == 1
|
||||
assert result.total_count == 1
|
||||
assert result.help_commands[0].name == 'trading-rules'
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_help_topics(self, help_commands_service_instance):
|
||||
"""Test getting all help topics."""
|
||||
# Mock API response
|
||||
api_response = {
|
||||
'help_commands': [
|
||||
{
|
||||
'id': i,
|
||||
'name': f'topic-{i}',
|
||||
'title': f'Topic {i}',
|
||||
'content': f'Content {i}',
|
||||
'category': 'rules' if i % 2 == 0 else 'guides',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
'is_active': True,
|
||||
'view_count': i * 10,
|
||||
'display_order': i
|
||||
}
|
||||
for i in range(1, 6)
|
||||
],
|
||||
'total_count': 5,
|
||||
'page': 1,
|
||||
'page_size': 100,
|
||||
'total_pages': 1,
|
||||
'has_more': False
|
||||
}
|
||||
|
||||
help_commands_service_instance._client.get.return_value = api_response
|
||||
|
||||
result = await help_commands_service_instance.get_all_help_topics()
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 5
|
||||
assert all(isinstance(cmd, HelpCommand) for cmd in result)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_help_names_for_autocomplete(self, help_commands_service_instance):
|
||||
"""Test getting help names for autocomplete."""
|
||||
# Mock API response
|
||||
api_response = {
|
||||
'results': [
|
||||
{
|
||||
'name': 'trading-rules',
|
||||
'title': 'Trading Rules',
|
||||
'category': 'rules'
|
||||
},
|
||||
{
|
||||
'name': 'trading-deadline',
|
||||
'title': 'Trading Deadline',
|
||||
'category': 'info'
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
help_commands_service_instance._client.get.return_value = api_response
|
||||
|
||||
result = await help_commands_service_instance.get_help_names_for_autocomplete(
|
||||
partial_name='trading',
|
||||
limit=25
|
||||
)
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 2
|
||||
assert 'trading-rules' in result
|
||||
assert 'trading-deadline' in result
|
||||
|
||||
|
||||
class TestHelpCommandsServiceViewTracking:
|
||||
"""Test view count tracking."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_increment_view_count(self, help_commands_service_instance, sample_help_command):
|
||||
"""Test incrementing view count."""
|
||||
# Mock the API patch call
|
||||
help_commands_service_instance._client.patch = AsyncMock()
|
||||
|
||||
# Mock getting the updated help command
|
||||
updated_help = HelpCommand(
|
||||
id=sample_help_command.id,
|
||||
name=sample_help_command.name,
|
||||
title=sample_help_command.title,
|
||||
content=sample_help_command.content,
|
||||
category=sample_help_command.category,
|
||||
created_by_discord_id=sample_help_command.created_by_discord_id,
|
||||
created_at=sample_help_command.created_at,
|
||||
is_active=sample_help_command.is_active,
|
||||
view_count=sample_help_command.view_count + 1,
|
||||
display_order=sample_help_command.display_order
|
||||
)
|
||||
|
||||
async def mock_get_help_by_name(name, include_inactive=False):
|
||||
return updated_help
|
||||
|
||||
help_commands_service_instance.get_help_by_name = mock_get_help_by_name
|
||||
|
||||
result = await help_commands_service_instance.increment_view_count("trading-rules")
|
||||
|
||||
assert isinstance(result, HelpCommand)
|
||||
assert result.view_count == 101
|
||||
|
||||
|
||||
class TestHelpCommandsServiceStatistics:
|
||||
"""Test statistics gathering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_statistics(self, help_commands_service_instance):
|
||||
"""Test getting help command statistics."""
|
||||
# Mock API response
|
||||
api_response = {
|
||||
'total_commands': 50,
|
||||
'active_commands': 45,
|
||||
'total_views': 5000,
|
||||
'most_viewed_command': {
|
||||
'id': 1,
|
||||
'name': 'popular-topic',
|
||||
'title': 'Popular Topic',
|
||||
'content': 'Content',
|
||||
'created_by_discord_id': 123,
|
||||
'created_at': datetime.now(timezone.utc).isoformat(),
|
||||
'is_active': True,
|
||||
'view_count': 500,
|
||||
'display_order': 0
|
||||
},
|
||||
'recent_commands_count': 5
|
||||
}
|
||||
|
||||
help_commands_service_instance._client.get.return_value = api_response
|
||||
|
||||
result = await help_commands_service_instance.get_statistics()
|
||||
|
||||
assert isinstance(result, HelpCommandStats)
|
||||
assert result.total_commands == 50
|
||||
assert result.active_commands == 45
|
||||
assert result.total_views == 5000
|
||||
assert result.most_viewed_command is not None
|
||||
assert result.most_viewed_command.name == 'popular-topic'
|
||||
assert result.recent_commands_count == 5
|
||||
|
||||
|
||||
class TestHelpCommandsServiceErrorHandling:
|
||||
"""Test error handling scenarios."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_api_connection_error(self, help_commands_service_instance):
|
||||
"""Test handling of API connection errors."""
|
||||
from exceptions import APIException, BotException
|
||||
|
||||
# Mock the API client to raise an APIException
|
||||
help_commands_service_instance._client.get.side_effect = APIException("Connection error")
|
||||
|
||||
with pytest.raises(BotException, match="Failed to retrieve help topic 'test'"):
|
||||
await help_commands_service_instance.get_help_by_name("test")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_empty_statistics_on_error(self, help_commands_service_instance):
|
||||
"""Test that get_statistics returns empty stats on error."""
|
||||
# Mock the API client to raise an exception
|
||||
help_commands_service_instance._client.get.side_effect = Exception("API Error")
|
||||
|
||||
result = await help_commands_service_instance.get_statistics()
|
||||
|
||||
# Should return empty stats instead of raising
|
||||
assert isinstance(result, HelpCommandStats)
|
||||
assert result.total_commands == 0
|
||||
assert result.active_commands == 0
|
||||
assert result.total_views == 0
|
||||
399
views/help_commands.py
Normal file
399
views/help_commands.py
Normal file
@ -0,0 +1,399 @@
|
||||
"""
|
||||
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."""
|
||||
# Store results
|
||||
self.result = {
|
||||
'name': self.topic_name.value.strip(),
|
||||
'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
|
||||
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
|
||||
Loading…
Reference in New Issue
Block a user