import { appendDedupe, dedupe, NostrPrefix, removeUndefined, sanitizeRelayUrl, unwrap } from "@snort/shared" import type EventKind from "./event-kind" import { EventExt, NostrLink, type ToNostrEventTag } from "." import type { ReqFilter, TaggedNostrEvent } from "./nostr" import type { RequestRouter } from "./request-router" import type { NostrEvent } from "nostr-social-graph" /** * A built REQ filter ready for sending to System */ export interface BuiltRawReqFilter { filters: Array relay: string // Use set sync from an existing set of events syncFrom?: Array } export interface RequestBuilderOptions { /** * Dont send CLOSE directly after EOSE and allow events to stream in */ leaveOpen?: boolean /** * Pick N relays per pubkey when using outbox strategy */ outboxPickN?: number /** * How many milli-seconds to wait to allow grouping */ groupingDelay?: number /** * Replace the query every time a change in the query is detected * * eg. Live stream chat reactions */ replaceable?: boolean /*** * If this query should skip the cache system */ skipCache?: boolean /** * Enable sync module (negentropy/range-sync) * When true, will use sync protocol if cache data is available * When false, will send plain REQ without sync * Default: false */ useSyncModule?: boolean /** * Extra events to include in the store, added automatically */ extraEvents?: Array } /** * Nostr REQ builder */ export class RequestBuilder { id: string instance: string #builders: Array #options?: RequestBuilderOptions constructor(id: string) { this.instance = crypto.randomUUID() this.id = id this.#builders = [] } get numFilters() { return this.#builders.length } get filterBuilders() { return this.#builders } get options() { return this.#options } clear() { this.#builders = [] this.#options = undefined } /** * Add another request builders filters to this one */ add(other: RequestBuilder) { this.#builders.push(...other.#builders) } withFilter() { const ret = new RequestFilterBuilder() this.#builders.push(ret) return ret } withBareFilter(f: ReqFilter) { const ret = new RequestFilterBuilder(f) this.#builders.push(ret) return ret } withOptions(opt: RequestBuilderOptions) { this.#options = { ...this.#options, ...opt, } return this } /** * Set relays for all filters in this builder */ withRelays(relays: Array) { for (const builder of this.#builders) { builder.relay(relays) } return this } buildRaw(): Array { return this.#builders.map(f => f.filter) } } /** * Builder class for a single request filter */ export class RequestFilterBuilder { #filter: ReqFilter constructor(f?: ReqFilter) { this.#filter = f ?? {} } get filter() { return { ...this.#filter, } } /** * Use a specific relay for this request filter */ relay(u: string | Array) { const relays = Array.isArray(u) ? u : [u] this.#filter.relays = appendDedupe(this.#filter.relays, removeUndefined(relays.map(a => sanitizeRelayUrl(a)))) // make sure we dont have an empty array if (this.#filter.relays?.length === 0) { this.#filter.relays = undefined } return this } ids(ids: Array) { this.#filter.ids = appendDedupe(this.#filter.ids, ids) return this } authors(authors?: Array) { if (!authors) return this this.#filter.authors = appendDedupe( this.#filter.authors, authors.filter(a => a.length === 64), ) return this } kinds(kinds?: Array) { if (!kinds) return this this.#filter.kinds = appendDedupe(this.#filter.kinds, kinds) return this } since(since?: number) { if (!since) return this this.#filter.since = since return this } until(until?: number) { if (!until) return this this.#filter.until = until return this } limit(limit?: number) { if (!limit) return this this.#filter.limit = limit return this } tag(key: "e" | "p" | "d" | "t" | "r" | "a" | "g" | string, value?: Array) { if (!value) return this this.#filter[`#${key}`] = appendDedupe(this.#filter[`#${key}`] as Array, value) return this } /** * Query by a nostr tag */ tags(tags: Array) { for (const tag of tags) { const tt = tag.toEventTag() if (tt) { this.tag(tt[0], [tt[1]]) } } return this } search(keyword?: string) { if (!keyword) return this this.#filter.search = keyword return this } /** * Get event from link */ link(link: NostrLink | ToNostrEventTag) { if (NostrLink.isInstance(link)) { if (link.type === NostrPrefix.Address) { this.tag("d", [link.id]) .kinds([unwrap(link.kind)]) .authors([unwrap(link.author)]) } else { // dont query with ids when looking for replaceable events if (!link.kind || !EventExt.isReplaceable(link.kind)) { this.ids([link.id]) } if (link.author) { this.authors([link.author]) } if (link.kind !== undefined) { this.kinds([link.kind]) } } link.relays?.forEach(v => this.relay(v)) } else { const [k, v] = link.toEventTag()! this.#filter[`#${k}`] = appendDedupe(this.#filter[`#${k}`] as Array, [v]) } return this } /** * Get replies to link with e/a tags */ replyToLink(links: Array) { const types = dedupe(links.map(a => a.type)) if (types.length > 1) throw new Error("Cannot add multiple links of different kinds") const tags = removeUndefined(links.map(a => a.toEventTag())) this.tag( tags[0][0], tags.map(v => v[1]), ) this.relay(removeUndefined(links.flatMap(a => a.relays))) return this } /** * Build/expand this filter into a set of relay specific queries */ build(model?: RequestRouter, options?: RequestBuilderOptions): Array { if (model) { return model.forRequest(this.filter, options?.outboxPickN) } return [this.filter] } }