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
274 lines
6.6 KiB
Go
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] + "..."
|
|
} |