package main import ( "encoding/json" "fmt" "os" "path/filepath" "strings" ) type MCPServer struct { Name string `json:"name"` URL string `json:"url"` AuthorizationToken string `json:"authorization_token,omitempty"` AllowedTools []string `json:"allowed_tools,omitempty"` } const ( ThinkingModeAdaptive = "adaptive" ThinkingModeDisabled = "disabled" ThinkingDisplaySummarized = "summarized" ThinkingDisplayOmitted = "omitted" ) type BotConfig struct { ID string `json:"id"` TelegramToken string `json:"telegram_token"` MemorySize int `json:"memory_size"` MessagePerHour int `json:"messages_per_hour"` MessagePerDay int `json:"messages_per_day"` TempBanDuration string `json:"temp_ban_duration"` Model string `json:"model"` Temperature *float32 `json:"temperature,omitempty"` MaxTokens int `json:"max_tokens,omitempty"` Thinking string `json:"thinking,omitempty"` ThinkingDisplay string `json:"thinking_display,omitempty"` SystemPrompts map[string]string `json:"system_prompts"` Active bool `json:"active"` OwnerTelegramID int64 `json:"owner_telegram_id"` AnthropicAPIKey string `json:"anthropic_api_key"` ElevenLabsAPIKey string `json:"elevenlabs_api_key"` ElevenLabsVoiceID string `json:"elevenlabs_voice_id"` ElevenLabsModel string `json:"elevenlabs_model"` DebugScreening bool `json:"debug_screening"` MCPServers []MCPServer `json:"mcp_servers,omitempty"` ConfigFilePath string `json:"-"` } func validateConfigPath(configDir, filename string) (string, error) { configDir = filepath.Clean(configDir) filename = filepath.Clean(filename) absConfigDir, err := filepath.Abs(configDir) if err != nil { return "", fmt.Errorf("failed to get absolute path for config directory: %w", err) } fullPath := filepath.Join(absConfigDir, filename) absPath, err := filepath.Abs(fullPath) if err != nil { return "", fmt.Errorf("failed to get absolute path for config file: %w", err) } rel, err := filepath.Rel(absConfigDir, absPath) if err != nil || strings.HasPrefix(rel, "..") || strings.Contains(rel, "..") { return "", fmt.Errorf("invalid config path: file must be within the config directory") } if filepath.Ext(absPath) != ".json" { return "", fmt.Errorf("invalid file extension: must be .json") } return absPath, nil } func loadAllConfigs(dir string) ([]BotConfig, error) { var configs []BotConfig ids := make(map[string]bool) tokens := make(map[string]bool) files, err := os.ReadDir(dir) if err != nil { return nil, fmt.Errorf("failed to read config directory: %w", err) } for _, file := range files { if filepath.Ext(file.Name()) == ".json" { validPath, err := validateConfigPath(dir, file.Name()) if err != nil { InfoLogger.Printf("Invalid config path for %s: %v", file.Name(), err) continue } config, err := loadConfig(validPath) if err != nil { InfoLogger.Printf("Failed to load config %s: %v", validPath, err) continue } if !config.Active { InfoLogger.Printf("Skipping inactive bot: %s", config.ID) continue } if err := validateConfig(&config, ids, tokens); err != nil { InfoLogger.Printf("Config validation failed for %s: %v", validPath, err) continue } if config.Thinking == ThinkingModeAdaptive && config.MaxTokens > 0 && config.MaxTokens < 4000 { InfoLogger.Printf("[%s] thinking=adaptive with max_tokens=%d: thinking tokens count toward max_tokens; consider >= 4000", config.ID, config.MaxTokens) } config.ConfigFilePath = validPath configs = append(configs, config) } } if len(configs) == 0 { return nil, fmt.Errorf("no valid configs found") } return configs, nil } func validateConfig(config *BotConfig, ids, tokens map[string]bool) error { if config.ID == "" { return fmt.Errorf("missing 'id' field") } if _, exists := ids[config.ID]; exists { return fmt.Errorf("duplicate bot id '%s'", config.ID) } ids[config.ID] = true if config.TelegramToken == "" { return fmt.Errorf("missing 'telegram_token' field") } if _, exists := tokens[config.TelegramToken]; exists { return fmt.Errorf("duplicate telegram_token") } tokens[config.TelegramToken] = true if config.Model == "" { return fmt.Errorf("missing 'model' field") } switch config.Thinking { case "", ThinkingModeAdaptive, ThinkingModeDisabled: default: return fmt.Errorf("invalid 'thinking' value %q: must be %q or %q (or omitted)", config.Thinking, ThinkingModeAdaptive, ThinkingModeDisabled) } switch config.ThinkingDisplay { case "": case ThinkingDisplaySummarized, ThinkingDisplayOmitted: if config.Thinking != ThinkingModeAdaptive { return fmt.Errorf("'thinking_display' requires 'thinking': %q (the API rejects display with thinking disabled)", ThinkingModeAdaptive) } default: return fmt.Errorf("invalid 'thinking_display' value %q: must be %q or %q (or omitted)", config.ThinkingDisplay, ThinkingDisplaySummarized, ThinkingDisplayOmitted) } if config.MaxTokens < 0 { return fmt.Errorf("'max_tokens' must be greater than 0 when set") } if config.MessagePerHour <= 0 { return fmt.Errorf("'messages_per_hour' must be greater than 0") } if config.MessagePerDay <= 0 { return fmt.Errorf("'messages_per_day' must be greater than 0") } return nil } func loadConfig(filename string) (BotConfig, error) { var config BotConfig file, err := os.OpenFile(filepath.Clean(filename), os.O_RDONLY, 0) if err != nil { return config, fmt.Errorf("failed to open config file %s: %w", filename, err) } defer func() { if err := file.Close(); err != nil { InfoLogger.Printf("Failed to close config file: %v", err) } }() decoder := json.NewDecoder(file) if err := decoder.Decode(&config); err != nil { return config, fmt.Errorf("failed to decode JSON from %s: %w", filename, err) } return config, nil } func (c *BotConfig) Reload(configDir, filename string) error { validPath, err := validateConfigPath(configDir, filename) if err != nil { return fmt.Errorf("invalid config path: %w", err) } cleanPath := filepath.Clean(validPath) file, err := os.OpenFile(cleanPath, os.O_RDONLY, 0) if err != nil { return fmt.Errorf("failed to open config file %s: %w", cleanPath, err) } defer func() { if err := file.Close(); err != nil { InfoLogger.Printf("Failed to close config file: %v", err) } }() decoder := json.NewDecoder(file) if err := decoder.Decode(c); err != nil { return fmt.Errorf("failed to decode JSON from %s: %w", validPath, err) } return nil } func (c *BotConfig) PersistModel(newModel string) error { if c.ConfigFilePath == "" { return fmt.Errorf("config file path not set; cannot persist model") } data, err := os.ReadFile(c.ConfigFilePath) if err != nil { return fmt.Errorf("failed to read config for update: %w", err) } var raw map[string]any if err := json.Unmarshal(data, &raw); err != nil { return fmt.Errorf("failed to parse config for update: %w", err) } raw["model"] = newModel updated, err := json.MarshalIndent(raw, "", "\t") if err != nil { return fmt.Errorf("failed to re-encode config: %w", err) } if err := os.WriteFile(c.ConfigFilePath, updated, 0600); err != nil { return fmt.Errorf("failed to write config: %w", err) } c.Model = newModel return nil }