# server_manager.py import json import os import asyncio from typing import Dict, List, Optional, Set from dataclasses import dataclass, asdict 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 datetime import datetime, timedelta @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: int = None angry_wakeup_timer = None forced_angry_until = 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: print(f"âš ī¸ Failed to parse enabled_features string '{data['enabled_features']}': {e}") # Fallback to default features data['enabled_features'] = {"autonomous", "bedtime", "monday_video"} 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.server_memories: Dict[int, Dict] = {} 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) self.server_memories[guild_id] = {} print(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: print(f"âš ī¸ Failed to load server config: {e}") self._create_default_config() else: self._create_default_config() 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 print(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: print(f"âš ī¸ Failed to repair enabled_features for {server.guild_name}: {e}") server.enabled_features = {"autonomous", "bedtime", "monday_video"} if needs_repair: print("🔧 Saving repaired configuration...") self.save_config() except Exception as e: print(f"âš ī¸ Failed to repair config: {e}") def _create_default_config(self): """Create default configuration for backward compatibility""" default_server = ServerConfig( guild_id=759889672804630530, guild_name="Default Server", autonomous_channel_id=761014220707332107, autonomous_channel_name="miku-chat", bedtime_channel_ids=[761014220707332107], enabled_features={"autonomous", "bedtime", "monday_video"}, autonomous_interval_minutes=10, conversation_detection_interval_minutes=3 ) self.servers[default_server.guild_id] = default_server self.server_memories[default_server.guild_id] = {} self.save_config() print("📋 Created default server configuration") 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: print(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: print(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.server_memories[guild_id] = {} self.save_config() print(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] # Remove memory if guild_id in self.server_memories: del self.server_memories[guild_id] self.save_config() print(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() print(f"✅ Updated config for server: {server.guild_name}") return True def get_server_memory(self, guild_id: int, key: str = None): """Get or set server-specific memory""" if guild_id not in self.server_memories: self.server_memories[guild_id] = {} if key is None: return self.server_memories[guild_id] return self.server_memories[guild_id].get(key) def set_server_memory(self, guild_id: int, key: str, value): """Set server-specific memory""" if guild_id not in self.server_memories: self.server_memories[guild_id] = {} self.server_memories[guild_id][key] = value # ========== 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""" 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: print(f"âš ī¸ Failed to load mood description for {mood_name}: {e}") server.current_mood_description = f"I'm feeling {mood_name} today." self.save_config() print(f"😊 Server {server.guild_name} mood changed to: {mood_name}") print(f"😊 Mood description: {server.current_mood_description[:100]}{'...' if len(server.current_mood_description) > 100 else ''}") 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 self.save_config() return True 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: print(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: print(f"⏰ Setting up bedtime scheduler for server {server_config.guild_name}") print(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() print(f"⏰ Started scheduler for server: {server_config.guild_name}") def start_all_schedulers(self, client: discord.Client): """Start schedulers for all servers""" print("🚀 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) print(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: print(f"âš ī¸ No server config found for guild {guild_id}") return False scheduler = self.schedulers.get(guild_id) if not scheduler: print(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) print(f"đŸ—‘ī¸ Removed old bedtime job for server {guild_id}") except Exception as e: print(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: print(f"⏰ Updating bedtime scheduler for server {server_config.guild_name}") print(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 ) print(f"✅ Updated bedtime job for server {server_config.guild_name}") return True else: print(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 print("🔄 DM mood rotation scheduler started (every 2 hours)") except Exception as e: print(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)) print("✅ Figurine DM send task queued") else: print("âš ī¸ Client loop not available for figurine DM send") except Exception as e: print(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) print(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 print("đŸ—“ī¸ Figurine updates scheduler started") except Exception as e: print(f"❌ Failed to setup figurine updates scheduler: {e}") def stop_all_schedulers(self): """Stop all schedulers""" print("🛑 Stopping all schedulers...") for scheduler in self.schedulers.values(): try: scheduler.shutdown() except Exception as e: print(f"âš ī¸ Error stopping scheduler: {e}") self.schedulers.clear() print("✅ 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)) print(f"✅ [V2] Autonomous tick queued for server {guild_id}") else: print(f"âš ī¸ Client loop not available for autonomous tick in server {guild_id}") except Exception as e: print(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)) print(f"✅ [V2] Autonomous reaction queued for server {guild_id}") else: print(f"âš ī¸ Client loop not available for autonomous reaction in server {guild_id}") except Exception as e: print(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)) print(f"✅ Conversation detection queued for server {guild_id}") else: print(f"âš ī¸ Client loop not available for conversation detection in server {guild_id}") except Exception as e: print(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)) print(f"✅ Monday video queued for server {guild_id}") else: print(f"âš ī¸ Client loop not available for Monday video in server {guild_id}") except Exception as e: print(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""" print(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: print(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 print(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 print(f"🌙 Cross-midnight range detected, adjusted end to {end_minutes} minutes") random_minutes = random.randint(start_minutes, end_minutes) print(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() print(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)) print(f"✅ Random bedtime reminder sent for server {guild_id}") else: print(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())}" ) print(f"✅ Bedtime reminder scheduled for server {guild_id} at {target_time.strftime('%Y-%m-%d %H:%M:%S')}") else: print(f"âš ī¸ No scheduler found for server {guild_id}") except Exception as e: print(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)) print(f"✅ Mood rotation queued for server {guild_id}") else: print(f"âš ī¸ Client loop not available for mood rotation in server {guild_id}") except Exception as e: print(f"âš ī¸ Error in mood rotation for server {guild_id}: {e}") # Global instance server_manager = ServerManager()