This commit is contained in:
pacnpal
2024-11-16 22:32:08 +00:00
parent b7d99490cf
commit dac21f2fcd
30 changed files with 5854 additions and 2279 deletions

View File

@@ -4,31 +4,68 @@ from __future__ import annotations
import asyncio
import logging
from typing import Dict, Any, Optional
from typing import Dict, Any, Optional, TypedDict, ClassVar, List, Set, Union
from datetime import datetime
from pathlib import Path
import discord
from redbot.core.bot import Red
from redbot.core.commands import GroupCog
from redbot.core.commands import GroupCog, Context
from .settings import Settings
from .lifecycle import LifecycleManager
from .lifecycle import LifecycleManager, LifecycleState
from .component_manager import ComponentManager, ComponentState
from .error_handler import error_manager, handle_command_error
from .response_handler import response_manager
from .commands import setup_archiver_commands, setup_database_commands, setup_settings_commands
from .events import setup_events
from .commands.archiver_commands import setup_archiver_commands
from .commands.database_commands import setup_database_commands
from .commands.settings_commands import setup_settings_commands
from .events import setup_events, EventManager
from ..processor.core import Processor
from ..queue.manager import QueueManager
from ..ffmpeg.ffmpeg_manager import FFmpegManager
from ..database.video_archive_db import VideoArchiveDB
from ..config_manager import ConfigManager
from ..utils.exceptions import (
CogError,
ErrorContext,
ErrorSeverity
)
logger = logging.getLogger("VideoArchiver")
class CogStatus:
class CogHealthCheck(TypedDict):
"""Type definition for health check status"""
name: str
status: bool
last_check: str
details: Optional[Dict[str, Any]]
class CogStatus(TypedDict):
"""Type definition for cog status"""
uptime: float
last_error: Optional[str]
error_count: int
command_count: int
last_command: Optional[str]
health_checks: Dict[str, CogHealthCheck]
state: str
ready: bool
class StatusTracker:
"""Tracks cog status and health"""
def __init__(self):
HEALTH_CHECK_INTERVAL: ClassVar[int] = 30 # Seconds between health checks
ERROR_THRESHOLD: ClassVar[int] = 100 # Maximum errors before health warning
def __init__(self) -> None:
self.start_time = datetime.utcnow()
self.last_error: Optional[str] = None
self.error_count = 0
self.command_count = 0
self.last_command_time: Optional[datetime] = None
self.health_checks: Dict[str, bool] = {}
self.health_checks: Dict[str, CogHealthCheck] = {}
def record_error(self, error: str) -> None:
"""Record an error occurrence"""
@@ -40,36 +77,70 @@ class CogStatus:
self.command_count += 1
self.last_command_time = datetime.utcnow()
def update_health_check(self, check: str, status: bool) -> None:
def update_health_check(
self,
name: str,
status: bool,
details: Optional[Dict[str, Any]] = None
) -> None:
"""Update health check status"""
self.health_checks[check] = status
self.health_checks[name] = CogHealthCheck(
name=name,
status=status,
last_check=datetime.utcnow().isoformat(),
details=details
)
def get_status(self) -> Dict[str, Any]:
def get_status(self) -> CogStatus:
"""Get current status"""
return {
"uptime": (datetime.utcnow() - self.start_time).total_seconds(),
"last_error": self.last_error,
"error_count": self.error_count,
"command_count": self.command_count,
"last_command": self.last_command_time.isoformat() if self.last_command_time else None,
"health_checks": self.health_checks.copy()
}
return CogStatus(
uptime=(datetime.utcnow() - self.start_time).total_seconds(),
last_error=self.last_error,
error_count=self.error_count,
command_count=self.command_count,
last_command=self.last_command_time.isoformat() if self.last_command_time else None,
health_checks=self.health_checks.copy(),
state="healthy" if self.is_healthy() else "unhealthy",
ready=True
)
def is_healthy(self) -> bool:
"""Check if cog is healthy"""
if self.error_count > self.ERROR_THRESHOLD:
return False
return all(check["status"] for check in self.health_checks.values())
class ComponentAccessor:
"""Provides safe access to components"""
def __init__(self, component_manager: ComponentManager):
def __init__(self, component_manager: ComponentManager) -> None:
self._component_manager = component_manager
def get_component(self, name: str) -> Optional[Any]:
"""Get a component with state validation"""
"""
Get a component with state validation.
Args:
name: Component name
Returns:
Component instance if ready, None otherwise
"""
component = self._component_manager.get(name)
if component and component.state == ComponentState.READY:
return component
return None
def get_component_status(self, name: str) -> Dict[str, Any]:
"""Get component status"""
"""
Get component status.
Args:
name: Component name
Returns:
Component status dictionary
"""
return self._component_manager.get_component_status().get(name, {})
class VideoArchiver(GroupCog, Settings):
@@ -85,7 +156,19 @@ class VideoArchiver(GroupCog, Settings):
self.lifecycle_manager = LifecycleManager(self)
self.component_manager = ComponentManager(self)
self.component_accessor = ComponentAccessor(self.component_manager)
self.status = CogStatus()
self.status_tracker = StatusTracker()
self.event_manager: Optional[EventManager] = None
# Initialize task trackers
self._init_task: Optional[asyncio.Task] = None
self._cleanup_task: Optional[asyncio.Task] = None
self._queue_task: Optional[asyncio.Task] = None
self._health_tasks: Set[asyncio.Task] = set()
# Initialize component storage
self.components: Dict[int, Dict[str, Any]] = {}
self.update_checker = None
self._db = None
# Set up commands
setup_archiver_commands(self)
@@ -93,42 +176,85 @@ class VideoArchiver(GroupCog, Settings):
setup_settings_commands(self)
# Set up events
setup_events(self)
self.event_manager = setup_events(self)
# Register cleanup handlers
self.lifecycle_manager.register_cleanup_handler(self._cleanup_handler)
async def cog_load(self) -> None:
"""Handle cog loading"""
"""
Handle cog loading.
Raises:
CogError: If loading fails
"""
try:
await self.lifecycle_manager.handle_load()
await self._start_health_monitoring()
except Exception as e:
self.status.record_error(str(e))
raise
error = f"Failed to load cog: {str(e)}"
self.status_tracker.record_error(error)
logger.error(error, exc_info=True)
raise CogError(
error,
context=ErrorContext(
"VideoArchiver",
"cog_load",
None,
ErrorSeverity.CRITICAL
)
)
async def cog_unload(self) -> None:
"""Handle cog unloading"""
"""
Handle cog unloading.
Raises:
CogError: If unloading fails
"""
try:
# Cancel health monitoring
for task in self._health_tasks:
task.cancel()
self._health_tasks.clear()
await self.lifecycle_manager.handle_unload()
except Exception as e:
self.status.record_error(str(e))
raise
error = f"Failed to unload cog: {str(e)}"
self.status_tracker.record_error(error)
logger.error(error, exc_info=True)
raise CogError(
error,
context=ErrorContext(
"VideoArchiver",
"cog_unload",
None,
ErrorSeverity.CRITICAL
)
)
async def cog_command_error(self, ctx, error):
async def cog_command_error(
self,
ctx: Context,
error: Exception
) -> None:
"""Handle command errors"""
self.status.record_error(str(error))
self.status_tracker.record_error(str(error))
await handle_command_error(ctx, error)
async def cog_before_invoke(self, ctx) -> bool:
async def cog_before_invoke(self, ctx: Context) -> bool:
"""Pre-command hook"""
self.status.record_command()
self.status_tracker.record_command()
return True
async def _start_health_monitoring(self) -> None:
"""Start health monitoring tasks"""
asyncio.create_task(self._monitor_component_health())
asyncio.create_task(self._monitor_system_health())
self._health_tasks.add(
asyncio.create_task(self._monitor_component_health())
)
self._health_tasks.add(
asyncio.create_task(self._monitor_system_health())
)
async def _monitor_component_health(self) -> None:
"""Monitor component health"""
@@ -136,98 +262,134 @@ class VideoArchiver(GroupCog, Settings):
try:
component_status = self.component_manager.get_component_status()
for name, status in component_status.items():
self.status.update_health_check(
self.status_tracker.update_health_check(
f"component_{name}",
status["state"] == ComponentState.READY.value
status["state"] == ComponentState.READY.name,
status
)
except Exception as e:
logger.error(f"Error monitoring component health: {e}")
await asyncio.sleep(60) # Check every minute
logger.error(f"Error monitoring component health: {e}", exc_info=True)
await asyncio.sleep(self.status_tracker.HEALTH_CHECK_INTERVAL)
async def _monitor_system_health(self) -> None:
"""Monitor system health metrics"""
while True:
try:
# Check queue health
queue_manager = self.queue_manager
if queue_manager:
if queue_manager := self.queue_manager:
queue_status = await queue_manager.get_queue_status()
self.status.update_health_check(
self.status_tracker.update_health_check(
"queue_health",
queue_status["active"] and not queue_status["stalled"]
queue_status["active"] and not queue_status["stalled"],
queue_status
)
# Check processor health
processor = self.processor
if processor:
if processor := self.processor:
processor_status = await processor.get_status()
self.status.update_health_check(
self.status_tracker.update_health_check(
"processor_health",
processor_status["active"]
processor_status["active"],
processor_status
)
# Check database health
db = self.db
if db:
if db := self.db:
db_status = await db.get_status()
self.status.update_health_check(
self.status_tracker.update_health_check(
"database_health",
db_status["connected"]
db_status["connected"],
db_status
)
# Check event system health
if self.event_manager:
event_stats = self.event_manager.get_stats()
self.status_tracker.update_health_check(
"event_health",
event_stats["health"],
event_stats
)
except Exception as e:
logger.error(f"Error monitoring system health: {e}")
await asyncio.sleep(30) # Check every 30 seconds
logger.error(f"Error monitoring system health: {e}", exc_info=True)
await asyncio.sleep(self.status_tracker.HEALTH_CHECK_INTERVAL)
async def _cleanup_handler(self) -> None:
"""Custom cleanup handler"""
try:
# Perform any custom cleanup
pass
# Cancel health monitoring tasks
for task in self._health_tasks:
if not task.done():
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
self._health_tasks.clear()
except Exception as e:
logger.error(f"Error in cleanup handler: {e}")
logger.error(f"Error in cleanup handler: {e}", exc_info=True)
def get_status(self) -> Dict[str, Any]:
"""Get comprehensive cog status"""
"""
Get comprehensive cog status.
Returns:
Dictionary containing cog status information
"""
return {
"cog": self.status.get_status(),
"cog": self.status_tracker.get_status(),
"lifecycle": self.lifecycle_manager.get_status(),
"components": self.component_manager.get_component_status(),
"errors": error_manager.tracker.get_error_stats()
"errors": error_manager.tracker.get_error_stats(),
"events": self.event_manager.get_stats() if self.event_manager else None
}
# Component property accessors
@property
def processor(self):
def processor(self) -> Optional[Processor]:
"""Get the processor component"""
return self.component_accessor.get_component("processor")
@property
def queue_manager(self):
def queue_manager(self) -> Optional[QueueManager]:
"""Get the queue manager component"""
return self.component_accessor.get_component("queue_manager")
@property
def config_manager(self):
def config_manager(self) -> Optional[ConfigManager]:
"""Get the config manager component"""
return self.component_accessor.get_component("config_manager")
@property
def ffmpeg_mgr(self):
def ffmpeg_mgr(self) -> Optional[FFmpegManager]:
"""Get the FFmpeg manager component"""
return self.component_accessor.get_component("ffmpeg_mgr")
@property
def db(self):
def db(self) -> Optional[VideoArchiveDB]:
"""Get the database component"""
return self.component_accessor.get_component("db")
return self._db
@db.setter
def db(self, value: VideoArchiveDB) -> None:
"""Set the database component"""
self._db = value
@property
def data_path(self):
def data_path(self) -> Optional[Path]:
"""Get the data path"""
return self.component_accessor.get_component("data_path")
@property
def download_path(self):
def download_path(self) -> Optional[Path]:
"""Get the download path"""
return self.component_accessor.get_component("download_path")
@property
def queue_handler(self):
"""Get the queue handler from processor"""
if processor := self.processor:
return processor.queue_handler
return None

View File

@@ -2,33 +2,61 @@
import logging
import asyncio
from typing import Dict, Any, Optional, Set, List
from enum import Enum
from typing import Dict, Any, Optional, Set, List, TypedDict, ClassVar, Type, Union, Protocol
from enum import Enum, auto
from datetime import datetime
from pathlib import Path
from ..utils.exceptions import (
ComponentError,
ErrorContext,
ErrorSeverity
)
from ..utils.path_manager import ensure_directory
logger = logging.getLogger("VideoArchiver")
class ComponentState(Enum):
"""Possible states of a component"""
UNREGISTERED = "unregistered"
REGISTERED = "registered"
INITIALIZING = "initializing"
READY = "ready"
ERROR = "error"
SHUTDOWN = "shutdown"
UNREGISTERED = auto()
REGISTERED = auto()
INITIALIZING = auto()
READY = auto()
ERROR = auto()
SHUTDOWN = auto()
class ComponentDependencyError(Exception):
"""Raised when component dependencies cannot be satisfied"""
pass
class ComponentHistory(TypedDict):
"""Type definition for component history entry"""
component: str
state: str
timestamp: str
error: Optional[str]
duration: float
class ComponentLifecycleError(Exception):
"""Raised when component lifecycle operations fail"""
pass
class ComponentStatus(TypedDict):
"""Type definition for component status"""
state: str
registration_time: Optional[str]
initialization_time: Optional[str]
dependencies: Set[str]
dependents: Set[str]
error: Optional[str]
health: bool
class Initializable(Protocol):
"""Protocol for initializable components"""
async def initialize(self) -> None:
"""Initialize the component"""
...
async def shutdown(self) -> None:
"""Shutdown the component"""
...
class Component:
"""Base class for managed components"""
def __init__(self, name: str):
def __init__(self, name: str) -> None:
self.name = name
self.state = ComponentState.UNREGISTERED
self.dependencies: Set[str] = set()
@@ -36,33 +64,74 @@ class Component:
self.registration_time: Optional[datetime] = None
self.initialization_time: Optional[datetime] = None
self.error: Optional[str] = None
self._health_check_task: Optional[asyncio.Task] = None
async def initialize(self) -> None:
"""Initialize the component"""
"""
Initialize the component.
Raises:
ComponentError: If initialization fails
"""
pass
async def shutdown(self) -> None:
"""Shutdown the component"""
pass
"""
Shutdown the component.
Raises:
ComponentError: If shutdown fails
"""
if self._health_check_task:
self._health_check_task.cancel()
try:
await self._health_check_task
except asyncio.CancelledError:
pass
def is_healthy(self) -> bool:
"""Check if component is healthy"""
return self.state == ComponentState.READY and not self.error
class ComponentTracker:
"""Tracks component states and relationships"""
def __init__(self):
self.states: Dict[str, ComponentState] = {}
self.history: List[Dict[str, Any]] = []
MAX_HISTORY: ClassVar[int] = 1000 # Maximum history entries to keep
def update_state(self, name: str, state: ComponentState, error: Optional[str] = None) -> None:
def __init__(self) -> None:
self.states: Dict[str, ComponentState] = {}
self.history: List[ComponentHistory] = []
def update_state(
self,
name: str,
state: ComponentState,
error: Optional[str] = None
) -> None:
"""Update component state"""
self.states[name] = state
self.history.append({
"component": name,
"state": state.value,
"timestamp": datetime.utcnow(),
"error": error
})
# Add history entry
now = datetime.utcnow()
duration = 0.0
if self.history:
last_entry = self.history[-1]
last_time = datetime.fromisoformat(last_entry["timestamp"])
duration = (now - last_time).total_seconds()
def get_component_history(self, name: str) -> List[Dict[str, Any]]:
self.history.append(ComponentHistory(
component=name,
state=state.name,
timestamp=now.isoformat(),
error=error,
duration=duration
))
# Cleanup old history
if len(self.history) > self.MAX_HISTORY:
self.history = self.history[-self.MAX_HISTORY:]
def get_component_history(self, name: str) -> List[ComponentHistory]:
"""Get state history for a component"""
return [
entry for entry in self.history
@@ -72,12 +141,33 @@ class ComponentTracker:
class DependencyManager:
"""Manages component dependencies"""
def __init__(self):
def __init__(self) -> None:
self.dependencies: Dict[str, Set[str]] = {}
self.dependents: Dict[str, Set[str]] = {}
def add_dependency(self, component: str, dependency: str) -> None:
"""Add a dependency relationship"""
"""
Add a dependency relationship.
Args:
component: Component name
dependency: Dependency name
Raises:
ComponentError: If dependency cycle is detected
"""
# Check for cycles
if self._would_create_cycle(component, dependency):
raise ComponentError(
f"Dependency cycle detected: {component} -> {dependency}",
context=ErrorContext(
"DependencyManager",
"add_dependency",
{"component": component, "dependency": dependency},
ErrorSeverity.HIGH
)
)
if component not in self.dependencies:
self.dependencies[component] = set()
self.dependencies[component].add(dependency)
@@ -86,6 +176,23 @@ class DependencyManager:
self.dependents[dependency] = set()
self.dependents[dependency].add(component)
def _would_create_cycle(self, component: str, dependency: str) -> bool:
"""Check if adding dependency would create a cycle"""
visited = set()
def has_path(start: str, end: str) -> bool:
if start == end:
return True
if start in visited:
return False
visited.add(start)
return any(
has_path(dep, end)
for dep in self.dependencies.get(start, set())
)
return has_path(dependency, component)
def get_dependencies(self, component: str) -> Set[str]:
"""Get dependencies for a component"""
return self.dependencies.get(component, set())
@@ -95,27 +202,72 @@ class DependencyManager:
return self.dependents.get(component, set())
def get_initialization_order(self) -> List[str]:
"""Get components in dependency order"""
visited = set()
order = []
"""
Get components in dependency order.
Returns:
List of component names in initialization order
Raises:
ComponentError: If dependency cycle is detected
"""
visited: Set[str] = set()
temp_visited: Set[str] = set()
order: List[str] = []
def visit(component: str) -> None:
if component in temp_visited:
cycle = " -> ".join(
name for name in self.dependencies
if name in temp_visited
)
raise ComponentError(
f"Dependency cycle detected: {cycle}",
context=ErrorContext(
"DependencyManager",
"get_initialization_order",
{"cycle": cycle},
ErrorSeverity.HIGH
)
)
if component in visited:
return
visited.add(component)
temp_visited.add(component)
for dep in self.dependencies.get(component, set()):
visit(dep)
temp_visited.remove(component)
visited.add(component)
order.append(component)
for component in self.dependencies:
visit(component)
try:
for component in self.dependencies:
if component not in visited:
visit(component)
except RecursionError:
raise ComponentError(
"Dependency resolution exceeded maximum recursion depth",
context=ErrorContext(
"DependencyManager",
"get_initialization_order",
None,
ErrorSeverity.HIGH
)
)
return order
class ComponentManager:
"""Manages VideoArchiver components"""
def __init__(self, cog):
CORE_COMPONENTS: ClassVar[Dict[str, Tuple[Type[Any], Set[str]]]] = {
"config_manager": ("..config_manager.ConfigManager", set()),
"processor": ("..processor.core.Processor", {"config_manager"}),
"queue_manager": ("..queue.manager.EnhancedVideoQueueManager", {"config_manager"}),
"ffmpeg_mgr": ("..ffmpeg.ffmpeg_manager.FFmpegManager", set())
}
def __init__(self, cog: Any) -> None:
self.cog = cog
self._components: Dict[str, Component] = {}
self.tracker = ComponentTracker()
@@ -124,21 +276,41 @@ class ComponentManager:
def register(
self,
name: str,
component: Any,
component: Union[Component, Any],
dependencies: Optional[Set[str]] = None
) -> None:
"""Register a component with dependencies"""
"""
Register a component with dependencies.
Args:
name: Component name
component: Component instance
dependencies: Optional set of dependency names
Raises:
ComponentError: If registration fails
"""
try:
# Wrap non-Component objects
if not isinstance(component, Component):
component = Component(name)
wrapped = Component(name)
if isinstance(component, Initializable):
wrapped.initialize = component.initialize
wrapped.shutdown = component.shutdown
component = wrapped
# Register dependencies
if dependencies:
for dep in dependencies:
if dep not in self._components:
raise ComponentDependencyError(
f"Dependency {dep} not registered for {name}"
raise ComponentError(
f"Dependency {dep} not registered for {name}",
context=ErrorContext(
"ComponentManager",
"register",
{"component": name, "dependency": dep},
ErrorSeverity.HIGH
)
)
self.dependency_manager.add_dependency(name, dep)
@@ -149,19 +321,33 @@ class ComponentManager:
logger.debug(f"Registered component: {name}")
except Exception as e:
logger.error(f"Error registering component {name}: {e}")
error = f"Failed to register component {name}: {str(e)}"
logger.error(error, exc_info=True)
self.tracker.update_state(name, ComponentState.ERROR, str(e))
raise ComponentLifecycleError(f"Failed to register component: {str(e)}")
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"register",
{"component": name},
ErrorSeverity.HIGH
)
)
async def initialize_components(self) -> None:
"""Initialize all components in dependency order"""
"""
Initialize all components in dependency order.
Raises:
ComponentError: If initialization fails
"""
try:
# Get initialization order
init_order = self.dependency_manager.get_initialization_order()
# Initialize core components first
await self._initialize_core_components()
# Get initialization order
init_order = self.dependency_manager.get_initialization_order()
# Initialize remaining components
for name in init_order:
if name not in self._components:
@@ -174,88 +360,172 @@ class ComponentManager:
component.initialization_time = datetime.utcnow()
self.tracker.update_state(name, ComponentState.READY)
except Exception as e:
logger.error(f"Error initializing component {name}: {e}")
error = f"Failed to initialize component {name}: {str(e)}"
logger.error(error, exc_info=True)
self.tracker.update_state(name, ComponentState.ERROR, str(e))
raise ComponentLifecycleError(
f"Failed to initialize component {name}: {str(e)}"
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"initialize_components",
{"component": name},
ErrorSeverity.HIGH
)
)
except Exception as e:
logger.error(f"Error during component initialization: {e}")
raise ComponentLifecycleError(f"Component initialization failed: {str(e)}")
error = f"Component initialization failed: {str(e)}"
logger.error(error, exc_info=True)
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"initialize_components",
None,
ErrorSeverity.HIGH
)
)
async def _initialize_core_components(self) -> None:
"""Initialize core system components"""
from ..config_manager import ConfigManager
from ..processor.core import Processor
from ..queue.manager import EnhancedVideoQueueManager
from ..ffmpeg.ffmpeg_manager import FFmpegManager
"""
Initialize core system components.
Raises:
ComponentError: If core component initialization fails
"""
try:
for name, (component_path, deps) in self.CORE_COMPONENTS.items():
module_path, class_name = component_path.rsplit(".", 1)
module = __import__(module_path, fromlist=[class_name])
component_class = getattr(module, class_name)
if name == "processor":
component = component_class(self.cog)
elif name == "ffmpeg_mgr":
component = component_class(self.cog)
else:
component = component_class()
core_components = {
"config_manager": (ConfigManager(self.cog), set()),
"processor": (Processor(self.cog), {"config_manager"}),
"queue_manager": (EnhancedVideoQueueManager(), {"config_manager"}),
"ffmpeg_mgr": (FFmpegManager(self.cog), set())
}
self.register(name, component, deps)
for name, (component, deps) in core_components.items():
self.register(name, component, deps)
# Initialize paths
await self._initialize_paths()
# Initialize paths
await self._initialize_paths()
except Exception as e:
error = f"Failed to initialize core components: {str(e)}"
logger.error(error, exc_info=True)
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"_initialize_core_components",
None,
ErrorSeverity.HIGH
)
)
async def _initialize_paths(self) -> None:
"""Initialize required paths"""
from pathlib import Path
from ..utils.path_manager import ensure_directory
"""
Initialize required paths.
Raises:
ComponentError: If path initialization fails
"""
try:
data_dir = Path(self.cog.bot.data_path) / "VideoArchiver"
download_dir = data_dir / "downloads"
data_dir = Path(self.cog.bot.data_path) / "VideoArchiver"
download_dir = data_dir / "downloads"
# Ensure directories exist
await ensure_directory(data_dir)
await ensure_directory(download_dir)
# Ensure directories exist
await ensure_directory(data_dir)
await ensure_directory(download_dir)
# Register paths
self.register("data_path", data_dir)
self.register("download_path", download_dir)
# Register paths
self.register("data_path", data_dir)
self.register("download_path", download_dir)
except Exception as e:
error = f"Failed to initialize paths: {str(e)}"
logger.error(error, exc_info=True)
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"_initialize_paths",
None,
ErrorSeverity.HIGH
)
)
def get(self, name: str) -> Optional[Any]:
def get(self, name: str) -> Optional[Component]:
"""Get a registered component"""
component = self._components.get(name)
return component if isinstance(component, Component) else None
return self._components.get(name)
async def shutdown_components(self) -> None:
"""Shutdown components in reverse dependency order"""
shutdown_order = reversed(self.dependency_manager.get_initialization_order())
"""
Shutdown components in reverse dependency order.
for name in shutdown_order:
if name not in self._components:
continue
component = self._components[name]
try:
await component.shutdown()
self.tracker.update_state(name, ComponentState.SHUTDOWN)
except Exception as e:
logger.error(f"Error shutting down component {name}: {e}")
self.tracker.update_state(name, ComponentState.ERROR, str(e))
Raises:
ComponentError: If shutdown fails
"""
try:
shutdown_order = reversed(self.dependency_manager.get_initialization_order())
for name in shutdown_order:
if name not in self._components:
continue
component = self._components[name]
try:
await component.shutdown()
self.tracker.update_state(name, ComponentState.SHUTDOWN)
except Exception as e:
error = f"Error shutting down component {name}: {str(e)}"
logger.error(error, exc_info=True)
self.tracker.update_state(name, ComponentState.ERROR, str(e))
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"shutdown_components",
{"component": name},
ErrorSeverity.HIGH
)
)
except Exception as e:
error = f"Component shutdown failed: {str(e)}"
logger.error(error, exc_info=True)
raise ComponentError(
error,
context=ErrorContext(
"ComponentManager",
"shutdown_components",
None,
ErrorSeverity.HIGH
)
)
def clear(self) -> None:
"""Clear all registered components"""
self._components.clear()
logger.debug("Cleared all components")
def get_component_status(self) -> Dict[str, Any]:
"""Get status of all components"""
def get_component_status(self) -> Dict[str, ComponentStatus]:
"""
Get status of all components.
Returns:
Dictionary mapping component names to their status
"""
return {
name: {
"state": self.tracker.states.get(name, ComponentState.UNREGISTERED).value,
"registration_time": component.registration_time,
"initialization_time": component.initialization_time,
"dependencies": self.dependency_manager.get_dependencies(name),
"dependents": self.dependency_manager.get_dependents(name),
"error": component.error
}
name: ComponentStatus(
state=self.tracker.states.get(name, ComponentState.UNREGISTERED).name,
registration_time=component.registration_time.isoformat() if component.registration_time else None,
initialization_time=component.initialization_time.isoformat() if component.initialization_time else None,
dependencies=self.dependency_manager.get_dependencies(name),
dependents=self.dependency_manager.get_dependents(name),
error=component.error,
health=component.is_healthy()
)
for name, component in self._components.items()
}

View File

@@ -2,7 +2,8 @@
import logging
import traceback
from typing import Dict, Optional, Tuple, Type
from typing import Dict, Optional, Tuple, Type, TypedDict, ClassVar
from enum import Enum, auto
import discord
from redbot.core.commands import (
Context,
@@ -13,98 +14,179 @@ from redbot.core.commands import (
CommandError
)
from ..utils.exceptions import VideoArchiverError as ProcessingError, ConfigurationError as ConfigError
from ..utils.exceptions import (
VideoArchiverError,
ErrorSeverity,
ErrorContext,
ProcessorError,
ValidationError,
DisplayError,
URLExtractionError,
MessageHandlerError,
QueueHandlerError,
QueueProcessorError,
FFmpegError,
DatabaseError,
HealthCheckError,
TrackingError,
NetworkError,
ResourceExhaustedError,
ConfigurationError
)
from .response_handler import response_manager
logger = logging.getLogger("VideoArchiver")
class ErrorCategory(Enum):
"""Categories of errors"""
PERMISSION = auto()
ARGUMENT = auto()
CONFIGURATION = auto()
PROCESSING = auto()
NETWORK = auto()
RESOURCE = auto()
DATABASE = auto()
VALIDATION = auto()
QUEUE = auto()
CLEANUP = auto()
HEALTH = auto()
UNEXPECTED = auto()
class ErrorStats(TypedDict):
"""Type definition for error statistics"""
counts: Dict[str, int]
patterns: Dict[str, Dict[str, int]]
severities: Dict[str, Dict[str, int]]
class ErrorFormatter:
"""Formats error messages for display"""
@staticmethod
def format_permission_error(error: Exception) -> str:
"""Format permission error messages"""
def format_error_message(error: Exception, context: Optional[ErrorContext] = None) -> str:
"""Format error message with context"""
base_message = str(error)
if context:
return f"{context}: {base_message}"
return base_message
@staticmethod
def format_user_message(error: Exception, category: ErrorCategory) -> str:
"""Format user-friendly error message"""
if isinstance(error, MissingPermissions):
return "You don't have permission to use this command."
elif isinstance(error, BotMissingPermissions):
return "I don't have the required permissions to do that."
return str(error)
@staticmethod
def format_argument_error(error: Exception) -> str:
"""Format argument error messages"""
if isinstance(error, MissingRequiredArgument):
elif isinstance(error, MissingRequiredArgument):
return f"Missing required argument: {error.param.name}"
elif isinstance(error, BadArgument):
return f"Invalid argument: {str(error)}"
elif isinstance(error, VideoArchiverError):
return str(error)
elif category == ErrorCategory.UNEXPECTED:
return "An unexpected error occurred. Please check the logs for details."
return str(error)
@staticmethod
def format_processing_error(error: ProcessingError) -> str:
"""Format processing error messages"""
return f"Processing error: {str(error)}"
@staticmethod
def format_config_error(error: ConfigError) -> str:
"""Format configuration error messages"""
return f"Configuration error: {str(error)}"
@staticmethod
def format_unexpected_error(error: Exception) -> str:
"""Format unexpected error messages"""
return "An unexpected error occurred. Check the logs for details."
class ErrorCategorizer:
"""Categorizes errors and determines handling strategy"""
ERROR_TYPES = {
MissingPermissions: ("permission", "error"),
BotMissingPermissions: ("permission", "error"),
MissingRequiredArgument: ("argument", "warning"),
BadArgument: ("argument", "warning"),
ConfigError: ("configuration", "error"),
ProcessingError: ("processing", "error"),
ERROR_MAPPING: ClassVar[Dict[Type[Exception], Tuple[ErrorCategory, ErrorSeverity]]] = {
# Discord command errors
MissingPermissions: (ErrorCategory.PERMISSION, ErrorSeverity.MEDIUM),
BotMissingPermissions: (ErrorCategory.PERMISSION, ErrorSeverity.HIGH),
MissingRequiredArgument: (ErrorCategory.ARGUMENT, ErrorSeverity.LOW),
BadArgument: (ErrorCategory.ARGUMENT, ErrorSeverity.LOW),
# VideoArchiver errors
ProcessorError: (ErrorCategory.PROCESSING, ErrorSeverity.HIGH),
ValidationError: (ErrorCategory.VALIDATION, ErrorSeverity.MEDIUM),
DisplayError: (ErrorCategory.PROCESSING, ErrorSeverity.LOW),
URLExtractionError: (ErrorCategory.PROCESSING, ErrorSeverity.MEDIUM),
MessageHandlerError: (ErrorCategory.PROCESSING, ErrorSeverity.MEDIUM),
QueueHandlerError: (ErrorCategory.QUEUE, ErrorSeverity.HIGH),
QueueProcessorError: (ErrorCategory.QUEUE, ErrorSeverity.HIGH),
FFmpegError: (ErrorCategory.PROCESSING, ErrorSeverity.HIGH),
DatabaseError: (ErrorCategory.DATABASE, ErrorSeverity.HIGH),
HealthCheckError: (ErrorCategory.HEALTH, ErrorSeverity.HIGH),
TrackingError: (ErrorCategory.PROCESSING, ErrorSeverity.MEDIUM),
NetworkError: (ErrorCategory.NETWORK, ErrorSeverity.MEDIUM),
ResourceExhaustedError: (ErrorCategory.RESOURCE, ErrorSeverity.HIGH),
ConfigurationError: (ErrorCategory.CONFIGURATION, ErrorSeverity.HIGH)
}
@classmethod
def categorize_error(cls, error: Exception) -> Tuple[str, str]:
"""Categorize an error and determine its severity
Returns:
Tuple[str, str]: (Error category, Severity level)
def categorize_error(cls, error: Exception) -> Tuple[ErrorCategory, ErrorSeverity]:
"""
for error_type, (category, severity) in cls.ERROR_TYPES.items():
Categorize an error and determine its severity.
Args:
error: Exception to categorize
Returns:
Tuple of (Error category, Severity level)
"""
for error_type, (category, severity) in cls.ERROR_MAPPING.items():
if isinstance(error, error_type):
return category, severity
return "unexpected", "error"
return ErrorCategory.UNEXPECTED, ErrorSeverity.HIGH
class ErrorTracker:
"""Tracks error occurrences and patterns"""
def __init__(self):
def __init__(self) -> None:
self.error_counts: Dict[str, int] = {}
self.error_patterns: Dict[str, Dict[str, int]] = {}
self.error_severities: Dict[str, Dict[str, int]] = {}
def track_error(self, error: Exception, category: str) -> None:
"""Track an error occurrence"""
def track_error(
self,
error: Exception,
category: ErrorCategory,
severity: ErrorSeverity
) -> None:
"""
Track an error occurrence.
Args:
error: Exception that occurred
category: Error category
severity: Error severity
"""
error_type = type(error).__name__
# Track error counts
self.error_counts[error_type] = self.error_counts.get(error_type, 0) + 1
if category not in self.error_patterns:
self.error_patterns[category] = {}
self.error_patterns[category][error_type] = self.error_patterns[category].get(error_type, 0) + 1
# Track error patterns by category
if category.value not in self.error_patterns:
self.error_patterns[category.value] = {}
self.error_patterns[category.value][error_type] = (
self.error_patterns[category.value].get(error_type, 0) + 1
)
# Track error severities
if severity.value not in self.error_severities:
self.error_severities[severity.value] = {}
self.error_severities[severity.value][error_type] = (
self.error_severities[severity.value].get(error_type, 0) + 1
)
def get_error_stats(self) -> Dict:
"""Get error statistics"""
return {
"counts": self.error_counts.copy(),
"patterns": self.error_patterns.copy()
}
def get_error_stats(self) -> ErrorStats:
"""
Get error statistics.
Returns:
Dictionary containing error statistics
"""
return ErrorStats(
counts=self.error_counts.copy(),
patterns=self.error_patterns.copy(),
severities=self.error_severities.copy()
)
class ErrorManager:
"""Manages error handling and reporting"""
def __init__(self):
def __init__(self) -> None:
self.formatter = ErrorFormatter()
self.categorizer = ErrorCategorizer()
self.tracker = ErrorTracker()
@@ -114,7 +196,8 @@ class ErrorManager:
ctx: Context,
error: Exception
) -> None:
"""Handle a command error
"""
Handle a command error.
Args:
ctx: Command context
@@ -124,24 +207,40 @@ class ErrorManager:
# Categorize error
category, severity = self.categorizer.categorize_error(error)
# Track error
self.tracker.track_error(error, category)
# Create error context
context = ErrorContext(
component=ctx.command.qualified_name if ctx.command else "unknown",
operation="command_execution",
details={
"guild_id": str(ctx.guild.id) if ctx.guild else "DM",
"channel_id": str(ctx.channel.id),
"user_id": str(ctx.author.id)
},
severity=severity
)
# Format error message
error_msg = await self._format_error_message(error, category)
# Track error
self.tracker.track_error(error, category, severity)
# Format error messages
log_message = self.formatter.format_error_message(error, context)
user_message = self.formatter.format_user_message(error, category)
# Log error details
self._log_error(ctx, error, category, severity)
self._log_error(log_message, severity)
# Send response
await response_manager.send_response(
ctx,
content=error_msg,
response_type=severity
content=user_message,
response_type=severity.name.lower()
)
except Exception as e:
logger.error(f"Error handling command error: {str(e)}")
logger.error(
f"Error handling command error: {str(e)}\n"
f"Original error: {traceback.format_exc()}"
)
try:
await response_manager.send_response(
ctx,
@@ -151,46 +250,25 @@ class ErrorManager:
except Exception:
pass
async def _format_error_message(
self,
error: Exception,
category: str
) -> str:
"""Format error message based on category"""
try:
if category == "permission":
return self.formatter.format_permission_error(error)
elif category == "argument":
return self.formatter.format_argument_error(error)
elif category == "processing":
return self.formatter.format_processing_error(error)
elif category == "configuration":
return self.formatter.format_config_error(error)
else:
return self.formatter.format_unexpected_error(error)
except Exception as e:
logger.error(f"Error formatting error message: {e}")
return "An error occurred. Please check the logs."
def _log_error(
self,
ctx: Context,
error: Exception,
category: str,
severity: str
message: str,
severity: ErrorSeverity
) -> None:
"""Log error details"""
"""
Log error details.
Args:
message: Error message to log
severity: Error severity
"""
try:
if severity == "error":
logger.error(
f"Command error in {ctx.command} (Category: {category}):\n"
f"{traceback.format_exc()}"
)
if severity in (ErrorSeverity.HIGH, ErrorSeverity.CRITICAL):
logger.error(f"{message}\n{traceback.format_exc()}")
elif severity == ErrorSeverity.MEDIUM:
logger.warning(message)
else:
logger.warning(
f"Command warning in {ctx.command} (Category: {category}):\n"
f"{str(error)}"
)
logger.info(message)
except Exception as e:
logger.error(f"Error logging error details: {e}")
@@ -198,5 +276,11 @@ class ErrorManager:
error_manager = ErrorManager()
async def handle_command_error(ctx: Context, error: Exception) -> None:
"""Helper function to handle command errors using the error manager"""
"""
Helper function to handle command errors using the error manager.
Args:
ctx: Command context
error: Exception to handle
"""
await error_manager.handle_error(ctx, error)

View File

@@ -1,59 +1,147 @@
"""Event handlers for VideoArchiver"""
import logging
import discord
import asyncio
import logging
import traceback
from typing import TYPE_CHECKING, Dict, Any, Optional
from datetime import datetime
from enum import Enum, auto
from typing import TYPE_CHECKING, Dict, Any, Optional, TypedDict, ClassVar, List
import discord
from ..processor.constants import REACTIONS
from ..processor.reactions import handle_archived_reaction
from .guild import initialize_guild_components, cleanup_guild_components
from .error_handler import error_manager
from .response_handler import response_manager
from ..utils.exceptions import EventError, ErrorContext, ErrorSeverity
if TYPE_CHECKING:
from .base import VideoArchiver
logger = logging.getLogger("VideoArchiver")
class EventType(Enum):
"""Types of Discord events"""
GUILD_JOIN = auto()
GUILD_REMOVE = auto()
MESSAGE = auto()
REACTION_ADD = auto()
MESSAGE_PROCESSING = auto()
REACTION_PROCESSING = auto()
class EventStats(TypedDict):
"""Type definition for event statistics"""
counts: Dict[str, int]
last_events: Dict[str, str]
errors: Dict[str, int]
error_rate: float
health: bool
class EventHistory(TypedDict):
"""Type definition for event history entry"""
event_type: str
timestamp: str
guild_id: Optional[int]
channel_id: Optional[int]
message_id: Optional[int]
user_id: Optional[int]
error: Optional[str]
duration: float
class EventTracker:
"""Tracks event occurrences and patterns"""
def __init__(self):
MAX_HISTORY: ClassVar[int] = 1000 # Maximum history entries to keep
ERROR_THRESHOLD: ClassVar[float] = 0.1 # 10% error rate threshold
def __init__(self) -> None:
self.event_counts: Dict[str, int] = {}
self.last_events: Dict[str, datetime] = {}
self.error_counts: Dict[str, int] = {}
self.history: List[EventHistory] = []
def record_event(self, event_type: str) -> None:
def record_event(
self,
event_type: EventType,
guild_id: Optional[int] = None,
channel_id: Optional[int] = None,
message_id: Optional[int] = None,
user_id: Optional[int] = None,
) -> None:
"""Record an event occurrence"""
self.event_counts[event_type] = self.event_counts.get(event_type, 0) + 1
self.last_events[event_type] = datetime.utcnow()
event_name = event_type.name
self.event_counts[event_name] = self.event_counts.get(event_name, 0) + 1
self.last_events[event_name] = datetime.utcnow()
def record_error(self, event_type: str) -> None:
# Add to history
self.history.append(
EventHistory(
event_type=event_name,
timestamp=datetime.utcnow().isoformat(),
guild_id=guild_id,
channel_id=channel_id,
message_id=message_id,
user_id=user_id,
error=None,
duration=0.0,
)
)
# Cleanup old history
if len(self.history) > self.MAX_HISTORY:
self.history = self.history[-self.MAX_HISTORY:]
def record_error(
self, event_type: EventType, error: str, duration: float = 0.0
) -> None:
"""Record an event error"""
self.error_counts[event_type] = self.error_counts.get(event_type, 0) + 1
event_name = event_type.name
self.error_counts[event_name] = self.error_counts.get(event_name, 0) + 1
def get_stats(self) -> Dict[str, Any]:
# Update last history entry with error
if self.history:
self.history[-1].update({"error": error, "duration": duration})
def get_stats(self) -> EventStats:
"""Get event statistics"""
return {
"counts": self.event_counts.copy(),
"last_events": {k: v.isoformat() for k, v in self.last_events.items()},
"errors": self.error_counts.copy()
}
total_events = sum(self.event_counts.values())
total_errors = sum(self.error_counts.values())
error_rate = total_errors / total_events if total_events > 0 else 0.0
return EventStats(
counts=self.event_counts.copy(),
last_events={k: v.isoformat() for k, v in self.last_events.items()},
errors=self.error_counts.copy(),
error_rate=error_rate,
health=error_rate < self.ERROR_THRESHOLD,
)
class GuildEventHandler:
"""Handles guild-related events"""
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
def __init__(self, cog: "VideoArchiver", tracker: EventTracker) -> None:
self.cog = cog
self.tracker = tracker
async def handle_guild_join(self, guild: discord.Guild) -> None:
"""Handle bot joining a new guild"""
self.tracker.record_event("guild_join")
"""
Handle bot joining a new guild.
Args:
guild: Discord guild that was joined
Raises:
EventError: If guild initialization fails
"""
start_time = datetime.utcnow()
self.tracker.record_event(EventType.GUILD_JOIN, guild_id=guild.id)
if not self.cog.ready.is_set():
return
@@ -61,29 +149,72 @@ class GuildEventHandler:
await initialize_guild_components(self.cog, guild.id)
logger.info(f"Initialized components for new guild {guild.id}")
except Exception as e:
self.tracker.record_error("guild_join")
logger.error(f"Failed to initialize new guild {guild.id}: {str(e)}")
duration = (datetime.utcnow() - start_time).total_seconds()
self.tracker.record_error(EventType.GUILD_JOIN, str(e), duration)
error = f"Failed to initialize new guild {guild.id}: {str(e)}"
logger.error(error, exc_info=True)
raise EventError(
error,
context=ErrorContext(
"GuildEventHandler",
"handle_guild_join",
{"guild_id": guild.id},
ErrorSeverity.HIGH,
),
)
async def handle_guild_remove(self, guild: discord.Guild) -> None:
"""Handle bot leaving a guild"""
self.tracker.record_event("guild_remove")
"""
Handle bot leaving a guild.
Args:
guild: Discord guild that was left
Raises:
EventError: If guild cleanup fails
"""
start_time = datetime.utcnow()
self.tracker.record_event(EventType.GUILD_REMOVE, guild_id=guild.id)
try:
await cleanup_guild_components(self.cog, guild.id)
except Exception as e:
self.tracker.record_error("guild_remove")
logger.error(f"Error cleaning up removed guild {guild.id}: {str(e)}")
duration = (datetime.utcnow() - start_time).total_seconds()
self.tracker.record_error(EventType.GUILD_REMOVE, str(e), duration)
error = f"Error cleaning up removed guild {guild.id}: {str(e)}"
logger.error(error, exc_info=True)
raise EventError(
error,
context=ErrorContext(
"GuildEventHandler",
"handle_guild_remove",
{"guild_id": guild.id},
ErrorSeverity.HIGH,
),
)
class MessageEventHandler:
"""Handles message-related events"""
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
def __init__(self, cog: "VideoArchiver", tracker: EventTracker) -> None:
self.cog = cog
self.tracker = tracker
async def handle_message(self, message: discord.Message) -> None:
"""Handle new messages for video processing"""
self.tracker.record_event("message")
"""
Handle new messages for video processing.
Args:
message: Discord message to process
"""
self.tracker.record_event(
EventType.MESSAGE,
guild_id=message.guild.id if message.guild else None,
channel_id=message.channel.id,
message_id=message.id,
user_id=message.author.id,
)
# Skip if not ready or if message is from DM/bot
if not self.cog.ready.is_set() or message.guild is None or message.author.bot:
@@ -99,21 +230,19 @@ class MessageEventHandler:
async def _process_message_background(self, message: discord.Message) -> None:
"""Process message in background to avoid blocking"""
start_time = datetime.utcnow()
try:
await self.cog.processor.process_message(message)
except Exception as e:
self.tracker.record_error("message_processing")
duration = (datetime.utcnow() - start_time).total_seconds()
self.tracker.record_error(EventType.MESSAGE_PROCESSING, str(e), duration)
await self._handle_processing_error(message, e)
async def _handle_processing_error(
self,
message: discord.Message,
error: Exception
self, message: discord.Message, error: Exception
) -> None:
"""Handle message processing errors"""
logger.error(
f"Error processing message {message.id}: {traceback.format_exc()}"
)
logger.error(f"Error processing message {message.id}: {traceback.format_exc()}")
try:
log_channel = await self.cog.config_manager.get_channel(
message.guild, "log"
@@ -126,24 +255,35 @@ class MessageEventHandler:
f"Message ID: {message.id}\n"
f"Channel: {message.channel.mention}"
),
response_type="error"
response_type=ErrorSeverity.HIGH,
)
except Exception as log_error:
logger.error(f"Failed to log error to guild: {str(log_error)}")
class ReactionEventHandler:
"""Handles reaction-related events"""
def __init__(self, cog: "VideoArchiver", tracker: EventTracker):
def __init__(self, cog: "VideoArchiver", tracker: EventTracker) -> None:
self.cog = cog
self.tracker = tracker
async def handle_reaction_add(
self,
payload: discord.RawReactionActionEvent
self, payload: discord.RawReactionActionEvent
) -> None:
"""Handle reactions to messages"""
self.tracker.record_event("reaction_add")
"""
Handle reactions to messages.
Args:
payload: Reaction event payload
"""
self.tracker.record_event(
EventType.REACTION_ADD,
guild_id=payload.guild_id,
channel_id=payload.channel_id,
message_id=payload.message_id,
user_id=payload.user_id,
)
if payload.user_id == self.cog.bot.user.id:
return
@@ -151,47 +291,80 @@ class ReactionEventHandler:
try:
await self._process_reaction(payload)
except Exception as e:
self.tracker.record_error("reaction_processing")
logger.error(f"Error handling reaction: {e}")
self.tracker.record_error(EventType.REACTION_PROCESSING, str(e))
logger.error(f"Error handling reaction: {e}", exc_info=True)
async def _process_reaction(
self,
payload: discord.RawReactionActionEvent
) -> None:
"""Process a reaction event"""
# Get the channel and message
channel = self.cog.bot.get_channel(payload.channel_id)
if not channel:
return
message = await channel.fetch_message(payload.message_id)
if not message:
return
async def _process_reaction(self, payload: discord.RawReactionActionEvent) -> None:
"""
Process a reaction event.
Args:
payload: Reaction event payload
Raises:
EventError: If reaction processing fails
"""
try:
# Get the channel and message
channel = self.cog.bot.get_channel(payload.channel_id)
if not channel:
return
message = await channel.fetch_message(payload.message_id)
if not message:
return
# Check if it's the archived reaction
if str(payload.emoji) == REACTIONS["archived"]:
# Only process if database is enabled
if self.cog.db:
user = self.cog.bot.get_user(payload.user_id)
asyncio.create_task(
handle_archived_reaction(message, user, self.cog.db)
)
except Exception as e:
error = f"Failed to process reaction: {str(e)}"
logger.error(error, exc_info=True)
raise EventError(
error,
context=ErrorContext(
"ReactionEventHandler",
"_process_reaction",
{
"message_id": payload.message_id,
"user_id": payload.user_id,
"emoji": str(payload.emoji),
},
ErrorSeverity.MEDIUM,
),
)
# Check if it's the archived reaction
if str(payload.emoji) == REACTIONS["archived"]:
# Only process if database is enabled
if self.cog.db:
user = self.cog.bot.get_user(payload.user_id)
asyncio.create_task(
handle_archived_reaction(message, user, self.cog.db)
)
class EventManager:
"""Manages Discord event handling"""
def __init__(self, cog: "VideoArchiver"):
def __init__(self, cog: "VideoArchiver") -> None:
self.tracker = EventTracker()
self.guild_handler = GuildEventHandler(cog, self.tracker)
self.message_handler = MessageEventHandler(cog, self.tracker)
self.reaction_handler = ReactionEventHandler(cog, self.tracker)
def get_stats(self) -> Dict[str, Any]:
def get_stats(self) -> EventStats:
"""Get event statistics"""
return self.tracker.get_stats()
def setup_events(cog: "VideoArchiver") -> None:
"""Set up event handlers for the cog"""
def setup_events(cog: "VideoArchiver") -> EventManager:
"""
Set up event handlers for the cog.
Args:
cog: VideoArchiver cog instance
Returns:
Configured EventManager instance
"""
event_manager = EventManager(cog)
@cog.listener()
@@ -209,3 +382,5 @@ def setup_events(cog: "VideoArchiver") -> None:
@cog.listener()
async def on_raw_reaction_add(payload: discord.RawReactionActionEvent) -> None:
await event_manager.reaction_handler.handle_reaction_add(payload)
return event_manager

View File

@@ -1,16 +1,102 @@
"""Module for handling VideoArchiver initialization"""
from typing import TYPE_CHECKING
from typing import TYPE_CHECKING, Optional, Dict, Any
import asyncio
import logging
from ..utils.exceptions import (
ComponentError,
ErrorContext,
ErrorSeverity
)
from .lifecycle import LifecycleState
if TYPE_CHECKING:
from .base import VideoArchiver
# Re-export initialization functions from lifecycle
logger = logging.getLogger("VideoArchiver")
async def initialize_cog(cog: "VideoArchiver") -> None:
"""Initialize all components with proper error handling"""
await cog.lifecycle_manager.initialize_cog()
"""
Initialize all components with proper error handling.
This is a re-export of lifecycle_manager.initialize_cog with additional
error context and logging.
Args:
cog: VideoArchiver cog instance
Raises:
ComponentError: If initialization fails
"""
try:
logger.info("Starting cog initialization...")
await cog.lifecycle_manager.initialize_cog()
logger.info("Cog initialization completed successfully")
except Exception as e:
error = f"Failed to initialize cog: {str(e)}"
logger.error(error, exc_info=True)
raise ComponentError(
error,
context=ErrorContext(
"Initialization",
"initialize_cog",
{"state": cog.lifecycle_manager.state_tracker.state.name},
ErrorSeverity.HIGH
)
)
def init_callback(cog: "VideoArchiver", task: asyncio.Task) -> None:
"""Handle initialization task completion"""
cog.lifecycle_manager.init_callback(task)
"""
Handle initialization task completion.
This is a re-export of lifecycle_manager.init_callback with additional
error context and logging.
Args:
cog: VideoArchiver cog instance
task: Initialization task
"""
try:
logger.debug("Processing initialization task completion...")
cog.lifecycle_manager.init_callback(task)
# Log final state
state = cog.lifecycle_manager.state_tracker.state
if state == LifecycleState.READY:
logger.info("Initialization completed successfully")
elif state == LifecycleState.ERROR:
logger.error("Initialization failed")
else:
logger.warning(f"Unexpected state after initialization: {state.name}")
except Exception as e:
logger.error(f"Error in initialization callback: {str(e)}", exc_info=True)
# We don't raise here since this is a callback
def get_init_status(cog: "VideoArchiver") -> Dict[str, Any]:
"""
Get initialization status information.
Args:
cog: VideoArchiver cog instance
Returns:
Dictionary containing initialization status
"""
return {
"state": cog.lifecycle_manager.state_tracker.state.name,
"ready": cog.ready.is_set(),
"components_initialized": all(
hasattr(cog, attr) and getattr(cog, attr) is not None
for attr in [
"processor",
"queue_manager",
"update_checker",
"ffmpeg_mgr",
"components",
"db"
]
),
"history": cog.lifecycle_manager.state_tracker.get_state_history()
}

View File

@@ -3,82 +3,195 @@
import asyncio
import logging
import traceback
from typing import Optional, Dict, Any, Set, List, Callable
from enum import Enum
from typing import Optional, Dict, Any, Set, List, Callable, TypedDict, ClassVar, Union
from enum import Enum, auto
from datetime import datetime
from .cleanup import cleanup_resources, force_cleanup_resources
from ..utils.exceptions import VideoArchiverError
from ..utils.exceptions import (
VideoArchiverError,
ErrorContext,
ErrorSeverity,
ComponentError,
CleanupError
)
logger = logging.getLogger("VideoArchiver")
class LifecycleState(Enum):
"""Possible states in the cog lifecycle"""
UNINITIALIZED = "uninitialized"
INITIALIZING = "initializing"
READY = "ready"
UNLOADING = "unloading"
ERROR = "error"
UNINITIALIZED = auto()
INITIALIZING = auto()
READY = auto()
UNLOADING = auto()
ERROR = auto()
class TaskStatus(Enum):
"""Task execution status"""
RUNNING = auto()
COMPLETED = auto()
CANCELLED = auto()
FAILED = auto()
class TaskHistory(TypedDict):
"""Type definition for task history entry"""
start_time: str
end_time: Optional[str]
status: str
error: Optional[str]
duration: float
class StateHistory(TypedDict):
"""Type definition for state history entry"""
state: str
timestamp: str
duration: float
details: Optional[Dict[str, Any]]
class LifecycleStatus(TypedDict):
"""Type definition for lifecycle status"""
state: str
state_history: List[StateHistory]
tasks: Dict[str, Any]
health: bool
class TaskManager:
"""Manages asyncio tasks"""
def __init__(self):
TASK_TIMEOUT: ClassVar[int] = 30 # Default task timeout in seconds
def __init__(self) -> None:
self._tasks: Dict[str, asyncio.Task] = {}
self._task_history: Dict[str, Dict[str, Any]] = {}
self._task_history: Dict[str, TaskHistory] = {}
async def create_task(
self,
name: str,
coro,
callback: Optional[Callable] = None
coro: Callable[..., Any],
callback: Optional[Callable[[asyncio.Task], None]] = None,
timeout: Optional[float] = None
) -> asyncio.Task:
"""Create and track a task"""
task = asyncio.create_task(coro)
self._tasks[name] = task
self._task_history[name] = {
"start_time": datetime.utcnow(),
"status": "running"
}
"""
Create and track a task.
Args:
name: Task name
coro: Coroutine to run
callback: Optional completion callback
timeout: Optional timeout in seconds
Returns:
Created task
Raises:
ComponentError: If task creation fails
"""
try:
task = asyncio.create_task(coro)
self._tasks[name] = task
self._task_history[name] = TaskHistory(
start_time=datetime.utcnow().isoformat(),
end_time=None,
status=TaskStatus.RUNNING.name,
error=None,
duration=0.0
)
if callback:
task.add_done_callback(lambda t: self._handle_completion(name, t, callback))
else:
task.add_done_callback(lambda t: self._handle_completion(name, t))
if timeout:
asyncio.create_task(self._handle_timeout(name, task, timeout))
return task
if callback:
task.add_done_callback(lambda t: self._handle_completion(name, t, callback))
else:
task.add_done_callback(lambda t: self._handle_completion(name, t))
return task
except Exception as e:
error = f"Failed to create task {name}: {str(e)}"
logger.error(error, exc_info=True)
raise ComponentError(
error,
context=ErrorContext(
"TaskManager",
"create_task",
{"task_name": name},
ErrorSeverity.HIGH
)
)
async def _handle_timeout(
self,
name: str,
task: asyncio.Task,
timeout: float
) -> None:
"""Handle task timeout"""
try:
await asyncio.wait_for(asyncio.shield(task), timeout=timeout)
except asyncio.TimeoutError:
if not task.done():
logger.warning(f"Task {name} timed out after {timeout}s")
task.cancel()
self._update_task_history(
name,
TaskStatus.FAILED,
f"Task timed out after {timeout}s"
)
def _handle_completion(
self,
name: str,
task: asyncio.Task,
callback: Optional[Callable] = None
callback: Optional[Callable[[asyncio.Task], None]] = None
) -> None:
"""Handle task completion"""
try:
task.result() # Raises exception if task failed
status = "completed"
status = TaskStatus.COMPLETED
error = None
except asyncio.CancelledError:
status = "cancelled"
status = TaskStatus.CANCELLED
error = "Task was cancelled"
except Exception as e:
status = "failed"
logger.error(f"Task {name} failed: {e}")
status = TaskStatus.FAILED
error = str(e)
logger.error(f"Task {name} failed: {error}", exc_info=True)
self._task_history[name].update({
"end_time": datetime.utcnow(),
"status": status
})
self._update_task_history(name, status, error)
if callback:
try:
callback(task)
except Exception as e:
logger.error(f"Task callback error for {name}: {e}")
logger.error(f"Task callback error for {name}: {e}", exc_info=True)
self._tasks.pop(name, None)
def _update_task_history(
self,
name: str,
status: TaskStatus,
error: Optional[str] = None
) -> None:
"""Update task history entry"""
if name in self._task_history:
end_time = datetime.utcnow()
start_time = datetime.fromisoformat(self._task_history[name]["start_time"])
self._task_history[name].update({
"end_time": end_time.isoformat(),
"status": status.name,
"error": error,
"duration": (end_time - start_time).total_seconds()
})
async def cancel_task(self, name: str) -> None:
"""Cancel a specific task"""
"""
Cancel a specific task.
Args:
name: Task name to cancel
"""
if task := self._tasks.get(name):
if not task.done():
task.cancel()
@@ -87,7 +200,7 @@ class TaskManager:
except asyncio.CancelledError:
pass
except Exception as e:
logger.error(f"Error cancelling task {name}: {e}")
logger.error(f"Error cancelling task {name}: {e}", exc_info=True)
async def cancel_all_tasks(self) -> None:
"""Cancel all tracked tasks"""
@@ -95,7 +208,12 @@ class TaskManager:
await self.cancel_task(name)
def get_task_status(self) -> Dict[str, Any]:
"""Get status of all tasks"""
"""
Get status of all tasks.
Returns:
Dictionary containing task status information
"""
return {
"active_tasks": list(self._tasks.keys()),
"history": self._task_history.copy()
@@ -104,42 +222,80 @@ class TaskManager:
class StateTracker:
"""Tracks lifecycle state and transitions"""
def __init__(self):
def __init__(self) -> None:
self.state = LifecycleState.UNINITIALIZED
self.state_history: List[Dict[str, Any]] = []
self.state_history: List[StateHistory] = []
self._record_state()
def set_state(self, state: LifecycleState) -> None:
"""Set current state"""
def set_state(
self,
state: LifecycleState,
details: Optional[Dict[str, Any]] = None
) -> None:
"""
Set current state.
Args:
state: New state
details: Optional state transition details
"""
self.state = state
self._record_state()
self._record_state(details)
def _record_state(self) -> None:
def _record_state(
self,
details: Optional[Dict[str, Any]] = None
) -> None:
"""Record state transition"""
self.state_history.append({
"state": self.state.value,
"timestamp": datetime.utcnow()
})
now = datetime.utcnow()
duration = 0.0
if self.state_history:
last_state = datetime.fromisoformat(self.state_history[-1]["timestamp"])
duration = (now - last_state).total_seconds()
def get_state_history(self) -> List[Dict[str, Any]]:
self.state_history.append(StateHistory(
state=self.state.name,
timestamp=now.isoformat(),
duration=duration,
details=details
))
def get_state_history(self) -> List[StateHistory]:
"""Get state transition history"""
return self.state_history.copy()
class LifecycleManager:
"""Manages the lifecycle of the VideoArchiver cog"""
def __init__(self, cog):
INIT_TIMEOUT: ClassVar[int] = 60 # 1 minute timeout for initialization
UNLOAD_TIMEOUT: ClassVar[int] = 30 # 30 seconds timeout for unloading
CLEANUP_TIMEOUT: ClassVar[int] = 15 # 15 seconds timeout for cleanup
def __init__(self, cog: Any) -> None:
self.cog = cog
self.task_manager = TaskManager()
self.state_tracker = StateTracker()
self._cleanup_handlers: Set[Callable] = set()
def register_cleanup_handler(self, handler: Callable) -> None:
"""Register a cleanup handler"""
def register_cleanup_handler(
self,
handler: Union[Callable[[], None], Callable[[], Any]]
) -> None:
"""
Register a cleanup handler.
Args:
handler: Cleanup handler function
"""
self._cleanup_handlers.add(handler)
async def initialize_cog(self) -> None:
"""Initialize all components with proper error handling"""
"""
Initialize all components with proper error handling.
Raises:
ComponentError: If initialization fails
"""
try:
# Initialize components in sequence
await self.cog.component_manager.initialize_components()
@@ -149,24 +305,47 @@ class LifecycleManager:
logger.info("VideoArchiver initialization completed successfully")
except Exception as e:
logger.error(f"Error during initialization: {str(e)}")
error = f"Error during initialization: {str(e)}"
logger.error(error, exc_info=True)
await cleanup_resources(self.cog)
raise
raise ComponentError(
error,
context=ErrorContext(
"LifecycleManager",
"initialize_cog",
None,
ErrorSeverity.HIGH
)
)
def init_callback(self, task: asyncio.Task) -> None:
"""Handle initialization task completion"""
try:
task.result()
logger.info("Initialization completed successfully")
self.state_tracker.set_state(LifecycleState.READY)
except asyncio.CancelledError:
logger.warning("Initialization was cancelled")
self.state_tracker.set_state(
LifecycleState.ERROR,
{"reason": "cancelled"}
)
asyncio.create_task(cleanup_resources(self.cog))
except Exception as e:
logger.error(f"Initialization failed: {str(e)}\n{traceback.format_exc()}")
logger.error(f"Initialization failed: {str(e)}", exc_info=True)
self.state_tracker.set_state(
LifecycleState.ERROR,
{"error": str(e)}
)
asyncio.create_task(cleanup_resources(self.cog))
async def handle_load(self) -> None:
"""Handle cog loading without blocking"""
"""
Handle cog loading without blocking.
Raises:
VideoArchiverError: If load fails
"""
try:
self.state_tracker.set_state(LifecycleState.INITIALIZING)
@@ -174,7 +353,8 @@ class LifecycleManager:
await self.task_manager.create_task(
"initialization",
self.initialize_cog(),
self.init_callback
self.init_callback,
timeout=self.INIT_TIMEOUT
)
logger.info("Initialization started in background")
@@ -184,14 +364,27 @@ class LifecycleManager:
try:
await asyncio.wait_for(
force_cleanup_resources(self.cog),
timeout=15 # CLEANUP_TIMEOUT
timeout=self.CLEANUP_TIMEOUT
)
except asyncio.TimeoutError:
logger.error("Force cleanup during load error timed out")
raise VideoArchiverError(f"Error during cog load: {str(e)}")
raise VideoArchiverError(
f"Error during cog load: {str(e)}",
context=ErrorContext(
"LifecycleManager",
"handle_load",
None,
ErrorSeverity.HIGH
)
)
async def handle_unload(self) -> None:
"""Clean up when cog is unloaded"""
"""
Clean up when cog is unloaded.
Raises:
CleanupError: If cleanup fails
"""
self.state_tracker.set_state(LifecycleState.UNLOADING)
try:
@@ -205,9 +398,10 @@ class LifecycleManager:
try:
cleanup_task = await self.task_manager.create_task(
"cleanup",
cleanup_resources(self.cog)
cleanup_resources(self.cog),
timeout=self.UNLOAD_TIMEOUT
)
await asyncio.wait_for(cleanup_task, timeout=30) # UNLOAD_TIMEOUT
await cleanup_task
logger.info("Normal cleanup completed")
except (asyncio.TimeoutError, Exception) as e:
@@ -220,17 +414,50 @@ class LifecycleManager:
try:
await asyncio.wait_for(
force_cleanup_resources(self.cog),
timeout=15 # CLEANUP_TIMEOUT
timeout=self.CLEANUP_TIMEOUT
)
logger.info("Force cleanup completed")
except asyncio.TimeoutError:
logger.error("Force cleanup timed out")
error = "Force cleanup timed out"
logger.error(error)
raise CleanupError(
error,
context=ErrorContext(
"LifecycleManager",
"handle_unload",
None,
ErrorSeverity.CRITICAL
)
)
except Exception as e:
logger.error(f"Error during force cleanup: {str(e)}")
error = f"Error during force cleanup: {str(e)}"
logger.error(error)
raise CleanupError(
error,
context=ErrorContext(
"LifecycleManager",
"handle_unload",
None,
ErrorSeverity.CRITICAL
)
)
except Exception as e:
logger.error(f"Error during cog unload: {str(e)}")
self.state_tracker.set_state(LifecycleState.ERROR)
error = f"Error during cog unload: {str(e)}"
logger.error(error, exc_info=True)
self.state_tracker.set_state(
LifecycleState.ERROR,
{"error": str(e)}
)
raise CleanupError(
error,
context=ErrorContext(
"LifecycleManager",
"handle_unload",
None,
ErrorSeverity.CRITICAL
)
)
finally:
# Clear all references
await self._cleanup_references()
@@ -244,7 +471,7 @@ class LifecycleManager:
else:
handler()
except Exception as e:
logger.error(f"Error in cleanup handler: {e}")
logger.error(f"Error in cleanup handler: {e}", exc_info=True)
async def _cleanup_references(self) -> None:
"""Clean up all references"""
@@ -257,10 +484,16 @@ class LifecycleManager:
self.cog.components.clear()
self.cog.db = None
def get_status(self) -> Dict[str, Any]:
"""Get current lifecycle status"""
return {
"state": self.state_tracker.state.value,
"state_history": self.state_tracker.get_state_history(),
"tasks": self.task_manager.get_task_status()
}
def get_status(self) -> LifecycleStatus:
"""
Get current lifecycle status.
Returns:
Dictionary containing lifecycle status information
"""
return LifecycleStatus(
state=self.state_tracker.state.name,
state_history=self.state_tracker.get_state_history(),
tasks=self.task_manager.get_task_status(),
health=self.state_tracker.state == LifecycleState.READY
)

View File

@@ -1,57 +1,116 @@
"""Module for handling command responses"""
import logging
from enum import Enum, auto
from typing import Optional, Union, Dict, Any, TypedDict, ClassVar
from datetime import datetime
import discord
from typing import Optional, Union, Dict, Any
from redbot.core.commands import Context
from ..utils.exceptions import ErrorSeverity
logger = logging.getLogger("VideoArchiver")
class ResponseType(Enum):
"""Types of responses"""
NORMAL = auto()
SUCCESS = auto()
ERROR = auto()
WARNING = auto()
INFO = auto()
DEBUG = auto()
class ResponseTheme(TypedDict):
"""Type definition for response theme"""
emoji: str
color: discord.Color
class ResponseFormat(TypedDict):
"""Type definition for formatted response"""
content: str
color: discord.Color
timestamp: str
class ResponseFormatter:
"""Formats responses for consistency"""
@staticmethod
def format_success(message: str) -> Dict[str, Any]:
"""Format a success message"""
return {
"content": f"{message}",
"color": discord.Color.green()
}
THEMES: ClassVar[Dict[ResponseType, ResponseTheme]] = {
ResponseType.SUCCESS: ResponseTheme(emoji="", color=discord.Color.green()),
ResponseType.ERROR: ResponseTheme(emoji="", color=discord.Color.red()),
ResponseType.WARNING: ResponseTheme(emoji="⚠️", color=discord.Color.gold()),
ResponseType.INFO: ResponseTheme(emoji="", color=discord.Color.blue()),
ResponseType.DEBUG: ResponseTheme(emoji="🔧", color=discord.Color.greyple())
}
@staticmethod
def format_error(message: str) -> Dict[str, Any]:
"""Format an error message"""
return {
"content": f"{message}",
"color": discord.Color.red()
}
SEVERITY_MAPPING: ClassVar[Dict[ErrorSeverity, ResponseType]] = {
ErrorSeverity.LOW: ResponseType.INFO,
ErrorSeverity.MEDIUM: ResponseType.WARNING,
ErrorSeverity.HIGH: ResponseType.ERROR,
ErrorSeverity.CRITICAL: ResponseType.ERROR
}
@staticmethod
def format_warning(message: str) -> Dict[str, Any]:
"""Format a warning message"""
return {
"content": f"⚠️ {message}",
"color": discord.Color.yellow()
}
@classmethod
def format_response(
cls,
message: str,
response_type: ResponseType = ResponseType.NORMAL
) -> ResponseFormat:
"""
Format a response message.
Args:
message: Message to format
response_type: Type of response
Returns:
Formatted response dictionary
"""
theme = cls.THEMES.get(response_type)
if theme:
return ResponseFormat(
content=f"{theme['emoji']} {message}",
color=theme['color'],
timestamp=datetime.utcnow().isoformat()
)
return ResponseFormat(
content=message,
color=discord.Color.default(),
timestamp=datetime.utcnow().isoformat()
)
@staticmethod
def format_info(message: str) -> Dict[str, Any]:
"""Format an info message"""
return {
"content": f" {message}",
"color": discord.Color.blue()
}
@classmethod
def get_response_type(cls, severity: ErrorSeverity) -> ResponseType:
"""
Get response type for error severity.
Args:
severity: Error severity level
Returns:
Appropriate response type
"""
return cls.SEVERITY_MAPPING.get(severity, ResponseType.ERROR)
class InteractionHandler:
"""Handles slash command interactions"""
@staticmethod
async def send_initial_response(
self,
interaction: discord.Interaction,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None
) -> bool:
"""Send initial interaction response"""
"""
Send initial interaction response.
Args:
interaction: Discord interaction
content: Optional message content
embed: Optional embed
Returns:
True if response was sent successfully
"""
try:
if not interaction.response.is_done():
if embed:
@@ -61,16 +120,26 @@ class InteractionHandler:
return True
return False
except Exception as e:
logger.error(f"Error sending initial interaction response: {e}")
logger.error(f"Error sending initial interaction response: {e}", exc_info=True)
return False
@staticmethod
async def send_followup(
self,
interaction: discord.Interaction,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None
) -> bool:
"""Send interaction followup"""
"""
Send interaction followup.
Args:
interaction: Discord interaction
content: Optional message content
embed: Optional embed
Returns:
True if followup was sent successfully
"""
try:
if embed:
await interaction.followup.send(content=content, embed=embed)
@@ -78,13 +147,13 @@ class InteractionHandler:
await interaction.followup.send(content=content)
return True
except Exception as e:
logger.error(f"Error sending interaction followup: {e}")
logger.error(f"Error sending interaction followup: {e}", exc_info=True)
return False
class ResponseManager:
"""Manages command responses"""
def __init__(self):
def __init__(self) -> None:
self.formatter = ResponseFormatter()
self.interaction_handler = InteractionHandler()
@@ -93,25 +162,37 @@ class ResponseManager:
ctx: Context,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None,
response_type: str = "normal"
response_type: Union[ResponseType, str, ErrorSeverity] = ResponseType.NORMAL
) -> None:
"""Send a response to a command
"""
Send a response to a command.
Args:
ctx: Command context
content: Optional message content
embed: Optional embed
response_type: Type of response (normal, success, error, warning, info)
response_type: Type of response or error severity
"""
try:
# Format response if type specified
if response_type != "normal":
format_method = getattr(self.formatter, f"format_{response_type}", None)
if format_method and content:
formatted = format_method(content)
content = formatted["content"]
if not embed:
embed = discord.Embed(color=formatted["color"])
# Convert string response type to enum
if isinstance(response_type, str):
try:
response_type = ResponseType[response_type.upper()]
except KeyError:
response_type = ResponseType.NORMAL
# Convert error severity to response type
elif isinstance(response_type, ErrorSeverity):
response_type = self.formatter.get_response_type(response_type)
# Format response
if response_type != ResponseType.NORMAL and content:
formatted = self.formatter.format_response(content, response_type)
content = formatted["content"]
if not embed:
embed = discord.Embed(
color=formatted["color"],
timestamp=datetime.fromisoformat(formatted["timestamp"])
)
# Handle response
if self._is_interaction(ctx):
@@ -120,7 +201,7 @@ class ResponseManager:
await self._handle_regular_response(ctx, content, embed)
except Exception as e:
logger.error(f"Error sending response: {e}")
logger.error(f"Error sending response: {e}", exc_info=True)
await self._send_fallback_response(ctx, content, embed)
def _is_interaction(self, ctx: Context) -> bool:
@@ -151,7 +232,7 @@ class ResponseManager:
await self._handle_regular_response(ctx, content, embed)
except Exception as e:
logger.error(f"Error handling interaction response: {e}")
logger.error(f"Error handling interaction response: {e}", exc_info=True)
await self._send_fallback_response(ctx, content, embed)
async def _handle_regular_response(
@@ -167,7 +248,7 @@ class ResponseManager:
else:
await ctx.send(content=content)
except Exception as e:
logger.error(f"Error sending regular response: {e}")
logger.error(f"Error sending regular response: {e}", exc_info=True)
await self._send_fallback_response(ctx, content, embed)
async def _send_fallback_response(
@@ -183,7 +264,7 @@ class ResponseManager:
else:
await ctx.send(content=content)
except Exception as e:
logger.error(f"Failed to send fallback response: {e}")
logger.error(f"Failed to send fallback response: {e}", exc_info=True)
# Global response manager instance
response_manager = ResponseManager()
@@ -192,7 +273,15 @@ async def handle_response(
ctx: Context,
content: Optional[str] = None,
embed: Optional[discord.Embed] = None,
response_type: str = "normal"
response_type: Union[ResponseType, str, ErrorSeverity] = ResponseType.NORMAL
) -> None:
"""Helper function to handle responses using the response manager"""
"""
Helper function to handle responses using the response manager.
Args:
ctx: Command context
content: Optional message content
embed: Optional embed
response_type: Type of response or error severity
"""
await response_manager.send_response(ctx, content, embed, response_type)

View File

@@ -1,8 +1,14 @@
"""Module for managing VideoArchiver settings"""
from typing import Dict, Any, List, Optional
from dataclasses import dataclass
from enum import Enum
from typing import Dict, Any, List, Optional, Union, TypedDict, ClassVar
from dataclasses import dataclass, field
from enum import Enum, auto
from ..utils.exceptions import (
ConfigurationError,
ErrorContext,
ErrorSeverity
)
class VideoFormat(Enum):
"""Supported video formats"""
@@ -17,133 +23,177 @@ class VideoQuality(Enum):
HIGH = "high" # 1080p
ULTRA = "ultra" # 4K
class SettingCategory(Enum):
"""Setting categories"""
GENERAL = auto()
CHANNELS = auto()
PERMISSIONS = auto()
VIDEO = auto()
MESSAGES = auto()
PERFORMANCE = auto()
FEATURES = auto()
class ValidationResult(TypedDict):
"""Type definition for validation result"""
valid: bool
error: Optional[str]
details: Dict[str, Any]
@dataclass
class SettingDefinition:
"""Defines a setting's properties"""
name: str
category: str
category: SettingCategory
default_value: Any
description: str
data_type: type
required: bool = True
min_value: Optional[int] = None
max_value: Optional[int] = None
min_value: Optional[Union[int, float]] = None
max_value: Optional[Union[int, float]] = None
choices: Optional[List[Any]] = None
depends_on: Optional[str] = None
validation_func: Optional[callable] = None
error_message: Optional[str] = None
class SettingCategory(Enum):
"""Setting categories"""
GENERAL = "general"
CHANNELS = "channels"
PERMISSIONS = "permissions"
VIDEO = "video"
MESSAGES = "messages"
PERFORMANCE = "performance"
FEATURES = "features"
def __post_init__(self) -> None:
"""Validate setting definition"""
if self.choices and self.default_value not in self.choices:
raise ConfigurationError(
f"Default value {self.default_value} not in choices {self.choices}",
context=ErrorContext(
"Settings",
"definition_validation",
{"setting": self.name},
ErrorSeverity.HIGH
)
)
if self.min_value is not None and self.max_value is not None:
if self.min_value > self.max_value:
raise ConfigurationError(
f"Min value {self.min_value} greater than max value {self.max_value}",
context=ErrorContext(
"Settings",
"definition_validation",
{"setting": self.name},
ErrorSeverity.HIGH
)
)
class Settings:
"""Manages VideoArchiver settings"""
# Setting definitions
SETTINGS = {
SETTINGS: ClassVar[Dict[str, SettingDefinition]] = {
"enabled": SettingDefinition(
name="enabled",
category=SettingCategory.GENERAL.value,
category=SettingCategory.GENERAL,
default_value=False,
description="Whether the archiver is enabled for this guild",
data_type=bool
),
"archive_channel": SettingDefinition(
name="archive_channel",
category=SettingCategory.CHANNELS.value,
category=SettingCategory.CHANNELS,
default_value=None,
description="Channel where archived videos are posted",
data_type=int,
required=False
required=False,
error_message="Archive channel must be a valid channel ID"
),
"log_channel": SettingDefinition(
name="log_channel",
category=SettingCategory.CHANNELS.value,
category=SettingCategory.CHANNELS,
default_value=None,
description="Channel for logging archiver actions",
data_type=int,
required=False
required=False,
error_message="Log channel must be a valid channel ID"
),
"enabled_channels": SettingDefinition(
name="enabled_channels",
category=SettingCategory.CHANNELS.value,
category=SettingCategory.CHANNELS,
default_value=[],
description="Channels to monitor (empty means all channels)",
data_type=list
data_type=list,
error_message="Enabled channels must be a list of valid channel IDs"
),
"allowed_roles": SettingDefinition(
name="allowed_roles",
category=SettingCategory.PERMISSIONS.value,
category=SettingCategory.PERMISSIONS,
default_value=[],
description="Roles allowed to use archiver (empty means all roles)",
data_type=list
data_type=list,
error_message="Allowed roles must be a list of valid role IDs"
),
"video_format": SettingDefinition(
name="video_format",
category=SettingCategory.VIDEO.value,
category=SettingCategory.VIDEO,
default_value=VideoFormat.MP4.value,
description="Format for archived videos",
data_type=str,
choices=[format.value for format in VideoFormat]
choices=[format.value for format in VideoFormat],
error_message=f"Video format must be one of: {', '.join(f.value for f in VideoFormat)}"
),
"video_quality": SettingDefinition(
name="video_quality",
category=SettingCategory.VIDEO.value,
category=SettingCategory.VIDEO,
default_value=VideoQuality.HIGH.value,
description="Quality preset for archived videos",
data_type=str,
choices=[quality.value for quality in VideoQuality]
choices=[quality.value for quality in VideoQuality],
error_message=f"Video quality must be one of: {', '.join(q.value for q in VideoQuality)}"
),
"max_file_size": SettingDefinition(
name="max_file_size",
category=SettingCategory.VIDEO.value,
category=SettingCategory.VIDEO,
default_value=8,
description="Maximum file size in MB",
data_type=int,
min_value=1,
max_value=100
max_value=100,
error_message="Max file size must be between 1 and 100 MB"
),
"message_duration": SettingDefinition(
name="message_duration",
category=SettingCategory.MESSAGES.value,
category=SettingCategory.MESSAGES,
default_value=30,
description="Duration to show status messages (seconds)",
data_type=int,
min_value=5,
max_value=300
max_value=300,
error_message="Message duration must be between 5 and 300 seconds"
),
"message_template": SettingDefinition(
name="message_template",
category=SettingCategory.MESSAGES.value,
category=SettingCategory.MESSAGES,
default_value="{author} archived a video from {channel}",
description="Template for archive messages",
data_type=str
data_type=str,
error_message="Message template must contain {author} and {channel} placeholders"
),
"concurrent_downloads": SettingDefinition(
name="concurrent_downloads",
category=SettingCategory.PERFORMANCE.value,
category=SettingCategory.PERFORMANCE,
default_value=2,
description="Maximum concurrent downloads",
data_type=int,
min_value=1,
max_value=5
max_value=5,
error_message="Concurrent downloads must be between 1 and 5"
),
"enabled_sites": SettingDefinition(
name="enabled_sites",
category=SettingCategory.FEATURES.value,
category=SettingCategory.FEATURES,
default_value=None,
description="Sites to enable archiving for (None means all sites)",
data_type=list,
required=False
required=False,
error_message="Enabled sites must be a list of valid site identifiers"
),
"use_database": SettingDefinition(
name="use_database",
category=SettingCategory.FEATURES.value,
category=SettingCategory.FEATURES,
default_value=False,
description="Enable database tracking of archived videos",
data_type=bool
@@ -152,12 +202,28 @@ class Settings:
@classmethod
def get_setting_definition(cls, setting: str) -> Optional[SettingDefinition]:
"""Get definition for a setting"""
"""
Get definition for a setting.
Args:
setting: Setting name
Returns:
Setting definition or None if not found
"""
return cls.SETTINGS.get(setting)
@classmethod
def get_settings_by_category(cls, category: str) -> Dict[str, SettingDefinition]:
"""Get all settings in a category"""
def get_settings_by_category(cls, category: SettingCategory) -> Dict[str, SettingDefinition]:
"""
Get all settings in a category.
Args:
category: Setting category
Returns:
Dictionary of settings in the category
"""
return {
name: definition
for name, definition in cls.SETTINGS.items()
@@ -165,36 +231,109 @@ class Settings:
}
@classmethod
def validate_setting(cls, setting: str, value: Any) -> bool:
"""Validate a setting value"""
def validate_setting(cls, setting: str, value: Any) -> ValidationResult:
"""
Validate a setting value.
Args:
setting: Setting name
value: Value to validate
Returns:
Validation result dictionary
Raises:
ConfigurationError: If setting definition is not found
"""
definition = cls.get_setting_definition(setting)
if not definition:
return False
raise ConfigurationError(
f"Unknown setting: {setting}",
context=ErrorContext(
"Settings",
"validation",
{"setting": setting},
ErrorSeverity.HIGH
)
)
details = {
"setting": setting,
"value": value,
"type": type(value).__name__,
"expected_type": definition.data_type.__name__
}
# Check type
if not isinstance(value, definition.data_type):
return False
return ValidationResult(
valid=False,
error=f"Invalid type: expected {definition.data_type.__name__}, got {type(value).__name__}",
details=details
)
# Check required
if definition.required and value is None:
return False
return ValidationResult(
valid=False,
error="Required setting cannot be None",
details=details
)
# Check choices
if definition.choices and value not in definition.choices:
return False
return ValidationResult(
valid=False,
error=f"Value must be one of: {', '.join(map(str, definition.choices))}",
details=details
)
# Check numeric bounds
if isinstance(value, (int, float)):
if definition.min_value is not None and value < definition.min_value:
return False
return ValidationResult(
valid=False,
error=f"Value must be at least {definition.min_value}",
details=details
)
if definition.max_value is not None and value > definition.max_value:
return False
return ValidationResult(
valid=False,
error=f"Value must be at most {definition.max_value}",
details=details
)
return True
# Custom validation
if definition.validation_func:
try:
result = definition.validation_func(value)
if not result:
return ValidationResult(
valid=False,
error=definition.error_message or "Validation failed",
details=details
)
except Exception as e:
return ValidationResult(
valid=False,
error=str(e),
details=details
)
return ValidationResult(
valid=True,
error=None,
details=details
)
@property
def default_guild_settings(self) -> Dict[str, Any]:
"""Default settings for guild configuration"""
"""
Default settings for guild configuration.
Returns:
Dictionary of default settings
"""
return {
name: definition.default_value
for name, definition in self.SETTINGS.items()
@@ -202,14 +341,22 @@ class Settings:
@classmethod
def get_setting_help(cls, setting: str) -> Optional[str]:
"""Get help text for a setting"""
"""
Get help text for a setting.
Args:
setting: Setting name
Returns:
Help text or None if setting not found
"""
definition = cls.get_setting_definition(setting)
if not definition:
return None
help_text = [
f"Setting: {definition.name}",
f"Category: {definition.category}",
f"Category: {definition.category.name}",
f"Description: {definition.description}",
f"Type: {definition.data_type.__name__}",
f"Required: {definition.required}",
@@ -224,5 +371,7 @@ class Settings:
help_text.append(f"Maximum: {definition.max_value}")
if definition.depends_on:
help_text.append(f"Depends on: {definition.depends_on}")
if definition.error_message:
help_text.append(f"Error: {definition.error_message}")
return "\n".join(help_text)