// internal/nostr/relay/manager.go package relay import ( "context" "fmt" "sync" "time" "github.com/nbd-wtf/go-nostr" "go.uber.org/zap" ) // Manager handles connections to Nostr relays type Manager struct { relays map[string]*nostr.Relay readURLs []string writeURLs []string mu sync.RWMutex logger *zap.Logger } // NewManager creates a new relay manager func NewManager(logger *zap.Logger) *Manager { if logger == nil { // Create a default logger if none is provided var err error logger, err = zap.NewProduction() if err != nil { // If we can't create a logger, use a no-op logger logger = zap.NewNop() } } return &Manager{ relays: make(map[string]*nostr.Relay), readURLs: []string{}, writeURLs: []string{}, logger: logger, } } // AddRelay adds a relay to the manager and connects to it func (m *Manager) AddRelay(url string, read, write bool) error { m.mu.Lock() defer m.mu.Unlock() // Check if we're already connected to this relay if relay, exists := m.relays[url]; exists { // Update read/write flags if read && !isInSlice(url, m.readURLs) { m.readURLs = append(m.readURLs, url) } else if !read { m.readURLs = removeFromSlice(url, m.readURLs) } if write && !isInSlice(url, m.writeURLs) { m.writeURLs = append(m.writeURLs, url) } else if !write { m.writeURLs = removeFromSlice(url, m.writeURLs) } // If we don't need to read or write to this relay, close the connection if !read && !write { relay.Close() delete(m.relays, url) } return nil } // Only connect if we need to read or write if !read && !write { return nil } // Connect to the relay ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() relay, err := nostr.RelayConnect(ctx, url) if err != nil { return fmt.Errorf("failed to connect to relay %s: %w", url, err) } // Store the relay m.relays[url] = relay // Update read/write lists if read { m.readURLs = append(m.readURLs, url) } if write { m.writeURLs = append(m.writeURLs, url) } return nil } // RemoveRelay removes a relay from the manager and closes the connection func (m *Manager) RemoveRelay(url string) { m.mu.Lock() defer m.mu.Unlock() // Check if we're connected to this relay if relay, exists := m.relays[url]; exists { relay.Close() delete(m.relays, url) } // Remove from read/write lists m.readURLs = removeFromSlice(url, m.readURLs) m.writeURLs = removeFromSlice(url, m.writeURLs) } // PublishEvent publishes an event to all write relays func (m *Manager) PublishEvent(ctx context.Context, event *nostr.Event) ([]string, error) { m.mu.RLock() writeURLs := make([]string, len(m.writeURLs)) copy(writeURLs, m.writeURLs) m.mu.RUnlock() if len(writeURLs) == 0 { return nil, fmt.Errorf("no write relays configured") } // Keep track of successful publishes var successful []string var wg sync.WaitGroup var mu sync.Mutex // Publish to all write relays in parallel for _, url := range writeURLs { wg.Add(1) go func(relayURL string) { defer wg.Done() m.mu.RLock() relay, exists := m.relays[relayURL] m.mu.RUnlock() if !exists { m.logger.Warn("Relay not found in connection pool", zap.String("relay", relayURL)) return } // Create a new context with timeout publishCtx, cancel := context.WithTimeout(ctx, 15*time.Second) defer cancel() // Publish the event err := relay.Publish(publishCtx, *event) status := err == nil // Assuming no error means success if err != nil { m.logger.Warn("Failed to publish event", zap.String("relay", relayURL), zap.Error(err)) return } // Check if the publish was successful if status { mu.Lock() successful = append(successful, relayURL) mu.Unlock() m.logger.Info("Event published successfully", zap.String("relay", relayURL), zap.String("event_id", event.ID)) } else { m.logger.Warn("Relay rejected event", zap.String("relay", relayURL), zap.String("event_id", event.ID)) } }(url) } // Wait for all publish operations to complete wg.Wait() if len(successful) == 0 { return nil, fmt.Errorf("failed to publish event to any relay") } return successful, nil } // SubscribeToEvents subscribes to events matching the given filters func (m *Manager) SubscribeToEvents(ctx context.Context, filters []nostr.Filter) (<-chan *nostr.Event, error) { m.mu.RLock() readURLs := make([]string, len(m.readURLs)) copy(readURLs, m.readURLs) m.mu.RUnlock() if len(readURLs) == 0 { return nil, fmt.Errorf("no read relays configured") } // Create a channel for events eventChan := make(chan *nostr.Event) // Create a new context with timeout for subscriptions subCtx, cancel := context.WithCancel(ctx) // Keep track of subscriptions var subscriptions []*nostr.Subscription // Subscribe to all read relays var wg sync.WaitGroup for _, url := range readURLs { wg.Add(1) go func(relayURL string) { defer wg.Done() m.mu.RLock() relay, exists := m.relays[relayURL] m.mu.RUnlock() if !exists { m.logger.Warn("Relay not found in connection pool", zap.String("relay", relayURL)) return } // Subscribe to events sub, err := relay.Subscribe(subCtx, filters) if err != nil { m.logger.Warn("Failed to subscribe to relay", zap.String("relay", relayURL), zap.Error(err)) return } m.mu.Lock() subscriptions = append(subscriptions, sub) m.mu.Unlock() // Handle events go func() { for ev := range sub.Events { select { case eventChan <- ev: // Event sent to caller case <-subCtx.Done(): return } } }() }(url) } // Wait for all subscriptions to be set up wg.Wait() // Return a cleanup function go func() { <-ctx.Done() cancel() close(eventChan) // Close all subscriptions for _, sub := range subscriptions { sub.Unsub() } }() return eventChan, nil } // Close closes all relay connections func (m *Manager) Close() { m.mu.Lock() defer m.mu.Unlock() for url, relay := range m.relays { relay.Close() delete(m.relays, url) } m.readURLs = []string{} m.writeURLs = []string{} } // GetRelays returns the list of connected relays func (m *Manager) GetRelays() map[string]struct{ Read, Write bool } { m.mu.RLock() defer m.mu.RUnlock() result := make(map[string]struct{ Read, Write bool }) for url := range m.relays { result[url] = struct { Read bool Write bool }{ Read: isInSlice(url, m.readURLs), Write: isInSlice(url, m.writeURLs), } } return result } // Helper functions // isInSlice checks if a string is in a slice func isInSlice(s string, slice []string) bool { for _, item := range slice { if item == s { return true } } return false } // removeFromSlice removes a string from a slice func removeFromSlice(s string, slice []string) []string { var result []string for _, item := range slice { if item != s { result = append(result, item) } } return result }