""" Unified Configuration Manager for Miku Discord Bot. Handles: - Static configuration from config.yaml - Runtime overrides from Web UI - Per-server configuration - Priority system: Runtime > Static > Defaults - Persistence of runtime changes """ import json import os from pathlib import Path from typing import Any, Dict, Optional, Union from datetime import datetime import yaml from config import CONFIG, SECRETS from utils.logger import get_logger logger = get_logger('config_manager') class ConfigManager: """ Unified configuration manager with runtime overrides. Priority: 1. Runtime overrides (from Web UI, API, CLI) 2. Static config (from config.yaml) 3. Hardcoded defaults (fallback) """ def __init__(self, config_path: Optional[str] = None): """Initialize configuration manager.""" self.config_path = Path(config_path) if config_path else Path(__file__).parent.parent / "config.yaml" self.runtime_config_path = Path(__file__).parent.parent / "config_runtime.yaml" # Memory directory for server configs and state self.memory_dir = Path(__file__).parent / "memory" self.memory_dir.mkdir(exist_ok=True) # Load configurations self.static_config: Dict = self._load_static_config() self.runtime_config: Dict = self._load_runtime_config() # Runtime state (not persisted) self.runtime_state: Dict = { "dm_mood": "neutral", "evil_mode": False, "bipolar_mode": False, "language_mode": "english", "current_gpu": "nvidia", } # Load persisted state self._load_runtime_state() logger.info("✅ ConfigManager initialized") def _load_static_config(self) -> Dict: """Load static configuration from config.yaml.""" if not self.config_path.exists(): logger.warning(f"⚠️ config.yaml not found: {self.config_path}") return {} try: with open(self.config_path, "r") as f: config = yaml.safe_load(f) or {} logger.debug(f"✅ Loaded static config from {self.config_path}") return config except Exception as e: logger.error(f"❌ Failed to load config.yaml: {e}") return {} def _load_runtime_config(self) -> Dict: """Load runtime overrides from config_runtime.yaml.""" if not self.runtime_config_path.exists(): logger.debug("ℹ️ config_runtime.yaml not found (no overrides)") return {} try: with open(self.runtime_config_path, "r") as f: config = yaml.safe_load(f) or {} logger.debug(f"✅ Loaded runtime config from {self.runtime_config_path}") return config except Exception as e: logger.error(f"❌ Failed to load config_runtime.yaml: {e}") return {} def _load_runtime_state(self): """Load runtime state from memory files.""" # Load GPU state gpu_state_file = self.memory_dir / "gpu_state.json" try: if gpu_state_file.exists(): with open(gpu_state_file, "r") as f: gpu_state = json.load(f) self.runtime_state["current_gpu"] = gpu_state.get("current_gpu", "nvidia") logger.debug(f"✅ Loaded GPU state: {self.runtime_state['current_gpu']}") except Exception as e: logger.error(f"❌ Failed to load GPU state: {e}") def restore_runtime_settings(self): """ Restore persisted runtime settings from config_runtime.yaml into globals. Called once at startup (in on_ready) so that settings changed via the Web UI or API survive bot restarts. Settings with their own persistence (EVIL_MODE, BIPOLAR_MODE) are handled by their respective modules and are intentionally skipped here. """ import globals as g # Map: config_runtime.yaml key path -> (globals attribute, converter) _SETTINGS_MAP = { "discord.language_mode": ("LANGUAGE_MODE", str), "autonomous.debug_mode": ("AUTONOMOUS_DEBUG", bool), "voice.debug_mode": ("VOICE_DEBUG_MODE", bool), "memory.use_cheshire_cat": ("USE_CHESHIRE_CAT", bool), "gpu.prefer_amd": ("PREFER_AMD_GPU", bool), } restored = [] for key_path, (attr, converter) in _SETTINGS_MAP.items(): value = self._get_nested_value(self.runtime_config, key_path) if value is not None: try: setattr(g, attr, converter(value)) restored.append(f"{attr}={getattr(g, attr)}") except (ValueError, TypeError) as exc: logger.warning(f"⚠️ Could not restore {key_path}: {exc}") # DM mood needs special handling (load description too) dm_mood = self._get_nested_value(self.runtime_config, "runtime.mood.dm_mood") if dm_mood and isinstance(dm_mood, str) and dm_mood in getattr(g, "AVAILABLE_MOODS", []): g.DM_MOOD = dm_mood try: from utils.moods import load_mood_description g.DM_MOOD_DESCRIPTION = load_mood_description(dm_mood) except Exception: g.DM_MOOD_DESCRIPTION = f"I'm feeling {dm_mood} today." restored.append(f"DM_MOOD={dm_mood}") if restored: logger.info(f"🔄 Restored {len(restored)} runtime settings: {', '.join(restored)}") else: logger.debug("ℹ️ No runtime settings to restore") def get(self, key_path: str, default: Any = None) -> Any: """ Get configuration value with priority system. Args: key_path: Dot-separated path (e.g., "discord.language_mode") default: Fallback value if not found Returns: Configuration value (runtime > static > default) """ # Try runtime config first value = self._get_nested_value(self.runtime_config, key_path) if value is not None: logger.debug(f"⚡ Runtime config: {key_path} = {value}") return value # Try static config second value = self._get_nested_value(self.static_config, key_path) if value is not None: logger.debug(f"📄 Static config: {key_path} = {value}") return value # Return default logger.debug(f"⚙️ Default value: {key_path} = {default}") return default def _get_nested_value(self, config: Dict, key_path: str) -> Any: """Get nested value from config using dot notation.""" keys = key_path.split(".") value = config for key in keys: if isinstance(value, dict) and key in value: value = value[key] else: return None return value def set(self, key_path: str, value: Any, persist: bool = True): """ Set configuration value. Args: key_path: Dot-separated path (e.g., "discord.language_mode") value: New value to set persist: Whether to save to config_runtime.yaml """ # Set in runtime config keys = key_path.split(".") config = self.runtime_config for key in keys[:-1]: if key not in config: config[key] = {} config = config[key] config[keys[-1]] = value logger.info(f"✅ Config set: {key_path} = {value}") # Persist if requested if persist: self.save_runtime_config() def save_runtime_config(self): """Save runtime configuration to config_runtime.yaml.""" try: with open(self.runtime_config_path, "w") as f: yaml.dump(self.runtime_config, f, default_flow_style=False) logger.info(f"💾 Saved runtime config to {self.runtime_config_path}") except Exception as e: logger.error(f"❌ Failed to save runtime config: {e}") def reset_to_defaults(self, key_path: Optional[str] = None): """ Reset configuration to defaults. Args: key_path: Specific key to reset, or None to reset all runtime config """ if key_path: # Remove specific key from runtime config self._remove_nested_key(self.runtime_config, key_path) logger.info(f"🔄 Reset {key_path} to default") else: # Clear all runtime config self.runtime_config = {} logger.info("🔄 Reset all config to defaults") self.save_runtime_config() def _remove_nested_key(self, config: Dict, key_path: str): """Remove nested key from config.""" keys = key_path.split(".") obj = config for key in keys[:-1]: if isinstance(obj, dict) and key in obj: obj = obj[key] else: return if isinstance(obj, dict) and keys[-1] in obj: del obj[keys[-1]] # ========== Runtime State Management ========== def get_state(self, key: str, default: Any = None) -> Any: """Get runtime state value (not persisted to config).""" return self.runtime_state.get(key, default) def set_state(self, key: str, value: Any): """Set runtime state value.""" self.runtime_state[key] = value logger.debug(f"📊 State: {key} = {value}") # ========== Server Configuration ========== def get_server_config(self, guild_id: int) -> Dict: """Get configuration for a specific server.""" server_config_file = self.memory_dir / "servers_config.json" try: if server_config_file.exists(): with open(server_config_file, "r") as f: all_servers = json.load(f) return all_servers.get(str(guild_id), {}) except Exception as e: logger.error(f"❌ Failed to load server config: {e}") return {} def set_server_config(self, guild_id: int, config: Dict): """Set configuration for a specific server.""" server_config_file = self.memory_dir / "servers_config.json" try: # Load existing config all_servers = {} if server_config_file.exists(): with open(server_config_file, "r") as f: all_servers = json.load(f) # Update server config all_servers[str(guild_id)] = { **all_servers.get(str(guild_id), {}), **config, "last_updated": datetime.now().isoformat() } # Save with open(server_config_file, "w") as f: json.dump(all_servers, f, indent=2) logger.info(f"💾 Saved server config for {guild_id}") except Exception as e: logger.error(f"❌ Failed to save server config: {e}") # ========== GPU State ========== def get_gpu(self) -> str: """Get current GPU selection.""" return self.get_state("current_gpu", "nvidia") def set_gpu(self, gpu: str): """Set current GPU selection and persist.""" gpu = gpu.lower() if gpu not in ["nvidia", "amd"]: logger.warning(f"⚠️ Invalid GPU: {gpu}") return False # Update state self.set_state("current_gpu", gpu) # Persist to file gpu_state_file = self.memory_dir / "gpu_state.json" try: state = { "current_gpu": gpu, "last_updated": datetime.now().isoformat() } with open(gpu_state_file, "w") as f: json.dump(state, f, indent=2) logger.info(f"💾 Saved GPU state: {gpu}") return True except Exception as e: logger.error(f"❌ Failed to save GPU state: {e}") return False # ========== Configuration Export ========== def get_full_config(self) -> Dict: """ Get full configuration (merged static + runtime). Useful for API responses and debugging. """ return { "static": self.static_config, "runtime": self.runtime_config, "state": self.runtime_state, "merged": self._merge_configs(self.static_config, self.runtime_config) } def _merge_configs(self, base: Dict, override: Dict) -> Dict: """Deep merge two dictionaries.""" result = base.copy() for key, value in override.items(): if key in result and isinstance(result[key], dict) and isinstance(value, dict): result[key] = self._merge_configs(result[key], value) else: result[key] = value return result # ========== Validation ========== def validate_config(self) -> tuple[bool, list[str]]: """ Validate current configuration. Returns: Tuple of (is_valid, list_of_errors) """ errors = [] # Check required secrets if not SECRETS.discord_bot_token or SECRETS.discord_bot_token.startswith("your_"): errors.append("DISCORD_BOT_TOKEN not set or using placeholder") # Validate language mode language = self.get("discord.language_mode", "english") if language not in ["english", "japanese"]: errors.append(f"Invalid language_mode: {language}") # Validate GPU gpu = self.get_gpu() if gpu not in ["nvidia", "amd"]: errors.append(f"Invalid GPU selection: {gpu}") return len(errors) == 0, errors # ========== Global Instance ========== # Create global config manager instance config_manager = ConfigManager()