216 lines
5.5 KiB
Go
216 lines
5.5 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
|
|
|
|
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
|
|
}
|