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.
1575 lines
57 KiB
1575 lines
57 KiB
<?php |
|
|
|
namespace App\Service; |
|
|
|
use App\Entity\Article; |
|
use App\Enum\KindsEnum; |
|
use App\Factory\ArticleFactory; |
|
use App\Util\NostrPhp\TweakedRequest; |
|
use Doctrine\ORM\EntityManagerInterface; |
|
use Doctrine\Persistence\ManagerRegistry; |
|
use nostriphant\NIP19\Data; |
|
use Psr\Cache\CacheItemPoolInterface; |
|
use Psr\Log\LoggerInterface; |
|
use swentel\nostr\Event\Event; |
|
use swentel\nostr\Filter\Filter; |
|
use swentel\nostr\Key\Key; |
|
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; |
|
|
|
class NostrClient |
|
{ |
|
private RelaySet $defaultRelaySet; |
|
|
|
/** |
|
* List of reputable relays in descending order of reputation |
|
*/ |
|
private const REPUTABLE_RELAYS = [ |
|
'wss://theforest.nostr1.com', |
|
'wss://nostr.land', |
|
'wss://purplepag.es', |
|
'wss://nos.lol', |
|
'wss://relay.snort.social', |
|
'wss://relay.damus.io', |
|
'wss://relay.primal.net', |
|
]; |
|
|
|
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 CacheItemPoolInterface $npubCache, |
|
private readonly NostrRelayPool $relayPool, |
|
private readonly ?string $nostrDefaultRelay = null) |
|
{ |
|
// Initialize default relay set using the relay pool to avoid duplicate connections |
|
// Prefer local relay if configured, otherwise use public relays |
|
$defaultRelayUrls = []; |
|
|
|
if ($this->nostrDefaultRelay) { |
|
// Use configured default relay (typically local strfry instance) |
|
$defaultRelayUrls = [$this->nostrDefaultRelay]; |
|
$this->logger->info('Using configured default Nostr relay', ['relay' => $this->nostrDefaultRelay]); |
|
} else { |
|
// Fallback to public relays |
|
$defaultRelayUrls = [ |
|
'wss://theforest.nostr1.com', |
|
'wss://nostr.land', |
|
'wss://relay.primal.net' |
|
]; |
|
$this->logger->info('Using public Nostr relays (no default relay configured)'); |
|
} |
|
|
|
$this->defaultRelaySet = new RelaySet(); |
|
foreach ($defaultRelayUrls as $url) { |
|
$this->defaultRelaySet->addRelay($this->relayPool->getRelay($url)); |
|
} |
|
} |
|
|
|
/** |
|
* Creates a RelaySet from a list of relay URLs using the connection pool |
|
*/ |
|
private function createRelaySet(array $relayUrls): RelaySet |
|
{ |
|
$relaySet = new RelaySet(); |
|
foreach ($relayUrls as $relayUrl) { |
|
// Use the pool to get persistent relay connections |
|
$relay = $this->relayPool->getRelay($relayUrl); |
|
$relaySet->addRelay($relay); |
|
} |
|
return $relaySet; |
|
} |
|
|
|
/** |
|
* Get top 3 reputable relays from an author's relay list |
|
*/ |
|
private function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array |
|
{ |
|
try { |
|
$authorRelays = $this->getNpubRelays($pubkey); |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error getting author relays', [ |
|
'pubkey' => $pubkey, |
|
'error' => $e->getMessage() |
|
]); |
|
// fall through |
|
$authorRelays = []; |
|
} |
|
if (empty($authorRelays)) { |
|
return self::REPUTABLE_RELAYS; // Default to theforest if no author relays |
|
} |
|
|
|
$reputableAuthorRelays = []; |
|
foreach (self::REPUTABLE_RELAYS as $relay) { |
|
if (in_array($relay, $authorRelays) && count($reputableAuthorRelays) < $limit) { |
|
$reputableAuthorRelays[] = $relay; |
|
} |
|
} |
|
|
|
// If no reputable relays found in author's list, take the top 3 from author's list |
|
// But make sure they start with wss: and are not localhost |
|
if (empty($reputableAuthorRelays)) { |
|
$authorRelays = array_filter($authorRelays, function ($relay) { |
|
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); |
|
}); |
|
return $limit != 0 ? array_slice($authorRelays, 0, $limit) : $authorRelays; |
|
} |
|
|
|
return $reputableAuthorRelays; |
|
} |
|
|
|
/** |
|
* @throws \Exception |
|
*/ |
|
public function getPubkeyMetadata($pubkey): \stdClass |
|
{ |
|
// Use relay pool for all relays including purplepag.es, with local relay prioritized |
|
$relayUrls = $this->relayPool->ensureLocalRelayInList([ |
|
'wss://theforest.nostr1.com', |
|
'wss://nostr.land', |
|
'wss://relay.primal.net', |
|
'wss://purplepag.es' |
|
]); |
|
$relaySet = $this->createRelaySet($relayUrls); |
|
|
|
$this->logger->info('Getting metadata for pubkey ' . $pubkey ); |
|
$request = $this->createNostrRequest( |
|
kinds: [KindsEnum::METADATA], |
|
filters: ['authors' => [$pubkey]], |
|
relaySet: $relaySet |
|
); |
|
|
|
$events = $this->processResponse($request->send(), function($received) { |
|
$this->logger->info('Getting metadata for pubkey', ['item' => $received]); |
|
return $received; |
|
}); |
|
|
|
$this->logger->info('Getting metadata for pubkey', ['response' => $events]); |
|
|
|
if (empty($events)) { |
|
throw new \Exception('No metadata found for pubkey: ' . $pubkey); |
|
} |
|
|
|
// Sort by date and return newest |
|
usort($events, fn($a, $b) => $b->created_at <=> $a->created_at); |
|
return $events[0]; |
|
} |
|
|
|
public function publishEvent(Event $event, array $relays): array |
|
{ |
|
$eventMessage = new EventMessage($event); |
|
// If no relays, fetch relays for user then post to those |
|
if (empty($relays)) { |
|
$key = new Key(); |
|
$relays = $this->getTopReputableRelaysForAuthor($key->convertPublicKeyToBech32($event->getPublicKey()), 0); |
|
} else { |
|
// Ensure local relay is included when publishing |
|
$relays = $this->relayPool->ensureLocalRelayInList($relays); |
|
} |
|
|
|
// Use relay pool instead of creating new Relay instances |
|
$relaySet = $this->createRelaySet($relays); |
|
$relaySet->setMessage($eventMessage); |
|
try { |
|
$this->logger->info('Publishing event to relays', [ |
|
'event_id' => $event->getId(), |
|
'relays' => $relays |
|
]); |
|
return $relaySet->send(); |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error logging publish event', [ |
|
'error' => $e->getMessage() |
|
]); |
|
return []; |
|
} |
|
} |
|
|
|
/** |
|
* Long-form Content |
|
* NIP-23 |
|
*/ |
|
public function getLongFormContent($from = null, $to = null): void |
|
{ |
|
$subscription = new Subscription(); |
|
$subscriptionId = $subscription->setId(); |
|
$filter = new Filter(); |
|
$filter->setKinds([KindsEnum::LONGFORM]); |
|
$filter->setSince(strtotime('-1 week')); // default |
|
if ($from !== null) { |
|
$filter->setSince($from); |
|
} |
|
if ($to !== null) { |
|
$filter->setUntil($to); |
|
} |
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
|
|
|
// Create relay set from all reputable relays on record, with local relay prioritized |
|
$relayUrls = $this->relayPool->ensureLocalRelayInList(self::REPUTABLE_RELAYS); |
|
$relaySet = $this->createRelaySet($relayUrls); |
|
|
|
$request = new Request($relaySet, $requestMessage); |
|
|
|
// Process the response |
|
$events = $this->processResponse($request->send(), function($event) { |
|
return $event; |
|
}); |
|
|
|
if (!empty($events)) { |
|
foreach ($events as $event) { |
|
$article = $this->articleFactory->createFromLongFormContentEvent($event); |
|
// check if event with same eventId already in DB |
|
$this->saveEachArticleToTheDatabase($article); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* @throws \Exception |
|
*/ |
|
public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void |
|
{ |
|
$this->logger->info('Getting long form from ' . $slug, [ |
|
'relay_list' => $relayList, |
|
'author' => $author, |
|
'kind' => $kind |
|
]); |
|
|
|
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); |
|
$authorRelaySet = $this->createRelaySet($topAuthorRelays); |
|
$this->logger->info('Author relays for long form fetch', [ |
|
'author' => $author, |
|
'from_event' => $this->getNpubRelays($author), |
|
'relays' => $topAuthorRelays |
|
]); |
|
|
|
if (empty($relayList)) { |
|
$topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); |
|
$authorRelaySet = $this->createRelaySet($topAuthorRelays); |
|
} else { |
|
$authorRelaySet = $this->createRelaySet($relayList); |
|
} |
|
|
|
try { |
|
// Create request using the helper method for forest relay set |
|
$request = $this->createNostrRequest( |
|
kinds: [$kind], |
|
filters: [ |
|
'authors' => [$author], |
|
'tag' => ['#d', [$slug]] |
|
], |
|
relaySet: $authorRelaySet |
|
); |
|
|
|
// Process the response |
|
$events = $this->processResponse($request->send(), function($event) { |
|
return $event; |
|
}); |
|
|
|
if (!empty($events)) { |
|
// Save only the first event (most recent) |
|
$event = $events[0]; |
|
$wrapper = new \stdClass(); |
|
$wrapper->type = 'EVENT'; |
|
$wrapper->event = $event; |
|
$this->saveLongFormContent([$wrapper]); |
|
} |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error querying relays', [ |
|
'error' => $e->getMessage() |
|
]); |
|
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]); |
|
|
|
// Ensure local relay is included in the relay list |
|
$relays = $this->relayPool->ensureLocalRelayInList($relays); |
|
|
|
// Merge relays with reputable ones |
|
$allRelays = array_unique(array_merge($relays, self::REPUTABLE_RELAYS)); |
|
// Loop over reputable relays and bail as soon as you get a valid event back |
|
foreach ($allRelays as $reputableRelay) { |
|
$this->logger->info('Trying reputable relay first', ['relay' => $reputableRelay]); |
|
$request = $this->createNostrRequest( |
|
kinds: [], |
|
filters: ['ids' => [$eventId], 'limit' => 1], |
|
relaySet: $this->createRelaySet([$reputableRelay]), |
|
stopGap: $eventId |
|
); |
|
$events = $this->processResponse($request->send(), function($event) { |
|
$this->logger->debug('Received event', ['event' => $event]); |
|
return $event; |
|
}); |
|
if (!empty($events)) { |
|
return $events[0]; |
|
} |
|
} |
|
|
|
// Use provided relays or default if empty |
|
$relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays); |
|
|
|
// Create request using the helper method |
|
$request = $this->createNostrRequest( |
|
kinds: [], |
|
filters: ['ids' => [$eventId]], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response |
|
$events = $this->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]; |
|
} |
|
|
|
/** |
|
* Get multiple events by their IDs |
|
* |
|
* @param array $eventIds Array of event IDs |
|
* @param array $relays Optional array of relay URLs to query |
|
* @return array Array of events indexed by ID |
|
* @throws \Exception |
|
*/ |
|
public function getEventsByIds(array $eventIds, array $relays = []): array |
|
{ |
|
if (empty($eventIds)) { |
|
return []; |
|
} |
|
|
|
$this->logger->info('Getting events by IDs', ['event_ids' => $eventIds, 'relays' => $relays]); |
|
|
|
// Ensure local relay is included |
|
if (!empty($relays)) { |
|
$relays = $this->relayPool->ensureLocalRelayInList($relays); |
|
} |
|
|
|
// Use provided relays or default if empty |
|
$relaySet = empty($relays) ? $this->defaultRelaySet : $this->createRelaySet($relays); |
|
|
|
// Create request using the helper method |
|
$request = $this->createNostrRequest( |
|
kinds: [], |
|
filters: ['ids' => $eventIds], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response |
|
$events = $this->processResponse($request->send(), function($event) { |
|
$this->logger->debug('Received event', ['event' => $event]); |
|
return $event; |
|
}); |
|
|
|
// Index events by ID |
|
$eventsMap = []; |
|
foreach ($events as $event) { |
|
$eventsMap[$event->id] = $event; |
|
} |
|
|
|
return $eventsMap; |
|
} |
|
|
|
/** |
|
* 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->getTopReputableRelaysForAuthor($pubkey) : $relays; |
|
$relaySet = $this->createRelaySet($authorRelays); |
|
|
|
// Create request using the helper method |
|
$request = $this->createNostrRequest( |
|
kinds: [$kind], |
|
filters: [ |
|
'authors' => [$pubkey], |
|
'tag' => ['#d', [$identifier]] |
|
], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response |
|
$events = $this->processResponse($request->send(), function($event) { |
|
return $event; |
|
}); |
|
|
|
if (!empty($events)) { |
|
return $events[0]; |
|
} |
|
|
|
// Try default relays as fallback |
|
$request = $this->createNostrRequest( |
|
kinds: [$kind], |
|
filters: [ |
|
'authors' => [$pubkey], |
|
'tag' => ['#d', [$identifier]] |
|
] |
|
); |
|
|
|
$events = $this->processResponse($request->send(), function($event) { |
|
return $event; |
|
}); |
|
|
|
return !empty($events) ? $events[0] : null; |
|
} |
|
|
|
private function saveLongFormContent(mixed $filtered): void |
|
{ |
|
foreach ($filtered as $wrapper) { |
|
$article = $this->articleFactory->createFromLongFormContentEvent($wrapper->event); |
|
// check if event with same eventId already in DB |
|
$this->saveEachArticleToTheDatabase($article); |
|
} |
|
} |
|
|
|
/** |
|
* @throws \Exception |
|
*/ |
|
public function getNpubRelays($npub): array |
|
{ |
|
$cacheKey = 'npub_relays_' . $npub; |
|
try { |
|
// $this->npubCache->deleteItem($cacheKey); |
|
$cachedItem = $this->npubCache->getItem($cacheKey); |
|
if ($cachedItem->isHit()) { |
|
$this->logger->debug('Using cached relays for npub', ['npub' => $npub]); |
|
return $cachedItem->get(); |
|
} |
|
} catch (\Exception $e) { |
|
$this->logger->warning('Cache error', ['error' => $e->getMessage()]); |
|
} |
|
|
|
$relays = new RelaySet(); |
|
$relays->createFromUrls(self::REPUTABLE_RELAYS); |
|
|
|
// Get relays |
|
$request = $this->createNostrRequest( |
|
kinds: [KindsEnum::RELAY_LIST->value], |
|
filters: ['authors' => [$npub]], |
|
relaySet: $relays |
|
); |
|
$response = $this->processResponse($request->send(), function($received) { |
|
return $received; |
|
}); |
|
if (empty($response)) { |
|
return []; |
|
} |
|
// Sort by date and use newest |
|
usort($response, fn($a, $b) => $b->created_at <=> $a->created_at); |
|
// Process tags of the $response[0] and extract relays |
|
$relays = []; |
|
foreach ($response[0]->tags as $tag) { |
|
if ($tag[0] === 'r') { |
|
$relays[] = $tag[1]; |
|
} |
|
} |
|
// Remove duplicates, localhost and any non-wss relays |
|
$relays = array_filter(array_unique($relays), function ($relay) { |
|
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost'); |
|
}); |
|
|
|
// Cache the result |
|
try { |
|
$cachedItem = $this->npubCache->getItem($cacheKey); |
|
$cachedItem->set($relays); |
|
$cachedItem->expiresAfter(3600); // 1 hour |
|
$this->npubCache->save($cachedItem); |
|
} catch (\Exception $e) { |
|
$this->logger->warning('Cache save error', ['error' => $e->getMessage()]); |
|
} |
|
|
|
return $relays; |
|
} |
|
|
|
/** |
|
* Get comments for a specific coordinate |
|
* |
|
* @param string $coordinate The event coordinate (kind:pubkey:identifier) |
|
* @return array Array of comment events |
|
* @throws \Exception |
|
*/ |
|
public function getComments(string $coordinate, ?int $since = null): array |
|
{ |
|
$this->logger->info('Getting comments for coordinate', ['coordinate' => $coordinate]); |
|
|
|
// Get author from coordinate, then relays |
|
$parts = explode(':', $coordinate, 3); |
|
if (count($parts) < 3) { |
|
throw new \InvalidArgumentException('Invalid coordinate format, expected kind:pubkey:identifier'); |
|
} |
|
$kind = (int)$parts[0]; |
|
$pubkey = $parts[1]; |
|
$identifier = end($parts); |
|
|
|
// 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(); |
|
$subscriptionId = $subscription->setId(); |
|
$filter = new Filter(); |
|
$filter->setKinds([ |
|
KindsEnum::COMMENTS->value, |
|
// KindsEnum::ZAP_RECEIPT->value // Not yet |
|
]); |
|
$filter->setTag('#A', [$coordinate]); |
|
|
|
if (is_int($since) && $since > 0) { |
|
$filter->setSince($since); |
|
} |
|
|
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
|
|
|
// Use the relay pool to send the request |
|
// Pass subscription ID so the pool can send CLOSE message after receiving responses |
|
$responses = $this->relayPool->sendToRelays( |
|
$authorRelays, |
|
fn() => $requestMessage, |
|
30, // timeout in seconds |
|
$subscriptionId // close subscription after receiving responses |
|
); |
|
|
|
// Process the response and deduplicate by eventId |
|
$uniqueEvents = []; |
|
$this->processResponse($responses, function($event) use (&$uniqueEvents) { |
|
$this->logger->debug('Received comment event', ['event_id' => $event->id]); |
|
$uniqueEvents[$event->id] = $event; |
|
return null; |
|
}); |
|
|
|
return array_values($uniqueEvents); |
|
} |
|
|
|
/** |
|
* 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, 3); |
|
$pubkey = $parts[1]; |
|
|
|
// Get author's relays for better chances of finding zaps (includes local relay) |
|
$authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); |
|
$relaySet = $this->createRelaySet($authorRelays); |
|
|
|
// Create request using the helper method |
|
// Zaps are kind 9735 |
|
$request = $this->createNostrRequest( |
|
kinds: [KindsEnum::ZAP_RECEIPT], |
|
filters: ['tag' => ['#a', [$coordinate]]], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response |
|
return $this->processResponse($request->send(), function($event) { |
|
$this->logger->debug('Received zap event', ['event_id' => $event->id]); |
|
return $event; |
|
}); |
|
} |
|
|
|
/** |
|
* @throws \Exception |
|
*/ |
|
public function getLongFormContentForPubkey(string $ident, ?int $since = null, ?int $kind = KindsEnum::LONGFORM->value ): array |
|
{ |
|
// 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 |
|
$filters = [ |
|
'authors' => [$ident], |
|
'limit' => 20 // default limit |
|
]; |
|
if ($since !== null && $since > 0) { |
|
$filters['since'] = $since; |
|
} |
|
|
|
$request = $this->createNostrRequest( |
|
kinds: [$kind], |
|
filters: $filters, |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response using the helper method |
|
return $this->processResponse($request->send(), function($event) { |
|
$article = $this->articleFactory->createFromLongFormContentEvent($event); |
|
// Save each article to the database |
|
$this->saveEachArticleToTheDatabase($article); |
|
return $article; // Return the article so it gets added to the results array |
|
}); |
|
} |
|
|
|
/** |
|
* Get picture events (kind 20) for a specific author |
|
* @throws \Exception |
|
*/ |
|
public function getPictureEventsForPubkey(string $ident, int $limit = 20): array |
|
{ |
|
// 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) |
|
$request = $this->createNostrRequest( |
|
kinds: [20], // NIP-68 Picture events |
|
filters: [ |
|
'authors' => [$ident], |
|
'limit' => $limit |
|
], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response and return raw events |
|
return $this->processResponse($request->send(), function($event) { |
|
return $event; // Return the raw event |
|
}); |
|
} |
|
|
|
/** |
|
* Get video shorts (kind 22) for a given pubkey |
|
* @throws \Exception |
|
*/ |
|
public function getVideoShortsForPubkey(string $ident, int $limit = 20): array |
|
{ |
|
// 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) |
|
$request = $this->createNostrRequest( |
|
kinds: [22], // NIP-71 Short video events |
|
filters: [ |
|
'authors' => [$ident], |
|
'limit' => $limit |
|
], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response and return raw events |
|
return $this->processResponse($request->send(), function($event) { |
|
return $event; // Return the raw event |
|
}); |
|
} |
|
|
|
/** |
|
* Get normal video events (kind 21) for a given pubkey |
|
* @throws \Exception |
|
*/ |
|
public function getNormalVideosForPubkey(string $ident, int $limit = 20): array |
|
{ |
|
// 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) |
|
$request = $this->createNostrRequest( |
|
kinds: [21], // NIP-71 Normal video events |
|
filters: [ |
|
'authors' => [$ident], |
|
'limit' => $limit |
|
], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response and return raw events |
|
return $this->processResponse($request->send(), function($event) { |
|
return $event; // Return the raw event |
|
}); |
|
} |
|
|
|
/** |
|
* Get all media events (pictures and videos) for a given pubkey in a single request |
|
* This is more efficient than making 3 separate requests |
|
* @throws \Exception |
|
*/ |
|
public function getAllMediaEventsForPubkey(string $ident, int $limit = 30): array |
|
{ |
|
// 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 |
|
$request = $this->createNostrRequest( |
|
kinds: [20, 21, 22], // NIP-68 Pictures, NIP-71 Videos (normal and shorts) |
|
filters: [ |
|
'authors' => [$ident], |
|
'limit' => $limit |
|
], |
|
relaySet: $relaySet |
|
); |
|
|
|
// Process the response and return raw events |
|
return $this->processResponse($request->send(), function($event) { |
|
return $event; // Return the raw event |
|
}); |
|
} |
|
|
|
/** |
|
* Get recent media events (pictures and videos) from the last 3 days |
|
* Fetches from all relays without filtering by author |
|
* @throws \Exception |
|
*/ |
|
public function getRecentMediaEvents(int $limit = 100): array |
|
{ |
|
// Create request for all media kinds (20, 21, 22) from the last 3 days |
|
$request = $this->createNostrRequest( |
|
kinds: [20, 21, 22], // NIP-68 Pictures, NIP-71 Videos (normal and shorts) |
|
filters: [ |
|
'limit' => $limit |
|
], |
|
relaySet: $this->defaultRelaySet |
|
); |
|
|
|
// Process the response and return raw events |
|
return $this->processResponse($request->send(), function($event) { |
|
return $event; // Return the raw event |
|
}); |
|
} |
|
|
|
/** |
|
* Get media events filtered by specific hashtags |
|
* @throws \Exception |
|
*/ |
|
public function getMediaEventsByHashtags(array $hashtags): array |
|
{ |
|
$allEvents = []; |
|
|
|
// Fetch events for each hashtag |
|
|
|
$request = $this->createNostrRequest( |
|
kinds: [20], // NIP-68 Pictures; consider expanding to 21/22 later |
|
filters: [ |
|
'tag' => ['#t', $hashtags], |
|
'limit' => 500 // Fetch up to 100 per hashtag |
|
], |
|
relaySet: $this->createRelaySet(['wss://theforest.nostr1.com']) |
|
); |
|
|
|
$events = $this->processResponse($request->send(), function($event) { |
|
return $event; |
|
}); |
|
|
|
$allEvents = array_merge($allEvents, $events); |
|
|
|
|
|
return $allEvents; |
|
} |
|
|
|
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 = new Request($this->defaultRelaySet, $requestMessage); |
|
$response = $request->send(); |
|
$hasEvents = false; |
|
|
|
// Check if we got any events |
|
foreach ($response as $value) { |
|
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 = new Request($this->defaultRelaySet, $requestMessage); |
|
$response = $request->send(); |
|
|
|
foreach ($response as $value) { |
|
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'])) { |
|
$this->logger->error('An error while getting articles.', ['response' => $item]); |
|
} |
|
} |
|
} |
|
} |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error querying relays', [ |
|
'error' => $e->getMessage() |
|
]); |
|
|
|
// Fall back to default relay set |
|
$request = new Request($this->defaultRelaySet, $requestMessage); |
|
$response = $request->send(); |
|
|
|
foreach ($response as $value) { |
|
foreach ($value as $item) { |
|
if ($item->type === 'EVENT') { |
|
if (!isset($articles[$item->event->id])) { |
|
$articles[$item->event->id] = $item->event; |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
return $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->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 no author relays found, add default relay |
|
if (empty($relayList)) { |
|
$relayList = [self::REPUTABLE_RELAYS[0]]; |
|
} |
|
|
|
// Ensure we use a RelaySet |
|
$relaySet = $this->createRelaySet($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]); |
|
|
|
try { |
|
$request = new Request($relaySet, $requestMessage); |
|
$response = $request->send(); |
|
$found = false; |
|
|
|
// Check responses from each relay |
|
foreach ($response as $value) { |
|
foreach ($value as $item) { |
|
if ($item->type === 'EVENT') { |
|
$articlesMap[$coordinate] = $item->event; |
|
$found = true; |
|
break 2; // Found what we need, exit both loops |
|
} |
|
} |
|
} |
|
|
|
// If still not found, try with default relay set as fallback |
|
if (!$found) { |
|
$this->logger->info('Article not found in author relays, trying default relays', [ |
|
'coordinate' => $coordinate |
|
]); |
|
|
|
$request = new Request($this->defaultRelaySet, $requestMessage); |
|
$response = $request->send(); |
|
|
|
foreach ($response as $value) { |
|
foreach ($value as $item) { |
|
if ($item->type === 'EVENT') { |
|
$articlesMap[$coordinate] = $item->event; |
|
break 2; |
|
} |
|
} |
|
} |
|
} |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error fetching article', [ |
|
'coordinate' => $coordinate, |
|
'error' => $e->getMessage() |
|
]); |
|
} |
|
} |
|
|
|
return $articlesMap; |
|
} |
|
|
|
/** |
|
* Get article highlights (NIP-84) - kind 9802 events that reference articles |
|
* @throws \Exception |
|
*/ |
|
public function getArticleHighlights(int $limit = 50): array |
|
{ |
|
$this->logger->info('Fetching article highlights from default relay'); |
|
|
|
// Use relay pool to send request |
|
$subscription = new Subscription(); |
|
$subscriptionId = $subscription->setId(); |
|
$filter = new Filter(); |
|
$filter->setKinds([9802]); // NIP-84 highlights |
|
$filter->setLimit($limit); |
|
$filter->setSince(strtotime('-7 days')); // Last 7 days |
|
|
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
|
|
|
// Use only the configured default relay |
|
$relayUrls = $this->nostrDefaultRelay |
|
? [$this->nostrDefaultRelay] |
|
: [($this->relayPool->getDefaultRelays()[0] ?? null)]; |
|
$relayUrls = array_filter($relayUrls); // Remove nulls if fallback fails |
|
|
|
$responses = $this->relayPool->sendToRelays( |
|
$relayUrls, |
|
fn() => $requestMessage, |
|
30, |
|
$subscriptionId |
|
); |
|
|
|
// Process the response and deduplicate by eventId |
|
$uniqueEvents = []; |
|
$this->processResponse($responses, function($event) use (&$uniqueEvents) { |
|
// Filter to only include highlights that reference articles |
|
$hasArticleRef = false; |
|
foreach ($event->tags ?? [] as $tag) { |
|
if (is_array($tag) && count($tag) >= 2) { |
|
if (in_array($tag[0], ['a', 'A'])) { |
|
// Check if it references a kind 30023 (article) |
|
if (str_starts_with($tag[1] ?? '', '30023:')) { |
|
$hasArticleRef = true; |
|
break; |
|
} |
|
} |
|
} |
|
} |
|
|
|
if ($hasArticleRef) { |
|
$this->logger->debug('Received article highlight event', ['event_id' => $event->id]); |
|
$uniqueEvents[$event->id] = $event; |
|
} |
|
return null; |
|
}); |
|
|
|
return array_values($uniqueEvents); |
|
} |
|
|
|
/** |
|
* Get highlights for a specific article |
|
* @throws \Exception |
|
*/ |
|
public function getHighlightsForArticle(string $articleCoordinate, int $limit = 100): array |
|
{ |
|
$this->logger->info('Fetching highlights for article', ['coordinate' => $articleCoordinate]); |
|
|
|
$subscription = new Subscription(); |
|
$subscriptionId = $subscription->setId(); |
|
$filter = new Filter(); |
|
$filter->setKinds([9802]); // NIP-84 highlights |
|
$filter->setLimit($limit); |
|
$filter->setTags(['#a' => [$articleCoordinate]]); |
|
|
|
$requestMessage = new RequestMessage($subscriptionId, [$filter]); |
|
|
|
// 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 []; |
|
} |
|
|
|
$responses = $this->relayPool->sendToRelays( |
|
$relayUrls, |
|
fn() => $requestMessage, |
|
30, |
|
$subscriptionId |
|
); |
|
|
|
$uniqueEvents = []; |
|
$this->processResponse($responses, function($event) use (&$uniqueEvents) { |
|
$this->logger->debug('Received highlight event for article', ['event_id' => $event->id]); |
|
$uniqueEvents[$event->id] = $event; |
|
return null; |
|
}); |
|
|
|
return array_values($uniqueEvents); |
|
} |
|
|
|
public function getLatestLongFormArticles(int $limit = 50, ?int $since = null): array |
|
{ |
|
// Prefer ONLY the local relay if configured; otherwise use the default relay set |
|
if ($this->nostrDefaultRelay) { |
|
$relaySet = $this->createRelaySet([$this->nostrDefaultRelay]); |
|
$this->logger->info('Fetching latest long-form articles from local relay', [ |
|
'relay' => $this->nostrDefaultRelay, |
|
'limit' => $limit, |
|
'since' => $since |
|
]); |
|
} else { |
|
$relaySet = $this->defaultRelaySet; |
|
$this->logger->info('Fetching latest long-form articles from default relay set (no local relay configured)', [ |
|
'limit' => $limit, |
|
'since' => $since |
|
]); |
|
} |
|
|
|
$filters = [ 'limit' => $limit ]; |
|
if ($since !== null && $since > 0) { |
|
$filters['since'] = $since; |
|
} |
|
|
|
try { |
|
$request = $this->createNostrRequest( |
|
kinds: [KindsEnum::LONGFORM->value], |
|
filters: $filters, |
|
relaySet: $relaySet |
|
); |
|
|
|
$events = $this->processResponse($request->send(), function($event) { |
|
try { |
|
$article = $this->articleFactory->createFromLongFormContentEvent($event); |
|
// Persist newest revision if not saved yet |
|
$this->saveEachArticleToTheDatabase($article); |
|
return $article; |
|
} catch (\Throwable $e) { |
|
$this->logger->error('Failed converting event to Article', [ |
|
'error' => $e->getMessage(), |
|
'event_id' => $event->id ?? null |
|
]); |
|
return null; |
|
} |
|
}); |
|
} catch (\Throwable $e) { |
|
$this->logger->error('Error fetching latest long-form articles', [ 'error' => $e->getMessage() ]); |
|
return []; |
|
} |
|
|
|
// Filter out nulls |
|
$articles = array_filter($events, fn($a) => $a instanceof Article); |
|
|
|
// Deduplicate by slug keeping latest createdAt |
|
$bySlug = []; |
|
foreach ($articles as $article) { |
|
$slug = $article->getSlug(); |
|
if ($slug === '') { continue; } |
|
if (!isset($bySlug[$slug]) || $article->getCreatedAt() > $bySlug[$slug]->getCreatedAt()) { |
|
$bySlug[$slug] = $article; |
|
} |
|
} |
|
|
|
// Sort descending by createdAt |
|
$deduped = array_values($bySlug); |
|
usort($deduped, fn($a, $b) => $b->getCreatedAt() <=> $a->getCreatedAt()); |
|
|
|
return $deduped; |
|
} |
|
|
|
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null, $stopGap = null ): TweakedRequest |
|
{ |
|
$subscription = new Subscription(); |
|
$filter = new Filter(); |
|
if (!empty($kinds)) { |
|
$filter->setKinds($kinds); |
|
} |
|
|
|
foreach ($filters as $key => $value) { |
|
$method = 'set' . ucfirst($key); |
|
if (method_exists($filter, $method)) { |
|
// If it's tags, we need to handle it differently |
|
if ($key === 'tag') { |
|
$filter->setTag($value[0], $value[1]); |
|
} else { |
|
// Call the method with the value |
|
$filter->$method($value); |
|
} |
|
} |
|
} |
|
$this->logger->info('Relay set for request', ['relays' => $relaySet ? $relaySet->getRelays() : 'default']); |
|
|
|
$requestMessage = new RequestMessage($subscription->getId(), [$filter]); |
|
return (new TweakedRequest( |
|
$relaySet ?? $this->defaultRelaySet, |
|
$requestMessage, |
|
$this->logger |
|
))->stopOnEventId($stopGap); |
|
} |
|
|
|
private function processResponse(array $response, callable $eventHandler): array |
|
{ |
|
$results = []; |
|
foreach ($response as $relayUrl => $relayRes) { |
|
// Skip if the relay response is an Exception |
|
if ($relayRes instanceof \Exception) { |
|
$this->logger->error('Relay error', [ |
|
'relay' => $relayUrl, |
|
'error' => $relayRes->getMessage() |
|
]); |
|
continue; |
|
} |
|
|
|
$this->logger->debug('Processing relay response', [ |
|
'relay' => $relayUrl, |
|
'response' => $relayRes |
|
]); |
|
|
|
foreach ($relayRes as $item) { |
|
try { |
|
if (!is_object($item)) { |
|
$this->logger->warning('Invalid response item from ' . $relayUrl , [ |
|
'relay' => $relayUrl, |
|
'item' => $item |
|
]); |
|
continue; |
|
} |
|
|
|
switch ($item->type) { |
|
case 'EVENT': |
|
$this->logger->debug('Processing event', [ |
|
'relay' => $relayUrl, |
|
'event_id' => $item->event->id ?? 'unknown' |
|
]); |
|
$result = $eventHandler($item->event); |
|
if ($result !== null) { |
|
$results[] = $result; |
|
} |
|
break; |
|
case 'AUTH': |
|
$this->logger->info('Relay required authentication (handled during request)', [ |
|
'relay' => $relayUrl, |
|
'response' => $item |
|
]); |
|
break; |
|
case 'ERROR': |
|
case 'NOTICE': |
|
$this->logger->warning('Relay error/notice', [ |
|
'relay' => $relayUrl, |
|
'type' => $item->type, |
|
'message' => $item->message ?? 'No message' |
|
]); |
|
break; |
|
} |
|
} catch (\Exception $e) { |
|
$this->logger->error('Error processing event from relay', [ |
|
'relay' => $relayUrl, |
|
'error' => $e->getMessage() |
|
]); |
|
continue; // Skip this item but continue processing others |
|
} |
|
} |
|
} |
|
return $results; |
|
} |
|
|
|
/** |
|
* @param Article $article |
|
* @return void |
|
*/ |
|
public function saveEachArticleToTheDatabase(Article $article): void |
|
{ |
|
$saved = $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $article->getEventId()]); |
|
if (!$saved) { |
|
try { |
|
$this->logger->info('Saving article', ['article' => $article]); |
|
$this->entityManager->persist($article); |
|
$this->entityManager->flush(); |
|
} catch (\Exception $e) { |
|
$this->logger->error($e->getMessage()); |
|
$this->managerRegistry->resetManager(); |
|
} |
|
} |
|
} |
|
|
|
/** |
|
* @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 |
|
/** @var Data $ata */ |
|
$data = json_decode($descriptor->decoded); |
|
// If id is set, search by id and kind |
|
if (isset($data->id)) { |
|
$request = $this->createNostrRequest( |
|
kinds: [$data->kind], |
|
filters: ['e' => [$data->id]], |
|
relaySet: $this->defaultRelaySet |
|
); |
|
} else { |
|
$request = $this->createNostrRequest( |
|
kinds: [$data->kind], |
|
filters: ['authors' => [$data->pubkey], 'd' => [$data->identifier]], |
|
relaySet: $this->defaultRelaySet |
|
); |
|
} |
|
|
|
$events = $this->processResponse($request->send(), function($received) { |
|
$this->logger->info('Getting event', ['item' => $received]); |
|
return $received; |
|
}); |
|
|
|
if (!empty($events)) { |
|
// Return the first event found |
|
return $events[0]; |
|
} else { |
|
$this->logger->warning('No events found for descriptor', ['descriptor' => $descriptor]); |
|
return null; |
|
} |
|
} else { |
|
$this->logger->error('Invalid descriptor format', ['descriptor' => $descriptor]); |
|
return null; |
|
} |
|
} |
|
|
|
/** |
|
* Fetch the latest interest list (kind 10015) for a pubkey and return the 't' tags. |
|
*/ |
|
public function getUserInterests(string $pubkey): array |
|
{ |
|
$request = $this->createNostrRequest( |
|
kinds: [10015], |
|
filters: ['authors' => [$pubkey]], |
|
relaySet: $this->defaultRelaySet |
|
); |
|
|
|
$events = $this->processResponse($request->send(), function($received) use ($pubkey) { |
|
$this->logger->info('Getting interests for pubkey', ['pubkey' => $pubkey, 'item' => $received]); |
|
return $received; |
|
}); |
|
|
|
if (empty($events)) { |
|
return []; |
|
} |
|
|
|
// Sort by created_at descending and take the latest |
|
usort($events, fn($a, $b) => $b->created_at <=> $a->created_at); |
|
$latest = $events[0]; |
|
|
|
$tTags = []; |
|
foreach ($latest->tags as $tag) { |
|
if (is_array($tag) && isset($tag[0]) && $tag[0] === 't' && isset($tag[1])) { |
|
$tTags[] = strtolower(trim($tag[1])); |
|
} |
|
} |
|
|
|
return $tTags; |
|
} |
|
|
|
/** |
|
* Fetch user's follow list (kind 3 event) from relays |
|
* |
|
* @param string $pubkey Hex-encoded pubkey |
|
* @return array Array of followed pubkeys extracted from 'p' tags, or empty array if not found |
|
* @throws \Exception |
|
*/ |
|
public function getUserFollows(string $pubkey): array |
|
{ |
|
$this->logger->info('Fetching follow list for pubkey', ['pubkey' => $pubkey]); |
|
|
|
// Use relay pool with local relay prioritized |
|
$relayUrls = $this->relayPool->ensureLocalRelayInList([ |
|
'wss://theforest.nostr1.com', |
|
'wss://nostr.land', |
|
'wss://relay.primal.net', |
|
'wss://purplepag.es' |
|
]); |
|
$relaySet = $this->createRelaySet($relayUrls); |
|
|
|
$request = $this->createNostrRequest( |
|
kinds: [KindsEnum::FOLLOWS->value], |
|
filters: ['authors' => [$pubkey], 'limit' => 1], |
|
relaySet: $relaySet |
|
); |
|
|
|
$events = $this->processResponse($request->send(), function($received) { |
|
$this->logger->info('Received follow list event', ['event' => $received]); |
|
return $received; |
|
}); |
|
|
|
if (empty($events)) { |
|
$this->logger->info('No follow list found for pubkey', ['pubkey' => $pubkey]); |
|
return []; |
|
} |
|
|
|
// Sort by created_at descending and take the most recent |
|
usort($events, fn($a, $b) => $b->created_at <=> $a->created_at); |
|
$latestFollowEvent = $events[0]; |
|
|
|
// Extract followed pubkeys from 'p' tags |
|
$followedPubkeys = []; |
|
foreach ($latestFollowEvent->tags as $tag) { |
|
if (is_array($tag) && isset($tag[0]) && $tag[0] === 'p' && isset($tag[1])) { |
|
$followedPubkeys[] = $tag[1]; |
|
} |
|
} |
|
|
|
$this->logger->info('Follow list fetched successfully', [ |
|
'pubkey' => $pubkey, |
|
'follows_count' => count($followedPubkeys) |
|
]); |
|
|
|
return $followedPubkeys; |
|
} |
|
|
|
/** |
|
* 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(84000); // 24 hours |
|
$this->npubCache->save($cachedItem); |
|
} |
|
} catch (\Throwable $e) { |
|
// Non-critical |
|
} |
|
} |
|
|
|
return $results; // map pubkey => metadata event |
|
} |
|
}
|
|
|