# server_manager.py import json import os import asyncio from typing import Dict, List, Optional, Set from dataclasses import dataclass, asdict, fields as dataclass_fields from datetime import datetime, timedelta import discord from apscheduler.schedulers.asyncio import AsyncIOScheduler from apscheduler.triggers.interval import IntervalTrigger from apscheduler.triggers.cron import CronTrigger from apscheduler.triggers.date import DateTrigger import random from utils.logger import get_logger logger = get_logger('server') @dataclass class ServerConfig: """Configuration for a single Discord server""" guild_id: int guild_name: str autonomous_channel_id: int autonomous_channel_name: str bedtime_channel_ids: List[int] enabled_features: Set[str] # autonomous, bedtime, monday_video, etc. autonomous_interval_minutes: int = 15 conversation_detection_interval_minutes: int = 3 bedtime_hour: int = 21 bedtime_minute: int = 0 bedtime_hour_end: int = 21 # End of bedtime range (default 11PM) bedtime_minute_end: int = 59 # End of bedtime range (default 11:59PM) monday_video_hour: int = 4 monday_video_minute: int = 30 # Per-server mood tracking current_mood_name: str = "neutral" current_mood_description: str = "" previous_mood_name: str = "neutral" is_sleeping: bool = False sleepy_responses_left: Optional[int] = None angry_wakeup_timer: Optional[float] = None # TODO: implement angry-wakeup mechanic or remove field forced_angry_until: Optional[str] = None # ISO format datetime string, or None just_woken_up: bool = False def to_dict(self): return asdict(self) @classmethod def from_dict(cls, data: dict): # Convert set back from list, or handle old string format if 'enabled_features' in data: if isinstance(data['enabled_features'], list): data['enabled_features'] = set(data['enabled_features']) elif isinstance(data['enabled_features'], str): # Handle old string format like "{'bedtime', 'monday_video', 'autonomous'}" try: # Remove the outer braces and split by comma features_str = data['enabled_features'].strip('{}') features_list = [f.strip().strip("'\"") for f in features_str.split(',') if f.strip()] data['enabled_features'] = set(features_list) except Exception as e: logger.warning(f"Failed to parse enabled_features string '{data['enabled_features']}': {e}") # Fallback to default features data['enabled_features'] = {"autonomous", "bedtime", "monday_video"} # Strip any keys that aren't valid dataclass fields (forward-compat safety) valid_fields = {f.name for f in dataclass_fields(cls)} data = {k: v for k, v in data.items() if k in valid_fields} return cls(**data) class ServerManager: """Manages multiple Discord servers with independent configurations""" def __init__(self, config_file: str = "memory/servers_config.json"): self.config_file = config_file self.servers: Dict[int, ServerConfig] = {} self.schedulers: Dict[int, AsyncIOScheduler] = {} self._wakeup_tasks: Dict[int, asyncio.Task] = {} # guild_id -> delayed wakeup task self.load_config() def load_config(self): """Load server configurations from file""" if os.path.exists(self.config_file): try: with open(self.config_file, "r", encoding="utf-8") as f: data = json.load(f) for guild_id_str, server_data in data.items(): guild_id = int(guild_id_str) self.servers[guild_id] = ServerConfig.from_dict(server_data) logger.info(f"Loaded config for server: {server_data['guild_name']} (ID: {guild_id})") # After loading, check if we need to repair the config self.repair_config() except Exception as e: logger.error(f"Failed to load server config: {e}") logger.info("Starting with zero servers — add servers via the API or dashboard") else: logger.info("No servers_config.json found — starting with zero servers") def repair_config(self): """Repair corrupted configuration data and save it back""" try: needs_repair = False for server in self.servers.values(): # Check if enabled_features is a string (corrupted) if isinstance(server.enabled_features, str): needs_repair = True logger.info(f"Repairing corrupted enabled_features for server: {server.guild_name}") # Re-parse the features try: features_str = server.enabled_features.strip('{}') features_list = [f.strip().strip("'\"") for f in features_str.split(',') if f.strip()] server.enabled_features = set(features_list) except Exception as e: logger.warning(f"Failed to repair enabled_features for {server.guild_name}: {e}") server.enabled_features = {"autonomous", "bedtime", "monday_video"} if needs_repair: logger.info("Saving repaired configuration...") self.save_config() except Exception as e: logger.error(f"Failed to repair config: {e}") def save_config(self): """Save server configurations to file""" try: os.makedirs(os.path.dirname(self.config_file), exist_ok=True) config_data = {} for guild_id, server in self.servers.items(): # Convert the server config to dict, but handle sets properly server_dict = server.to_dict() # Convert set to list for JSON serialization if 'enabled_features' in server_dict and isinstance(server_dict['enabled_features'], set): server_dict['enabled_features'] = list(server_dict['enabled_features']) config_data[str(guild_id)] = server_dict with open(self.config_file, "w", encoding="utf-8") as f: json.dump(config_data, f, indent=2) except Exception as e: logger.error(f"Failed to save server config: {e}") def add_server(self, guild_id: int, guild_name: str, autonomous_channel_id: int, autonomous_channel_name: str, bedtime_channel_ids: List[int] = None, enabled_features: Set[str] = None) -> bool: """Add a new server configuration""" if guild_id in self.servers: logger.info(f"Server {guild_id} already exists") return False if bedtime_channel_ids is None: bedtime_channel_ids = [autonomous_channel_id] if enabled_features is None: enabled_features = {"autonomous", "bedtime", "monday_video"} server = ServerConfig( guild_id=guild_id, guild_name=guild_name, autonomous_channel_id=autonomous_channel_id, autonomous_channel_name=autonomous_channel_name, bedtime_channel_ids=bedtime_channel_ids, enabled_features=enabled_features ) self.servers[guild_id] = server self.save_config() logger.info(f"Added new server: {guild_name} (ID: {guild_id})") return True def remove_server(self, guild_id: int) -> bool: """Remove a server configuration""" if guild_id not in self.servers: return False server_name = self.servers[guild_id].guild_name del self.servers[guild_id] # Stop and remove scheduler if guild_id in self.schedulers: self.schedulers[guild_id].shutdown() del self.schedulers[guild_id] self.save_config() logger.info(f"Removed server: {server_name} (ID: {guild_id})") return True def get_server_config(self, guild_id: int) -> Optional[ServerConfig]: """Get configuration for a specific server""" return self.servers.get(guild_id) def get_all_servers(self) -> List[ServerConfig]: """Get all server configurations""" return list(self.servers.values()) def update_server_config(self, guild_id: int, **kwargs) -> bool: """Update configuration for a specific server""" if guild_id not in self.servers: return False server = self.servers[guild_id] for key, value in kwargs.items(): if hasattr(server, key): setattr(server, key, value) self.save_config() logger.info(f"Updated config for server: {server.guild_name}") return True # ========== Mood Management Methods ========== def get_server_mood(self, guild_id: int) -> tuple[str, str]: """Get current mood name and description for a server""" if guild_id not in self.servers: return "neutral", "" server = self.servers[guild_id] return server.current_mood_name, server.current_mood_description def set_server_mood(self, guild_id: int, mood_name: str, mood_description: str = None): """Set mood for a specific server. Also handles: - Syncing is_sleeping state (fix #4: sleep/mood desync) - Notifying the autonomous engine (fix #9: engine mood desync) """ if guild_id not in self.servers: return False server = self.servers[guild_id] server.previous_mood_name = server.current_mood_name server.current_mood_name = mood_name if mood_description: server.current_mood_description = mood_description else: # Load mood description if not provided try: from utils.moods import load_mood_description server.current_mood_description = load_mood_description(mood_name) except Exception as e: logger.error(f"Failed to load mood description for {mood_name}: {e}") server.current_mood_description = f"I'm feeling {mood_name} today." # Fix #4: Keep is_sleeping in sync with mood # If mood changes away from 'asleep', clear sleeping state if mood_name != "asleep" and server.is_sleeping: server.is_sleeping = False self.cancel_wakeup_task(guild_id) logger.info(f"Cleared sleep state for server {server.guild_name} (mood changed to {mood_name})") self.save_config() logger.info(f"Server {server.guild_name} mood changed to: {mood_name}") logger.debug(f"Mood description: {server.current_mood_description[:100]}{'...' if len(server.current_mood_description) > 100 else ''}") # Fix #9: Always notify autonomous engine of mood change try: from utils.autonomous import on_mood_change on_mood_change(guild_id, mood_name) except Exception as e: logger.error(f"Failed to notify autonomous engine of mood change to {mood_name}: {e}") return True def get_server_sleep_state(self, guild_id: int) -> bool: """Get sleep state for a specific server""" if guild_id not in self.servers: return False return self.servers[guild_id].is_sleeping def set_server_sleep_state(self, guild_id: int, sleeping: bool): """Set sleep state for a specific server""" if guild_id not in self.servers: return False server = self.servers[guild_id] server.is_sleeping = sleeping # If waking up, cancel any pending delayed wakeup task if not sleeping: self.cancel_wakeup_task(guild_id) self.save_config() return True def schedule_wakeup_task(self, guild_id: int, delay_seconds: int = 3600): """Schedule a delayed wakeup task for a server, cancelling any existing one first. Args: guild_id: The server to schedule wakeup for delay_seconds: How long to sleep before waking (default 1 hour) """ # Cancel any existing wakeup task for this server self.cancel_wakeup_task(guild_id) import globals as _globals async def _delayed_wakeup(): try: await asyncio.sleep(delay_seconds) # Check if we're still asleep (might have been woken manually) server = self.servers.get(guild_id) if server and server.is_sleeping: self.set_server_sleep_state(guild_id, False) self.set_server_mood(guild_id, "neutral") # Update nickname try: from utils.moods import update_server_nickname await update_server_nickname(guild_id) except Exception as e: logger.error(f"Failed to update nickname on wake-up: {e}") logger.info(f"Server {guild_id} woke up from auto-sleep after {delay_seconds}s") else: logger.debug(f"Wakeup task for {guild_id} completed but server already awake, skipping") except asyncio.CancelledError: logger.debug(f"Wakeup task for server {guild_id} was cancelled") finally: # Clean up our reference self._wakeup_tasks.pop(guild_id, None) task = _globals.client.loop.create_task(_delayed_wakeup()) self._wakeup_tasks[guild_id] = task logger.info(f"Scheduled auto-wake for server {guild_id} in {delay_seconds}s") return task def cancel_wakeup_task(self, guild_id: int): """Cancel a pending wakeup task for a server, if any.""" task = self._wakeup_tasks.pop(guild_id, None) if task and not task.done(): task.cancel() logger.info(f"Cancelled pending wakeup task for server {guild_id}") def get_server_mood_state(self, guild_id: int) -> dict: """Get complete mood state for a specific server""" if guild_id not in self.servers: return {} server = self.servers[guild_id] return { "current_mood_name": server.current_mood_name, "current_mood_description": server.current_mood_description, "previous_mood_name": server.previous_mood_name, "is_sleeping": server.is_sleeping, "sleepy_responses_left": server.sleepy_responses_left, "forced_angry_until": server.forced_angry_until, "just_woken_up": server.just_woken_up } def set_server_mood_state(self, guild_id: int, **kwargs): """Set multiple mood state properties for a server""" if guild_id not in self.servers: return False server = self.servers[guild_id] for key, value in kwargs.items(): if hasattr(server, key): setattr(server, key, value) self.save_config() return True def setup_server_scheduler(self, guild_id: int, client: discord.Client): """Setup independent scheduler for a specific server""" if guild_id not in self.servers: logger.warning(f"Cannot setup scheduler for unknown server: {guild_id}") return server_config = self.servers[guild_id] # Create new scheduler for this server scheduler = AsyncIOScheduler() # Add autonomous speaking job if "autonomous" in server_config.enabled_features: scheduler.add_job( self._run_autonomous_for_server, IntervalTrigger(minutes=server_config.autonomous_interval_minutes), args=[guild_id, client], id=f"autonomous_{guild_id}" ) # Add autonomous reaction job (parallel to speaking, runs every 20 minutes) if "autonomous" in server_config.enabled_features: scheduler.add_job( self._run_autonomous_reaction_for_server, IntervalTrigger(minutes=20), args=[guild_id, client], id=f"autonomous_reaction_{guild_id}" ) # Note: Conversation detection is now handled by V2 system via message events # No need for separate scheduler job # Add Monday video job if "monday_video" in server_config.enabled_features: scheduler.add_job( self._send_monday_video_for_server, CronTrigger(day_of_week='mon', hour=server_config.monday_video_hour, minute=server_config.monday_video_minute), args=[guild_id, client], id=f"monday_video_{guild_id}" ) # Add bedtime reminder job if "bedtime" in server_config.enabled_features: logger.info(f"Setting up bedtime scheduler for server {server_config.guild_name}") logger.debug(f" Random time range: {server_config.bedtime_hour:02d}:{server_config.bedtime_minute:02d} - {server_config.bedtime_hour_end:02d}:{server_config.bedtime_minute_end:02d}") scheduler.add_job( self._schedule_random_bedtime_for_server, CronTrigger(hour=server_config.bedtime_hour, minute=server_config.bedtime_minute), args=[guild_id, client], id=f"bedtime_schedule_{guild_id}" ) # Add mood rotation job (every hour) scheduler.add_job( self._rotate_server_mood, IntervalTrigger(hours=1), args=[guild_id, client], id=f"mood_rotation_{guild_id}" ) self.schedulers[guild_id] = scheduler scheduler.start() logger.info(f"Started scheduler for server: {server_config.guild_name}") def start_all_schedulers(self, client: discord.Client): """Start schedulers for all servers""" logger.info("Starting all server schedulers...") for guild_id in self.servers: self.setup_server_scheduler(guild_id, client) # Start DM mood rotation scheduler self.setup_dm_mood_scheduler(client) # Start Figurine DM scheduler self.setup_figurine_updates_scheduler(client) logger.info(f"Started {len(self.servers)} server schedulers + DM mood scheduler") def update_server_bedtime_job(self, guild_id: int, client: discord.Client): """Update just the bedtime job for a specific server without restarting all schedulers""" server_config = self.servers.get(guild_id) if not server_config: logger.warning(f"No server config found for guild {guild_id}") return False scheduler = self.schedulers.get(guild_id) if not scheduler: logger.warning(f"No scheduler found for guild {guild_id}") return False # Remove existing bedtime job if it exists bedtime_job_id = f"bedtime_schedule_{guild_id}" try: scheduler.remove_job(bedtime_job_id) logger.info(f"Removed old bedtime job for server {guild_id}") except Exception as e: logger.debug(f"No existing bedtime job to remove for server {guild_id}: {e}") # Add new bedtime job with updated configuration if "bedtime" in server_config.enabled_features: logger.info(f"Updating bedtime scheduler for server {server_config.guild_name}") logger.debug(f" New random time range: {server_config.bedtime_hour:02d}:{server_config.bedtime_minute:02d} - {server_config.bedtime_hour_end:02d}:{server_config.bedtime_minute_end:02d}") scheduler.add_job( self._schedule_random_bedtime_for_server, CronTrigger(hour=server_config.bedtime_hour, minute=server_config.bedtime_minute), args=[guild_id, client], id=bedtime_job_id ) logger.info(f"Updated bedtime job for server {server_config.guild_name}") return True else: logger.info(f"Bedtime feature not enabled for server {guild_id}") return True def setup_dm_mood_scheduler(self, client: discord.Client): """Setup DM mood rotation scheduler""" try: from utils.moods import rotate_dm_mood # Create DM mood rotation job (every 2 hours) scheduler = AsyncIOScheduler() scheduler.add_job( rotate_dm_mood, IntervalTrigger(hours=2), id="dm_mood_rotation" ) scheduler.start() self.schedulers["dm_mood"] = scheduler logger.info("DM mood rotation scheduler started (every 2 hours)") except Exception as e: logger.error(f"Failed to setup DM mood scheduler: {e}") def _enqueue_figurine_send(self, client: discord.Client): """Enqueue the figurine DM send task in the client's loop.""" try: from utils.figurine_notifier import send_figurine_dm_to_all_subscribers if client.loop and client.loop.is_running(): client.loop.create_task(send_figurine_dm_to_all_subscribers(client)) logger.debug("Figurine DM send task queued") else: logger.warning("Client loop not available for figurine DM send") except Exception as e: logger.error(f"Error enqueuing figurine DM: {e}") def _schedule_one_figurine_send_today(self, scheduler: AsyncIOScheduler, client: discord.Client): """Schedule one figurine DM send at a random non-evening time today (or tomorrow if time passed).""" now = datetime.now() # Define non-evening hours: 08:00-17:59 random_hour = random.randint(8, 17) random_minute = random.randint(0, 59) target_time = now.replace(hour=random_hour, minute=random_minute, second=0, microsecond=0) if target_time <= now: target_time = target_time + timedelta(days=1) logger.info(f"Scheduling figurine DM at {target_time.strftime('%Y-%m-%d %H:%M')} (random non-evening)") scheduler.add_job( self._enqueue_figurine_send, DateTrigger(run_date=target_time), args=[client], id=f"figurine_dm_{int(target_time.timestamp())}", replace_existing=False ) def setup_figurine_updates_scheduler(self, client: discord.Client): """Create a daily scheduler that schedules one random non-evening figurine DM send per day.""" try: scheduler = AsyncIOScheduler() # Every day at 07:30, schedule today's random send (will roll to tomorrow if time passed) scheduler.add_job( self._schedule_one_figurine_send_today, CronTrigger(hour=7, minute=30), args=[scheduler, client], id="figurine_daily_scheduler" ) # Also schedule one immediately on startup for today/tomorrow self._schedule_one_figurine_send_today(scheduler, client) scheduler.start() self.schedulers["figurine_dm"] = scheduler logger.info("Figurine updates scheduler started") except Exception as e: logger.error(f"Failed to setup figurine updates scheduler: {e}") def stop_all_schedulers(self): """Stop all schedulers""" logger.info("Stopping all schedulers...") for scheduler in self.schedulers.values(): try: scheduler.shutdown() except Exception as e: logger.warning(f"Error stopping scheduler: {e}") self.schedulers.clear() logger.info("All schedulers stopped") # Implementation of autonomous functions - these integrate with the autonomous system def _run_autonomous_for_server(self, guild_id: int, client: discord.Client): """Run autonomous behavior for a specific server - called by APScheduler""" try: # V2: Use the new context-aware autonomous system from utils.autonomous import autonomous_tick # Create an async task in the client's event loop if client.loop and client.loop.is_running(): client.loop.create_task(autonomous_tick(guild_id)) logger.debug(f"[V2] Autonomous tick queued for server {guild_id}") else: logger.warning(f"Client loop not available for autonomous tick in server {guild_id}") except Exception as e: logger.error(f"Error in autonomous tick for server {guild_id}: {e}") def _run_autonomous_reaction_for_server(self, guild_id: int, client: discord.Client): """Run autonomous reaction for a specific server - called by APScheduler""" try: # V2: Use the new context-aware reaction system from utils.autonomous import autonomous_reaction_tick # Create an async task in the client's event loop if client.loop and client.loop.is_running(): client.loop.create_task(autonomous_reaction_tick(guild_id)) logger.debug(f"[V2] Autonomous reaction queued for server {guild_id}") else: logger.warning(f"Client loop not available for autonomous reaction in server {guild_id}") except Exception as e: logger.error(f"Error in autonomous reaction for server {guild_id}: {e}") def _run_conversation_detection_for_server(self, guild_id: int, client: discord.Client): """Run conversation detection for a specific server - called by APScheduler""" try: from utils.autonomous import miku_detect_and_join_conversation_for_server # Create an async task in the client's event loop if client.loop and client.loop.is_running(): client.loop.create_task(miku_detect_and_join_conversation_for_server(guild_id)) logger.debug(f"Conversation detection queued for server {guild_id}") else: logger.warning(f"Client loop not available for conversation detection in server {guild_id}") except Exception as e: logger.error(f"Error in conversation detection for server {guild_id}: {e}") def _send_monday_video_for_server(self, guild_id: int, client: discord.Client): """Send Monday video for a specific server - called by APScheduler""" try: from utils.scheduled import send_monday_video_for_server # Create an async task in the client's event loop if client.loop and client.loop.is_running(): client.loop.create_task(send_monday_video_for_server(guild_id)) logger.debug(f"Monday video queued for server {guild_id}") else: logger.warning(f"Client loop not available for Monday video in server {guild_id}") except Exception as e: logger.error(f"Error in Monday video for server {guild_id}: {e}") def _schedule_random_bedtime_for_server(self, guild_id: int, client: discord.Client): """Schedule bedtime reminder for a specific server at a random time within the configured range""" logger.info(f"Bedtime scheduler triggered for server {guild_id} at {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") # Get server config to determine the random time range server_config = self.servers.get(guild_id) if not server_config: logger.warning(f"No server config found for guild {guild_id}") return # Calculate random time within the bedtime range start_minutes = server_config.bedtime_hour * 60 + server_config.bedtime_minute end_minutes = server_config.bedtime_hour_end * 60 + server_config.bedtime_minute_end logger.debug(f"Bedtime range calculation: {server_config.bedtime_hour:02d}:{server_config.bedtime_minute:02d} ({start_minutes} min) to {server_config.bedtime_hour_end:02d}:{server_config.bedtime_minute_end:02d} ({end_minutes} min)") # Handle case where end time is next day (e.g., 23:30 to 00:30) if end_minutes <= start_minutes: end_minutes += 24 * 60 # Add 24 hours logger.debug(f"Cross-midnight range detected, adjusted end to {end_minutes} minutes") random_minutes = random.randint(start_minutes, end_minutes) logger.debug(f"Random time selected: {random_minutes} minutes from midnight") # Convert back to hours and minutes random_hour = (random_minutes // 60) % 24 random_minute = random_minutes % 60 # Calculate delay until the random time now = datetime.now() target_time = now.replace(hour=random_hour, minute=random_minute, second=0, microsecond=0) # If the target time has already passed today, schedule for tomorrow if target_time <= now: target_time += timedelta(days=1) delay_seconds = (target_time - now).total_seconds() logger.info(f"Random bedtime for server {server_config.guild_name}: {random_hour:02d}:{random_minute:02d} (in {delay_seconds/60:.1f} minutes)") # Schedule the actual bedtime reminder try: from utils.scheduled import send_bedtime_reminder_for_server def send_bedtime_delayed(): if client.loop and client.loop.is_running(): client.loop.create_task(send_bedtime_reminder_for_server(guild_id, client)) logger.info(f"Random bedtime reminder sent for server {guild_id}") else: logger.warning(f"Client loop not available for bedtime reminder in server {guild_id}") # Use the scheduler to schedule the delayed bedtime reminder scheduler = self.schedulers.get(guild_id) if scheduler: scheduler.add_job( send_bedtime_delayed, DateTrigger(run_date=target_time), id=f"bedtime_reminder_{guild_id}_{int(target_time.timestamp())}" ) logger.info(f"Bedtime reminder scheduled for server {guild_id} at {target_time.strftime('%Y-%m-%d %H:%M:%S')}") else: logger.warning(f"No scheduler found for server {guild_id}") except Exception as e: logger.error(f"Error scheduling bedtime reminder for server {guild_id}: {e}") def _rotate_server_mood(self, guild_id: int, client: discord.Client): """Rotate mood for a specific server - called by APScheduler""" try: from utils.moods import rotate_server_mood # Create an async task in the client's event loop if client.loop and client.loop.is_running(): client.loop.create_task(rotate_server_mood(guild_id)) logger.debug(f"Mood rotation queued for server {guild_id}") else: logger.warning(f"Client loop not available for mood rotation in server {guild_id}") except Exception as e: logger.error(f"Error in mood rotation for server {guild_id}: {e}") # Global instance server_manager = ServerManager()