""" Configuration management for Miku Discord Bot. Uses Pydantic for type-safe configuration loading from: - .env (secrets only) - config.yaml (all other configuration) """ import os from pathlib import Path from typing import Any, Optional from pydantic import BaseModel, Field, field_validator from pydantic_settings import BaseSettings, SettingsConfigDict # ============================================ # Pydantic Models for Configuration # ============================================ class ServicesConfig(BaseModel): """External service endpoint configuration""" url: str = "http://llama-swap:8080" amd_url: str = "http://llama-swap-amd:8080" class CheshireCatConfig(BaseModel): """Cheshire Cat AI memory system configuration""" url: str = "http://cheshire-cat:80" timeout_seconds: int = Field(default=120, ge=1, le=600) enabled: bool = True class FaceDetectorConfig(BaseModel): """Face detection service configuration""" startup_timeout_seconds: int = Field(default=60, ge=10, le=300) class ModelsConfig(BaseModel): """AI model configuration""" text: str = "llama3.1" vision: str = "vision" evil: str = "darkidol" japanese: str = "swallow" class DiscordConfig(BaseModel): """Discord bot configuration""" language_mode: str = Field(default="english", pattern="^(english|japanese)$") api_port: int = Field(default=3939, ge=1024, le=65535) class AutonomousConfig(BaseModel): """Autonomous system configuration""" debug_mode: bool = False class VoiceConfig(BaseModel): """Voice chat configuration""" debug_mode: bool = False class MemoryConfig(BaseModel): """Memory and logging configuration""" log_dir: str = "/app/memory/logs" conversation_history_length: int = Field(default=5, ge=1, le=50) class ServerConfig(BaseModel): """Server settings""" host: str = "0.0.0.0" log_level: str = Field(default="critical", pattern="^(debug|info|warning|error|critical)$") class GPUConfig(BaseModel): """GPU configuration""" prefer_amd: bool = False amd_models_enabled: bool = True class AppConfig(BaseModel): """Main application configuration""" services: ServicesConfig = Field(default_factory=ServicesConfig) cheshire_cat: CheshireCatConfig = Field(default_factory=CheshireCatConfig) face_detector: FaceDetectorConfig = Field(default_factory=FaceDetectorConfig) models: ModelsConfig = Field(default_factory=ModelsConfig) discord: DiscordConfig = Field(default_factory=DiscordConfig) autonomous: AutonomousConfig = Field(default_factory=AutonomousConfig) voice: VoiceConfig = Field(default_factory=VoiceConfig) memory: MemoryConfig = Field(default_factory=MemoryConfig) server: ServerConfig = Field(default_factory=ServerConfig) gpu: GPUConfig = Field(default_factory=GPUConfig) class Secrets(BaseSettings): """ Secrets loaded from environment variables (.env file) These are sensitive values that should never be committed to git """ model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", env_prefix="", # No prefix for env vars extra="ignore" # Ignore extra env vars ) # Discord discord_bot_token: str = Field(..., description="Discord bot token") # API Keys cheshire_cat_api_key: str = Field(default="", description="Cheshire Cat API key (empty if no auth)") # Error Reporting error_webhook_url: Optional[str] = Field(default=None, description="Discord webhook for error notifications") # Owner owner_user_id: int = Field(default=209381657369772032, description="Bot owner Discord user ID") # ============================================ # Configuration Loader # ============================================ def load_config(config_path: str = None) -> AppConfig: """ Load configuration from YAML file. Args: config_path: Path to config.yaml (defaults to ../config.yaml from bot directory) Returns: AppConfig instance """ import yaml if config_path is None: # Default: try Docker path first, then fall back to relative path # In Docker, config.yaml is mounted at /app/config.yaml docker_config = Path("/app/config.yaml") if docker_config.exists(): config_path = docker_config else: # Not in Docker, go up one level from bot/ directory config_path = Path(__file__).parent.parent / "config.yaml" config_file = Path(config_path) if not config_file.exists(): # Fall back to default config if file doesn't exist print(f"āš ļø Config file not found: {config_file}") print("Using default configuration") return AppConfig() with open(config_file, "r") as f: config_data = yaml.safe_load(f) or {} return AppConfig(**config_data) def load_secrets() -> Secrets: """ Load secrets from environment variables (.env file). Returns: Secrets instance """ return Secrets() # ============================================ # Unified Configuration Instance # ============================================ # Load configuration at module import time CONFIG = load_config() SECRETS = load_secrets() # ============================================ # Config Manager Integration # ============================================ # Import config_manager for unified configuration with Web UI support try: from config_manager import config_manager HAS_CONFIG_MANAGER = True except ImportError: # Fallback if config_manager is not yet imported HAS_CONFIG_MANAGER = False config_manager = None # ============================================ # Backward Compatibility Globals # ============================================ # These provide a transition path from globals.py to config.py # These now support runtime overrides via config_manager # TODO: Gradually migrate all code to use CONFIG/SECRETS directly # Legacy globals (for backward compatibility) # These now support runtime overrides via config_manager def _get_config_value(static_value: Any, key_path: str, default: Any = None) -> Any: """Get configuration value with config_manager fallback.""" if HAS_CONFIG_MANAGER and config_manager: runtime_value = config_manager.get(key_path) return runtime_value if runtime_value is not None else static_value return static_value def _get_config_state(static_value: Any, state_key: str) -> Any: """Get configuration state from config_manager.""" if HAS_CONFIG_MANAGER and config_manager: state_value = config_manager.get_state(state_key) return state_value if state_value is not None else static_value return static_value # Service URLs DISCORD_BOT_TOKEN = SECRETS.discord_bot_token CHESHIRE_CAT_API_KEY = SECRETS.cheshire_cat_api_key CHESHIRE_CAT_URL = _get_config_value(CONFIG.cheshire_cat.url, "services.cheshire_cat.url", "http://cheshire-cat:80") USE_CHESHIRE_CAT = _get_config_value(CONFIG.cheshire_cat.enabled, "services.cheshire_cat.enabled", True) CHESHIRE_CAT_TIMEOUT = _get_config_value(CONFIG.cheshire_cat.timeout_seconds, "services.cheshire_cat.timeout_seconds", 120) LLAMA_URL = _get_config_value(CONFIG.services.url, "services.llama.url", "http://llama-swap:8080") LLAMA_AMD_URL = _get_config_value(CONFIG.services.amd_url, "services.llama.amd_url", "http://llama-swap-amd:8080") TEXT_MODEL = _get_config_value(CONFIG.models.text, "models.text", "llama3.1") VISION_MODEL = _get_config_value(CONFIG.models.vision, "models.vision", "vision") EVIL_TEXT_MODEL = _get_config_value(CONFIG.models.evil, "models.evil", "darkidol") JAPANESE_TEXT_MODEL = _get_config_value(CONFIG.models.japanese, "models.japanese", "swallow") OWNER_USER_ID = SECRETS.owner_user_id AUTONOMOUS_DEBUG = _get_config_value(CONFIG.autonomous.debug_mode, "autonomous.debug_mode", False) VOICE_DEBUG_MODE = _get_config_value(CONFIG.voice.debug_mode, "voice.debug_mode", False) LANGUAGE_MODE = _get_config_value(CONFIG.discord.language_mode, "discord.language_mode", "english") LOG_DIR = _get_config_value(CONFIG.memory.log_dir, "memory.log_dir", "/app/memory/logs") PREFER_AMD_GPU = _get_config_value(CONFIG.gpu.prefer_amd, "gpu.prefer_amd", False) AMD_MODELS_ENABLED = _get_config_value(CONFIG.gpu.amd_models_enabled, "gpu.amd_models_enabled", True) ERROR_WEBHOOK_URL = SECRETS.error_webhook_url # ============================================ # Validation & Health Check # ============================================ def validate_config() -> tuple[bool, list[str]]: """ Validate that all required configuration is present. Returns: Tuple of (is_valid, list_of_errors) """ errors = [] # Check secrets if not SECRETS.discord_bot_token or SECRETS.discord_bot_token == "your_discord_bot_token_here": errors.append("DISCORD_BOT_TOKEN not set or using placeholder value") # Validate Cheshire Cat config if CONFIG.cheshire_cat.enabled and not CONFIG.cheshire_cat.url: errors.append("Cheshire Cat enabled but URL not configured") return len(errors) == 0, errors def print_config_summary(): """Print a summary of current configuration (without secrets)""" print("\n" + "="*60) print("šŸŽµ Miku Bot Configuration Summary") print("="*60) print(f"\nšŸ“Š Configuration loaded from: config.yaml") print(f"šŸ” Secrets loaded from: .env") print(f"\nšŸ¤– Models:") print(f" - Text: {CONFIG.models.text}") print(f" - Vision: {CONFIG.models.vision}") print(f" - Evil: {CONFIG.models.evil}") print(f" - Japanese: {CONFIG.models.japanese}") print(f"\nšŸ”— Services:") print(f" - Llama: {CONFIG.services.url}") print(f" - Llama AMD: {CONFIG.services.amd_url}") print(f" - Cheshire Cat: {CONFIG.cheshire_cat.url} (enabled: {CONFIG.cheshire_cat.enabled})") print(f"\nāš™ļø Settings:") print(f" - Language Mode: {CONFIG.discord.language_mode}") print(f" - Autonomous Debug: {CONFIG.autonomous.debug_mode}") print(f" - Voice Debug: {CONFIG.voice.debug_mode}") print(f" - Prefer AMD GPU: {CONFIG.gpu.prefer_amd}") print(f"\nšŸ“ Secrets: {'āœ… Loaded' if SECRETS.discord_bot_token else 'āŒ Missing'}") print("\n" + "="*60 + "\n") # Auto-validate on import is_valid, validation_errors = validate_config() if not is_valid: print("āŒ Configuration Validation Failed:") for error in validation_errors: print(f" - {error}") print("\nPlease check your .env file and try again.") # Note: We don't exit here because the bot might be started in a different context # The calling code should check validate_config() if needed