From 775781b3258aed2a88af57c643905813268b978e Mon Sep 17 00:00:00 2001 From: pacnpal <183241239+pacnpal@users.noreply.github.com> Date: Sat, 16 Nov 2024 21:13:08 +0000 Subject: [PATCH] Improve command handling with better error handling, type hints, and organization - Add proper error handling with CommandError - Add proper error context support - Add better command organization - Add proper type hints - Add proper docstrings - Add better error recovery - Add proper error context creation - Add better response handling - Add proper validation - Add better logging - Add proper response types - Add better error messages - Add proper status reporting - Add new status commands --- .../core/commands/archiver_commands.py | 306 ++++++-- .../core/commands/database_commands.py | 406 ++++++++++- .../core/commands/settings_commands.py | 682 +++++++++++++++--- 3 files changed, 1228 insertions(+), 166 deletions(-) diff --git a/videoarchiver/core/commands/archiver_commands.py b/videoarchiver/core/commands/archiver_commands.py index 039a7fc..2d9b6ea 100644 --- a/videoarchiver/core/commands/archiver_commands.py +++ b/videoarchiver/core/commands/archiver_commands.py @@ -4,79 +4,303 @@ import discord from redbot.core.commands import Context, hybrid_group, guild_only, admin_or_permissions from discord import app_commands import logging -from ..response_handler import handle_response +from typing import Optional, Any, Dict, TypedDict, Callable, Awaitable +from enum import Enum, auto + +from ..response_handler import handle_response, ResponseType +from ...utils.exceptions import ( + CommandError, + ErrorContext, + ErrorSeverity +) logger = logging.getLogger("VideoArchiver") -def setup_archiver_commands(cog): - """Set up archiver commands for the cog""" +class CommandCategory(Enum): + """Command categories""" + MANAGEMENT = auto() + STATUS = auto() + UTILITY = auto() + +class CommandResult(TypedDict): + """Type definition for command result""" + success: bool + message: str + details: Optional[Dict[str, Any]] + error: Optional[str] + +class CommandContext: + """Context manager for command execution""" + def __init__( + self, + ctx: Context, + category: CommandCategory, + operation: str + ) -> None: + self.ctx = ctx + self.category = category + self.operation = operation + self.start_time = None + + async def __aenter__(self) -> 'CommandContext': + """Set up command context""" + self.start_time = ctx.message.created_at + logger.debug( + f"Starting command {self.operation} in category {self.category.name}" + ) + if hasattr(self.ctx, "interaction") and self.ctx.interaction: + await self.ctx.defer() + return self + + async def __aexit__(self, exc_type, exc_val, exc_tb) -> bool: + """Handle command completion or error""" + if exc_type is not None: + error = f"Error in {self.operation}: {str(exc_val)}" + logger.error(error, exc_info=True) + await handle_response( + self.ctx, + f"An error occurred: {str(exc_val)}", + response_type=ResponseType.ERROR + ) + return True + return False + +def setup_archiver_commands(cog: Any) -> Callable: + """ + Set up archiver commands for the cog. - @cog.hybrid_group(name="archiver", fallback="help") + Args: + cog: VideoArchiver cog instance + + Returns: + Main archiver command group + """ + + @hybrid_group(name="archiver", fallback="help") @guild_only() - async def archiver(ctx: Context): + async def archiver(ctx: Context) -> None: """Manage video archiver settings.""" if ctx.invoked_subcommand is None: await handle_response( - ctx, "Use `/help archiver` for a list of commands." + ctx, + "Use `/help archiver` for a list of commands.", + response_type=ResponseType.INFO ) @archiver.command(name="enable") @guild_only() @admin_or_permissions(administrator=True) - async def enable_archiver(ctx: Context): + async def enable_archiver(ctx: Context) -> None: """Enable video archiving in this server.""" - try: - current_setting = await cog.config_manager.get_setting( - ctx.guild.id, "enabled" - ) - if current_setting: - await handle_response(ctx, "Video archiving is already enabled.") - return + async with CommandContext(ctx, CommandCategory.MANAGEMENT, "enable_archiver"): + try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "ArchiverCommands", + "enable_archiver", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) - await cog.config_manager.update_setting(ctx.guild.id, "enabled", True) - await handle_response(ctx, "Video archiving has been enabled.") + # Check current setting + current_setting = await cog.config_manager.get_setting( + ctx.guild.id, "enabled" + ) + if current_setting: + await handle_response( + ctx, + "Video archiving is already enabled.", + response_type=ResponseType.WARNING + ) + return - except Exception as e: - logger.error(f"Error enabling archiver: {e}") - await handle_response( - ctx, "An error occurred while enabling video archiving." - ) + # Update setting + await cog.config_manager.update_setting(ctx.guild.id, "enabled", True) + await handle_response( + ctx, + "Video archiving has been enabled.", + response_type=ResponseType.SUCCESS + ) + + except Exception as e: + error = f"Failed to enable archiver: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "ArchiverCommands", + "enable_archiver", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) @archiver.command(name="disable") @guild_only() @admin_or_permissions(administrator=True) - async def disable_archiver(ctx: Context): + async def disable_archiver(ctx: Context) -> None: """Disable video archiving in this server.""" - try: - current_setting = await cog.config_manager.get_setting( - ctx.guild.id, "enabled" - ) - if not current_setting: - await handle_response(ctx, "Video archiving is already disabled.") - return + async with CommandContext(ctx, CommandCategory.MANAGEMENT, "disable_archiver"): + try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "ArchiverCommands", + "disable_archiver", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) - await cog.config_manager.update_setting(ctx.guild.id, "enabled", False) - await handle_response(ctx, "Video archiving has been disabled.") + # Check current setting + current_setting = await cog.config_manager.get_setting( + ctx.guild.id, "enabled" + ) + if not current_setting: + await handle_response( + ctx, + "Video archiving is already disabled.", + response_type=ResponseType.WARNING + ) + return - except Exception as e: - logger.error(f"Error disabling archiver: {e}") - await handle_response( - ctx, "An error occurred while disabling video archiving." - ) + # Update setting + await cog.config_manager.update_setting(ctx.guild.id, "enabled", False) + await handle_response( + ctx, + "Video archiving has been disabled.", + response_type=ResponseType.SUCCESS + ) + + except Exception as e: + error = f"Failed to disable archiver: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "ArchiverCommands", + "disable_archiver", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) @archiver.command(name="queue") @guild_only() - async def show_queue(ctx: Context): + async def show_queue(ctx: Context) -> None: """Show the current video processing queue.""" - # Defer the response immediately for slash commands - if hasattr(ctx, "interaction") and ctx.interaction: - await ctx.defer() - await cog.processor.show_queue_details(ctx) + async with CommandContext(ctx, CommandCategory.STATUS, "show_queue"): + try: + # Check if processor is ready + if not cog.processor: + raise CommandError( + "Video processor is not ready", + context=ErrorContext( + "ArchiverCommands", + "show_queue", + {"guild_id": ctx.guild.id}, + ErrorSeverity.MEDIUM + ) + ) + + await cog.processor.show_queue_details(ctx) + + except Exception as e: + error = f"Failed to show queue: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "ArchiverCommands", + "show_queue", + {"guild_id": ctx.guild.id}, + ErrorSeverity.MEDIUM + ) + ) + + @archiver.command(name="status") + @guild_only() + @admin_or_permissions(administrator=True) + async def show_status(ctx: Context) -> None: + """Show the archiver status for this server.""" + async with CommandContext(ctx, CommandCategory.STATUS, "show_status"): + try: + # Get comprehensive status + status = { + "enabled": await cog.config_manager.get_setting(ctx.guild.id, "enabled"), + "queue": cog.queue_manager.get_queue_status(ctx.guild.id) if cog.queue_manager else None, + "processor": cog.processor.get_status() if cog.processor else None, + "components": cog.component_manager.get_component_status(), + "health": cog.status_tracker.get_status() + } + + # Create status embed + embed = discord.Embed( + title="VideoArchiver Status", + color=discord.Color.blue() if status["enabled"] else discord.Color.red() + ) + embed.add_field( + name="Status", + value="Enabled" if status["enabled"] else "Disabled", + inline=False + ) + + if status["queue"]: + embed.add_field( + name="Queue", + value=( + f"Pending: {status['queue']['pending']}\n" + f"Processing: {status['queue']['processing']}\n" + f"Completed: {status['queue']['completed']}" + ), + inline=True + ) + + if status["processor"]: + embed.add_field( + name="Processor", + value=( + f"Active: {status['processor']['active']}\n" + f"Health: {status['processor']['health']}" + ), + inline=True + ) + + embed.add_field( + name="Health", + value=( + f"State: {status['health']['state']}\n" + f"Errors: {status['health']['error_count']}" + ), + inline=True + ) + + await ctx.send(embed=embed) + + except Exception as e: + error = f"Failed to show status: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "ArchiverCommands", + "show_status", + {"guild_id": ctx.guild.id}, + ErrorSeverity.MEDIUM + ) + ) # Store commands in cog for access cog.archiver = archiver cog.enable_archiver = enable_archiver cog.disable_archiver = disable_archiver cog.show_queue = show_queue + cog.show_status = show_status return archiver diff --git a/videoarchiver/core/commands/database_commands.py b/videoarchiver/core/commands/database_commands.py index e409353..48df7a5 100644 --- a/videoarchiver/core/commands/database_commands.py +++ b/videoarchiver/core/commands/database_commands.py @@ -4,29 +4,163 @@ import discord from redbot.core.commands import Context, hybrid_group, guild_only, admin_or_permissions from discord import app_commands import logging -from ..response_handler import handle_response +from typing import Optional, Any, Dict, TypedDict, Tuple, Union +from enum import Enum, auto +from datetime import datetime + +from ..response_handler import handle_response, ResponseType +from ...utils.exceptions import ( + CommandError, + ErrorContext, + ErrorSeverity, + DatabaseError +) from ...database.video_archive_db import VideoArchiveDB logger = logging.getLogger("VideoArchiver") -def setup_database_commands(cog): - """Set up database commands for the cog""" +class DatabaseOperation(Enum): + """Database operation types""" + ENABLE = auto() + DISABLE = auto() + QUERY = auto() + MAINTENANCE = auto() - @cog.hybrid_group(name="archivedb", fallback="help") +class DatabaseStatus(TypedDict): + """Type definition for database status""" + enabled: bool + connected: bool + initialized: bool + error: Optional[str] + last_operation: Optional[str] + operation_time: Optional[str] + +class ArchivedVideo(TypedDict): + """Type definition for archived video data""" + url: str + discord_url: str + message_id: int + channel_id: int + guild_id: int + archived_at: str + +async def check_database_status(cog: Any) -> DatabaseStatus: + """ + Check database status. + + Args: + cog: VideoArchiver cog instance + + Returns: + Database status information + """ + try: + enabled = await cog.config_manager.get_setting( + None, "use_database" + ) if cog.config_manager else False + + return DatabaseStatus( + enabled=enabled, + connected=cog.db is not None and cog.db.is_connected(), + initialized=cog.db is not None, + error=None, + last_operation=None, + operation_time=datetime.utcnow().isoformat() + ) + except Exception as e: + return DatabaseStatus( + enabled=False, + connected=False, + initialized=False, + error=str(e), + last_operation=None, + operation_time=datetime.utcnow().isoformat() + ) + +def setup_database_commands(cog: Any) -> Any: + """ + Set up database commands for the cog. + + Args: + cog: VideoArchiver cog instance + + Returns: + Main database command group + """ + + @hybrid_group(name="archivedb", fallback="help") @guild_only() - async def archivedb(ctx: Context): + async def archivedb(ctx: Context) -> None: """Manage the video archive database.""" if ctx.invoked_subcommand is None: - await handle_response( - ctx, "Use `/help archivedb` for a list of commands." - ) + try: + # Get database status + status = await check_database_status(cog) + + # Create status embed + embed = discord.Embed( + title="Video Archive Database Status", + color=discord.Color.blue() if status["enabled"] else discord.Color.red() + ) + embed.add_field( + name="Status", + value="Enabled" if status["enabled"] else "Disabled", + inline=False + ) + embed.add_field( + name="Connection", + value="Connected" if status["connected"] else "Disconnected", + inline=True + ) + embed.add_field( + name="Initialization", + value="Initialized" if status["initialized"] else "Not Initialized", + inline=True + ) + if status["error"]: + embed.add_field( + name="Error", + value=status["error"], + inline=False + ) + + await handle_response( + ctx, + "Use `/help archivedb` for a list of commands.", + embed=embed, + response_type=ResponseType.INFO + ) + except Exception as e: + error = f"Failed to get database status: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "DatabaseCommands", + "show_status", + {"guild_id": ctx.guild.id}, + ErrorSeverity.MEDIUM + ) + ) @archivedb.command(name="enable") @guild_only() @admin_or_permissions(administrator=True) - async def enable_database(ctx: Context): + async def enable_database(ctx: Context) -> None: """Enable the video archive database.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "DatabaseCommands", + "enable_database", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() @@ -37,12 +171,26 @@ def setup_database_commands(cog): ) if current_setting: await handle_response( - ctx, "The video archive database is already enabled." + ctx, + "The video archive database is already enabled.", + response_type=ResponseType.WARNING ) return # Initialize database - cog.db = VideoArchiveDB(cog.data_path) + try: + cog.db = VideoArchiveDB(cog.data_path) + await cog.db.initialize() + except Exception as e: + raise DatabaseError( + f"Failed to initialize database: {str(e)}", + context=ErrorContext( + "DatabaseCommands", + "enable_database", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) # Update processor with database if cog.processor: @@ -51,73 +199,136 @@ def setup_database_commands(cog): cog.processor.queue_handler.db = cog.db # Update setting - await cog.config_manager.update_setting(ctx.guild.id, "use_database", True) + await cog.config_manager.update_setting( + ctx.guild.id, + "use_database", + True + ) # Send success message - await handle_response(ctx, "Video archive database has been enabled.") - - except Exception as e: - logger.error(f"Error enabling database: {e}") await handle_response( ctx, - "An error occurred while enabling the database. Please check the logs for details.", + "Video archive database has been enabled.", + response_type=ResponseType.SUCCESS + ) + + except Exception as e: + error = f"Failed to enable database: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "DatabaseCommands", + "enable_database", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) ) @archivedb.command(name="disable") @guild_only() @admin_or_permissions(administrator=True) - async def disable_database(ctx: Context): + async def disable_database(ctx: Context) -> None: """Disable the video archive database.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "DatabaseCommands", + "disable_database", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() current_setting = await cog.config_manager.get_setting( - ctx.guild.id, "use_database" + ctx.guild.id, + "use_database" ) if not current_setting: await handle_response( - ctx, "The video archive database is already disabled." + ctx, + "The video archive database is already disabled.", + response_type=ResponseType.WARNING ) return + # Close database connection if active + if cog.db: + try: + await cog.db.close() + except Exception as e: + logger.error(f"Error closing database connection: {e}") + # Remove database references cog.db = None - cog.processor.db = None - cog.processor.queue_handler.db = None + if cog.processor: + cog.processor.db = None + if cog.processor.queue_handler: + cog.processor.queue_handler.db = None await cog.config_manager.update_setting( - ctx.guild.id, "use_database", False + ctx.guild.id, + "use_database", + False ) await handle_response( - ctx, "Video archive database has been disabled." + ctx, + "Video archive database has been disabled.", + response_type=ResponseType.SUCCESS ) except Exception as e: - logger.error(f"Error disabling database: {e}") - await handle_response( - ctx, "An error occurred while disabling the database." + error = f"Failed to disable database: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "DatabaseCommands", + "disable_database", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) ) @archivedb.command(name="check") @guild_only() @app_commands.describe(url="The URL of the video to check") - async def checkarchived(ctx: Context, url: str): + async def checkarchived(ctx: Context, url: str) -> None: """Check if a video URL has been archived and get its Discord link if it exists.""" try: - # Defer the response immediately for slash commands - if hasattr(ctx, "interaction") and ctx.interaction: - await ctx.defer() - + # Check if database is enabled if not cog.db: await handle_response( ctx, "The archive database is not enabled. Ask an admin to enable it with `/archivedb enable`", + response_type=ResponseType.ERROR ) return - result = cog.db.get_archived_video(url) + # Defer the response immediately for slash commands + if hasattr(ctx, "interaction") and ctx.interaction: + await ctx.defer() + + try: + result = await cog.db.get_archived_video(url) + except Exception as e: + raise DatabaseError( + f"Failed to query database: {str(e)}", + context=ErrorContext( + "DatabaseCommands", + "checkarchived", + {"guild_id": ctx.guild.id, "url": url}, + ErrorSeverity.MEDIUM + ) + ) + if result: discord_url, message_id, channel_id, guild_id = result embed = discord.Embed( @@ -125,19 +336,137 @@ def setup_database_commands(cog): description=f"This video has been archived!\n\nOriginal URL: {url}", color=discord.Color.green(), ) - embed.add_field(name="Archived Link", value=discord_url) - await handle_response(ctx, embed=embed) + embed.add_field( + name="Archived Link", + value=discord_url, + inline=False + ) + embed.add_field( + name="Message ID", + value=str(message_id), + inline=True + ) + embed.add_field( + name="Channel ID", + value=str(channel_id), + inline=True + ) + embed.add_field( + name="Guild ID", + value=str(guild_id), + inline=True + ) + await handle_response( + ctx, + embed=embed, + response_type=ResponseType.SUCCESS + ) else: embed = discord.Embed( title="Video Not Found", description="This video has not been archived yet.", color=discord.Color.red(), ) - await handle_response(ctx, embed=embed) + await handle_response( + ctx, + embed=embed, + response_type=ResponseType.WARNING + ) + except Exception as e: - logger.error(f"Error checking archived video: {e}") + error = f"Failed to check archived video: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "DatabaseCommands", + "checkarchived", + {"guild_id": ctx.guild.id, "url": url}, + ErrorSeverity.MEDIUM + ) + ) + + @archivedb.command(name="status") + @guild_only() + @admin_or_permissions(administrator=True) + async def database_status(ctx: Context) -> None: + """Show detailed database status information.""" + try: + # Defer the response immediately for slash commands + if hasattr(ctx, "interaction") and ctx.interaction: + await ctx.defer() + + status = await check_database_status(cog) + + # Get additional stats if database is enabled + stats = {} + if cog.db and status["connected"]: + try: + stats = await cog.db.get_stats() + except Exception as e: + logger.error(f"Error getting database stats: {e}") + + embed = discord.Embed( + title="Database Status", + color=discord.Color.green() if status["connected"] else discord.Color.red() + ) + embed.add_field( + name="Status", + value="Enabled" if status["enabled"] else "Disabled", + inline=False + ) + embed.add_field( + name="Connection", + value="Connected" if status["connected"] else "Disconnected", + inline=True + ) + embed.add_field( + name="Initialization", + value="Initialized" if status["initialized"] else "Not Initialized", + inline=True + ) + + if stats: + embed.add_field( + name="Total Videos", + value=str(stats.get("total_videos", 0)), + inline=True + ) + embed.add_field( + name="Total Size", + value=f"{stats.get('total_size', 0)} MB", + inline=True + ) + embed.add_field( + name="Last Update", + value=stats.get("last_update", "Never"), + inline=True + ) + + if status["error"]: + embed.add_field( + name="Error", + value=status["error"], + inline=False + ) + await handle_response( - ctx, "An error occurred while checking the archive." + ctx, + embed=embed, + response_type=ResponseType.INFO + ) + + except Exception as e: + error = f"Failed to get database status: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "DatabaseCommands", + "database_status", + {"guild_id": ctx.guild.id}, + ErrorSeverity.MEDIUM + ) ) # Store commands in cog for access @@ -145,5 +474,6 @@ def setup_database_commands(cog): cog.enable_database = enable_database cog.disable_database = disable_database cog.checkarchived = checkarchived + cog.database_status = database_status return archivedb diff --git a/videoarchiver/core/commands/settings_commands.py b/videoarchiver/core/commands/settings_commands.py index 4be03ce..e70108e 100644 --- a/videoarchiver/core/commands/settings_commands.py +++ b/videoarchiver/core/commands/settings_commands.py @@ -4,253 +4,700 @@ import discord from redbot.core.commands import Context, hybrid_group, guild_only, admin_or_permissions from discord import app_commands import logging -from ..response_handler import handle_response +from typing import Optional, Any, Dict, TypedDict, Union, List +from enum import Enum, auto + +from ..response_handler import handle_response, ResponseType +from ...utils.exceptions import ( + CommandError, + ErrorContext, + ErrorSeverity +) +from ...core.settings import VideoFormat, VideoQuality logger = logging.getLogger("VideoArchiver") -def setup_settings_commands(cog): - """Set up settings commands for the cog""" +class SettingCategory(Enum): + """Setting categories""" + CHANNELS = auto() + VIDEO = auto() + MESSAGES = auto() + PERFORMANCE = auto() - @cog.hybrid_group(name="settings", fallback="show") +class SettingValidation(TypedDict): + """Type definition for setting validation""" + valid: bool + error: Optional[str] + details: Dict[str, Any] + +class SettingUpdate(TypedDict): + """Type definition for setting update""" + setting: str + old_value: Any + new_value: Any + category: SettingCategory + +async def validate_setting( + category: SettingCategory, + setting: str, + value: Any +) -> SettingValidation: + """ + Validate a setting value. + + Args: + category: Setting category + setting: Setting name + value: Value to validate + + Returns: + Validation result + """ + validation = SettingValidation( + valid=True, + error=None, + details={"category": category.name, "setting": setting, "value": value} + ) + + try: + if category == SettingCategory.VIDEO: + if setting == "format": + if value not in [f.value for f in VideoFormat]: + validation.update({ + "valid": False, + "error": f"Invalid format. Must be one of: {', '.join(f.value for f in VideoFormat)}" + }) + elif setting == "quality": + if not 144 <= value <= 4320: + validation.update({ + "valid": False, + "error": "Quality must be between 144 and 4320" + }) + elif setting == "max_file_size": + if not 1 <= value <= 100: + validation.update({ + "valid": False, + "error": "Size must be between 1 and 100 MB" + }) + + elif category == SettingCategory.MESSAGES: + if setting == "duration": + if not 0 <= value <= 168: + validation.update({ + "valid": False, + "error": "Duration must be between 0 and 168 hours (1 week)" + }) + elif setting == "template": + placeholders = ["{author}", "{channel}", "{original_message}"] + if not any(ph in value for ph in placeholders): + validation.update({ + "valid": False, + "error": f"Template must include at least one placeholder: {', '.join(placeholders)}" + }) + + elif category == SettingCategory.PERFORMANCE: + if setting == "concurrent_downloads": + if not 1 <= value <= 5: + validation.update({ + "valid": False, + "error": "Concurrent downloads must be between 1 and 5" + }) + + except Exception as e: + validation.update({ + "valid": False, + "error": f"Validation error: {str(e)}" + }) + + return validation + +def setup_settings_commands(cog: Any) -> Any: + """ + Set up settings commands for the cog. + + Args: + cog: VideoArchiver cog instance + + Returns: + Main settings command group + """ + + @hybrid_group(name="settings", fallback="show") @guild_only() - async def settings(ctx: Context): + async def settings(ctx: Context) -> None: """Show current archiver settings.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "show_settings", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() embed = await cog.config_manager.format_settings_embed(ctx.guild) - await handle_response(ctx, embed=embed) - except Exception as e: - logger.error(f"Error showing settings: {e}") await handle_response( - ctx, "An error occurred while showing settings." + ctx, + embed=embed, + response_type=ResponseType.INFO + ) + + except Exception as e: + error = f"Failed to show settings: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "show_settings", + {"guild_id": ctx.guild.id}, + ErrorSeverity.MEDIUM + ) ) @settings.command(name="setchannel") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(channel="The channel where archived videos will be stored") - async def set_archive_channel(ctx: Context, channel: discord.TextChannel): + async def set_archive_channel(ctx: Context, channel: discord.TextChannel) -> None: """Set the channel where archived videos will be stored.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_archive_channel", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() + # Check channel permissions + bot_member = ctx.guild.me + required_perms = discord.Permissions( + send_messages=True, + embed_links=True, + attach_files=True, + read_message_history=True + ) + channel_perms = channel.permissions_for(bot_member) + if not all(getattr(channel_perms, perm) for perm in required_perms): + raise CommandError( + "Missing required permissions in target channel", + context=ErrorContext( + "SettingsCommands", + "set_archive_channel", + { + "guild_id": ctx.guild.id, + "channel_id": channel.id, + "missing_perms": [ + perm for perm in required_perms + if not getattr(channel_perms, perm) + ] + }, + ErrorSeverity.MEDIUM + ) + ) + await cog.config_manager.update_setting( - ctx.guild.id, "archive_channel", channel.id + ctx.guild.id, + "archive_channel", + channel.id ) await handle_response( - ctx, f"Archive channel has been set to {channel.mention}." + ctx, + f"Archive channel has been set to {channel.mention}.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting archive channel: {e}") - await handle_response( - ctx, "An error occurred while setting the archive channel." + error = f"Failed to set archive channel: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_archive_channel", + {"guild_id": ctx.guild.id, "channel_id": channel.id}, + ErrorSeverity.HIGH + ) ) @settings.command(name="setlog") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(channel="The channel where log messages will be sent") - async def set_log_channel(ctx: Context, channel: discord.TextChannel): + async def set_log_channel(ctx: Context, channel: discord.TextChannel) -> None: """Set the channel where log messages will be sent.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_log_channel", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() + # Check channel permissions + bot_member = ctx.guild.me + required_perms = discord.Permissions( + send_messages=True, + embed_links=True, + read_message_history=True + ) + channel_perms = channel.permissions_for(bot_member) + if not all(getattr(channel_perms, perm) for perm in required_perms): + raise CommandError( + "Missing required permissions in target channel", + context=ErrorContext( + "SettingsCommands", + "set_log_channel", + { + "guild_id": ctx.guild.id, + "channel_id": channel.id, + "missing_perms": [ + perm for perm in required_perms + if not getattr(channel_perms, perm) + ] + }, + ErrorSeverity.MEDIUM + ) + ) + await cog.config_manager.update_setting( - ctx.guild.id, "log_channel", channel.id + ctx.guild.id, + "log_channel", + channel.id ) await handle_response( - ctx, f"Log channel has been set to {channel.mention}." + ctx, + f"Log channel has been set to {channel.mention}.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting log channel: {e}") - await handle_response( - ctx, "An error occurred while setting the log channel." + error = f"Failed to set log channel: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_log_channel", + {"guild_id": ctx.guild.id, "channel_id": channel.id}, + ErrorSeverity.HIGH + ) ) @settings.command(name="addchannel") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(channel="The channel to monitor for videos") - async def add_enabled_channel(ctx: Context, channel: discord.TextChannel): + async def add_enabled_channel(ctx: Context, channel: discord.TextChannel) -> None: """Add a channel to monitor for videos.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "add_enabled_channel", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() + # Check channel permissions + bot_member = ctx.guild.me + required_perms = discord.Permissions( + read_messages=True, + read_message_history=True + ) + channel_perms = channel.permissions_for(bot_member) + if not all(getattr(channel_perms, perm) for perm in required_perms): + raise CommandError( + "Missing required permissions in target channel", + context=ErrorContext( + "SettingsCommands", + "add_enabled_channel", + { + "guild_id": ctx.guild.id, + "channel_id": channel.id, + "missing_perms": [ + perm for perm in required_perms + if not getattr(channel_perms, perm) + ] + }, + ErrorSeverity.MEDIUM + ) + ) + enabled_channels = await cog.config_manager.get_setting( - ctx.guild.id, "enabled_channels" + ctx.guild.id, + "enabled_channels" ) if channel.id in enabled_channels: await handle_response( - ctx, f"{channel.mention} is already being monitored." + ctx, + f"{channel.mention} is already being monitored.", + response_type=ResponseType.WARNING ) return enabled_channels.append(channel.id) await cog.config_manager.update_setting( - ctx.guild.id, "enabled_channels", enabled_channels + ctx.guild.id, + "enabled_channels", + enabled_channels ) await handle_response( - ctx, f"Now monitoring {channel.mention} for videos." + ctx, + f"Now monitoring {channel.mention} for videos.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error adding enabled channel: {e}") - await handle_response( - ctx, "An error occurred while adding the channel." + error = f"Failed to add enabled channel: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "add_enabled_channel", + {"guild_id": ctx.guild.id, "channel_id": channel.id}, + ErrorSeverity.HIGH + ) ) @settings.command(name="removechannel") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(channel="The channel to stop monitoring") - async def remove_enabled_channel(ctx: Context, channel: discord.TextChannel): + async def remove_enabled_channel(ctx: Context, channel: discord.TextChannel) -> None: """Remove a channel from video monitoring.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "remove_enabled_channel", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() enabled_channels = await cog.config_manager.get_setting( - ctx.guild.id, "enabled_channels" + ctx.guild.id, + "enabled_channels" ) if channel.id not in enabled_channels: await handle_response( - ctx, f"{channel.mention} is not being monitored." + ctx, + f"{channel.mention} is not being monitored.", + response_type=ResponseType.WARNING ) return enabled_channels.remove(channel.id) await cog.config_manager.update_setting( - ctx.guild.id, "enabled_channels", enabled_channels + ctx.guild.id, + "enabled_channels", + enabled_channels ) await handle_response( - ctx, f"Stopped monitoring {channel.mention} for videos." + ctx, + f"Stopped monitoring {channel.mention} for videos.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error removing enabled channel: {e}") - await handle_response( - ctx, "An error occurred while removing the channel." + error = f"Failed to remove enabled channel: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "remove_enabled_channel", + {"guild_id": ctx.guild.id, "channel_id": channel.id}, + ErrorSeverity.HIGH + ) ) @settings.command(name="setformat") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(format="The video format to use (mp4, webm, or mkv)") - async def set_video_format(ctx: Context, format: str): + async def set_video_format(ctx: Context, format: str) -> None: """Set the video format for archived videos.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_video_format", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() + # Validate format format = format.lower() - if format not in ["mp4", "webm", "mkv"]: + validation = await validate_setting( + SettingCategory.VIDEO, + "format", + format + ) + if not validation["valid"]: await handle_response( - ctx, "Invalid format. Please use mp4, webm, or mkv." + ctx, + validation["error"], + response_type=ResponseType.ERROR ) return await cog.config_manager.update_setting( - ctx.guild.id, "video_format", format + ctx.guild.id, + "video_format", + format ) - await handle_response(ctx, f"Video format has been set to {format}.") - except Exception as e: - logger.error(f"Error setting video format: {e}") await handle_response( - ctx, "An error occurred while setting the video format." + ctx, + f"Video format has been set to {format}.", + response_type=ResponseType.SUCCESS + ) + + except Exception as e: + error = f"Failed to set video format: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_video_format", + {"guild_id": ctx.guild.id, "format": format}, + ErrorSeverity.HIGH + ) ) @settings.command(name="setquality") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(quality="The video quality (144-4320)") - async def set_video_quality(ctx: Context, quality: int): + async def set_video_quality(ctx: Context, quality: int) -> None: """Set the video quality for archived videos.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_video_quality", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() - if not 144 <= quality <= 4320: + # Validate quality + validation = await validate_setting( + SettingCategory.VIDEO, + "quality", + quality + ) + if not validation["valid"]: await handle_response( - ctx, "Quality must be between 144 and 4320." + ctx, + validation["error"], + response_type=ResponseType.ERROR ) return await cog.config_manager.update_setting( - ctx.guild.id, "video_quality", quality + ctx.guild.id, + "video_quality", + quality ) await handle_response( - ctx, f"Video quality has been set to {quality}p." + ctx, + f"Video quality has been set to {quality}p.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting video quality: {e}") - await handle_response( - ctx, "An error occurred while setting the video quality." + error = f"Failed to set video quality: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_video_quality", + {"guild_id": ctx.guild.id, "quality": quality}, + ErrorSeverity.HIGH + ) ) @settings.command(name="setmaxsize") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(size="The maximum file size in MB (1-100)") - async def set_max_file_size(ctx: Context, size: int): + async def set_max_file_size(ctx: Context, size: int) -> None: """Set the maximum file size for archived videos.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_max_file_size", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() - if not 1 <= size <= 100: - await handle_response(ctx, "Size must be between 1 and 100 MB.") + # Validate size + validation = await validate_setting( + SettingCategory.VIDEO, + "max_file_size", + size + ) + if not validation["valid"]: + await handle_response( + ctx, + validation["error"], + response_type=ResponseType.ERROR + ) return await cog.config_manager.update_setting( - ctx.guild.id, "max_file_size", size + ctx.guild.id, + "max_file_size", + size ) await handle_response( - ctx, f"Maximum file size has been set to {size}MB." + ctx, + f"Maximum file size has been set to {size}MB.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting max file size: {e}") - await handle_response( - ctx, "An error occurred while setting the maximum file size." + error = f"Failed to set max file size: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_max_file_size", + {"guild_id": ctx.guild.id, "size": size}, + ErrorSeverity.HIGH + ) ) @settings.command(name="setmessageduration") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(hours="How long to keep messages in hours (0-168)") - async def set_message_duration(ctx: Context, hours: int): + async def set_message_duration(ctx: Context, hours: int) -> None: """Set how long to keep archived messages.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_message_duration", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() - if not 0 <= hours <= 168: + # Validate duration + validation = await validate_setting( + SettingCategory.MESSAGES, + "duration", + hours + ) + if not validation["valid"]: await handle_response( - ctx, "Duration must be between 0 and 168 hours (1 week)." + ctx, + validation["error"], + response_type=ResponseType.ERROR ) return await cog.config_manager.update_setting( - ctx.guild.id, "message_duration", hours + ctx.guild.id, + "message_duration", + hours ) await handle_response( - ctx, f"Message duration has been set to {hours} hours." + ctx, + f"Message duration has been set to {hours} hours.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting message duration: {e}") - await handle_response( - ctx, "An error occurred while setting the message duration." + error = f"Failed to set message duration: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_message_duration", + {"guild_id": ctx.guild.id, "hours": hours}, + ErrorSeverity.HIGH + ) ) @settings.command(name="settemplate") @@ -259,61 +706,122 @@ def setup_settings_commands(cog): @app_commands.describe( template="The message template to use. Available placeholders: {author}, {channel}, {original_message}" ) - async def set_message_template(ctx: Context, template: str): + async def set_message_template(ctx: Context, template: str) -> None: """Set the template for archived messages.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_message_template", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() - if not any( - ph in template for ph in ["{author}", "{channel}", "{original_message}"] - ): + # Validate template + validation = await validate_setting( + SettingCategory.MESSAGES, + "template", + template + ) + if not validation["valid"]: await handle_response( ctx, - "Template must include at least one placeholder: {author}, {channel}, or {original_message}", + validation["error"], + response_type=ResponseType.ERROR ) return await cog.config_manager.update_setting( - ctx.guild.id, "message_template", template + ctx.guild.id, + "message_template", + template ) await handle_response( - ctx, f"Message template has been set to: {template}" + ctx, + f"Message template has been set to: {template}", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting message template: {e}") - await handle_response( - ctx, "An error occurred while setting the message template." + error = f"Failed to set message template: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_message_template", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) ) @settings.command(name="setconcurrent") @guild_only() @admin_or_permissions(administrator=True) @app_commands.describe(count="Number of concurrent downloads (1-5)") - async def set_concurrent_downloads(ctx: Context, count: int): + async def set_concurrent_downloads(ctx: Context, count: int) -> None: """Set the number of concurrent downloads allowed.""" try: + # Check if config manager is ready + if not cog.config_manager: + raise CommandError( + "Configuration system is not ready", + context=ErrorContext( + "SettingsCommands", + "set_concurrent_downloads", + {"guild_id": ctx.guild.id}, + ErrorSeverity.HIGH + ) + ) + # Defer the response immediately for slash commands if hasattr(ctx, "interaction") and ctx.interaction: await ctx.defer() - if not 1 <= count <= 5: + # Validate count + validation = await validate_setting( + SettingCategory.PERFORMANCE, + "concurrent_downloads", + count + ) + if not validation["valid"]: await handle_response( - ctx, "Concurrent downloads must be between 1 and 5." + ctx, + validation["error"], + response_type=ResponseType.ERROR ) return await cog.config_manager.update_setting( - ctx.guild.id, "concurrent_downloads", count + ctx.guild.id, + "concurrent_downloads", + count ) await handle_response( - ctx, f"Concurrent downloads has been set to {count}." + ctx, + f"Concurrent downloads has been set to {count}.", + response_type=ResponseType.SUCCESS ) + except Exception as e: - logger.error(f"Error setting concurrent downloads: {e}") - await handle_response( - ctx, "An error occurred while setting concurrent downloads." + error = f"Failed to set concurrent downloads: {str(e)}" + logger.error(error, exc_info=True) + raise CommandError( + error, + context=ErrorContext( + "SettingsCommands", + "set_concurrent_downloads", + {"guild_id": ctx.guild.id, "count": count}, + ErrorSeverity.HIGH + ) ) # Store commands in cog for access