package profile import ( "context" "encoding/json" "fmt" "log" "sync" "time" "github.com/nbd-wtf/go-nostr" ) // ProfileMetadata represents user profile information type ProfileMetadata struct { Name string `json:"name"` DisplayName string `json:"display_name"` About string `json:"about"` Picture string `json:"picture"` Banner string `json:"banner"` Website string `json:"website"` Nip05 string `json:"nip05"` LUD16 string `json:"lud16"` } // RelaySet represents a user's relay configuration (NIP-65) type RelaySet struct { Read []string `json:"read"` Write []string `json:"write"` } // ProfileFetcher handles fetching user profiles from their relay sets type ProfileFetcher struct { defaultRelays []string cache map[string]*CachedProfile cacheMutex sync.RWMutex cacheTimeout time.Duration } // CachedProfile represents a cached user profile type CachedProfile struct { Profile *ProfileMetadata RelaySet *RelaySet FetchedAt time.Time } // NewProfileFetcher creates a new profile fetcher func NewProfileFetcher(defaultRelays []string) *ProfileFetcher { return &ProfileFetcher{ defaultRelays: defaultRelays, cache: make(map[string]*CachedProfile), cacheTimeout: 30 * time.Minute, // Cache profiles for 30 minutes } } // GetUserProfile fetches a user's profile metadata using their relay set func (pf *ProfileFetcher) GetUserProfile(pubkeyHex string) (*ProfileMetadata, error) { // Check cache first pf.cacheMutex.RLock() if cached, exists := pf.cache[pubkeyHex]; exists { if time.Since(cached.FetchedAt) < pf.cacheTimeout { pf.cacheMutex.RUnlock() return cached.Profile, nil } } pf.cacheMutex.RUnlock() // Fetch relay set first (NIP-65) relaySet, err := pf.fetchRelaySet(pubkeyHex) if err != nil { log.Printf("Failed to fetch relay set for %s: %v", pubkeyHex[:8], err) relaySet = &RelaySet{ Read: pf.defaultRelays, Write: pf.defaultRelays, } } // Fetch profile from relay set profile, err := pf.fetchProfileFromRelays(pubkeyHex, relaySet.Read) if err != nil { log.Printf("Failed to fetch profile for %s: %v", pubkeyHex[:8], err) return nil, err } // Cache the result pf.cacheMutex.Lock() pf.cache[pubkeyHex] = &CachedProfile{ Profile: profile, RelaySet: relaySet, FetchedAt: time.Now(), } pf.cacheMutex.Unlock() return profile, nil } // fetchRelaySet discovers a user's relay set using NIP-65 func (pf *ProfileFetcher) fetchRelaySet(pubkeyHex string) (*RelaySet, error) { ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() // Try to fetch relay list from default relays for _, relayURL := range pf.defaultRelays { relay, err := nostr.RelayConnect(ctx, relayURL) if err != nil { continue } // Request relay list event (kind 10002 - NIP-65) filter := nostr.Filter{ Authors: []string{pubkeyHex}, Kinds: []int{10002}, // NIP-65 relay list Limit: 1, } sub, err := relay.Subscribe(ctx, []nostr.Filter{filter}) if err != nil { relay.Close() continue } select { case event := <-sub.Events: relay.Close() return pf.parseRelaySet(event), nil case <-time.After(5 * time.Second): relay.Close() continue } } return nil, fmt.Errorf("no relay set found") } // parseRelaySet parses NIP-65 relay list event func (pf *ProfileFetcher) parseRelaySet(event *nostr.Event) *RelaySet { relaySet := &RelaySet{ Read: []string{}, Write: []string{}, } for _, tag := range event.Tags { if len(tag) >= 2 && tag[0] == "r" { relayURL := tag[1] // Default to read+write if no marker specified if len(tag) == 2 { relaySet.Read = append(relaySet.Read, relayURL) relaySet.Write = append(relaySet.Write, relayURL) } else if len(tag) >= 3 { marker := tag[2] if marker == "read" || marker == "" { relaySet.Read = append(relaySet.Read, relayURL) } if marker == "write" || marker == "" { relaySet.Write = append(relaySet.Write, relayURL) } } } } // If no relays found, use defaults if len(relaySet.Read) == 0 { relaySet.Read = pf.defaultRelays } if len(relaySet.Write) == 0 { relaySet.Write = pf.defaultRelays } return relaySet } // fetchProfileFromRelays fetches user profile (kind 0) from their relay set func (pf *ProfileFetcher) fetchProfileFromRelays(pubkeyHex string, relays []string) (*ProfileMetadata, error) { ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) defer cancel() // Try each relay until we get a profile for _, relayURL := range relays { relay, err := nostr.RelayConnect(ctx, relayURL) if err != nil { continue } // Request profile event (kind 0) filter := nostr.Filter{ Authors: []string{pubkeyHex}, Kinds: []int{0}, // Profile metadata Limit: 1, } sub, err := relay.Subscribe(ctx, []nostr.Filter{filter}) if err != nil { relay.Close() continue } select { case event := <-sub.Events: relay.Close() return pf.parseProfile(event), nil case <-time.After(5 * time.Second): relay.Close() continue } } return nil, fmt.Errorf("no profile found") } // parseProfile parses a kind 0 profile event func (pf *ProfileFetcher) parseProfile(event *nostr.Event) *ProfileMetadata { var profile ProfileMetadata if err := json.Unmarshal([]byte(event.Content), &profile); err != nil { log.Printf("Failed to parse profile content: %v", err) return &ProfileMetadata{ Name: fmt.Sprintf("User %s", event.PubKey[:8]), } } // Set fallback name if empty if profile.Name == "" && profile.DisplayName == "" { profile.Name = fmt.Sprintf("User %s", event.PubKey[:8]) } return &profile } // GetBatchProfiles fetches profiles for multiple users efficiently func (pf *ProfileFetcher) GetBatchProfiles(pubkeyHexList []string) map[string]*ProfileMetadata { results := make(map[string]*ProfileMetadata) var wg sync.WaitGroup resultMutex := sync.Mutex{} // Limit concurrent requests semaphore := make(chan struct{}, 5) for _, pubkey := range pubkeyHexList { wg.Add(1) go func(pk string) { defer wg.Done() semaphore <- struct{}{} defer func() { <-semaphore }() profile, err := pf.GetUserProfile(pk) if err == nil && profile != nil { resultMutex.Lock() results[pk] = profile resultMutex.Unlock() } }(pubkey) } wg.Wait() return results } // GetDisplayName returns the best display name for a user func (pf *ProfileFetcher) GetDisplayName(pubkeyHex string) string { profile, err := pf.GetUserProfile(pubkeyHex) if err != nil || profile == nil { return pubkeyHex[:8] + "..." } if profile.DisplayName != "" { return profile.DisplayName } if profile.Name != "" { return profile.Name } return pubkeyHex[:8] + "..." }