- Deleted .env.example file as it is no longer needed. - Added .gitignore to manage ignored files and directories. - Introduced CLAUDE.md for AI provider integration documentation. - Created dev.sh for development setup and scripts. - Updated Dockerfile and Dockerfile.production for improved build processes. - Added multiple test files and directories for comprehensive testing. - Introduced new utility and service files for enhanced functionality. - Organized codebase with new directories and files for better maintainability.
352 lines
10 KiB
Python
352 lines
10 KiB
Python
"""
|
||
UI Utilities for Discord Voice Chat Quote Bot
|
||
|
||
Provides utility functions for consistent Discord UI formatting,
|
||
styling, and component creation across the bot.
|
||
"""
|
||
|
||
from datetime import datetime
|
||
from typing import Any, TypeVar
|
||
|
||
import discord
|
||
|
||
T = TypeVar("T")
|
||
|
||
|
||
class EmbedStyles:
|
||
"""Standard embed color schemes and styles"""
|
||
|
||
# Color palette
|
||
PRIMARY = 0x3498DB # Blue
|
||
SUCCESS = 0x2ECC71 # Green
|
||
WARNING = 0xF39C12 # Orange
|
||
DANGER = 0xE74C3C # Red
|
||
INFO = 0x9B59B6 # Purple
|
||
SECONDARY = 0x95A5A6 # Gray
|
||
|
||
# Quote category colors
|
||
FUNNY = 0xFFEB3B # Yellow
|
||
DARK = 0x424242 # Dark gray
|
||
SILLY = 0xFF9800 # Orange
|
||
SUSPICIOUS = 0xFF5722 # Deep orange
|
||
ASININE = 0x9C27B0 # Purple
|
||
|
||
|
||
class UIFormatter:
|
||
"""Utility functions for formatting UI elements"""
|
||
|
||
@staticmethod
|
||
def format_score_bar(
|
||
score: float, max_score: float = 10.0, length: int = 10
|
||
) -> str:
|
||
"""Create a visual score bar using Unicode blocks"""
|
||
if score <= 0:
|
||
return "░" * length
|
||
|
||
filled = int((score / max_score) * length)
|
||
filled = max(0, min(filled, length))
|
||
|
||
return "█" * filled + "░" * (length - filled)
|
||
|
||
@staticmethod
|
||
def format_percentage_bar(percentage: float, length: int = 10) -> str:
|
||
"""Create a percentage bar"""
|
||
filled = int((percentage / 100.0) * length)
|
||
filled = max(0, min(filled, length))
|
||
|
||
return "█" * filled + "░" * (length - filled)
|
||
|
||
@staticmethod
|
||
def format_timestamp(timestamp: datetime, style: str = "relative") -> str:
|
||
"""Format timestamp for Discord display"""
|
||
unix_timestamp = int(timestamp.timestamp())
|
||
|
||
if style == "relative":
|
||
return f"<t:{unix_timestamp}:R>"
|
||
elif style == "short":
|
||
return f"<t:{unix_timestamp}:d>"
|
||
elif style == "long":
|
||
return f"<t:{unix_timestamp}:F>"
|
||
else:
|
||
return timestamp.strftime("%Y-%m-%d %H:%M")
|
||
|
||
@staticmethod
|
||
def truncate_text(text: str, max_length: int = 100, suffix: str = "...") -> str:
|
||
"""Truncate text with ellipsis"""
|
||
if len(text) <= max_length:
|
||
return text
|
||
return text[: max_length - len(suffix)] + suffix
|
||
|
||
@staticmethod
|
||
def format_score_emojis(scores: dict[str, float], threshold: float = 5.0) -> str:
|
||
"""Format score dictionary into emoji representation"""
|
||
emoji_map = {
|
||
"funny_score": "😂",
|
||
"dark_score": "🖤",
|
||
"silly_score": "🤪",
|
||
"suspicious_score": "🤔",
|
||
"asinine_score": "🙄",
|
||
}
|
||
|
||
formatted_scores = []
|
||
for score_type, score in scores.items():
|
||
if score > threshold and score_type in emoji_map:
|
||
emoji = emoji_map[score_type]
|
||
formatted_scores.append(f"{emoji} {score:.1f}")
|
||
|
||
return (
|
||
" | ".join(formatted_scores)
|
||
if formatted_scores
|
||
else "No significant scores"
|
||
)
|
||
|
||
@staticmethod
|
||
def create_progress_indicator(current: int, total: int) -> str:
|
||
"""Create a progress indicator"""
|
||
if total == 0:
|
||
return "0/0"
|
||
|
||
percentage = int((current / total) * 100)
|
||
return f"{current}/{total} ({percentage}%)"
|
||
|
||
@staticmethod
|
||
def format_duration(seconds: float) -> str:
|
||
"""Format duration in human-readable format"""
|
||
if seconds < 60:
|
||
return f"{seconds:.1f}s"
|
||
elif seconds < 3600:
|
||
minutes = int(seconds // 60)
|
||
remaining_seconds = int(seconds % 60)
|
||
return f"{minutes}m {remaining_seconds}s"
|
||
else:
|
||
hours = int(seconds // 3600)
|
||
remaining_minutes = int((seconds % 3600) // 60)
|
||
return f"{hours}h {remaining_minutes}m"
|
||
|
||
|
||
class EmbedBuilder:
|
||
"""Enhanced embed builder with consistent styling"""
|
||
|
||
@staticmethod
|
||
def create_error_embed(
|
||
title: str, description: str, details: str | None = None
|
||
) -> discord.Embed:
|
||
"""Create standardized error embed"""
|
||
embed = discord.Embed(
|
||
title=f"❌ {title}", description=description, color=EmbedStyles.DANGER
|
||
)
|
||
|
||
if details:
|
||
embed.add_field(name="Details", value=details, inline=False)
|
||
|
||
return embed
|
||
|
||
@staticmethod
|
||
def create_success_embed(
|
||
title: str, description: str, details: str | None = None
|
||
) -> discord.Embed:
|
||
"""Create standardized success embed"""
|
||
embed = discord.Embed(
|
||
title=f"✅ {title}", description=description, color=EmbedStyles.SUCCESS
|
||
)
|
||
|
||
if details:
|
||
embed.add_field(name="Details", value=details, inline=False)
|
||
|
||
return embed
|
||
|
||
@staticmethod
|
||
def create_info_embed(
|
||
title: str, description: str, color: int = EmbedStyles.INFO
|
||
) -> discord.Embed:
|
||
"""Create standardized info embed"""
|
||
return discord.Embed(title=f"ℹ️ {title}", description=description, color=color)
|
||
|
||
@staticmethod
|
||
def create_warning_embed(
|
||
title: str, description: str, warning: str | None = None
|
||
) -> discord.Embed:
|
||
"""Create standardized warning embed"""
|
||
embed = discord.Embed(
|
||
title=f"⚠️ {title}", description=description, color=EmbedStyles.WARNING
|
||
)
|
||
|
||
if warning:
|
||
embed.add_field(name="Warning", value=warning, inline=False)
|
||
|
||
return embed
|
||
|
||
@staticmethod
|
||
def create_loading_embed(operation: str) -> discord.Embed:
|
||
"""Create loading/processing embed"""
|
||
return discord.Embed(
|
||
title="⏳ Processing",
|
||
description=f"Please wait while we {operation}...",
|
||
color=EmbedStyles.INFO,
|
||
)
|
||
|
||
|
||
class PaginationHelper:
|
||
"""Helper functions for pagination UI"""
|
||
|
||
@staticmethod
|
||
def calculate_pages(total_items: int, items_per_page: int) -> int:
|
||
"""Calculate total number of pages"""
|
||
if total_items == 0:
|
||
return 1
|
||
return (total_items + items_per_page - 1) // items_per_page
|
||
|
||
@staticmethod
|
||
def get_page_items(items: list[T], page: int, items_per_page: int) -> list[T]:
|
||
"""Get items for a specific page"""
|
||
start_idx = page * items_per_page
|
||
end_idx = start_idx + items_per_page
|
||
return items[start_idx:end_idx]
|
||
|
||
@staticmethod
|
||
def create_page_info(current_page: int, total_pages: int, total_items: int) -> str:
|
||
"""Create page information string"""
|
||
return f"Page {current_page + 1} of {total_pages} • {total_items} total items"
|
||
|
||
|
||
class ValidationHelper:
|
||
"""Helper functions for input validation"""
|
||
|
||
@staticmethod
|
||
def validate_quote_text(
|
||
text: str, min_length: int = 3, max_length: int = 1000
|
||
) -> tuple:
|
||
"""Validate quote text input"""
|
||
if not text or not text.strip():
|
||
return False, "Quote text cannot be empty"
|
||
|
||
text = text.strip()
|
||
|
||
if len(text) < min_length:
|
||
return False, f"Quote must be at least {min_length} characters long"
|
||
|
||
if len(text) > max_length:
|
||
return False, f"Quote must be no more than {max_length} characters long"
|
||
|
||
return True, text
|
||
|
||
@staticmethod
|
||
def validate_search_query(
|
||
query: str, min_length: int = 2, max_length: int = 100
|
||
) -> tuple:
|
||
"""Validate search query input"""
|
||
if not query or not query.strip():
|
||
return False, "Search query cannot be empty"
|
||
|
||
query = query.strip()
|
||
|
||
if len(query) < min_length:
|
||
return False, f"Search query must be at least {min_length} characters long"
|
||
|
||
if len(query) > max_length:
|
||
return (
|
||
False,
|
||
f"Search query must be no more than {max_length} characters long",
|
||
)
|
||
|
||
return True, query
|
||
|
||
@staticmethod
|
||
def sanitize_user_input(text: str) -> str:
|
||
"""Sanitize user input for safe display"""
|
||
# Remove potential markdown that could break formatting
|
||
text = text.replace("```", "'''")
|
||
text = text.replace("**", "\\*\\*")
|
||
text = text.replace("__", "\\_\\_")
|
||
text = text.replace("~~", "\\~\\~")
|
||
|
||
return text
|
||
|
||
|
||
class StatusIndicators:
|
||
"""Standard status indicators and emojis"""
|
||
|
||
# Status emojis
|
||
ONLINE = "🟢"
|
||
IDLE = "🟡"
|
||
DND = "🔴"
|
||
OFFLINE = "⚫"
|
||
|
||
# Feature status
|
||
ENABLED = "✅"
|
||
DISABLED = "❌"
|
||
PENDING = "⏳"
|
||
WARNING = "⚠️"
|
||
|
||
# Categories
|
||
FUNNY = "😂"
|
||
DARK = "🖤"
|
||
SILLY = "🤪"
|
||
SUSPICIOUS = "🤔"
|
||
ASININE = "🙄"
|
||
|
||
# Actions
|
||
PLAY = "▶️"
|
||
PAUSE = "⏸️"
|
||
STOP = "⏹️"
|
||
RECORD = "🎤"
|
||
MUTE = "🔇"
|
||
UNMUTE = "🔊"
|
||
|
||
@staticmethod
|
||
def get_health_indicator(healthy: bool) -> str:
|
||
"""Get health status indicator"""
|
||
return StatusIndicators.ENABLED if healthy else StatusIndicators.DISABLED
|
||
|
||
@staticmethod
|
||
def get_score_emoji(score_type: str) -> str:
|
||
"""Get emoji for score type"""
|
||
emoji_map = {
|
||
"funny": StatusIndicators.FUNNY,
|
||
"dark": StatusIndicators.DARK,
|
||
"silly": StatusIndicators.SILLY,
|
||
"suspicious": StatusIndicators.SUSPICIOUS,
|
||
"asinine": StatusIndicators.ASININE,
|
||
}
|
||
return emoji_map.get(score_type.lower(), "📊")
|
||
|
||
|
||
def create_field_list(
|
||
items: list[str],
|
||
max_fields: int = 25,
|
||
field_name: str = "Items",
|
||
inline: bool = True,
|
||
) -> list[dict[str, Any]]:
|
||
"""
|
||
Create list of embed fields for large item lists.
|
||
|
||
Args:
|
||
items: List of items to display
|
||
max_fields: Maximum number of fields per embed
|
||
field_name: Base name for fields
|
||
inline: Whether fields should be inline
|
||
|
||
Returns:
|
||
List of field dictionaries
|
||
"""
|
||
fields = []
|
||
items_per_field = 10 # Discord embed field value limit considerations
|
||
|
||
for i in range(0, len(items), items_per_field):
|
||
field_items = items[i : i + items_per_field]
|
||
field_value = "\n".join([f"• {item}" for item in field_items])
|
||
|
||
field_num = (i // items_per_field) + 1
|
||
field_title = (
|
||
f"{field_name}"
|
||
if len(items) <= items_per_field
|
||
else f"{field_name} ({field_num})"
|
||
)
|
||
|
||
fields.append({"name": field_title, "value": field_value, "inline": inline})
|
||
|
||
if len(fields) >= max_fields:
|
||
break
|
||
|
||
return fields
|