defaultRelaySet = $this->relayListFactory->getDefaultArticleRelaySet(); } /** * default_relay + article_relays (deduplicated) for publishing user comments. * * @return list */ 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 */ 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 $authorPubkeyHex * @return array 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 $authorPubkeyHex * @return array 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 $authorPubkeyHex * @return list 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 */ 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 */ 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, quotes: array, 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 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 $relayUrls * * @return list */ 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 $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)'); } }