You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 

254 lines
8.1 KiB

/**
* NIP-A7 Spells: parse and execute kind 777 events as portable relay query filters.
*/
import { ExtendedKind, FAST_WRITE_RELAY_URLS } from '@/constants'
import { getRelayUrlsWithFavoritesFastReadAndInbox } from '@/lib/favorites-feed-relays'
import { tagNameEquals } from '@/lib/tag'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import type { Event } from 'nostr-tools'
import type { Filter } from 'nostr-tools'
const RELATIVE_UNIT_SECONDS: Record<string, number> = {
s: 1,
m: 60,
h: 3600,
d: 86400,
w: 604800,
mo: 2592000,
y: 31536000
}
/**
* Resolve relative time to Unix timestamp.
* "now" -> current time; "7d" -> now - 7*86400; "1704067200" -> 1704067200.
*/
function resolveRelativeTime(value: string): number {
const trimmed = (value || '').trim()
if (trimmed === 'now' || trimmed === '') {
return Math.floor(Date.now() / 1000)
}
const num = parseInt(trimmed, 10)
if (!Number.isNaN(num) && trimmed === String(num)) {
return num
}
const match = trimmed.match(/^(\d+)(s|m|h|d|w|mo|y)$/)
if (!match) {
return Math.floor(Date.now() / 1000)
}
const n = parseInt(match[1]!, 10)
const unit = match[2]!
const sec = RELATIVE_UNIT_SECONDS[unit] ?? 86400
return Math.floor(Date.now() / 1000) - n * sec
}
export type SpellExecutionContext = {
pubkey: string | null
contacts: string[]
}
/** When the spell has no `relays` tag and NIP-65 write list is empty: known-good write relays. */
function defaultSpellWriteFallbackRelays(): string[] {
return dedupeRelayUrls([...FAST_WRITE_RELAY_URLS])
}
/** Max kind-777 events to pull when syncing spell definitions from relays (you only). */
export const SPELL_CATALOG_SYNC_LIMIT = 200
/**
* When also syncing spells authored by people you follow, allow a larger merged result so
* follow-authored spells are not squeezed out by your own.
*/
export const SPELL_CATALOG_SYNC_LIMIT_WITH_FOLLOWS = 600
/** Max distinct pubkeys in one catalog REQ (relay compatibility). Your pubkey is always first. */
const SPELL_CATALOG_MAX_AUTHORS = 400
/**
* If no relay sends EOSE, stop showing the catalog sync state and close the sub after this long.
* Keeps the UI from feeling stuck when relays are slow or silent.
*/
export const SPELL_CATALOG_SYNC_TIMEOUT_MS = 12_000
/** Build author list for spell catalog sync: always include `pubkey`, then follows, deduped. */
export function buildSpellCatalogAuthors(pubkey: string, contacts: string[]): string[] {
const rest = contacts.filter((c) => typeof c === 'string' && c.length > 0 && c !== pubkey)
const uniqueFollows = [...new Set(rest)]
const combined = [pubkey, ...uniqueFollows]
return combined.slice(0, SPELL_CATALOG_MAX_AUTHORS)
}
/**
* Relays to fetch the user's kind-777 spells: favorites + default fast-read relays + user read/inboxes
* (same extension as other non–favorites-feed reads; not the favorites-only home list).
*/
export function getRelaysForSpellCatalogSync(
favoriteRelays: string[],
blockedRelays: string[],
userInboxReadRelays: string[],
options?: { userWriteRelays?: string[] }
): string[] {
return getRelayUrlsWithFavoritesFastReadAndInbox(favoriteRelays, blockedRelays, userInboxReadRelays, {
userWriteRelays: options?.userWriteRelays ?? [],
applySocialKindBlockedFilter: false
})
}
function dedupeRelayUrls(urls: string[]): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of urls) {
const key = normalizeUrl(u) || u
if (!key || seen.has(key)) continue
seen.add(key)
out.push(key)
}
return out
}
export type GetRelaysForSpellOptions = {
/**
* When true (default): merge FAST_WRITE after the primary list (REQ feeds) for resilience.
* When false: use only spell `relays` tag, NIP-65 write relays, or write fallback — no extra padding (COUNT).
*/
mergeDefaultReadRelays?: boolean
}
/**
* Get relay URLs for executing a spell: spell `relays` tag, else the user's NIP-65 **write** (outbox) relays.
* Publishing and running spells use outboxes only (plus optional FAST_WRITE padding when mergeDefaults is true).
*/
export function getRelaysForSpell(
spell: Event,
context: { relayListWrite: string[] },
options?: GetRelaysForSpellOptions
): string[] {
const mergeDefaults = options?.mergeDefaultReadRelays !== false
let primary: string[] = []
const relayTag = spell.tags.find(tagNameEquals('relays'))
if (relayTag && relayTag.length > 1) {
const urls = relayTag
.slice(1)
.filter((u): u is string => typeof u === 'string' && (u.startsWith('wss://') || u.startsWith('ws://')))
if (urls.length) primary = urls
}
if (!primary.length && context.relayListWrite.length) {
primary = [...context.relayListWrite]
}
if (!primary.length) {
return defaultSpellWriteFallbackRelays()
}
if (mergeDefaults) {
return dedupeRelayUrls([...primary, ...FAST_WRITE_RELAY_URLS])
}
return dedupeRelayUrls(primary)
}
/**
* Resolve authors: replace $me with pubkey and $contacts with contacts array.
*/
function resolveAuthors(authorsTag: string[] | undefined, ctx: SpellExecutionContext): string[] | undefined {
const raw = authorsTag?.slice(1) ?? []
const out: string[] = []
for (const v of raw) {
if (v === '$me') {
if (ctx.pubkey) out.push(ctx.pubkey)
} else if (v === '$contacts') {
out.push(...ctx.contacts)
} else {
out.push(v)
}
}
return out.length ? out : undefined
}
/**
* Resolve tag filter values: replace $me and $contacts in ["tag", "p", "$me", "x"] etc.
*/
function resolveTagFilterValues(values: string[], ctx: SpellExecutionContext): string[] {
const out: string[] = []
for (const v of values) {
if (v === '$me') {
if (ctx.pubkey) out.push(ctx.pubkey)
} else if (v === '$contacts') {
out.push(...ctx.contacts)
} else {
out.push(v)
}
}
return out
}
/**
* Build a Nostr REQ filter from a spell event, resolving variables and relative times.
*/
export function spellEventToFilter(spell: Event, ctx: SpellExecutionContext): Filter | null {
const filter: Filter = {}
const cmd = spell.tags.find(tagNameEquals('cmd'))?.[1]
if (cmd !== 'REQ' && cmd !== 'COUNT') {
logger.warn('[Spell] Unsupported cmd', { cmd })
return null
}
const kTag = spell.tags.filter(tagNameEquals('k'))
if (kTag.length) {
const kinds = kTag
.map((t) => t[1])
.filter((x): x is string => x != null && x !== '')
.map((x) => parseInt(x, 10))
.filter((n) => !Number.isNaN(n))
if (kinds.length) filter.kinds = kinds
}
const authorsTag = spell.tags.find(tagNameEquals('authors'))
const authors = resolveAuthors(authorsTag ? [authorsTag[0]!, ...authorsTag.slice(1)] : undefined, ctx)
if (authors?.length) filter.authors = authors
const idsTag = spell.tags.find(tagNameEquals('ids'))
if (idsTag && idsTag.length > 1) {
filter.ids = idsTag.slice(1).filter((x): x is string => typeof x === 'string' && x.length > 0)
}
const limitTag = spell.tags.find(tagNameEquals('limit'))
if (limitTag?.[1]) {
const n = parseInt(limitTag[1], 10)
if (!Number.isNaN(n)) filter.limit = n
}
const sinceTag = spell.tags.find(tagNameEquals('since'))
if (sinceTag?.[1]) filter.since = resolveRelativeTime(sinceTag[1])
const untilTag = spell.tags.find(tagNameEquals('until'))
if (untilTag?.[1]) filter.until = resolveRelativeTime(untilTag[1])
const searchTag = spell.tags.find(tagNameEquals('search'))
if (searchTag?.[1]) filter.search = searchTag[1]
for (const tag of spell.tags) {
if (tag[0] === 'tag' && tag.length >= 2) {
const letter = tag[1]
const values = resolveTagFilterValues(tag.slice(2), ctx)
if (letter && values.length) {
(filter as any)[`#${letter}`] = values
}
}
}
return filter
}
/**
* Get display name for a spell (from "name" tag or content).
*/
export function getSpellName(spell: Event): string {
const nameTag = spell.tags.find(tagNameEquals('name'))
if (nameTag?.[1]) return nameTag[1]
if (spell.content?.trim()) return spell.content.trim().slice(0, 80)
return `Spell ${spell.id.slice(0, 8)}`
}
export function isSpellEvent(event: Event): boolean {
return event.kind === ExtendedKind.SPELL
}