Files
LaodingBot/internal/config/config.go

431 lines
13 KiB
Go

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
}