package config import ( "fmt" "log" "os" "path/filepath" "github.com/joho/godotenv" ) // Load reads configuration from environment variables, with optional .env file support. // It returns a fully populated Config or an error if the .env file failed to load. // Use ValidateOrFatal() after loading to enforce required fields. func Load() (*Config, error) { cfg := DefaultConfig() // Attempt to load .env file from multiple locations _ = loadEnvFile() // Merge environment variables over defaults cfg.mergeEnv() return cfg, nil } // loadEnvFile tries to load .env from the project root. // It does not return an error; missing .env is acceptable. func loadEnvFile() error { // Try current directory first if err := godotenv.Load(); err == nil { return nil } // Try relative to executable if exe, err := os.Executable(); err == nil { exeDir := filepath.Dir(exe) candidates := []string{ filepath.Join(exeDir, ".env"), filepath.Join(exeDir, "..", ".env"), filepath.Join(exeDir, "..", "..", ".env"), } for _, path := range candidates { if _, err := os.Stat(path); err == nil { _ = godotenv.Load(path) return nil } } } return fmt.Errorf(".env not found") } // mergeEnv reads all environment variables and overrides config defaults. func (c *Config) mergeEnv() { // Environment c.Env = getEnv("ENV", c.Env) c.LogLevel = getEnv("LOG_LEVEL", c.LogLevel) c.LogFmt = getEnv("LOG_FORMAT", c.LogFmt) // Server c.Port = getEnvInt("PORT", c.Port) c.Host = getEnv("HOST", c.Host) c.GinMode = getEnv("GIN_MODE", c.GinMode) // Database c.DatabaseURL = os.Getenv("DATABASE_URL") c.PostgresHost = getEnv("POSTGRES_HOST", c.PostgresHost) c.PostgresPort = getEnvInt("POSTGRES_PORT", c.PostgresPort) c.PostgresUser = getEnv("POSTGRES_USER", c.PostgresUser) c.PostgresPassword = getEnv("POSTGRES_PASSWORD", c.PostgresPassword) c.PostgresDB = getEnv("POSTGRES_DB", c.PostgresDB) c.PostgresSSLMode = getEnv("POSTGRES_SSLMODE", c.PostgresSSLMode) // NATS c.NATSUrl = getEnv("NATS_URL", c.NATSUrl) // Redis c.RedisHost = getEnv("REDIS_HOST", c.RedisHost) c.RedisPort = getEnvInt("REDIS_PORT", c.RedisPort) // LLM c.LLMBaseURL = getEnv("LLM_API_URL", c.LLMBaseURL) c.LLMAPIKey = getEnv("LLM_API_KEY", c.LLMAPIKey) c.LLMModel = getEnv("LLM_MODEL", c.LLMModel) c.LLMFallbackModel = getEnv("LLM_FALLBACK_MODEL", c.LLMFallbackModel) // Embedding c.EmbeddingAPIURL = getEnv("EMBEDDING_API_URL", c.EmbeddingAPIURL) c.EmbeddingModel = getEnv("EMBEDDING_MODEL", c.EmbeddingModel) c.EmbeddingAPIKey = getEnv("EMBEDDING_API_KEY", c.EmbeddingAPIKey) // Voice c.DashScopeAPIKey = getEnv("DASHSCOPE_API_KEY", c.DashScopeAPIKey) c.TTSProvider = getEnv("TTS_PROVIDER", c.TTSProvider) c.ASRProvider = getEnv("ASR_PROVIDER", c.ASRProvider) // Auth c.JWTSecret = getEnv("JWT_SECRET", c.JWTSecret) c.JWTExpiryHours = getEnvInt("JWT_EXPIRY_HOURS", c.JWTExpiryHours) c.AdminUsername = getEnv("ADMIN_USERNAME", c.AdminUsername) c.AdminPassword = getEnv("ADMIN_PASSWORD", c.AdminPassword) c.RegistrationOpen = getEnvBool("REGISTRATION_ENABLED", c.RegistrationOpen) // Internal Service c.InternalServiceToken = getEnv("INTERNAL_SERVICE_TOKEN", c.InternalServiceToken) // CORS c.AllowedOrigins = getEnvSlice("ALLOWED_ORIGINS", c.AllowedOrigins) // Background Thinking c.EnableBackgroundThinking = getEnvBool("ENABLE_BACKGROUND_THINKING", c.EnableBackgroundThinking) c.ThinkIntervalSec = getEnvInt("THINK_INTERVAL_SEC", c.ThinkIntervalSec) c.ThinkSilenceTimeoutSec = getEnvInt("THINK_SILENCE_TIMEOUT_SEC", c.ThinkSilenceTimeoutSec) // Platform Bridge c.PlatformBridgeEnabled = getEnvBool("PLATFORM_BRIDGE_ENABLED", c.PlatformBridgeEnabled) c.PlatformChannels = getEnv("PLATFORM_CHANNELS", c.PlatformChannels) // IoT c.IoTProvider = getEnv("IOT_PROVIDER", c.IoTProvider) c.IoTServiceURL = getEnv("IOT_SERVICE_URL", c.IoTServiceURL) // RAG & Data c.PersonaDir = getEnv("PERSONA_DIR", c.PersonaDir) c.KnowledgeDir = getEnv("KNOWLEDGE_DIR", c.KnowledgeDir) c.DataDir = getEnv("DATA_DIR", c.DataDir) // Search c.SearXNGURL = getEnv("SEARXNG_URL", c.SearXNGURL) // Host Exec c.HostExecBackend = getEnv("HOST_EXEC_BACKEND", c.HostExecBackend) // Caddy (production) c.Domain = getEnv("DOMAIN", c.Domain) c.ACMEEmail = getEnv("ACME_EMAIL", c.ACMEEmail) // Ethend (deprecated) c.EthendPort = getEnvInt("ETHEND_PORT", c.EthendPort) // WebSocket c.WSMaxConnections = getEnvInt("WS_MAX_CONNECTIONS", c.WSMaxConnections) c.SessionIdleTimeoutMin = getEnvInt("SESSION_IDLE_TIMEOUT_MIN", c.SessionIdleTimeoutMin) // Timezone c.TZ = getEnv("TZ", c.TZ) } // logSanitized prints the loaded config, with sensitive fields redacted. func (c *Config) LogSanitized() { log.Printf("[config] ENV=%s LOG_LEVEL=%s LOG_FORMAT=%s", c.Env, c.LogLevel, c.LogFmt) log.Printf("[config] PORT=%d GIN_MODE=%s", c.Port, c.GinMode) log.Printf("[config] DB_HOST=%s DB_PORT=%d DB_USER=%s DB_NAME=%s", c.PostgresHost, c.PostgresPort, c.PostgresUser, c.PostgresDB) log.Printf("[config] NATS_URL=%s REDIS=%s:%d", c.NATSUrl, c.RedisHost, c.RedisPort) log.Printf("[config] LLM_URL=%s LLM_MODEL=%s FALLBACK=%s", c.LLMBaseURL, c.LLMModel, c.LLMFallbackModel) log.Printf("[config] LLM_API_KEY=**** (len=%d)", len(c.LLMAPIKey)) if c.EmbeddingAPIURL != "" { log.Printf("[config] EMBEDDING_URL=%s MODEL=%s", c.EmbeddingAPIURL, c.EmbeddingModel) } else { log.Printf("[config] EMBEDDING=disabled (using n-gram fallback)") } log.Printf("[config] IOT_PROVIDER=%s", c.IoTProvider) log.Printf("[config] TTS=%s ASR=%s", c.TTSProvider, c.ASRProvider) log.Printf("[config] BACKGROUND_THINKING=%v INTERVAL=%ds SILENCE=%ds", c.EnableBackgroundThinking, c.ThinkIntervalSec, c.ThinkSilenceTimeoutSec) log.Printf("[config] JWT_SECRET=**** (len=%d)", len(c.JWTSecret)) log.Printf("[config] ADMIN=%s REGISTRATION=%v", c.AdminUsername, c.RegistrationOpen) }