Files
LaodingBot/internal/config/config.go

216 lines
5.5 KiB
Go
Raw Normal View History

2026-02-21 23:01:39 +08:00
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
}