- budget relay queries across a shared 20s deadline (5s per relay) and dedupe events by ID across relays - surface 'could not connect to any relay' when no query succeeded and abort early on relay-connect and stream-cancel errors - guard zero/negative limits across all fetchers - GetActor falls through relays and rejects empty profile metadata; SearchActors propagates relay errors instead of silently returning none - NewNostrClient validates relay ws://wss:// schemes, trims, and dedupes
372 lines
9.6 KiB
Go
372 lines
9.6 KiB
Go
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)
|
|
}
|