7 changed files with 288 additions and 6 deletions
@ -0,0 +1,49 @@
@@ -0,0 +1,49 @@
|
||||
import { describe, expect, it } from 'vitest' |
||||
import { kinds, nip19 } from 'nostr-tools' |
||||
import { |
||||
COMPANION_PUBLISH_CAP, |
||||
collectCompanionRefsInPublishOrder |
||||
} from './companion-publish' |
||||
|
||||
const HEX_A = 'a'.repeat(64) |
||||
const HEX_B = 'b'.repeat(64) |
||||
const HEX_C = 'c'.repeat(64) |
||||
const HEX_D = 'd'.repeat(64) |
||||
const HEX_PUB = 'e'.repeat(64) |
||||
|
||||
describe('collectCompanionRefsInPublishOrder', () => { |
||||
it('orders embedded before q before a before e', () => { |
||||
const note1 = nip19.noteEncode(HEX_B) |
||||
const ev = { |
||||
id: HEX_A, |
||||
kind: kinds.ShortTextNote, |
||||
content: `see nostr:${note1}`, |
||||
tags: [ |
||||
['q', HEX_B], |
||||
['a', `30023:${HEX_PUB}:doc`, HEX_C], |
||||
['e', HEX_D] |
||||
], |
||||
pubkey: HEX_PUB, |
||||
created_at: 1, |
||||
sig: 's'.repeat(128) |
||||
} |
||||
|
||||
const refs = collectCompanionRefsInPublishOrder(ev as never) |
||||
const tiers = refs.map((r) => r.tier) |
||||
const firstEmbedded = tiers.indexOf('embedded') |
||||
const firstQ = tiers.indexOf('q') |
||||
const firstA = tiers.indexOf('a') |
||||
const firstE = tiers.indexOf('e') |
||||
|
||||
expect(firstEmbedded).toBeGreaterThanOrEqual(0) |
||||
expect(firstQ).toBeGreaterThan(firstEmbedded) |
||||
expect(firstA).toBeGreaterThan(firstQ) |
||||
expect(firstE).toBeGreaterThan(firstA) |
||||
}) |
||||
}) |
||||
|
||||
describe('COMPANION_PUBLISH_CAP', () => { |
||||
it('is 5', () => { |
||||
expect(COMPANION_PUBLISH_CAP).toBe(5) |
||||
}) |
||||
}) |
||||
@ -0,0 +1,214 @@
@@ -0,0 +1,214 @@
|
||||
import { EMBEDDED_EVENT_REGEX } from '@/lib/content-patterns' |
||||
import { relayHintWssUrlsFromEvent } from '@/lib/event' |
||||
import { findTrailingStringifiedNostrEvent } from '@/lib/nostr-event-json' |
||||
import client from '@/services/client.service' |
||||
import type { TPublishEventExtras } from '@/types' |
||||
import { nip19, type Event } from 'nostr-tools' |
||||
|
||||
export const COMPANION_PUBLISH_CAP = 5 |
||||
|
||||
type CompanionRef = |
||||
| { tier: 'embedded'; hexId: string } |
||||
| { tier: 'embedded'; nip19: string } |
||||
| { tier: 'embedded'; inline: Event } |
||||
| { tier: 'q'; hexId: string } |
||||
| { tier: 'q'; coordinate: string } |
||||
| { tier: 'a'; hexId: string } |
||||
| { tier: 'a'; coordinate: string } |
||||
| { tier: 'e'; hexId: string } |
||||
|
||||
function normalizeHex(id: string | undefined): string | undefined { |
||||
if (!id) return undefined |
||||
const t = id.trim().toLowerCase() |
||||
return /^[0-9a-f]{64}$/.test(t) ? t : undefined |
||||
} |
||||
|
||||
function parseQOrATagValue(raw: string | undefined): { hexId?: string; coordinate?: string } | undefined { |
||||
if (raw == null) return undefined |
||||
let s0 = raw.trim() |
||||
if (s0.toLowerCase().startsWith('nostr:')) s0 = s0.slice(6).trim() |
||||
if (!s0) return undefined |
||||
|
||||
const hex = normalizeHex(s0) |
||||
if (hex) return { hexId: hex } |
||||
|
||||
const coordMatch = /^(\d+):([0-9a-f]{64}):(.*)$/i.exec(s0) |
||||
if (coordMatch) { |
||||
return { |
||||
coordinate: `${Number(coordMatch[1])}:${coordMatch[2].toLowerCase()}:${coordMatch[3]}` |
||||
} |
||||
} |
||||
|
||||
if (/^n(?:ote|event|addr)1/i.test(s0)) { |
||||
try { |
||||
const { type, data } = nip19.decode(s0) |
||||
if (type === 'note') return { hexId: normalizeHex(typeof data === 'string' ? data : (data as { id?: string }).id) } |
||||
if (type === 'nevent') return { hexId: normalizeHex((data as { id: string }).id) } |
||||
if (type === 'naddr') { |
||||
const d = data as { kind: number; pubkey: string; identifier: string } |
||||
return { |
||||
coordinate: `${d.kind}:${d.pubkey.toLowerCase()}:${d.identifier ?? ''}` |
||||
} |
||||
} |
||||
} catch { |
||||
/* ignore */ |
||||
} |
||||
} |
||||
|
||||
return undefined |
||||
} |
||||
|
||||
function collectEmbeddedRefsFromContent(ev: Event, out: CompanionRef[]): void { |
||||
for (const full of ev.content.match(EMBEDDED_EVENT_REGEX) ?? []) { |
||||
const colon = full.indexOf(':') |
||||
if (colon < 0) continue |
||||
const bech32 = full.slice(colon + 1) |
||||
try { |
||||
const { type, data } = nip19.decode(bech32) |
||||
if (type === 'note') { |
||||
const hex = normalizeHex(typeof data === 'string' ? data : (data as { id?: string }).id) |
||||
if (hex) out.push({ tier: 'embedded', hexId: hex }) |
||||
} else if (type === 'nevent') { |
||||
const hex = normalizeHex((data as { id: string }).id) |
||||
if (hex) out.push({ tier: 'embedded', hexId: hex }) |
||||
} else if (type === 'naddr') { |
||||
out.push({ tier: 'embedded', nip19: bech32 }) |
||||
} |
||||
} catch { |
||||
/* ignore */ |
||||
} |
||||
} |
||||
|
||||
const trailing = findTrailingStringifiedNostrEvent(ev.content) |
||||
if (trailing) { |
||||
out.push({ tier: 'embedded', inline: trailing.event }) |
||||
collectEmbeddedRefsFromContent(trailing.event, out) |
||||
} |
||||
} |
||||
|
||||
/** Ordered refs: embedded (content + trailing JSON), then all `q`, then `a`, then `e`. */ |
||||
export function collectCompanionRefsInPublishOrder(event: Event): CompanionRef[] { |
||||
const embedded: CompanionRef[] = [] |
||||
const qRefs: CompanionRef[] = [] |
||||
const aRefs: CompanionRef[] = [] |
||||
const eRefs: CompanionRef[] = [] |
||||
|
||||
collectEmbeddedRefsFromContent(event, embedded) |
||||
|
||||
for (const tag of event.tags) { |
||||
const name = tag[0] |
||||
if (name === 'q' || name === 'Q') { |
||||
const parsed = parseQOrATagValue(tag[1]) |
||||
if (parsed?.hexId) qRefs.push({ tier: 'q', hexId: parsed.hexId }) |
||||
else if (parsed?.coordinate) qRefs.push({ tier: 'q', coordinate: parsed.coordinate }) |
||||
continue |
||||
} |
||||
if (name === 'a' || name === 'A') { |
||||
const snap = normalizeHex(tag[3]) |
||||
if (snap) { |
||||
aRefs.push({ tier: 'a', hexId: snap }) |
||||
continue |
||||
} |
||||
const parsed = parseQOrATagValue(tag[1]) |
||||
if (parsed?.hexId) aRefs.push({ tier: 'a', hexId: parsed.hexId }) |
||||
else if (parsed?.coordinate) aRefs.push({ tier: 'a', coordinate: parsed.coordinate }) |
||||
continue |
||||
} |
||||
if (name === 'e' || name === 'E') { |
||||
const hex = normalizeHex(tag[1]) |
||||
if (hex) eRefs.push({ tier: 'e', hexId: hex }) |
||||
} |
||||
} |
||||
|
||||
return [...embedded, ...qRefs, ...aRefs, ...eRefs] |
||||
} |
||||
|
||||
/** |
||||
* Resolve referenced events for companion republish (boost target, quotes, replies with embeds). |
||||
* Order preserved: embedded → q → a → e; capped at {@link COMPANION_PUBLISH_CAP}. |
||||
*/ |
||||
export async function resolveCompanionEventsForPublish( |
||||
source: Event, |
||||
opts?: { excludeIds?: string[] } |
||||
): Promise<Event[]> { |
||||
const exclude = new Set( |
||||
[source.id, ...(opts?.excludeIds ?? [])].map((id) => id.trim().toLowerCase()).filter(Boolean) |
||||
) |
||||
const relayHints = relayHintWssUrlsFromEvent(source) |
||||
const fetchOpts = relayHints.length > 0 ? { relayHints } : undefined |
||||
|
||||
const refs = collectCompanionRefsInPublishOrder(source) |
||||
const resolved: Event[] = [] |
||||
const seen = new Set<string>() |
||||
|
||||
const tryAdd = (ev: Event | undefined) => { |
||||
if (!ev) return false |
||||
const k = ev.id.toLowerCase() |
||||
if (exclude.has(k) || seen.has(k)) return false |
||||
seen.add(k) |
||||
resolved.push(ev) |
||||
return resolved.length >= COMPANION_PUBLISH_CAP |
||||
} |
||||
|
||||
const resolveRef = async (ref: CompanionRef): Promise<Event | undefined> => { |
||||
if ('inline' in ref) return ref.inline |
||||
if ('nip19' in ref) { |
||||
try { |
||||
return await client.fetchEvent(ref.nip19, fetchOpts) |
||||
} catch { |
||||
return undefined |
||||
} |
||||
} |
||||
if ('coordinate' in ref) { |
||||
try { |
||||
return await client.fetchEvent(ref.coordinate, fetchOpts) |
||||
} catch { |
||||
return undefined |
||||
} |
||||
} |
||||
if ('hexId' in ref) { |
||||
try { |
||||
return await client.fetchEvent(ref.hexId, fetchOpts) |
||||
} catch { |
||||
return undefined |
||||
} |
||||
} |
||||
return undefined |
||||
} |
||||
|
||||
for (const ref of refs) { |
||||
if (resolved.length >= COMPANION_PUBLISH_CAP) break |
||||
if ('inline' in ref) { |
||||
if (tryAdd(ref.inline)) break |
||||
continue |
||||
} |
||||
if ('hexId' in ref) { |
||||
const k = ref.hexId |
||||
if (exclude.has(k) || seen.has(k)) continue |
||||
} |
||||
const ev = await resolveRef(ref) |
||||
if (tryAdd(ev)) break |
||||
} |
||||
|
||||
return resolved |
||||
} |
||||
|
||||
/** Fire-and-forget friendly: publish companions to the same relays; never throws. */ |
||||
export async function publishCompanionEventsBestEffort( |
||||
relayUrls: string[], |
||||
companions: readonly Event[], |
||||
extras: TPublishEventExtras |
||||
): Promise<void> { |
||||
if (!relayUrls.length || !companions.length) return |
||||
|
||||
for (const companion of companions) { |
||||
try { |
||||
await client.publishEvent(relayUrls, companion, { |
||||
...extras, |
||||
publishBatchLabel: 'companion' |
||||
}) |
||||
} catch { |
||||
/* best-effort */ |
||||
} |
||||
} |
||||
} |
||||
Loading…
Reference in new issue