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
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 |
|
}
|
|
|