diff --git a/config/services.yaml b/config/services.yaml index a58bdc8..4870809 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -15,6 +15,7 @@ services: autoconfigure: true # Automatically registers your services as commands, event subscribers, etc. bind: $nostrDefaultRelay: '%nostr_default_relay%' + $environment: '%kernel.environment%' # makes classes in src/ available to be used as services # this creates a service per class whose id is the fully-qualified class name diff --git a/src/Controller/HighlightsController.php b/src/Controller/HighlightsController.php index e717493..1778978 100644 --- a/src/Controller/HighlightsController.php +++ b/src/Controller/HighlightsController.php @@ -124,6 +124,7 @@ class HighlightsController extends AbstractController private function processHighlights(array $events): array { $processed = []; + $pubkeys = []; foreach ($events as $event) { $highlight = [ @@ -138,8 +139,13 @@ class HighlightsController extends AbstractController 'context' => null, 'url' => null, 'naddr' => null, + 'profile' => null, ]; + if ($highlight['pubkey']) { + $pubkeys[] = $highlight['pubkey']; + } + $relayHints = []; // Extract metadata from tags @@ -194,10 +200,8 @@ class HighlightsController extends AbstractController } } - // Sort by created_at descending (newest first) + // Sort & dedupe by article_ref usort($processed, fn($a, $b) => $b['created_at'] <=> $a['created_at']); - - // Deduplicate highlights by article_ref, keeping the latest $uniqueHighlights = []; foreach ($processed as $highlight) { $ref = $highlight['article_ref']; @@ -206,6 +210,43 @@ class HighlightsController extends AbstractController } } + // Batch fetch metadata for all pubkeys (local relay first, fallback to reputable relays for missing) + $uniquePubkeys = array_values(array_unique(array_filter($pubkeys, fn($p) => is_string($p) && strlen($p) === 64))); + if (!empty($uniquePubkeys)) { + try { + $metadataMap = $this->nostrClient->getMetadataForPubkeys($uniquePubkeys, true); // map pubkey => event + foreach ($uniqueHighlights as &$highlight) { + $pubkey = $highlight['pubkey']; + if ($pubkey && isset($metadataMap[$pubkey])) { + $metaEvent = $metadataMap[$pubkey]; + // Parse metadata JSON content + $profile = null; + if (isset($metaEvent->content)) { + $decoded = json_decode($metaEvent->content, true); + if (is_array($decoded)) { + $profile = [ + 'name' => $decoded['name'] ?? ($decoded['display_name'] ?? null), + 'display_name' => $decoded['display_name'] ?? null, + 'picture' => $decoded['picture'] ?? null, + 'banner' => $decoded['banner'] ?? null, + 'about' => $decoded['about'] ?? null, + 'website' => $decoded['website'] ?? null, + 'nip05' => $decoded['nip05'] ?? null, + ]; + } + } + $highlight['profile'] = $profile; + } + } + unset($highlight); + } catch (\Throwable $e) { + $this->logger->warning('Failed batch metadata enrichment for highlights', [ + 'error' => $e->getMessage(), + 'pubkeys_count' => count($uniquePubkeys) + ]); + } + } + return $uniqueHighlights; } diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 6265dab..5199539 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -510,8 +510,21 @@ class NostrClient $pubkey = $parts[1]; $identifier = end($parts); - // Get relays for the author - $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); + // Use local relay only if configured, otherwise use author relays + if ($this->nostrDefaultRelay) { + $authorRelays = [$this->nostrDefaultRelay]; + $this->logger->info('Using local relay for comments fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'coordinate' => $coordinate + ]); + } else { + // Fallback to author relays when no local relay is configured + $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); + $this->logger->info('Using author relays for comments fetch', [ + 'coordinate' => $coordinate, + 'relay_count' => count($authorRelays) + ]); + } // Build the request message $subscription = new Subscription(); @@ -588,12 +601,21 @@ class NostrClient */ public function getLongFormContentForPubkey(string $ident, ?int $since = null, ?int $kind = KindsEnum::LONGFORM->value ): array { - // Add user relays to the default set - $authorRelays = $this->getTopReputableRelaysForAuthor($ident); - // Create a RelaySet from the author's relays - $relaySet = $this->defaultRelaySet; - if (!empty($authorRelays)) { - $relaySet = $this->createRelaySet($authorRelays); + // Use local relay only if configured, otherwise use author relays + if ($this->nostrDefaultRelay) { + $relaySet = $this->defaultRelaySet; + $this->logger->info('Using local relay for article fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'pubkey' => $ident + ]); + } else { + // Fallback to author relays when no local relay is configured + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + $relaySet = empty($authorRelays) ? $this->defaultRelaySet : $this->createRelaySet($authorRelays); + $this->logger->info('Using author relays for article fetch', [ + 'pubkey' => $ident, + 'relay_count' => count($authorRelays) + ]); } // Create request using the helper method @@ -626,12 +648,17 @@ class NostrClient */ public function getPictureEventsForPubkey(string $ident, int $limit = 20): array { - // Add user relays to the default set - $authorRelays = $this->getTopReputableRelaysForAuthor($ident); - // Create a RelaySet from the author's relays - $relaySet = $this->defaultRelaySet; - if (!empty($authorRelays)) { - $relaySet = $this->createRelaySet($authorRelays); + // Use local relay only if configured, otherwise use author relays + if ($this->nostrDefaultRelay) { + $relaySet = $this->defaultRelaySet; + $this->logger->info('Using local relay for picture events fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'pubkey' => $ident + ]); + } else { + // Fallback to author relays when no local relay is configured + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + $relaySet = empty($authorRelays) ? $this->defaultRelaySet : $this->createRelaySet($authorRelays); } // Create request for kind 20 (picture events) @@ -656,12 +683,17 @@ class NostrClient */ public function getVideoShortsForPubkey(string $ident, int $limit = 20): array { - // Add user relays to the default set - $authorRelays = $this->getTopReputableRelaysForAuthor($ident); - // Create a RelaySet from the author's relays - $relaySet = $this->defaultRelaySet; - if (!empty($authorRelays)) { - $relaySet = $this->createRelaySet($authorRelays); + // Use local relay only if configured, otherwise use author relays + if ($this->nostrDefaultRelay) { + $relaySet = $this->defaultRelaySet; + $this->logger->info('Using local relay for video shorts fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'pubkey' => $ident + ]); + } else { + // Fallback to author relays when no local relay is configured + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + $relaySet = empty($authorRelays) ? $this->defaultRelaySet : $this->createRelaySet($authorRelays); } // Create request for kind 22 (short video events) @@ -686,12 +718,17 @@ class NostrClient */ public function getNormalVideosForPubkey(string $ident, int $limit = 20): array { - // Add user relays to the default set - $authorRelays = $this->getTopReputableRelaysForAuthor($ident); - // Create a RelaySet from the author's relays - $relaySet = $this->defaultRelaySet; - if (!empty($authorRelays)) { - $relaySet = $this->createRelaySet($authorRelays); + // Use local relay only if configured, otherwise use author relays + if ($this->nostrDefaultRelay) { + $relaySet = $this->defaultRelaySet; + $this->logger->info('Using local relay for normal videos fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'pubkey' => $ident + ]); + } else { + // Fallback to author relays when no local relay is configured + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + $relaySet = empty($authorRelays) ? $this->defaultRelaySet : $this->createRelaySet($authorRelays); } // Create request for kind 21 (normal video events) @@ -717,12 +754,17 @@ class NostrClient */ public function getAllMediaEventsForPubkey(string $ident, int $limit = 30): array { - // Add user relays to the default set - $authorRelays = $this->getTopReputableRelaysForAuthor($ident); - // Create a RelaySet from the author's relays - $relaySet = $this->defaultRelaySet; - if (!empty($authorRelays)) { - $relaySet = $this->createRelaySet($authorRelays); + // Use local relay only if configured, otherwise use author relays + if ($this->nostrDefaultRelay) { + $relaySet = $this->defaultRelaySet; + $this->logger->info('Using local relay for all media events fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'pubkey' => $ident + ]); + } else { + // Fallback to author relays when no local relay is configured + $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + $relaySet = empty($authorRelays) ? $this->defaultRelaySet : $this->createRelaySet($authorRelays); } // Create request for all media kinds (20, 21, 22) in ONE request @@ -1032,21 +1074,37 @@ class NostrClient { $this->logger->info('Fetching highlights for article', ['coordinate' => $articleCoordinate]); - // Use relay pool to send request $subscription = new Subscription(); $subscriptionId = $subscription->setId(); $filter = new Filter(); $filter->setKinds([9802]); // NIP-84 highlights $filter->setLimit($limit); - // Add tag filter for the specific article coordinate $filter->setTags(['#a' => [$articleCoordinate]]); $requestMessage = new RequestMessage($subscriptionId, [$filter]); - // Get default relay URLs - $relayUrls = $this->relayPool->getDefaultRelays(); + // Prefer ONLY the local relay if configured + if ($this->nostrDefaultRelay) { + $relayUrls = [$this->nostrDefaultRelay]; + $this->logger->info('Using local relay for highlights fetch', [ + 'relay' => $this->nostrDefaultRelay, + 'coordinate' => $articleCoordinate + ]); + } else { + // Fallback – use first default relay only (keep narrow to avoid huge queries) + $defaultRelays = $this->relayPool->getDefaultRelays(); + $relayUrls = empty($defaultRelays) ? [] : [$defaultRelays[0]]; + $this->logger->info('Using fallback public relay for highlights fetch', [ + 'coordinate' => $articleCoordinate, + 'relay' => $relayUrls[0] ?? 'none' + ]); + } + + if (empty($relayUrls)) { + $this->logger->warning('No relays available for highlights fetch', ['coordinate' => $articleCoordinate]); + return []; + } - // Use the relay pool to send the request $responses = $this->relayPool->sendToRelays( $relayUrls, fn() => $requestMessage, @@ -1054,7 +1112,6 @@ class NostrClient $subscriptionId ); - // Process the response and deduplicate by eventId $uniqueEvents = []; $this->processResponse($responses, function($event) use (&$uniqueEvents) { $this->logger->debug('Received highlight event for article', ['event_id' => $event->id]); @@ -1257,4 +1314,110 @@ class NostrClient return $tTags; } + + /** + * Batch fetch metadata for multiple pubkeys + * Queries local relay first, then fallback to reputable relays for any missing metadata. + * + * @param array $pubkeys Array of pubkeys (hex-encoded) to fetch metadata for + * @param bool $fallback Whether to fallback to reputable relays if metadata is missing from local relay + * @return array Map of pubkey => metadata event + * @throws \Exception + */ + public function getMetadataForPubkeys(array $pubkeys, bool $fallback = true): array + { + // Deduplicate & sanitize + $pubkeys = array_values(array_unique(array_filter($pubkeys, fn($p) => is_string($p) && strlen($p) === 64))); + if (empty($pubkeys)) { + return []; + } + + $this->logger->info('Batch fetching metadata', [ + 'count' => count($pubkeys), + 'fallback' => $fallback, + ]); + + $results = []; + $missing = $pubkeys; + + // Helper to process events list and keep newest per pubkey + $process = function(array $events) use (&$results, &$missing) { + foreach ($events as $event) { + $pubkey = $event->pubkey ?? null; + if (!$pubkey) { continue; } + // Keep only newest + if (!isset($results[$pubkey]) || ($event->created_at ?? 0) > ($results[$pubkey]->created_at ?? 0)) { + $results[$pubkey] = $event; + } + } + // Update missing list + $missing = array_values(array_diff($missing, array_keys($results))); + }; + + // First pass: local relay only + try { + if ($this->nostrDefaultRelay) { + $relaySet = $this->createRelaySet([$this->nostrDefaultRelay]); + $request = $this->createNostrRequest( + kinds: [KindsEnum::METADATA->value], + filters: [ 'authors' => $missing, 'limit' => count($missing) ], + relaySet: $relaySet + ); + $events = $this->processResponse($request->send(), fn($e) => $e); + $process($events); + $this->logger->info('Local relay metadata fetch complete', [ + 'found' => count($results), + 'remaining' => count($missing) + ]); + } + } catch (\Throwable $e) { + $this->logger->warning('Local relay batch metadata fetch failed', ['error' => $e->getMessage()]); + } + + // Fallback pass: reputable relays (group remaining into chunks to reduce payload size) + if ($fallback && !empty($missing)) { + $chunks = array_chunk($missing, 50); // chunk size adjustable + foreach ($chunks as $chunk) { + if (empty($chunk)) { continue; } + try { + $relaySet = $this->createRelaySet(self::REPUTABLE_RELAYS); + $request = $this->createNostrRequest( + kinds: [KindsEnum::METADATA->value], + filters: [ 'authors' => $chunk, 'limit' => count($chunk) ], + relaySet: $relaySet + ); + $events = $this->processResponse($request->send(), fn($e) => $e); + $process($events); + $this->logger->info('Fallback metadata chunk fetched', [ + 'chunk_size' => count($chunk), + 'found_total' => count($results), + 'remaining' => count($missing) + ]); + if (empty($missing)) { break; } + } catch (\Throwable $e) { + $this->logger->warning('Fallback metadata chunk failed', [ + 'error' => $e->getMessage(), + 'chunk_size' => count($chunk) + ]); + } + } + } + + // Optional: cache individual metadata events + foreach ($results as $pubkey => $event) { + try { + $cacheKey = 'pubkey_meta_' . $pubkey; + $cachedItem = $this->npubCache->getItem($cacheKey); + if (!$cachedItem->isHit() || ($event->created_at ?? 0) > ($cachedItem->get()->created_at ?? 0)) { + $cachedItem->set($event); + $cachedItem->expiresAfter(3600); // 1 hour TTL + $this->npubCache->save($cachedItem); + } + } catch (\Throwable $e) { + // Non-critical + } + } + + return $results; // map pubkey => metadata event + } } diff --git a/sync-strfry.sh b/sync-strfry.sh index 800e375..57ade2f 100644 --- a/sync-strfry.sh +++ b/sync-strfry.sh @@ -1,4 +1,12 @@ #!/bin/bash +# Sync articles, comments, media, and profiles from upstream relays +# Event kinds: 30023 (articles), 30024 (drafts), 1111 (comments), 20 (pictures), 21 (videos), 22 (short videos), 0 (profiles), 9802 (highlights) -docker compose exec strfry ./strfry sync wss://theforest.nostr1.com --filter '{"kinds":[9802,1111,30023,0]}' --dir down -docker compose exec strfry ./strfry sync wss://relay.damus.io --filter '{"kinds":[9802,1111,30023,0]}' --dir down +KINDS='{"kinds":[30023,30024,1111,20,21,22,0,9802]}' + +echo "Starting relay sync at $(date)" + +docker compose exec strfry ./strfry sync wss://theforest.nostr1.com --filter "$KINDS" --dir down +docker compose exec strfry ./strfry sync wss://relay.damus.io --filter "$KINDS" --dir down + +echo "Sync completed at $(date)"