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.
 
 
 
 
 
 

1937 lines
80 KiB

<?php
namespace App\Service;
use App\Entity\Article;
use App\Entity\Event as PublicationEventEntity;
use App\Enum\KindsEnum;
use App\Factory\ArticleFactory;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\EventMessage;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
/**
* Main integration point for swentel/nostr against configured relays: long-form fetch, kind-0 profile
* metadata, article discussion and comment publish relay lists, magazine 30040 / highlight 9802 ingest,
* and related REQ flows. Tuned via `default_relay`, `article_relays`, `profile_relays`, and
* `nostr_relay_request_timeout_sec` (see `config/unfold.yaml`). Shared building blocks:
* {@see NostrRelayRequestFactory} (timeouts), {@see NostrRelayQuery} (REQ + response fan-in),
* {@see NostrRelayFanoutTransport} (sequential vs parallel multi-relay REQ), {@see NostrRelayListFactory}
* (config relay lists, merge/dedupe, {@link RelaySet} for profile fetches, Nostr Land + aggr),
* {@see NostrAuthorRelayCache} (cached NIP-65 kind-10002 author relay lists),
* {@see NostrWireEventMerge} (NIP-33 / kind-0 merge, #d tags, npub→hex for wire objects),
* {@see NostrArticleDiscussionSupport} (article thread REQ filters and tag classifiers),
* {@see NostrKind5DeletionFilter} (NIP-09 kind-5 relevance for stored row kinds),
* {@see NostrNip65RelayUrls} (NIP-65 `r` → wss list from kind-10002 wire),
* {@see NostrLongformArticleStore} (DB upsert for long-form / NIP-23 article rows).
*/
class NostrClient
{
/**
* Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate)
* but blow up wall time when we fall back to sequential in-process {@see Request::send()}.
*/
private const MAX_DISCUSSION_RELAY_URLS = 10;
/**
* Kind-9802 highlight ingest ({@see fetchHighlightEventsForArticle} / prewarm): main + article + profile
* + author NIP-65, deduped. Higher than {@see MAX_DISCUSSION_RELAY_URLS} so profile relays are not dropped.
*/
private const MAX_HIGHLIGHT_RELAY_URLS = 32;
private RelaySet $defaultRelaySet;
/**
* @param NostrRelayRequestFactory $relayRequestFactory Per-relay WebSocket I/O cap (see `nostr_relay_request_timeout_sec` in `config/unfold.yaml`)
* @param NostrRelayFanoutTransport $relayFanout Multi-relay sequential/parallel + wire logging
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly ArticleFactory $articleFactory,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly string $projectDir,
private readonly NostrRelayRequestFactory $relayRequestFactory,
private readonly NostrRelayQuery $nostrRelayQuery,
private readonly NostrRelayFanoutTransport $relayFanout,
private readonly NostrRelayListFactory $relayListFactory,
private readonly NostrAuthorRelayCache $authorRelayCache,
private readonly NostrWireEventMerge $wireMerge,
private readonly NostrArticleDiscussionSupport $articleDiscussion,
private readonly NostrKind5DeletionFilter $kind5DeletionFilter,
private readonly NostrNip65RelayUrls $nip65RelayUrls,
private readonly NostrLongformArticleStore $longformArticleStore,
) {
$this->defaultRelaySet = $this->relayListFactory->getDefaultArticleRelaySet();
}
/**
* default_relay + article_relays (deduplicated) for publishing user comments.
*
* @return list<string>
*/
public function getArticleWriteRelayUrls(): array
{
return $this->relayListFactory->getConfiguredArticleRelayUrlList();
}
/**
* Relays to publish a kind-1111 reply: site defaults plus NIP-65 (kind-10002) for the
* article author and, when the direct parent is another pubkey (nested comment), that
* author’s relays as well.
*
* @param string $articleCoordinate kind:pubkey:identifier
* @param string $parentEventAuthorHex 64-char hex of the event being replied to
*
* @return list<string>
*/
public function getRelayUrlsForCommentPublish(string $articleCoordinate, string $parentEventAuthorHex): array
{
$base = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$parts = explode(':', $articleCoordinate, 3);
$articlePk = \count($parts) >= 2 ? strtolower((string) $parts[1]) : '';
if (64 !== \strlen($articlePk) || !ctype_xdigit($articlePk)) {
$articlePk = '';
}
$parentPk = strtolower(trim($parentEventAuthorHex));
if (64 !== \strlen($parentPk) || !ctype_xdigit($parentPk)) {
$parentPk = '';
}
$pubkeys = [];
if ($articlePk !== '') {
$pubkeys[] = $articlePk;
}
if ($parentPk !== '' && $parentPk !== $articlePk) {
$pubkeys[] = $parentPk;
}
$seen = array_fill_keys($base, true);
$out = $base;
foreach ($pubkeys as $pk) {
foreach ($this->authorRelayCache->getAuthorNip65RelaysList($pk) as $wss) {
if (!\is_string($wss) || $wss === '' || isset($seen[$wss])) {
continue;
}
$seen[$wss] = true;
$out[] = $wss;
}
}
return $out;
}
/**
* Suffix to segregate HTTP caches: aggr is only used for some logged-in readers, so results differ.
*
* @return string empty when aggr is not used, else a short token
*/
public function getNostrLandAggrReaderCacheSuffix(): string
{
return $this->relayListFactory->getNostrLandAggrReaderCacheSuffix();
}
/**
* Batched kind-0 profile fetch: one Nostr REQ per chunk with multiple "authors" (hex pubkeys).
*
* @param list<string> $authorPubkeyHex
* @return array<string, \stdClass> Newest kind-0 JSON per pubkey, keyed by hex
*/
public function fetchKind0MetadataForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === []) {
return [];
}
$authorsPerRequest = max(1, min(200, $authorsPerRequest));
$byPub = [];
$relaysTried = $this->relayListFactory->getProfileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$relaySet = $this->relayListFactory->getRelaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
foreach ($chunks as $i => $chunk) {
$t0 = microtime(true);
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::METADATA],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn ($ev) => $ev,
);
$this->logger->info('nostr.metadata.batch_chunk', [
'chunk' => 1 + $i,
'of' => \count($chunks),
'authors' => \count($chunk),
'events' => \count($events),
'relays' => $relaysTriedStr,
'ms' => (int) round((microtime(true) - $t0) * 1000),
]);
foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) {
if (!\is_object($ev) || !isset($ev->content)) {
continue;
}
$pk = \substr($addr, 2);
try {
$data = \json_decode((string) $ev->content, false, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
continue;
}
if (\is_object($data)) {
$byPub[$pk] = $data;
}
}
}
return $byPub;
}
/**
* Batched kind-0 fetch: one REQ per chunk; returns latest wire event per author (for DB persistence).
*
* @param list<string> $authorPubkeyHex
* @return array<string, object> Keyed by lowercase 64-hex pubkey
*/
public function fetchKind0WireEventsForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === []) {
return [];
}
$authorsPerRequest = max(1, min(200, $authorsPerRequest));
$byPub = [];
$relaysTried = $this->relayListFactory->getProfileMetadataQueryRelayUrlList();
$relaySet = $this->relayListFactory->getRelaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
foreach ($chunks as $chunk) {
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::METADATA],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn ($ev) => $ev,
);
foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) {
if (!\is_object($ev)) {
continue;
}
$pk = \substr((string) $addr, 2);
if (64 === \strlen($pk) && ctype_xdigit($pk)) {
$byPub[strtolower($pk)] = $ev;
}
}
}
return $byPub;
}
/**
* NIP-09 kind 5 deletion requests in $since..$until (unix), batched by author pubkey (hex).
*
* @param (callable(int, int, int): void)|null $afterChunk 1-based index, total chunks, pubkeys in chunk
* @param list<string> $authorPubkeyHex
* @return list<stdClass> Deduplicated by event `id` (highest {@see created_at} kept)
*/
public function fetchKind5DeletionEventsForAuthors(
array $authorPubkeyHex,
int $since,
int $until,
int $authorsPerRequest = 40,
?callable $afterChunk = null,
): array {
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === [] || $since >= $until) {
return [];
}
$authorsPerRequest = max(1, min(100, $authorsPerRequest));
$byId = [];
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
$numChunks = \count($chunks);
foreach ($chunks as $i => $chunk) {
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::DELETION_REQUEST],
filters: [
'authors' => $chunk,
'since' => $since,
'until' => $until,
],
);
$t0 = microtime(true);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn (object $event) => $event,
);
$this->logger->info('nostr.nip09.kind5_chunk', [
'authors' => \count($chunk),
'raw_events' => \count($events),
'ms' => (int) round((microtime(true) - $t0) * 1000),
]);
if ($afterChunk !== null) {
$afterChunk(1 + (int) $i, $numChunks, \count($chunk));
}
foreach ($events as $ev) {
if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) {
continue;
}
if (!$this->kind5DeletionFilter->isRelevantToStoredDbData($ev)) {
continue;
}
$id = (string) ($ev->id ?? '');
if (64 !== \strlen($id)) {
continue;
}
$t = (int) ($ev->created_at ?? 0);
if (isset($byId[$id]) && $t <= (int) ($byId[$id]->created_at ?? 0)) {
continue;
}
$byId[$id] = $ev;
}
}
return array_values($byId);
}
/**
* @throws \Exception
*/
public function getNpubMetadata($npub): \stdClass
{
$relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList());
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried);
$this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::METADATA],
filters: ['authors' => [$npub]],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
function ($received) {
$this->logger->debug('nostr.metadata.relay_event', ['event' => $received]);
return $received;
},
);
if (empty($events)) {
throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')');
}
$byAddr = $this->wireMerge->mergeKind0EventsByReplaceableAddress($events);
$authorHex = $this->wireMerge->npubToHexPubkey($npub);
if ($authorHex === null) {
throw new \Exception('Invalid npub for metadata: '.$npub);
}
$key = '0:'.$authorHex;
if (!isset($byAddr[$key])) {
throw new \Exception('No kind-0 metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')');
}
return $byAddr[$key];
}
/**
* NIP-A3 kind 10133: payment target events; NIP kind-range 10_000–19_999 is replaceable by
* (kind, pubkey), so multi-relay results are merged to the live revision per
* {@see wireEventSupersedes} (at most one event for this author).
*
* @return list<object>
*/
public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array
{
$relaysTried = $this->relayListFactory->capSequentialRelaysForProfileFetches($this->relayListFactory->getProfileMetadataQueryRelayUrlList());
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($relaysTried);
try {
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::PAYMENT_TARGETS],
filters: ['authors' => [$npub], 'limit' => max(1, min(50, $limit))],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn ($ev) => $ev,
);
} catch (\Throwable $e) {
$this->logger->warning('nostr.kind10133.fetch_failed', [
'npub' => $npub,
'relays' => $relaysTriedStr,
'error' => $e->getMessage(),
]);
return [];
}
if ($events === []) {
return [];
}
return $this->wireMerge->mergeNip33ParameterizedWireEvents($events);
}
public function getNpubLongForm($npub): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setAuthors([$npub]);
$filter->setSince(strtotime('-6 months')); // too much?
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
// if user is logged in, use their settings
/* @var $user */
$user = $this->tokenStorage->getToken()?->getUser();
$relays = $this->defaultRelaySet;
if ($user && $user->getRelays()) {
$relays = new RelaySet();
foreach ($user->getRelays() as $relayArr) {
if ($relayArr[2] == 'write') {
$relays->addRelay(new Relay($relayArr[1]));
}
}
}
$request = $this->relayRequestFactory->createTimedRequest($relays, $requestMessage);
$wrappers = $this->nostrRelayQuery->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
return $w;
});
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
}
// TODO handle relays that require auth
}
public function publishEvent(Event $event, array $relays): array
{
$eventMessage = new EventMessage($event);
$results = [];
foreach ($relays as $relayWss) {
if (!\is_string($relayWss) || $relayWss === '') {
continue;
}
try {
$relaySet = new RelaySet();
$relaySet->addRelay(new Relay($relayWss));
$relaySet->setMessage($eventMessage);
$this->relayRequestFactory->applySocketTimeoutToRelaySet($relaySet);
$sent = $relaySet->send();
if (\is_array($sent) && \array_key_exists($relayWss, $sent)) {
$results[$relayWss] = $sent[$relayWss];
} else {
$results[$relayWss] = $sent;
}
} catch (\Throwable $e) {
$this->logger->warning('nostr.publish.relay_failed', [
'relay' => $relayWss,
'error' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$results[$relayWss] = $e;
}
}
return $results;
}
/**
* Backfill long-form (NIP-23) in time windows so relay responses and PHP stay bounded (avoids
* OOM on year-wide queries with many relays). ~60 days per step (≈2 months).
*/
private const LONGFORM_BACKFILL_CHUNK_SECONDS = 5184000; // 60 days
/**
* Long-form Content
* NIP-23
*/
public function getLongFormContent($from = null, $to = null): void
{
$toTs = $to !== null ? (int) $to : time();
$fromTs = $from !== null ? (int) $from : strtotime('-1 week');
if ($fromTs >= $toTs) {
return;
}
$chunk = self::LONGFORM_BACKFILL_CHUNK_SECONDS;
for ($windowFrom = $fromTs; $windowFrom < $toTs; $windowFrom += $chunk) {
$windowTo = min($windowFrom + $chunk, $toTs);
$this->getLongFormContentForTimeWindow($windowFrom, $windowTo);
$this->entityManager->clear();
}
}
private function getLongFormContentForTimeWindow(int $since, int $until): void
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setSince($since);
$filter->setUntil($until);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$request = $this->relayRequestFactory->createTimedRequest($this->defaultRelaySet, $requestMessage);
$wrappers = $this->nostrRelayQuery->processResponse($request->send(), function (object $event) {
$w = new \stdClass();
$w->event = $event;
return $w;
});
if ($wrappers !== []) {
$this->saveLongFormContent($wrappers);
}
}
/**
* @throws \Exception
*/
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void
{
if (empty($relayList)) {
$topAuthorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($topAuthorRelays);
$relaysTried = $this->plannedRelayUrlsForSet($topAuthorRelays);
} else {
$authorRelaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($relayList);
$relaysTried = $this->plannedRelayUrlsForSet($relayList);
}
$relaysTriedStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
try {
// Create request using the helper method for forest relay set
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$author],
'tag' => ['#d', [$slug]]
],
relaySet: $authorRelaySet
);
// Process the response
$events = $this->nostrRelayQuery->processResponse($request->send(), function($event) {
return $event;
});
if (!empty($events)) {
$kindI = (int) $kind;
$authorH = $this->wireMerge->authorIdentToHexLower($author);
$event = $this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null
? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, (string) $slug)
: null;
if ($event === null) {
$event = $events[0];
}
$wrapper = new \stdClass();
$wrapper->type = 'EVENT';
$wrapper->event = $event;
$this->saveLongFormContent([$wrapper]);
}
} catch (\Exception $e) {
$this->logger->error(sprintf('Error querying relays (%s): %s', $relaysTriedStr, $e->getMessage()), [
'error' => $e->getMessage(),
'relays' => $relaysTried,
]);
throw new \Exception('Error querying relays', 0, $e);
}
}
/**
* Get event by its ID
*
* @param string $eventId The event ID
* @param array $relays Optional array of relay URLs to query
* @return object|null The event or null if not found
* @throws \Exception
*/
public function getEventById(string $eventId, array $relays = []): ?object
{
$this->logger->info('Getting event by ID', ['event_id' => $eventId, 'relays' => $relays]);
// Use provided relays or default if empty
$relaySet = empty($relays) ? $this->defaultRelaySet : $this->relayListFactory->createRelaySetMergedWithArticleList($relays);
// Create request using the helper method
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [], // Leave empty to accept any kind
filters: ['ids' => [$eventId]],
relaySet: $relaySet
);
// Process the response
$events = $this->nostrRelayQuery->processResponse($request->send(), function($event) {
$this->logger->debug('Received event', ['event' => $event]);
return $event;
});
if (empty($events)) {
return null;
}
// Return the first matching event
return $events[0];
}
/**
* Fetch event by naddr
*
* @param array $decoded Decoded naddr data
* @return object|null The event or null if not found
* @throws \Exception
*/
public function getEventByNaddr(array $decoded): ?object
{
$this->logger->info('Getting event by naddr', ['decoded' => $decoded]);
// Extract required fields from decoded data
$kind = $decoded['kind'] ?? 30023; // Default to long-form content
$pubkey = $decoded['pubkey'] ?? '';
$identifier = $decoded['identifier'] ?? '';
$relays = $decoded['relays'] ?? [];
if (empty($pubkey) || empty($identifier)) {
return null;
}
// Try author's relays first
$authorRelays = empty($relays) ? $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey) : $relays;
$relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays);
// Create request using the helper method
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$pubkey],
'tag' => ['#d', [$identifier]]
],
relaySet: $relaySet
);
// Process the response
$events = $this->nostrRelayQuery->processResponse($request->send(), function($event) {
return $event;
});
if (!empty($events)) {
return $events[0];
}
// Try default relays as fallback
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$pubkey],
'tag' => ['#d', [$identifier]]
]
);
$events = $this->nostrRelayQuery->processResponse($request->send(), function($event) {
return $event;
});
return !empty($events) ? $events[0] : null;
}
/**
* Fetch a note by its ID
*
* @param string $noteId The note ID
* @return object|null The note or null if not found
* @throws \Exception
*/
public function getNoteById(string $noteId): ?object
{
return $this->getEventById($noteId);
}
private function saveLongFormContent(mixed $filtered): void
{
$events = [];
foreach ($filtered as $wrapper) {
if (isset($wrapper->event) && \is_object($wrapper->event)) {
$events[] = $wrapper->event;
}
}
foreach ($this->wireMerge->mergeNip33ParameterizedWireEvents($events) as $event) {
$article = $this->articleFactory->createFromLongFormContentEvent($event);
$this->saveEachArticleToTheDatabase($article);
}
}
/**
* Merged NIP-65 (kind 10002) event for the author, or null.
*/
public function getNpubRelayList10002Wire($npub): ?object
{
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::RELAY_LIST],
filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet
);
$response = $this->nostrRelayQuery->processResponse($request->send(), function ($received) {
return $received;
});
if (empty($response)) {
return null;
}
$merged = $this->wireMerge->mergeNip33ParameterizedWireEvents($response);
$k10002 = (int) KindsEnum::RELAY_LIST->value;
foreach ($merged as $e) {
if (\is_object($e) && (int) ($e->kind ?? 0) === $k10002) {
return $e;
}
}
return null;
}
/**
* @return list<string>
*/
public function getNpubRelays($npub): array
{
$use = $this->getNpubRelayList10002Wire($npub);
if ($use === null) {
return [];
}
return $this->nip65RelayUrls->wssListFromKind10002Wire($use);
}
/**
* NIP-22 kind 1111 thread, legacy kind 1 replies (pre-NIP-22 clients), and quote/repost-style references.
* Kind 9802 highlights are excluded; they are stored in `article_highlight` via {@see fetchHighlightEventsForArticle()}.
*
* @param string $coordinate kind:pubkey:d-identifier (e.g. longform address)
* @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching
*
* @return array{thread: array<int, object>, quotes: array<int, object>, partial?: bool}
*/
public function getArticleDiscussion(string $coordinate, ?string $rootEventHexId = null): array
{
$this->logger->info('nostr.article_discussion.start', [
'coordinate' => $coordinate,
'root_event_hex' => $rootEventHexId,
]);
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier');
}
$pubkey = $parts[1];
$tRelays = microtime(true);
$authorRelays = $this->authorRelayCache->getAuthorNip65RelaysList($pubkey);
$this->logger->info('nostr.article_discussion.author_relays_ready', [
'elapsed_ms' => (int) round((microtime(true) - $tRelays) * 1000),
'author_relay_count' => \count($authorRelays),
]);
$baseForDiscussion = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$mergedForDiscussion = $this->relayListFactory->withAggrNostrLandIfUserSubscribesNostrLand(
array_merge($baseForDiscussion, $authorRelays)
);
$plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR));
if (\count($plannedRelayUrls) > self::MAX_DISCUSSION_RELAY_URLS) {
$plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_DISCUSSION_RELAY_URLS);
$this->logger->notice('nostr.article_discussion.relay_list_capped', [
'max' => self::MAX_DISCUSSION_RELAY_URLS,
]);
}
$filters = $this->articleDiscussion->createArticleDiscussionFilters($coordinate, $rootEventHexId);
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters);
$this->logger->info('nostr.article_discussion.req_sending', [
'subscription_id' => $subscriptionId,
'filter_count' => \count($filters),
'relay_urls' => $plannedRelayUrls,
'relay_count' => \count($plannedRelayUrls),
]);
$byId = [];
try {
$tSend = microtime(true);
$workerPath = $this->projectDir.'/bin/nostr_relay_request_worker.php';
if (!\is_file($workerPath) || \count($plannedRelayUrls) <= 1) {
$forSeq = $this->relayFanout->capUrlsForSequential($plannedRelayUrls);
$response = $this->relayFanout->sendSequential(
$this->relayListFactory->relaySetFromDistinctUrlList($forSeq),
$requestMessage
);
} else {
try {
$response = $this->relayFanout->sendParallelWorkers($plannedRelayUrls, $requestMessage);
} catch (\Throwable $e) {
$this->logger->warning('nostr.article_discussion.parallel_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$forSeq = $this->relayFanout->capUrlsForSequential($plannedRelayUrls);
$this->logger->warning('nostr.article_discussion.sequential_fallback', [
'relays' => $forSeq,
]);
$response = $this->relayFanout->sendSequential(
$this->relayListFactory->relaySetFromDistinctUrlList($forSeq),
$requestMessage
);
}
}
$sendMs = (int) round((microtime(true) - $tSend) * 1000);
$this->logger->info('nostr.article_discussion.req_response_envelope', [
'elapsed_ms' => $sendMs,
'subscription_id' => $subscriptionId,
]);
$this->relayFanout->logWireResponseSummary('article_discussion', $response);
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'nostr.article_discussion.req_send_failed (relays: %s): %s',
implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $plannedRelayUrls)),
$e->getMessage()
), [
'coordinate' => $coordinate,
'error' => $e->getMessage(),
'exception_class' => \get_class($e),
'relays' => $plannedRelayUrls,
]);
// Do not return a successful empty shape: callers (e.g. comment cache) must not
// persist [] as if relays responded — that would clobber a previously good thread.
throw new \RuntimeException('Nostr request failed for article discussion', 0, $e);
}
$respondedRelayCount = \count($response);
$partial = $respondedRelayCount < \count($plannedRelayUrls);
$tParse = microtime(true);
$this->nostrRelayQuery->processResponse($response, function ($event) use (&$byId) {
if (\is_object($event) && isset($event->id)) {
$byId[(string) $event->id] = $event;
}
return null;
});
$this->logger->info('nostr.article_discussion.events_collected', [
'elapsed_ms' => (int) round((microtime(true) - $tParse) * 1000),
'unique_events' => \count($byId),
]);
$all = array_values($byId);
$thread = [];
$threadIds = [];
foreach ($all as $event) {
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::COMMENTS->value && $this->articleDiscussion->eventIsNip22ArticleThreadReply($event, $coordinate)) {
$thread[] = $event;
$threadIds[(string) $event->id] = true;
continue;
}
if ($kind === KindsEnum::TEXT_NOTE->value && $this->articleDiscussion->eventIsLegacyThreadReply($event, $coordinate, $rootEventHexId)) {
$thread[] = $event;
$threadIds[(string) $event->id] = true;
}
}
$quotes = [];
foreach ($all as $event) {
$id = (string) ($event->id ?? '');
if ($id === '' || isset($threadIds[$id])) {
continue;
}
if ($this->articleDiscussion->eventIsArticleQuote($event, $coordinate, $rootEventHexId)) {
$quotes[] = $event;
}
}
$sortAsc = static function ($a, $b): int {
return ((int) ($a->created_at ?? 0)) <=> ((int) ($b->created_at ?? 0));
};
$sortDesc = static function ($a, $b): int {
return ((int) ($b->created_at ?? 0)) <=> ((int) ($a->created_at ?? 0));
};
usort($thread, $sortAsc);
usort($quotes, $sortDesc);
$this->logger->info('nostr.article_discussion.done', [
'thread_count' => \count($thread),
'quotes_count' => \count($quotes),
'partial' => $partial,
'responded_relays' => $respondedRelayCount,
'planned_relays' => \count($plannedRelayUrls),
]);
return ['thread' => $thread, 'quotes' => $quotes, 'partial' => $partial];
}
/**
* Fetches kind 9802 (highlights) that reference the long-form address. Used for DB ingest only
* ({@see HighlightSyncService} / prewarm). Relays: {@see NostrRelayListFactory::getConfiguredArticleRelayUrlList()} (main +
* article_relays), then config {@see NostrRelayListFactory::getProfileRelayUrlList()}, then author NIP-65, deduped (cap
* {@see MAX_HIGHLIGHT_RELAY_URLS}).
*
* @return list<object> unique wire events by id
*/
public function fetchHighlightEventsForArticle(string $coordinate): array
{
$parts = explode(':', $coordinate, 3);
if (\count($parts) < 3) {
throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier');
}
$pubkey = $parts[1];
$tRelays = microtime(true);
$authorRelays = $this->authorRelayCache->getAuthorNip65RelaysList($pubkey);
$this->logger->info('nostr.highlight_relay_list', [
'elapsed_ms' => (int) round((microtime(true) - $tRelays) * 1000),
'author_relay_count' => \count($authorRelays),
]);
$baseArticle = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$profileConfigured = $this->relayListFactory->getProfileRelayUrlList();
$mergedForDiscussion = $this->relayListFactory->withAggrNostrLandIfUserSubscribesNostrLand(
array_merge($baseArticle, $profileConfigured, $authorRelays)
);
$plannedRelayUrls = array_values(array_unique($mergedForDiscussion, \SORT_REGULAR));
$relayCountBeforeCap = \count($plannedRelayUrls);
if ($relayCountBeforeCap > self::MAX_HIGHLIGHT_RELAY_URLS) {
$this->logger->notice('nostr.highlight_relay_cap', [
'max' => self::MAX_HIGHLIGHT_RELAY_URLS,
'had' => $relayCountBeforeCap,
]);
$plannedRelayUrls = \array_slice($plannedRelayUrls, 0, self::MAX_HIGHLIGHT_RELAY_URLS);
}
$limH = 200;
$filters = [];
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limH);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limH);
$filters[] = $f;
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$requestMessage = new RequestMessage($subscriptionId, $filters);
$this->logger->info('nostr.highlight_req', [
'subscription_id' => $subscriptionId,
'coordinate' => $coordinate,
'relay_count' => \count($plannedRelayUrls),
]);
try {
if (!\is_file($this->projectDir.'/bin/nostr_relay_request_worker.php') || \count($plannedRelayUrls) <= 1) {
$forSeq = $this->relayFanout->capUrlsForSequential($plannedRelayUrls);
$response = $this->relayFanout->sendSequential(
$this->relayListFactory->relaySetFromDistinctUrlList($forSeq),
$requestMessage
);
} else {
try {
$response = $this->relayFanout->sendParallelWorkers($plannedRelayUrls, $requestMessage);
} catch (\Throwable $e) {
$this->logger->warning('nostr.highlight.parallel_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$forSeq = $this->relayFanout->capUrlsForSequential($plannedRelayUrls);
$response = $this->relayFanout->sendSequential(
$this->relayListFactory->relaySetFromDistinctUrlList($forSeq),
$requestMessage
);
}
}
} catch (\Throwable $e) {
$this->logger->error('nostr.highlight_req_failed: '.$e->getMessage(), [
'coordinate' => $coordinate,
]);
throw new \RuntimeException('Nostr request failed for highlights', 0, $e);
}
$byId = [];
$this->nostrRelayQuery->processResponse($response, function ($event) use (&$byId) {
if (\is_object($event) && isset($event->id) && (int) ($event->kind ?? 0) === KindsEnum::HIGHLIGHTS->value) {
$byId[(string) $event->id] = $event;
}
return null;
});
$this->logger->info('nostr.highlight_done', ['count' => \count($byId)]);
return array_values($byId);
}
/**
* Same merge/dedupe rules as {@see NostrRelayListFactory::createRelaySetMergedWithArticleList()} — used only for logging planned relay URLs.
*
* @param array<int, string> $relayUrls
*
* @return list<string>
*/
private function plannedRelayUrlsForSet(array $relayUrls): array
{
$seen = [];
$out = [];
foreach (array_merge($this->relayListFactory->getConfiguredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$out[] = $relayUrl;
}
return $out;
}
/**
* Get zap events for a specific event
*
* @param string $coordinate The event coordinate (kind:pubkey:identifier)
* @return array Array of zap events
* @throws \Exception
*/
public function getZapsForEvent(string $coordinate): array
{
$this->logger->info('Getting zaps for coordinate', ['coordinate' => $coordinate]);
// Parse the coordinate to get pubkey
$parts = explode(':', $coordinate);
if (count($parts) !== 3) {
throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier');
}
$pubkey = $parts[1];
// Get author's relays for better chances of finding zaps
$authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey);
$relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays);
// Create request using the helper method
// Zaps are kind 9735
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::ZAP],
filters: ['tag' => ['#a', [$coordinate]]],
relaySet: $relaySet
);
// Process the response
return $this->nostrRelayQuery->processResponse($request->send(), function($event) {
$this->logger->debug('Received zap event', ['event_id' => $event->id]);
return $event;
});
}
/**
* @throws \Exception
*/
public function getLongFormContentForPubkey(string $ident): array
{
$authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($ident);
$base = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$merged = $authorRelays !== [] ? array_merge($base, $authorRelays) : $base;
$seen = [];
$deduped = [];
foreach ($merged as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$deduped[] = $url;
}
$capped = $this->relayListFactory->capSequentialRelaysForProfileFetches($deduped);
$relaySet = $this->relayListFactory->relaySetFromDistinctUrlList($capped);
// Create request using the helper method
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [KindsEnum::LONGFORM],
filters: [
'authors' => [$ident],
'limit' => 10
],
relaySet: $relaySet
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn (object $event) => $event,
);
foreach ($this->wireMerge->mergeNip33ParameterizedWireEvents($events) as $event) {
if (!\is_object($event)) {
continue;
}
$article = $this->articleFactory->createFromLongFormContentEvent($event);
$this->saveEachArticleToTheDatabase($article);
}
return [];
}
public function getArticles(array $slugs): array
{
$articles = [];
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([KindsEnum::LONGFORM]);
$filter->setTag('#d', $slugs);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
try {
$request = $this->relayRequestFactory->createTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send();
$hasEvents = false;
// Check if we got any events
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticles: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
$value->getMessage()
), ['relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event;
$hasEvents = true;
}
}
}
}
// If no articles found, try the default relay set
if (!$hasEvents && !empty($slugs)) {
$this->logger->info('No results from theforest, trying default relays');
$request = $this->relayRequestFactory->createTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticles: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
$value->getMessage()
), ['relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event;
}
} elseif (in_array($item->type, ['AUTH', 'ERROR', 'NOTICE'], true)) {
$msg = (string) ($item->message ?? '');
$this->logger->error(sprintf(
'[%s] %s while getting articles: %s',
NostrRelayQuery::relayLogLabel($relayUrl),
$item->type,
$msg !== '' ? $msg : '(no message)'
), ['relay' => $relayUrl, 'response' => $item]);
}
}
}
}
} catch (\Exception $e) {
$relaysTried = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$relaysStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysTried));
$this->logger->error(sprintf('Error querying relays (%s): %s', $relaysStr, $e->getMessage()), [
'error' => $e->getMessage(),
'relays' => $relaysTried,
]);
// Fall back to default relay set
$request = $this->relayRequestFactory->createTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) {
if ($item->type === 'EVENT') {
if (!isset($articles[$item->event->id])) {
$articles[$item->event->id] = $item->event;
}
}
}
}
}
return $this->wireMerge->mergeNip33ParameterizedWireEvents(array_values($articles));
}
/**
* Fetch articles by coordinates (kind:author:slug)
* Returns a map of coordinate => event for successful fetches
*
* @param array $coordinates Array of coordinates in format kind:author:slug
* @return array Map of coordinate => event
* @throws \Exception
*/
public function getArticlesByCoordinates(array $coordinates): array
{
$articlesMap = [];
foreach ($coordinates as $coordinate) {
$parts = explode(':', $coordinate);
if (count($parts) !== 3) {
$this->logger->warning('Invalid coordinate format', ['coordinate' => $coordinate]);
continue;
}
$kind = (int)$parts[0];
$pubkey = $parts[1];
$slug = $parts[2];
// Try to get relays associated with the author first
$relayList = [];
try {
// Get relays where the author publishes
$authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey);
if (!empty($authorRelays)) {
$relayList = $authorRelays;
}
} catch (\Exception $e) {
$this->logger->warning('Failed to get author relays', [
'pubkey' => $pubkey,
'error' => $e->getMessage()
]);
// Continue with default relays
}
if (empty($relayList)) {
$relayList = [];
}
// Ensure we use a RelaySet
$relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($relayList);
// Create subscription and filter
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$filter->setKinds([$kind]);
$filter->setAuthors([$pubkey]);
$filter->setTag('#d', [$slug]);
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
$relaysForLog = $this->plannedRelayUrlsForSet($relayList);
$relaysLogStr = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $relaysForLog));
try {
$request = $this->relayRequestFactory->createTimedRequest($relaySet, $requestMessage);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn (object $event) => $event,
);
$ev = $this->wireMerge->pickEventForNip33OrFirst($events, $kind, (string) $pubkey, (string) $slug);
if ($ev !== null) {
$articlesMap[$coordinate] = $ev;
}
if (!isset($articlesMap[$coordinate])) {
$this->logger->info('Article not found in author relays, trying default relays', [
'coordinate' => $coordinate
]);
$request2 = $this->relayRequestFactory->createTimedRequest($this->defaultRelaySet, $requestMessage);
$events2 = $this->nostrRelayQuery->processResponse(
$request2->send(),
static fn (object $event) => $event,
);
$ev2 = $this->wireMerge->pickEventForNip33OrFirst($events2, $kind, (string) $pubkey, (string) $slug);
if ($ev2 !== null) {
$articlesMap[$coordinate] = $ev2;
}
}
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Error fetching article (relays: %s): %s',
$relaysLogStr,
$e->getMessage()
), [
'coordinate' => $coordinate,
'error' => $e->getMessage(),
'relays' => $relaysForLog,
]);
}
}
return $articlesMap;
}
/**
* @param Article $article
* @return void
*/
public function saveEachArticleToTheDatabase(Article $article): void
{
$newId = (string) ($article->getEventId() ?? '');
if ($newId === '') {
$this->logger->info('[longform_ingest] saveEachArticle: skip, empty eventId on Article', [
'title' => $article->getTitle(),
]);
return;
}
if ($this->longformArticleStore->isEventIdAlreadyStored($newId)) {
$this->logger->info('[longform_ingest] saveEachArticle: skip, DB already has this exact event id (no work)', [
'eventId' => $newId,
'slug' => $article->getSlug(),
]);
return;
}
$pubkey = strtolower((string) ($article->getPubkey() ?? ''));
$slug = trim((string) ($article->getSlug() ?? ''));
if ($pubkey === '' || $slug === '') {
$this->logger->info('[longform_ingest] saveEachArticle: persist new (missing pubkey or slug on entity)', [
'eventId' => $newId,
'pubkey_empty' => $pubkey === '',
'slug' => $slug,
]);
$this->longformArticleStore->persistNew($article, 'missing_pubkey_or_slug_on_entity');
return;
}
$incumbent = $this->longformArticleStore->findLatestByAuthorAndSlug($pubkey, $slug);
if ($incumbent === null) {
$this->logger->info('[longform_ingest] saveEachArticle: persist new row (no DB row for author+slug)', [
'eventId' => $newId,
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),
]);
$this->longformArticleStore->persistNew($article, 'no_db_row_for_nip33_address');
return;
}
$candidate = $article->getRaw();
if (!\is_object($candidate)) {
$this->logger->warning('[longform_ingest] saveEachArticle: new Article has no raw wire; trying insert as new', [
'eventId' => $newId,
]);
$this->longformArticleStore->persistNew($article, 'no_raw_on_incoming_article');
return;
}
$iWire = $this->longformArticleStore->longFormWireStubFromArticle($incumbent);
$cTs = $this->wireMerge->magazineEventCreatedAt($candidate);
$iTs = $this->wireMerge->magazineEventCreatedAt($iWire);
if ($this->wireMerge->wireEventSupersedes($candidate, $iWire)) {
$this->logger->info('[longform_ingest] saveEachArticle: NIP-33 update — candidate wins, flushing DB row', [
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),
'from_event_id' => $incumbent->getEventId(),
'to_event_id' => $newId,
'db_row_id' => $incumbent->getId(),
'incumbent_created_at' => $iTs,
'candidate_created_at' => $cTs,
]);
$this->longformArticleStore->applySourceOntoTarget($article, $incumbent);
if ($incumbent->getPubkey() !== $pubkey) {
$incumbent->setPubkey($pubkey);
}
try {
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error('[longform_ingest] saveEachArticle: flush after update failed: '.$e->getMessage());
$this->managerRegistry->resetManager();
}
return;
}
if ($this->wireMerge->wireEventSupersedes($iWire, $candidate)) {
$this->logger->info('[longform_ingest] saveEachArticle: keep DB — merged relay result is not newer (incumbent wins)', [
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),
'dbEventId' => $incumbent->getEventId(),
'seenEventId' => $newId,
'db_row_id' => $incumbent->getId(),
'dbCreatedAt' => $iTs,
'seenCreatedAt' => $cTs,
]);
} elseif ((string) $incumbent->getEventId() !== $newId) {
$this->logger->notice('[longform_ingest] saveEachArticle: inconclusive supersedes (different ids) — check relays / d-tag match', [
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),
'dbEventId' => $incumbent->getEventId(),
'seenEventId' => $newId,
'db_row_id' => $incumbent->getId(),
'dbCreatedAt' => $iTs,
'seenCreatedAt' => $cTs,
]);
}
}
/**
* @param mixed $descriptor
* @return Event|null
*/
public function getEventFromDescriptor(mixed $descriptor): ?\stdClass
{
// Descriptor is an stdClass with properties: type and decoded
if (is_object($descriptor) && isset($descriptor->type, $descriptor->decoded)) {
// construct a request from the descriptor to fetch the event
$data = json_decode($descriptor->decoded);
if (!\is_object($data)) {
$this->logger->error('Invalid descriptor decoded JSON', ['descriptor' => $descriptor]);
return null;
}
$byEventId = isset($data->id) && \is_string($data->id) && $data->id !== '';
if ($byEventId) {
// NIP-01: filter by "ids", not "#e" (which matches *tags* named "e").
$kind = isset($data->kind) ? (int) $data->kind : 1;
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: ['ids' => [$data->id]],
relaySet: $this->defaultRelaySet
);
} else {
// Replaceable address (naddr): must filter on #d like {@see getEventByNaddr()}.
// Using key "d" does not call Filter::setTag — relays then return any kind match for the author.
$pubkey = (string) ($data->pubkey ?? '');
$identifier = (string) ($data->identifier ?? '');
if ($pubkey === '' || $identifier === '') {
$this->logger->warning('Naddr descriptor missing pubkey or identifier', ['data' => $data]);
return null;
}
$kind = (int) ($data->kind ?? KindsEnum::LONGFORM->value);
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kind],
filters: [
'authors' => [$pubkey],
'tag' => ['#d', [$identifier]],
],
relaySet: $this->defaultRelaySet
);
}
$events = $this->nostrRelayQuery->processResponse($request->send(), function($received) {
$this->logger->info('Getting event', ['item' => $received]);
return $received;
});
if (empty($events)) {
$this->logger->warning('No events found for descriptor', ['descriptor' => $descriptor]);
return null;
}
if ($byEventId) {
foreach ($events as $event) {
if (isset($event->id) && $event->id === $data->id) {
return $event;
}
}
return $events[0];
}
$wantD = (string) ($data->identifier ?? '');
$kindI = (int) ($data->kind ?? KindsEnum::LONGFORM->value);
$authorH = $this->wireMerge->authorIdentToHexLower($data->pubkey ?? null);
if ($this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null) {
$picked = $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, $wantD);
if ($picked !== null) {
return $picked;
}
}
foreach ($events as $event) {
if ($this->eventHasDTag($event, $wantD)) {
return $event;
}
}
return $events[0];
} else {
$this->logger->error('Invalid descriptor format', ['descriptor' => $descriptor]);
return null;
}
}
private function eventHasDTag(object $event, string $identifier): bool
{
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'd' && (string) ($tag[1] ?? '') === $identifier) {
return true;
}
}
return false;
}
/**
* Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity}
* so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass).
*
* The magazine root uses the site d_tag from config. Each category uses the full child d
* (third segment of the root "a" address). A category 30040 lists 30023 article "a" tags, not
* further nested 30040 indices.
*
* Tries article relays first; if no 30040 is found, retries on config `profile_relays` not
* already listed in `article_relays` (see prewarm / category discovery).
*/
public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity
{
$urls = $this->relayListFactory->getConfiguredArticleRelayUrlList();
$relaysForLog = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $urls));
$result = $this->queryMagazineIndex($npub, $dTag, $this->defaultRelaySet, $relaysForLog);
if ($result !== null) {
return $result;
}
$profileExtra = $this->relayListFactory->getProfileRelayUrlsExcludedFromArticleRelays();
if ($profileExtra === []) {
return null;
}
$pfSet = $this->relayListFactory->createRelaySetFromUrlsOnly($profileExtra);
$relaysForLog2 = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $profileExtra)).' (profile_relays)';
return $this->queryMagazineIndex($npub, $dTag, $pfSet, $relaysForLog2);
}
private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity
{
$authorHex = $this->wireMerge->npubToHexPubkey($npub);
if ($authorHex === null) {
$this->logger->warning('Magazine index: could not resolve npub to hex pubkey', [
'npub' => $npub,
'dTag' => $dTag,
]);
return null;
}
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
relaySet: $relaySet,
kinds: [KindsEnum::PUBLICATION_INDEX],
filters: ['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]],
);
$this->logger->info(sprintf('Magazine index query (relays: %s)', $relaysForLog), [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
]);
$response = $request->send();
$events = $this->nostrRelayQuery->processResponse($response, function ($received) {
return $received;
});
if (empty($events)) {
return null;
}
$raw = $this->wireMerge->pickLatestNip33ParameterizedForQuery(
$events,
KindsEnum::PUBLICATION_INDEX->value,
$authorHex,
(string) $dTag
);
if ($raw === null) {
$this->logger->warning('Magazine index: no event matched NIP-33 address (kind:pubkey:d) after merge', [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
'event_count' => \count($events),
]);
return null;
}
return $this->wireMerge->magazineEventToPublicationEntity($raw);
}
/**
* Single long-form coordinate on config profile relays only (not already in article_relays).
*/
private function tryFetchLongformCoordinateOnProfileRelays(string $coordinate): ?object
{
$extra = $this->relayListFactory->getProfileRelayUrlsExcludedFromArticleRelays();
if ($extra === []) {
return null;
}
$parts = explode(':', $coordinate, 3);
if (\count($parts) !== 3) {
return null;
}
$kind = (int) $parts[0];
$pubkey = strtolower($parts[1]);
$slug = trim((string) $parts[2]);
$kindEnum = KindsEnum::tryFrom($kind);
if ($kindEnum === null || $pubkey === '' || $slug === '') {
return null;
}
$pfSet = $this->relayListFactory->createRelaySetFromUrlsOnly($extra);
try {
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
relaySet: $pfSet,
kinds: [$kindEnum],
filters: ['authors' => [$pubkey], 'tag' => ['#d', [$slug]]],
);
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn (object $event) => $event,
);
$ev = $this->wireMerge->pickEventForNip33OrFirst($events, $kind, $pubkey, $slug);
if ($ev !== null) {
return $ev;
}
$fallbackReq = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
relaySet: $pfSet,
kinds: [$kindEnum],
filters: ['tag' => ['#d', [$slug]]],
);
$fallbackEvents = $this->nostrRelayQuery->processResponse(
$fallbackReq->send(),
static fn (object $event) => $event,
);
$matched = [];
foreach ($fallbackEvents as $ev2) {
if (!\is_object($ev2)) {
continue;
}
if (strtolower((string) ($ev2->pubkey ?? '')) !== $pubkey) {
continue;
}
$d = $this->wireMerge->eventDTagValue($ev2);
if ($d === null || trim((string) $d) !== $slug) {
continue;
}
$matched[] = $ev2;
}
return $matched === [] ? null : $this->wireMerge->pickEventForNip33OrFirst($matched, $kind, $pubkey, $slug);
} catch (\Throwable) {
}
return null;
}
/**
* Batch-fetch latest longform for category `a` coordinates; one Nostr call per (author × kind)
* group. Uses the same full article {@see $defaultRelaySet} as kind 30040 index queries so merged
* NIP-33 results are not stuck on a single relay’s copy. {@see saveEachArticleToTheDatabase}
* upserts by NIP-33 address.
*
* After article relays return nothing (or some addresses stay missing), retries use config
* `profile_relays` not already in `article_relays`. The generic community listing at `/articles`
* is DB-only and does not add a profile-relay pass; {@see getArticles} stays article-relays-only.
*
* @param list<string> $addresses kind:pubkey:identifier
*/
public function ingestLongformForCategoryCoordinates(array $addresses): void
{
if ($addresses === []) {
$this->logger->info('[longform_ingest] ingestLongform: no addresses, exit');
return;
}
$relaysForLog = implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $this->relayListFactory->getConfiguredArticleRelayUrlList()));
$this->logger->info('[longform_ingest] ingestLongform: start', [
'address_count' => \count($addresses),
'relays' => $relaysForLog,
'addresses_sample' => \array_values(\array_slice($addresses, 0, 15)),
]);
$groups = [];
foreach ($addresses as $c) {
$parts = explode(':', (string) $c, 3);
if (\count($parts) < 3) {
$this->logger->notice('[longform_ingest] ingestLongform: skip malformed coordinate (not kind:pubkey:rest)', [
'coordinate' => $c,
]);
continue;
}
$kind = (int) $parts[0];
$pubkey = strtolower($parts[1]);
$d = trim((string) $parts[2]);
if ($d === '' || $kind <= 0) {
continue;
}
$gkey = $pubkey.':'.(string) $kind;
$groups[$gkey]['pubkey'] = $pubkey;
$groups[$gkey]['kind'] = $kind;
$groups[$gkey]['dTags'][] = $d;
}
$this->logger->info('[longform_ingest] ingestLongform: request groups (batched by author+kind)', [
'group_count' => \count($groups),
]);
foreach ($groups as $gkey => $g) {
$dTags = array_values(array_unique($g['dTags'] ?? []));
if ($dTags === [] || !isset($g['pubkey'], $g['kind'])) {
continue;
}
$kindEnum = KindsEnum::tryFrom((int) $g['kind']);
if ($kindEnum === null) {
$this->logger->notice('[longform_ingest] skip group: unknown kind', ['kind' => $g['kind']]);
continue;
}
$this->logger->info('[longform_ingest] ingestLongform: REQ group', [
'group_key' => $gkey,
'filter_kind' => (int) $g['kind'],
'author_hex64_prefix' => substr((string) $g['pubkey'], 0, 12),
'd_tag_count' => \count($dTags),
'd_tags' => array_map(
fn (string $dt): string => $this->wireMerge->longformIngestShortSlug($dt, 72),
$dTags
),
]);
$request = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kindEnum],
filters: ['authors' => [(string) $g['pubkey']], 'tag' => ['#d', $dTags]],
);
try {
$events = $this->nostrRelayQuery->processResponse(
$request->send(),
static fn (object $event) => $event,
);
$rawCount = \count($events);
$rawSample = [];
$si = 0;
foreach ($events as $ev) {
if (!\is_object($ev)) {
continue;
}
if ($si < 25) {
$rawSample[] = $this->wireMerge->longformIngestEventWireSummary($ev);
}
++$si;
}
$this->logger->info('[longform_ingest] ingestLongform: responses merged from relays (pre-NIP-33 per-address merge)', [
'raw_wire_count' => $rawCount,
'sample_up_to_25' => $rawSample,
]);
if ($rawCount === 0) {
$this->logger->warning('[longform_ingest] ingestLongform: no EVENT rows returned for this filter (check relay index / author filter / #d list)', [
'group_key' => $gkey,
'authors_filter' => $g['pubkey'],
]);
// Some relays fail to satisfy combined authors+#d filters for parameterized replaceables.
// Fallback: query by #d only, then enforce author and d-tag match client-side.
$fallbackReq = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
kinds: [$kindEnum],
filters: ['tag' => ['#d', $dTags]],
);
$fallbackEvents = $this->nostrRelayQuery->processResponse(
$fallbackReq->send(),
static fn (object $event) => $event,
);
$fallbackMatched = [];
$expectedPubkey = strtolower((string) $g['pubkey']);
$expectedD = array_fill_keys($dTags, true);
foreach ($fallbackEvents as $ev) {
if (!\is_object($ev)) {
continue;
}
$evPubkey = strtolower((string) ($ev->pubkey ?? ''));
if ($evPubkey !== $expectedPubkey) {
continue;
}
$evD = $this->wireMerge->eventDTagValue($ev);
if ($evD === null || !isset($expectedD[$evD])) {
continue;
}
$fallbackMatched[] = $ev;
}
$this->logger->info('[longform_ingest] ingestLongform: fallback #d-only query result', [
'group_key' => $gkey,
'fallback_raw_wire_count' => \count($fallbackEvents),
'fallback_matched_count' => \count($fallbackMatched),
]);
if ($fallbackMatched !== []) {
$events = $fallbackMatched;
$rawCount = \count($events);
}
}
if ($rawCount === 0) {
$profileExtra = $this->relayListFactory->getProfileRelayUrlsExcludedFromArticleRelays();
if ($profileExtra !== []) {
$pfSet = $this->relayListFactory->createRelaySetFromUrlsOnly($profileExtra);
$this->logger->info('[longform_ingest] ingestLongform: no rows on article relays; trying profile_relays', [
'group_key' => $gkey,
'relays' => implode(', ', array_map(NostrRelayQuery::relayLogLabel(...), $profileExtra)),
]);
$requestPf = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
relaySet: $pfSet,
kinds: [$kindEnum],
filters: ['authors' => [(string) $g['pubkey']], 'tag' => ['#d', $dTags]],
);
$events = $this->nostrRelayQuery->processResponse(
$requestPf->send(),
static fn (object $event) => $event,
);
$rawCount = \count($events);
if ($rawCount === 0) {
$fallbackPf = $this->nostrRelayQuery->createNostrRequest(
defaultRelaySet: $this->defaultRelaySet,
relaySet: $pfSet,
kinds: [$kindEnum],
filters: ['tag' => ['#d', $dTags]],
);
$fallbackEventsPf = $this->nostrRelayQuery->processResponse(
$fallbackPf->send(),
static fn (object $event) => $event,
);
$fallbackMatchedPf = [];
$expectedPubkeyPf = strtolower((string) $g['pubkey']);
$expectedDPf = array_fill_keys($dTags, true);
foreach ($fallbackEventsPf as $ev) {
if (!\is_object($ev)) {
continue;
}
$evPubkey = strtolower((string) ($ev->pubkey ?? ''));
if ($evPubkey !== $expectedPubkeyPf) {
continue;
}
$evD = $this->wireMerge->eventDTagValue($ev);
if ($evD === null || !isset($expectedDPf[$evD])) {
continue;
}
$fallbackMatchedPf[] = $ev;
}
$this->logger->info('[longform_ingest] ingestLongform: profile_relays #d-only fallback', [
'group_key' => $gkey,
'fallback_raw_wire_count' => \count($fallbackEventsPf),
'fallback_matched_count' => \count($fallbackMatchedPf),
]);
if ($fallbackMatchedPf !== []) {
$events = $fallbackMatchedPf;
$rawCount = \count($events);
}
}
}
}
$merged = $this->wireMerge->mergeNip33ParameterizedWireEvents($events);
$mergedDetail = [];
foreach ($merged as $ev) {
if (!\is_object($ev)) {
continue;
}
$mergedDetail[] = $this->wireMerge->longformIngestEventWireSummary($ev);
}
$this->logger->info('[longform_ingest] ingestLongform: after mergeNip33ParameterizedWireEvents', [
'merged_count' => \count($merged),
'one_row_per_nip33_address' => $mergedDetail,
]);
$kindInt = (int) $g['kind'];
$authorHex = strtolower((string) $g['pubkey']);
$expectedAddresses = [];
foreach ($dTags as $dt) {
$expectedAddresses[$kindInt.':'.$authorHex.':'.$dt] = true;
}
$seenAddresses = [];
foreach ($merged as $event) {
if (!\is_object($event)) {
continue;
}
$addr = $this->wireMerge->nip33ParameterizedReplaceableAddress($event);
if ($addr !== null) {
$seenAddresses[$addr] = true;
}
$article = $this->articleFactory->createFromLongFormContentEvent($event);
$this->saveEachArticleToTheDatabase($article);
}
foreach (array_keys($expectedAddresses) as $coordinate) {
if (isset($seenAddresses[$coordinate])) {
continue;
}
$this->logger->notice('[longform_ingest] ingestLongform: address missing after batch merge; trying author NIP-65 relays', [
'coordinate' => $coordinate,
]);
$byCoord = $this->getArticlesByCoordinates([$coordinate]);
$evExtra = $byCoord[$coordinate] ?? null;
if ($evExtra === null) {
$evExtra = $this->tryFetchLongformCoordinateOnProfileRelays($coordinate);
}
if ($evExtra === null) {
$this->logger->warning('[longform_ingest] ingestLongform: still no event for coordinate (not on article, author, or profile relays)', [
'coordinate' => $coordinate,
]);
continue;
}
$article = $this->articleFactory->createFromLongFormContentEvent($evExtra);
$this->saveEachArticleToTheDatabase($article);
}
} catch (\Throwable $e) {
$this->logger->error(
sprintf('[longform_ingest] ingestLongform: exception in group %s: %s', (string) $gkey, $e->getMessage()),
[
'message' => $e->getMessage(),
'pubkey' => $g['pubkey'] ?? null,
'trace' => $e->getTraceAsString(),
'relays' => $relaysForLog,
],
);
}
}
$this->logger->info('[longform_ingest] ingestLongform: done (all groups)');
}
}