package nostr import ( "context" "encoding/json" "fmt" "gitcitadel-online/internal/logger" "github.com/nbd-wtf/go-nostr" "github.com/nbd-wtf/go-nostr/nip19" ) // ShortenNpub shortens an npub to npub1...xyz format (first 8 + last 4 characters) func ShortenNpub(npub string) string { if len(npub) <= 12 { return npub } return npub[:8] + "..." + npub[len(npub)-4:] } // PubkeyToNpub converts a hex pubkey to npub format func PubkeyToNpub(pubkey string) (string, error) { npub, err := nip19.EncodePublicKey(pubkey) if err != nil { return "", fmt.Errorf("failed to encode pubkey to npub: %w", err) } return npub, nil } // ShortenPubkey converts a pubkey to shortened npub format func ShortenPubkey(pubkey string) string { npub, err := PubkeyToNpub(pubkey) if err != nil { // Fallback: shorten hex pubkey if len(pubkey) > 12 { return pubkey[:8] + "..." + pubkey[len(pubkey)-4:] } return pubkey } return ShortenNpub(npub) } // Profile represents a parsed kind 0 profile event type Profile struct { Event *nostr.Event Pubkey string Name string DisplayName string About string Picture string Website string NIP05 string Lud16 string Banner string RawJSON map[string]interface{} // Store all other fields } // FetchProfile fetches a kind 0 profile event from an npub func (c *Client) FetchProfile(ctx context.Context, npub string) (*Profile, error) { // Decode npub to get pubkey prefix, value, err := nip19.Decode(npub) if err != nil { return nil, fmt.Errorf("failed to decode npub: %w", err) } if prefix != "npub" { return nil, fmt.Errorf("invalid npub prefix: expected 'npub', got '%s'", prefix) } pubkey, ok := value.(string) if !ok { return nil, fmt.Errorf("failed to parse npub: unexpected type") } profiles, err := c.FetchProfilesBatch(ctx, []string{pubkey}) if err != nil { return nil, err } profile, exists := profiles[pubkey] if !exists { return nil, fmt.Errorf("profile not found for pubkey") } return profile, nil } // FetchProfilesBatch fetches kind 0 profile events for multiple pubkeys in a single query // Returns a map of pubkey -> Profile func (c *Client) FetchProfilesBatch(ctx context.Context, pubkeys []string) (map[string]*Profile, error) { if len(pubkeys) == 0 { return make(map[string]*Profile), nil } // Deduplicate pubkeys pubkeySet := make(map[string]bool) uniquePubkeys := make([]string, 0, len(pubkeys)) for _, pk := range pubkeys { if pk != "" && !pubkeySet[pk] { pubkeySet[pk] = true uniquePubkeys = append(uniquePubkeys, pk) } } if len(uniquePubkeys) == 0 { return make(map[string]*Profile), nil } // Query ALL kind 0 events for these authors in one batch filter := nostr.Filter{ Kinds: []int{KindProfile}, Authors: uniquePubkeys, Limit: len(uniquePubkeys), // One profile per author } logger.WithFields(map[string]interface{}{ "pubkeys": len(uniquePubkeys), }).Debug("Batch fetching profiles") // Fetch all profile events from fallback relays only (not The Forest) profileRelays := c.GetProfileRelays() if len(profileRelays) == 0 { // Fallback: if no profile relays configured, use all relays profileRelays = c.GetRelays() } events, err := c.FetchEventsFromRelays(ctx, filter, profileRelays) if err != nil { return nil, fmt.Errorf("failed to fetch profile events: %w", err) } // Parse profiles and map by pubkey profiles := make(map[string]*Profile) for _, event := range events { profile, err := ParseProfile(event) if err != nil { logger.WithFields(map[string]interface{}{ "pubkey": event.PubKey, "event_id": event.ID, }).Warnf("Error parsing profile: %v", err) continue } // Keep the newest profile if we have multiple for the same pubkey existing, exists := profiles[event.PubKey] if !exists || event.CreatedAt > existing.Event.CreatedAt { profiles[event.PubKey] = profile } } logger.WithFields(map[string]interface{}{ "requested": len(uniquePubkeys), "fetched": len(profiles), }).Debug("Batch profile fetch completed") return profiles, nil } // ParseProfile parses a kind 0 profile event func ParseProfile(event *nostr.Event) (*Profile, error) { if event.Kind != KindProfile { return nil, fmt.Errorf("expected kind %d, got %d", KindProfile, event.Kind) } profile := &Profile{ Event: event, Pubkey: event.PubKey, RawJSON: make(map[string]interface{}), } // Parse JSON content var content map[string]interface{} if err := json.Unmarshal([]byte(event.Content), &content); err != nil { return nil, fmt.Errorf("failed to parse profile JSON: %w", err) } // Extract common fields if name, ok := content["name"].(string); ok { profile.Name = name } if displayName, ok := content["display_name"].(string); ok { profile.DisplayName = displayName } if about, ok := content["about"].(string); ok { profile.About = about } if picture, ok := content["picture"].(string); ok { profile.Picture = picture } if website, ok := content["website"].(string); ok { profile.Website = website } if nip05, ok := content["nip05"].(string); ok { profile.NIP05 = nip05 } if lud16, ok := content["lud16"].(string); ok { profile.Lud16 = lud16 } if banner, ok := content["banner"].(string); ok { profile.Banner = banner } // Store all fields in RawJSON for access to any other fields profile.RawJSON = content return profile, nil }