Files
miku-discord/bot/config.py

293 lines
10 KiB
Python
Raw Permalink Normal View History

Implement comprehensive config system and clean up codebase Major changes: - Add Pydantic-based configuration system (bot/config.py, bot/config_manager.py) - Add config.yaml with all service URLs, models, and feature flags - Fix config.yaml path resolution in Docker (check /app/config.yaml first) - Remove Fish Audio API integration (tested feature that didn't work) - Remove hardcoded ERROR_WEBHOOK_URL, import from config instead - Add missing Pydantic models (LogConfigUpdateRequest, LogFilterUpdateRequest) - Enable Cheshire Cat memory system by default (USE_CHESHIRE_CAT=true) - Add .env.example template with all required environment variables - Add setup.sh script for user-friendly initialization - Update docker-compose.yml with proper env file mounting - Update .gitignore for config files and temporary files Config system features: - Static configuration from config.yaml - Runtime overrides from config_runtime.yaml - Environment variables for secrets (.env) - Web UI integration via config_manager - Graceful fallback to defaults Secrets handling: - Move ERROR_WEBHOOK_URL from hardcoded to .env - Add .env.example with all placeholder values - Document all required secrets - Fish API key and voice ID removed from .env Documentation: - CONFIG_README.md - Configuration system guide - CONFIG_SYSTEM_COMPLETE.md - Implementation summary - FISH_API_REMOVAL_COMPLETE.md - Removal record - SECRETS_CONFIGURED.md - Secrets setup record - BOT_STARTUP_FIX.md - Pydantic model fixes - MIGRATION_CHECKLIST.md - Setup checklist - WEB_UI_INTEGRATION_COMPLETE.md - Web UI config guide - Updated readmes/README.md with new features
2026-02-15 19:51:00 +02:00
"""
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