package config import ( "bufio" "errors" "fmt" "os" "path/filepath" "strconv" "strings" ) type Config struct { MessageChannel string LogLevel string SoulPath string SkillsDir string ReactMaxSteps int Telegram TelegramConfig Feishu FeishuConfig LLM LLMConfig Security SecurityConfig SQLitePath string } type TelegramConfig struct { Token string PollTimeoutSeconds int } type FeishuConfig struct { AppID string AppSecret string VerifyToken string ListenAddr string EventPath string } type LLMConfig struct { BaseURL string APIKey string Model string } type SecurityConfig struct { AllowedDirs []string AllowedCommands []string WorkDir string } func Load() (Config, error) { if err := preloadEnvFiles(); err != nil { return Config{}, err } cfg := Config{ MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"), LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"), SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), "./bot_context/soul.md"), SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), "./skills"), ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 4), Telegram: TelegramConfig{ Token: strings.TrimSpace(os.Getenv("TELEGRAM_BOT_TOKEN")), PollTimeoutSeconds: intFromEnv("TELEGRAM_POLL_TIMEOUT_SECONDS", 30), }, Feishu: FeishuConfig{ AppID: strings.TrimSpace(os.Getenv("FEISHU_APP_ID")), AppSecret: strings.TrimSpace(os.Getenv("FEISHU_APP_SECRET")), VerifyToken: strings.TrimSpace(os.Getenv("FEISHU_VERIFY_TOKEN")), ListenAddr: defaultIfEmpty(os.Getenv("FEISHU_LISTEN_ADDR"), ":8080"), EventPath: defaultIfEmpty(os.Getenv("FEISHU_EVENT_PATH"), "/feishu/events"), }, LLM: LLMConfig{ BaseURL: strings.TrimRight(defaultIfEmpty(os.Getenv("LLM_BASE_URL"), "https://api.openai.com/v1"), "/"), APIKey: strings.TrimSpace(os.Getenv("LLM_API_KEY")), Model: defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini"), }, SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), "./data/laodingbot.db"), Security: SecurityConfig{ AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), "./workspace,./data")), AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail")), WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), "./workspace"), }, } cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel)) cfg.LogLevel = strings.ToLower(strings.TrimSpace(cfg.LogLevel)) if cfg.MessageChannel != "telegram" && cfg.MessageChannel != "feishu" { return Config{}, fmt.Errorf("MESSAGE_CHANNEL must be telegram or feishu") } if cfg.LogLevel != "debug" && cfg.LogLevel != "info" && cfg.LogLevel != "warn" && cfg.LogLevel != "error" { return Config{}, fmt.Errorf("LOG_LEVEL must be debug, info, warn, or error") } if cfg.ReactMaxSteps < 1 || cfg.ReactMaxSteps > 8 { return Config{}, fmt.Errorf("REACT_MAX_STEPS must be between 1 and 8") } if cfg.MessageChannel == "telegram" { if cfg.Telegram.Token == "" { return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN is required when MESSAGE_CHANNEL=telegram") } if cfg.Feishu.AppID != "" || cfg.Feishu.AppSecret != "" { return Config{}, fmt.Errorf("feishu config must be empty when MESSAGE_CHANNEL=telegram") } } if cfg.MessageChannel == "feishu" { if cfg.Feishu.AppID == "" || cfg.Feishu.AppSecret == "" { return Config{}, fmt.Errorf("FEISHU_APP_ID and FEISHU_APP_SECRET are required when MESSAGE_CHANNEL=feishu") } if cfg.Telegram.Token != "" { return Config{}, fmt.Errorf("TELEGRAM_BOT_TOKEN must be empty when MESSAGE_CHANNEL=feishu") } } if cfg.LLM.APIKey == "" { return Config{}, fmt.Errorf("LLM_API_KEY is required") } return cfg, nil } func preloadEnvFiles() error { paths := []string{} if explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE")); explicit != "" { paths = append(paths, explicit) } paths = append(paths, "configs/env", ".env") for _, p := range paths { if err := loadEnvFile(p); err != nil { if errors.Is(err, os.ErrNotExist) { continue } return fmt.Errorf("load env file %s failed: %w", p, err) } } return nil } func loadEnvFile(path string) error { absPath, err := filepath.Abs(path) if err != nil { absPath = path } f, err := os.Open(absPath) if err != nil { return err } defer f.Close() scanner := bufio.NewScanner(f) for scanner.Scan() { line := strings.TrimSpace(scanner.Text()) if line == "" || strings.HasPrefix(line, "#") { continue } line = strings.TrimPrefix(line, "export ") idx := strings.Index(line, "=") if idx <= 0 { continue } key := strings.TrimSpace(line[:idx]) if key == "" { continue } val := strings.TrimSpace(line[idx+1:]) if len(val) >= 2 { if (val[0] == '"' && val[len(val)-1] == '"') || (val[0] == '\'' && val[len(val)-1] == '\'') { val = val[1 : len(val)-1] } } if _, exists := os.LookupEnv(key); !exists { if err := os.Setenv(key, val); err != nil { return err } } } return scanner.Err() } func defaultIfEmpty(v, d string) string { v = strings.TrimSpace(v) if v == "" { return d } return v } func intFromEnv(name string, d int) int { raw := strings.TrimSpace(os.Getenv(name)) if raw == "" { return d } v, err := strconv.Atoi(raw) if err != nil { return d } return v } func splitCSV(raw string) []string { parts := strings.Split(raw, ",") out := make([]string, 0, len(parts)) for _, p := range parts { v := strings.TrimSpace(p) if v != "" { out = append(out, v) } } return out }