// Package bridge provides configuration management for platform adapters. // Configs are stored as JSON on disk and loaded only when created via the admin panel. // Supports hot-reload: the router watches the config file and reloads adapters on change. package bridge import ( "encoding/json" "fmt" "git.yeij.top/AskaEth/Cyrene_v2/internal/logger" "os" "path/filepath" "sync" "time" ) // AdapterConfig holds configuration for a single platform adapter. type AdapterConfig struct { Name string `json:"name"` Type string `json:"type"` // "qq-obv11" | "telegram" | "discord" | "webhook" Enabled bool `json:"enabled"` Settings map[string]string `json:"settings"` CreatedAt string `json:"created_at"` UpdatedAt string `json:"updated_at"` } // BridgeConfig is the top-level configuration file structure. type BridgeConfig struct { Version string `json:"version"` UpdatedAt string `json:"updated_at"` Adapters []AdapterConfig `json:"adapters"` } // ConfigStore manages bridge configurations with file persistence. type ConfigStore struct { mu sync.RWMutex path string config *BridgeConfig onChange func(*BridgeConfig) // Called when config is loaded or reloaded stopCh chan struct{} } // NewConfigStore creates a bridge config store. Path is the config file location. // If the file doesn't exist, no config is created — it will be created on first save via admin panel. func NewConfigStore(path string, onChange func(*BridgeConfig)) *ConfigStore { s := &ConfigStore{ path: path, config: &BridgeConfig{Version: "1.0", Adapters: []AdapterConfig{}}, onChange: onChange, stopCh: make(chan struct{}), } // Try to load existing file if _, err := os.Stat(path); err == nil { s.load() if s.onChange != nil { s.onChange(s.config) } } return s } // StartWatching begins polling the config file for changes (hot-reload). func (s *ConfigStore) StartWatching(interval time.Duration) { var lastMod time.Time if info, err := os.Stat(s.path); err == nil { lastMod = info.ModTime() } go func() { ticker := time.NewTicker(interval) defer ticker.Stop() for { select { case <-s.stopCh: return case <-ticker.C: info, err := os.Stat(s.path) if err != nil { continue } if info.ModTime().After(lastMod) { lastMod = info.ModTime() s.mu.Lock() if s.load() { logger.Infof("[bridge] config hot-reloaded (%d adapters)", len(s.config.Adapters)) if s.onChange != nil { s.onChange(s.config) } } s.mu.Unlock() } } } }() } // Stop stops the file watcher. func (s *ConfigStore) Stop() { close(s.stopCh) } // GetAdapters returns all configured adapters. func (s *ConfigStore) GetAdapters() []AdapterConfig { s.mu.RLock() defer s.mu.RUnlock() result := make([]AdapterConfig, len(s.config.Adapters)) copy(result, s.config.Adapters) return result } // GetAdapter returns a single adapter by name. func (s *ConfigStore) GetAdapter(name string) (*AdapterConfig, error) { s.mu.RLock() defer s.mu.RUnlock() for _, a := range s.config.Adapters { if a.Name == name { ac := a return &ac, nil } } return nil, fmt.Errorf("adapter %s not found", name) } // SaveAdapter creates or updates an adapter config. func (s *ConfigStore) SaveAdapter(cfg AdapterConfig) error { s.mu.Lock() defer s.mu.Unlock() now := time.Now().Format(time.RFC3339) cfg.UpdatedAt = now if cfg.CreatedAt == "" { cfg.CreatedAt = now } found := false for i, a := range s.config.Adapters { if a.Name == cfg.Name { // Preserve creation time if a.CreatedAt != "" { cfg.CreatedAt = a.CreatedAt } s.config.Adapters[i] = cfg found = true break } } if !found { s.config.Adapters = append(s.config.Adapters, cfg) } s.config.UpdatedAt = now return s.persist() } // DeleteAdapter removes an adapter config. func (s *ConfigStore) DeleteAdapter(name string) error { s.mu.Lock() defer s.mu.Unlock() filtered := make([]AdapterConfig, 0) for _, a := range s.config.Adapters { if a.Name != name { filtered = append(filtered, a) } } s.config.Adapters = filtered s.config.UpdatedAt = time.Now().Format(time.RFC3339) return s.persist() } // Count returns the number of enabled adapters. func (s *ConfigStore) Count() int { s.mu.RLock() defer s.mu.RUnlock() n := 0 for _, a := range s.config.Adapters { if a.Enabled { n++ } } return n } // load reads the config file from disk. Returns true if loaded successfully. func (s *ConfigStore) load() bool { data, err := os.ReadFile(s.path) if err != nil { return false } var cfg BridgeConfig if err := json.Unmarshal(data, &cfg); err != nil { logger.Infof("[bridge] config parse error: %v", err) return false } if cfg.Adapters == nil { cfg.Adapters = []AdapterConfig{} } s.config = &cfg return true } // persist writes the current config to disk. func (s *ConfigStore) persist() error { data, err := json.MarshalIndent(s.config, "", " ") if err != nil { return fmt.Errorf("marshal config: %w", err) } // Ensure parent dir exists os.MkdirAll(filepath.Dir(s.path), 0o755) if err := os.WriteFile(s.path, data, 0o644); err != nil { return fmt.Errorf("write config: %w", err) } return nil }