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 PIPlanMaxChars int // PI 规划工具专用输出上限,独立于 TOOL_OUTPUT_MAX_CHARS EnableCapabilityGap bool AutoSkillDir string GapDraftTriggerCount int GapClusterLookbackHours int Telegram TelegramConfig Feishu FeishuConfig WebUI WebUIConfig LLM LLMConfig Security SecurityConfig WebSearch WebSearchConfig Gitea GiteaConfig 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 ExposeReasoning bool } type LLMConfig struct { BaseURL string APIKey string Model string FileModel string RouterModel string // 轻量路由模型,用于技能意图路由;为空则仅用关键词匹配 } type SecurityConfig struct { AllowedDirs []string AllowedCommands []string WorkDir string } type WebSearchConfig struct { Engine string // "duckduckgo" or "brave" APIKey string } type GiteaConfig struct { BaseURL string // Gitea 实例地址 Token string // Personal Access Token Owner string // 仓库所有者 Repo 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), PIPlanMaxChars: intFromEnv("PI_PLAN_MAX_CHARS", 40000), 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, ExposeReasoning: boolFromEnv("WEBUI_EXPOSE_REASONING", false), }, 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")), RouterModel: strings.TrimSpace(os.Getenv("LLM_ROUTER_MODEL")), }, 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), }, Gitea: GiteaConfig{ BaseURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GITEA_BASE_URL")), "/"), Token: strings.TrimSpace(os.Getenv("GITEA_TOKEN")), Owner: strings.TrimSpace(os.Getenv("GITEA_OWNER")), Repo: strings.TrimSpace(os.Getenv("GITEA_REPO")), }, } 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.PIPlanMaxChars < 1000 || cfg.PIPlanMaxChars > 500000 { return Config{}, fmt.Errorf("PI_PLAN_MAX_CHARS must be between 1000 and 500000") } 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") } 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 }