package config import ( "bufio" "errors" "fmt" "os" "path/filepath" "strconv" "strings" ) type Config struct { MessageChannel string LogLevel string SoulPath string SkillsDir string ReactMaxSteps int ToolCallTimeoutSec int ToolOutputMaxChars int EnableCapabilityGap bool AutoSkillDir string GapDraftTriggerCount int GapClusterLookbackHours int Telegram TelegramConfig Feishu FeishuConfig WebUI WebUIConfig LLM LLMConfig Security SecurityConfig WebSearch WebSearchConfig SQLitePath string } type TelegramConfig struct { Token string PollTimeoutSeconds int } type FeishuConfig struct { AppID string AppSecret string VerifyToken string ListenAddr string EventPath string } type WebUIConfig struct { ListenAddr string MaxUploadBytes int64 } type LLMConfig struct { BaseURL string APIKey string Model string FileModel string FilePromptMode string } type SecurityConfig struct { AllowedDirs []string AllowedCommands []string WorkDir string } type WebSearchConfig struct { Engine string // "duckduckgo" or "brave" APIKey string } func Load() (Config, error) { agentWorkspaceDir := resolveAgentWorkspaceDir() if err := preloadEnvFiles(); err != nil { return Config{}, err } defaultWorkSubdir := filepath.Join(agentWorkspaceDir, "workspace") defaultDataDir := filepath.Join(agentWorkspaceDir, "data") cfg := Config{ MessageChannel: defaultIfEmpty(os.Getenv("MESSAGE_CHANNEL"), "telegram"), LogLevel: defaultIfEmpty(os.Getenv("LOG_LEVEL"), "info"), SoulPath: defaultIfEmpty(os.Getenv("SOUL_PATH"), filepath.Join(agentWorkspaceDir, "bot_context", "soul.md")), SkillsDir: defaultIfEmpty(os.Getenv("SKILLS_DIR"), filepath.Join(agentWorkspaceDir, "skills")), ReactMaxSteps: intFromEnv("REACT_MAX_STEPS", 0), ToolCallTimeoutSec: intFromEnv("TOOL_CALL_TIMEOUT_SEC", 15), ToolOutputMaxChars: intFromEnv("TOOL_OUTPUT_MAX_CHARS", 4000), EnableCapabilityGap: boolFromEnv("ENABLE_CAPABILITY_GAP", true), AutoSkillDir: defaultIfEmpty(os.Getenv("AUTO_SKILL_DIR"), filepath.Join(agentWorkspaceDir, "skills")), GapDraftTriggerCount: intFromEnv("GAP_DRAFT_TRIGGER_COUNT", 3), GapClusterLookbackHours: intFromEnv("GAP_CLUSTER_LOOKBACK_HOURS", 168), 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"), }, WebUI: WebUIConfig{ ListenAddr: defaultIfEmpty(os.Getenv("WEBUI_LISTEN_ADDR"), ":8090"), MaxUploadBytes: int64(intFromEnv("WEBUI_MAX_UPLOAD_MB", 20)) * 1024 * 1024, }, 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"), FileModel: defaultIfEmpty(os.Getenv("LLM_FILE_MODEL"), defaultIfEmpty(os.Getenv("LLM_MODEL"), "gpt-4o-mini")), FilePromptMode: normalizeFilePromptMode(defaultIfEmpty(os.Getenv("LLM_FILE_PROMPT_MODE"), "user_content_file_parts")), }, SQLitePath: defaultIfEmpty(os.Getenv("SQLITE_PATH"), filepath.Join(defaultDataDir, "laodingbot.db")), WebSearch: WebSearchConfig{ Engine: defaultIfEmpty(os.Getenv("WEB_SEARCH_ENGINE"), "duckduckgo"), APIKey: strings.TrimSpace(os.Getenv("WEB_SEARCH_API_KEY")), }, Security: SecurityConfig{ AllowedDirs: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_DIRS"), strings.Join([]string{agentWorkspaceDir, defaultDataDir, defaultWorkSubdir}, ","))), AllowedCommands: splitCSV(defaultIfEmpty(os.Getenv("ALLOWED_COMMANDS"), "pwd,ls,cat,echo,grep,find,head,tail,go")), WorkDir: defaultIfEmpty(os.Getenv("WORK_DIR"), defaultWorkSubdir), }, } cfg.MessageChannel = strings.ToLower(strings.TrimSpace(cfg.MessageChannel)) cfg.LogLevel = strings.ToLower(strings.TrimSpace(cfg.LogLevel)) if cfg.MessageChannel != "telegram" && cfg.MessageChannel != "feishu" && cfg.MessageChannel != "webui" { return Config{}, fmt.Errorf("MESSAGE_CHANNEL must be telegram, feishu, or webui") } 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.ToolCallTimeoutSec < 1 || cfg.ToolCallTimeoutSec > 300 { return Config{}, fmt.Errorf("TOOL_CALL_TIMEOUT_SEC must be between 1 and 300") } if cfg.ToolOutputMaxChars < 256 || cfg.ToolOutputMaxChars > 200000 { return Config{}, fmt.Errorf("TOOL_OUTPUT_MAX_CHARS must be between 256 and 200000") } if cfg.GapDraftTriggerCount < 1 || cfg.GapDraftTriggerCount > 100 { return Config{}, fmt.Errorf("GAP_DRAFT_TRIGGER_COUNT must be between 1 and 100") } if cfg.GapClusterLookbackHours < 1 || cfg.GapClusterLookbackHours > 24*365 { return Config{}, fmt.Errorf("GAP_CLUSTER_LOOKBACK_HOURS must be between 1 and 8760") } if cfg.WebUI.MaxUploadBytes < 1024 || cfg.WebUI.MaxUploadBytes > 200*1024*1024 { return Config{}, fmt.Errorf("WEBUI_MAX_UPLOAD_MB must be between 1 and 200") } 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.MessageChannel == "webui" { if strings.TrimSpace(cfg.WebUI.ListenAddr) == "" { return Config{}, fmt.Errorf("WEBUI_LISTEN_ADDR is required when MESSAGE_CHANNEL=webui") } } if cfg.LLM.APIKey == "" { return Config{}, fmt.Errorf("LLM_API_KEY is required") } if cfg.LLM.FilePromptMode != "user_content_file_parts" && cfg.LLM.FilePromptMode != "system_fileid_uri" { return Config{}, fmt.Errorf("LLM_FILE_PROMPT_MODE must be one of: user_content_file_parts, system_fileid_uri") } cfg.SoulPath = resolvePathInWorkspace(cfg.SoulPath, agentWorkspaceDir) cfg.SkillsDir = resolvePathInWorkspace(cfg.SkillsDir, agentWorkspaceDir) cfg.AutoSkillDir = resolvePathInWorkspace(cfg.AutoSkillDir, agentWorkspaceDir) cfg.SQLitePath = resolvePathInWorkspace(cfg.SQLitePath, agentWorkspaceDir) cfg.Security.WorkDir = resolvePathInWorkspace(cfg.Security.WorkDir, agentWorkspaceDir) cfg.Security.AllowedDirs = resolveDirsInWorkspace(cfg.Security.AllowedDirs, agentWorkspaceDir) cfg.Security.AllowedDirs = ensureAllowedDirs(cfg.Security.AllowedDirs, filepath.Clean(agentWorkspaceDir), filepath.Join(agentWorkspaceDir, "skills"), filepath.Join(agentWorkspaceDir, "data"), filepath.Join(agentWorkspaceDir, "workspace"), ) cfg.Security.AllowedCommands = ensureAllowedCommands(cfg.Security.AllowedCommands, "go", "curl", "curl.exe") return cfg, nil } func preloadEnvFiles() error { explicit := strings.TrimSpace(os.Getenv("CONFIG_ENV_FILE")) if explicit != "" { if err := tryLoadEnvFile(explicit, true); err != nil { return fmt.Errorf("load env file %s failed: %w", explicit, err) } } workspaceDir := resolveAgentWorkspaceDir() workspaceEnv := filepath.Join(workspaceDir, "configs", "env") workspaceDotEnv := filepath.Join(workspaceDir, ".env") if err := tryLoadEnvFile(workspaceEnv, true); err != nil { return fmt.Errorf("load env file %s failed: %w", workspaceEnv, err) } if err := tryLoadEnvFile(workspaceDotEnv, true); err != nil { return fmt.Errorf("load env file %s failed: %w", workspaceDotEnv, err) } if err := tryLoadEnvFile("configs/env", false); err != nil { return fmt.Errorf("load env file %s failed: %w", "configs/env", err) } if err := tryLoadEnvFile(".env", false); err != nil { return fmt.Errorf("load env file %s failed: %w", ".env", err) } return nil } func tryLoadEnvFile(path string, override bool) error { err := loadEnvFile(path, override) if err != nil && errors.Is(err, os.ErrNotExist) { return nil } return err } func resolveAgentWorkspaceDir() string { raw := strings.TrimSpace(os.Getenv("AGENT_WORKSPACE_DIR")) if raw == "" { raw = filepath.Join(".", "workspace", "agent_runtime") } abs, err := filepath.Abs(raw) if err != nil { return raw } return abs } func resolvePathInWorkspace(path, workspaceDir string) string { path = strings.TrimSpace(path) if path == "" { return path } if filepath.IsAbs(path) { return filepath.Clean(path) } return filepath.Clean(filepath.Join(workspaceDir, path)) } func resolveDirsInWorkspace(dirs []string, workspaceDir string) []string { out := make([]string, 0, len(dirs)) for _, d := range dirs { resolved := resolvePathInWorkspace(d, workspaceDir) if strings.TrimSpace(resolved) != "" { out = append(out, resolved) } } if len(out) == 0 { out = append(out, filepath.Clean(workspaceDir)) } return out } func ensureAllowedDirs(existing []string, dirs ...string) []string { set := map[string]struct{}{} out := make([]string, 0, len(existing)+len(dirs)) for _, d := range existing { clean := filepath.Clean(strings.TrimSpace(d)) if clean == "" { continue } if _, ok := set[clean]; ok { continue } set[clean] = struct{}{} out = append(out, clean) } for _, d := range dirs { clean := filepath.Clean(strings.TrimSpace(d)) if clean == "" { continue } if _, ok := set[clean]; ok { continue } set[clean] = struct{}{} out = append(out, clean) } return out } func ensureAllowedCommands(existing []string, commands ...string) []string { set := map[string]struct{}{} out := make([]string, 0, len(existing)+len(commands)) for _, c := range existing { cmd := strings.ToLower(strings.TrimSpace(c)) if cmd == "" { continue } if _, ok := set[cmd]; ok { continue } set[cmd] = struct{}{} out = append(out, cmd) } for _, c := range commands { cmd := strings.ToLower(strings.TrimSpace(c)) if cmd == "" { continue } if _, ok := set[cmd]; ok { continue } set[cmd] = struct{}{} out = append(out, cmd) } return out } func loadEnvFile(path string, override bool) 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 !override { if _, exists := os.LookupEnv(key); exists { continue } } 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 boolFromEnv(name string, d bool) bool { raw := strings.ToLower(strings.TrimSpace(os.Getenv(name))) if raw == "" { return d } if raw == "1" || raw == "true" || raw == "yes" || raw == "on" { return true } if raw == "0" || raw == "false" || raw == "no" || raw == "off" { return false } return d } 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 } func normalizeFilePromptMode(v string) string { v = strings.ToLower(strings.TrimSpace(v)) if v == "" { return "user_content_file_parts" } if v == "system_fileid" || v == "system_fileid_url" || v == "system_fileid_uri" { return "system_fileid_uri" } return v }