package nostr import ( "context" "encoding/json" "fmt" "net/url" "strings" "time" "fiatjaf.com/nostr" "fiatjaf.com/nostr/nip19" "github.com/nacorid/naco-api/internal/actor" ) const ( // relayQueryTimeout bounds connect + subscribe + collect against a single // relay. The websocket dial, subscription handshake and event collection all // share this budget. relayQueryTimeout = 5 * time.Second // maxTotalRelayTime caps the fan-out across all relays when the caller's // context carries no deadline of its own. Without it a long relay list could // multiply the per-relay budget unboundedly. maxTotalRelayTime = 20 * time.Second ) type Client struct { relays []string } func NewNostrClient(relays []string) (*Client, error) { seen := make(map[string]struct{}, len(relays)) cleaned := make([]string, 0, len(relays)) for _, r := range relays { r = strings.TrimSpace(r) if r == "" { continue } u, err := url.Parse(r) if err != nil || (u.Scheme != "ws" && u.Scheme != "wss") { return nil, fmt.Errorf("invalid relay URL %q: must be a ws:// or wss:// endpoint", r) } if _, dup := seen[r]; dup { continue } seen[r] = struct{}{} cleaned = append(cleaned, r) } if len(cleaned) == 0 { return nil, fmt.Errorf("at least one relay is required") } return &Client{relays: cleaned}, nil } type Post struct { ID string Author string Content string CreatedAt time.Time } // queryBudget returns a context bounded to maxTotalRelayTime when ctx has no // deadline, so the relay fan-out cannot run away across many relays. Callers // must invoke the returned cancel. func (c *Client) queryBudget(ctx context.Context) (context.Context, context.CancelFunc) { if _, ok := ctx.Deadline(); ok { return context.WithCancel(ctx) } return context.WithTimeout(ctx, maxTotalRelayTime) } // collectFromRelay connects to url within ctx, subscribes with filter and // appends matching events to posts (deduplicated by event id) until the limit is // reached, EOSE arrives, or ctx is done. It reports whether the relay was // actually queryable (connected + subscribed), letting callers distinguish a // total relay outage from a legitimate empty result. func (c *Client) collectFromRelay(ctx context.Context, url string, filter nostr.Filter, limit int, posts *[]*Post, seen map[string]struct{}) (used bool) { relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { return false } defer relay.Close() sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{}) if err != nil { return false } defer sub.Unsub() for { select { case <-sub.EndOfStoredEvents: return true case ev, ok := <-sub.Events: if !ok { return true } id := ev.ID.Hex() if _, dup := seen[id]; dup { continue } seen[id] = struct{}{} *posts = append(*posts, &Post{ ID: id, Author: ev.PubKey.Hex(), Content: ev.Content, CreatedAt: ev.CreatedAt.Time(), }) if len(*posts) >= limit { return true } case <-ctx.Done(): return true } } } // collectPosts subscribes to filter across every relay, stopping once limit posts // are collected or the query context is done. It returns the deduplicated posts, // or an error if no relay could be queried at all or the operation was aborted. func (c *Client) collectPosts(ctx context.Context, filter nostr.Filter, limit int) ([]*Post, error) { posts := make([]*Post, 0, limit) seen := make(map[string]struct{}) queryCtx, cancel := c.queryBudget(ctx) defer cancel() var anyUsed bool for _, url := range c.relays { if queryCtx.Err() != nil { break } relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout) used := c.collectFromRelay(relayCtx, url, filter, limit, &posts, seen) relayCancel() anyUsed = anyUsed || used if len(posts) >= limit { break } } if err := queryCtx.Err(); err != nil { return nil, fmt.Errorf("nostr query aborted: %w", err) } if !anyUsed { return nil, fmt.Errorf("could not connect to any relay") } return posts, nil } func (c *Client) GetPost(ctx context.Context, id string) (*Post, error) { eid, err := resolveEventID(id) if err != nil { return nil, err } filter := nostr.Filter{ IDs: []nostr.ID{eid}, Kinds: []nostr.Kind{1}, } posts, err := c.collectPosts(ctx, filter, 1) if err != nil { return nil, err } if len(posts) == 0 { return nil, fmt.Errorf("post %q not found across configured relays", id) } return posts[0], nil } func (c *Client) GetLatestNPosts(ctx context.Context, author string, limit int) ([]*Post, error) { if limit <= 0 { return nil, nil } pubkey, err := resolvePubkey(author) if err != nil { return nil, err } filter := nostr.Filter{ Authors: []nostr.PubKey{pubkey}, Kinds: []nostr.Kind{1}, Limit: limit, } return c.collectPosts(ctx, filter, limit) } func (c *Client) GetPostsInTimeframe(ctx context.Context, author string, start, end time.Time, limit int) ([]*Post, error) { if limit <= 0 { return nil, nil } pubkey, err := resolvePubkey(author) if err != nil { return nil, err } filter := nostr.Filter{ Authors: []nostr.PubKey{pubkey}, Kinds: []nostr.Kind{1}, Since: nostr.Timestamp(start.Unix()), Until: nostr.Timestamp(end.Unix()), Limit: limit, } return c.collectPosts(ctx, filter, limit) } func (c *Client) GetActor(ctx context.Context, author string) (*actor.Profile, error) { pubkey, err := resolvePubkey(author) if err != nil { return nil, err } npub := nip19.EncodeNpub(pubkey) filter := nostr.Filter{ Authors: []nostr.PubKey{pubkey}, Kinds: []nostr.Kind{0}, Limit: 1, } queryCtx, cancel := c.queryBudget(ctx) defer cancel() var anyUsed bool for _, url := range c.relays { if queryCtx.Err() != nil { break } relayCtx, relayCancel := context.WithTimeout(queryCtx, relayQueryTimeout) profile, used := c.actorFromRelay(relayCtx, url, filter, npub) relayCancel() anyUsed = anyUsed || used if profile != nil { return profile, nil } } if err := queryCtx.Err(); err != nil { return nil, fmt.Errorf("nostr query aborted: %w", err) } if !anyUsed { return nil, fmt.Errorf("could not connect to any relay") } return nil, fmt.Errorf("no profile metadata found for %q", author) } // actorFromRelay looks up one kind-0 metadata event on url. It returns a // non-nil profile when a usable metadata event was found. used reports whether // the relay was queried at all (so a fully unreachable relay list is // distinguishable from an author without metadata). func (c *Client) actorFromRelay(ctx context.Context, url string, filter nostr.Filter, npub string) (profile *actor.Profile, used bool) { relay, err := nostr.RelayConnect(ctx, url, nostr.RelayOptions{}) if err != nil { return nil, false } defer relay.Close() sub, err := relay.Subscribe(ctx, filter, nostr.SubscriptionOptions{}) if err != nil { return nil, false } defer sub.Unsub() select { case <-sub.EndOfStoredEvents: return nil, true case ev, ok := <-sub.Events: if !ok { return nil, true } return profileFromEvent(ev, npub), true case <-ctx.Done(): return nil, true } } // profileFromEvent builds an actor.Profile from a kind-0 metadata event, or nil // when the content is not valid JSON or carries no usable fields. func profileFromEvent(ev nostr.Event, npub string) *actor.Profile { var meta struct { Name string `json:"name"` DisplayName string `json:"display_name"` About string `json:"about"` Picture string `json:"picture"` Website string `json:"website"` } if err := json.Unmarshal([]byte(ev.Content), &meta); err != nil { return nil } if meta.Name == "" && meta.DisplayName == "" && meta.About == "" && meta.Picture == "" { return nil } displayName := meta.DisplayName if displayName == "" { displayName = meta.Name } return &actor.Profile{ Platform: "Nostr", Handle: npub, DisplayName: displayName, AvatarURL: meta.Picture, Bio: meta.About, URL: "https://njump.me/" + npub, } } func (c *Client) SearchActors(ctx context.Context, query string, limit int) ([]*actor.Profile, error) { if _, err := resolvePubkey(query); err != nil { // Not a pubkey we can resolve (e.g. a name query). Treat it as no match // rather than an error so a cross-platform search isn't failed by a // non-nostr query. return nil, nil } prof, err := c.GetActor(ctx, query) if err != nil { return nil, err } return []*actor.Profile{prof}, nil } func resolvePubkey(actor string) (nostr.PubKey, error) { prefix, value, err := nip19.Decode(actor) if err == nil { switch prefix { case "npub": if pk, ok := value.(nostr.PubKey); ok { return pk, nil } case "nprofile": if pp, ok := value.(nostr.ProfilePointer); ok { return pp.PublicKey, nil } } return nostr.PubKey{}, fmt.Errorf("unsupported nostr entity %q", prefix) } if len(actor) == 64 { pk, e := nostr.PubKeyFromHex(actor) if e != nil { return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, e) } return pk, nil } return nostr.PubKey{}, fmt.Errorf("invalid nostr author %q: %w", actor, err) } func resolveEventID(id string) (nostr.ID, error) { prefix, value, err := nip19.Decode(id) if err == nil { switch prefix { case "note": if eid, ok := value.(nostr.ID); ok { return eid, nil } case "nevent": if ep, ok := value.(nostr.EventPointer); ok { return ep.ID, nil } } return nostr.ID{}, fmt.Errorf("unsupported nostr entity %q", prefix) } if len(id) == 64 { eid, e := nostr.IDFromHex(id) if e != nil { return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, e) } return eid, nil } return nostr.ID{}, fmt.Errorf("invalid nostr event id %q: %w", id, err) }