package config import ( "fmt" "os" "strconv" "strings" "time" "github.com/joho/godotenv" "log/slog" ) const ( // Environment variable prefix for the application envPrefix = "JUKEBOX_" ) // Config holds all the configuration for the application type Config struct { // Discord configuration DiscordToken string DiscordGuildID string DiscordChannelID string // Subsonic configuration SubsonicServer string SubsonicUsername string SubsonicPassword string SubsonicVersion string // Jukebox configuration AudioVolume float64 TimeoutSec int } // Load loads the configuration from environment variables and .env file func Load() (*Config, error) { // Try to load .env file if err := godotenv.Load(); err != nil { slog.Info("No .env file found, using environment variables only") } else { slog.Info("Loaded environment variables from .env file") } // Load the raw values first rawGuildID := getEnv(envPrefix+"DISCORD_GUILD_ID", "") // Clean up Guild ID - ensure we have just the snowflake cleanGuildID := cleanSnowflake(rawGuildID) if cleanGuildID != rawGuildID && rawGuildID != "" { slog.Warn("Cleaned Discord Guild ID", "original", rawGuildID, "cleaned", cleanGuildID) } config := &Config{ // Discord DiscordToken: getEnv(envPrefix+"DISCORD_TOKEN", ""), DiscordGuildID: cleanGuildID, DiscordChannelID: getEnv(envPrefix+"DISCORD_CHANNEL_ID", ""), // Subsonic SubsonicServer: getEnv(envPrefix+"SUBSONIC_SERVER", ""), SubsonicUsername: getEnv(envPrefix+"SUBSONIC_USERNAME", ""), SubsonicPassword: getEnv(envPrefix+"SUBSONIC_PASSWORD", ""), SubsonicVersion: getEnv(envPrefix+"SUBSONIC_VERSION", "1.16.1"), // Jukebox AudioVolume: getEnvFloat(envPrefix+"AUDIO_VOLUME", 0.5), TimeoutSec: getEnvInt(envPrefix+"TIMEOUT_SEC", 30), } if err := config.validate(); err != nil { // Print helpful debug information PrintDebugInfo() return nil, err } return config, nil } // validate checks if the required configuration values are set func (c *Config) validate() error { if c.DiscordToken == "" { return errorMissingConfig("discord token") } if c.SubsonicServer == "" { return errorMissingConfig("subsonic server") } if c.SubsonicUsername == "" { return errorMissingConfig("subsonic username") } if c.SubsonicPassword == "" { return errorMissingConfig("subsonic password") } return nil } // errorMissingConfig returns a formatted error for missing configuration func errorMissingConfig(name string) error { return fmt.Errorf("%s is required", name) } // GetTimeout returns the timeout duration func (c *Config) GetTimeout() time.Duration { return time.Duration(c.TimeoutSec) * time.Second } // getEnv gets an environment variable or returns a default value func getEnv(key, defaultValue string) string { if value, exists := os.LookupEnv(key); exists { return value } return defaultValue } // getEnvInt gets an environment variable as an integer or returns a default value func getEnvInt(key string, defaultValue int) int { if value, exists := os.LookupEnv(key); exists { if intValue, err := strconv.Atoi(value); err == nil { return intValue } } return defaultValue } // getEnvFloat gets an environment variable as a float or returns a default value func getEnvFloat(key string, defaultValue float64) float64 { if value, exists := os.LookupEnv(key); exists { if floatValue, err := strconv.ParseFloat(value, 64); err == nil { return floatValue } } return defaultValue } // PrintDebugInfo prints helpful debugging information for environment variables func PrintDebugInfo() { slog.Info("=== Configuration Debug Information ===") slog.Info("Required environment variables:") checkEnvVar(envPrefix + "DISCORD_TOKEN") checkEnvVar(envPrefix + "DISCORD_GUILD_ID") checkEnvVar(envPrefix + "SUBSONIC_SERVER") checkEnvVar(envPrefix + "SUBSONIC_USERNAME") checkEnvVar(envPrefix + "SUBSONIC_PASSWORD") slog.Info("Optional environment variables:") checkEnvVar(envPrefix + "DISCORD_CHANNEL_ID") checkEnvVar(envPrefix + "SUBSONIC_VERSION") checkEnvVar(envPrefix + "AUDIO_VOLUME") checkEnvVar(envPrefix + "TIMEOUT_SEC") slog.Info("Troubleshooting tips:") slog.Info("1. Your .env file is in the correct directory") slog.Info("2. All variable names have the JUKEBOX_ prefix") slog.Info("3. There are no spaces around the = sign in your .env file") slog.Info(" Example: JUKEBOX_DISCORD_TOKEN=your_token_here") slog.Info("4. Your .env file has been loaded (check for errors above)") slog.Info("===================================") } // checkEnvVar checks if an environment variable is set and prints its status func checkEnvVar(name string) { if value, exists := os.LookupEnv(name); exists { // Mask sensitive values displayValue := value if contains(name, "TOKEN", "PASSWORD") { if len(displayValue) > 8 { displayValue = displayValue[:4] + "..." + displayValue[len(displayValue)-4:] } else { displayValue = "****" } } // Special handling for Guild ID to detect common issues if strings.Contains(name, "GUILD_ID") && strings.Contains(value, "=") { slog.Warn("Environment variable contains equals sign", "name", name, "raw_value", displayValue, "cleaned_value", cleanSnowflake(value)) } else { slog.Info("Environment variable check", "name", name, "value", displayValue, "status", "set") } } else { slog.Warn("Environment variable not set", "name", name) } } // contains checks if the string contains any of the substrings func contains(str string, substrings ...string) bool { for _, substring := range substrings { if strings.Contains(str, substring) { return true } } return false } // cleanSnowflake cleans a Discord snowflake (ID) from potential contamination // This handles cases where the ID might include the variable name or other text func cleanSnowflake(input string) string { // If the input contains an equals sign, it might be contaminated if strings.Contains(input, "=") { parts := strings.SplitN(input, "=", 2) if len(parts) == 2 { input = parts[1] // Take the part after the equals sign } } // Extract only digits var result strings.Builder for _, char := range input { if char >= '0' && char <= '9' { result.WriteRune(char) } } return result.String() }