enki b3204ea07a
Some checks are pending
CI Pipeline / Run Tests (push) Waiting to run
CI Pipeline / Lint Code (push) Waiting to run
CI Pipeline / Security Scan (push) Waiting to run
CI Pipeline / Build Docker Images (push) Blocked by required conditions
CI Pipeline / E2E Tests (push) Blocked by required conditions
first commit
2025-08-18 00:40:15 -07:00

274 lines
6.6 KiB
Go

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] + "..."
}