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.
 
 
 
 
 
 

2195 lines
78 KiB

<?php
namespace App\Service;
use App\Entity\Article;
use App\Entity\User;
use App\Entity\Event as PublicationEventEntity;
use App\Enum\KindsEnum;
use App\Factory\ArticleFactory;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use nostriphant\NIP19\Data;
use Psr\Log\LoggerInterface;
use swentel\nostr\Event\Event;
use swentel\nostr\Filter\Filter;
use swentel\nostr\Message\EventMessage;
use swentel\nostr\Message\RequestMessage;
use swentel\nostr\Relay\Relay;
use swentel\nostr\Relay\RelaySet;
use swentel\nostr\Request\Request;
use swentel\nostr\Subscription\Subscription;
use Symfony\Component\Process\PhpExecutableFinder;
use Symfony\Component\Process\Process;
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface;
use Symfony\Contracts\Cache\CacheInterface;
use Symfony\Contracts\Cache\ItemInterface;
class NostrClient
{
/** Per-relay WebSocket I/O cap (seconds), applied on each relay’s {@see \WebSocket\Client}. */
private const RELAY_REQUEST_TIMEOUT_SEC = 15;
/** Extra wall time for {@see bin/nostr_relay_request_worker.php} process vs. WebSocket timeout. */
private const DISCUSSION_WORKER_GRACE_SEC = 5.0;
/**
* Hard cap on unique relay URLs for article discussion. More relays do not help much (indexers duplicate)
* but blow up wall time when we fall back to sequential in-process {@see Request::send()}.
*/
private const MAX_DISCUSSION_RELAY_URLS = 10;
/**
* {@see sendArticleDiscussionToRelaysSequential} visits relays one after another (~RELAY_REQUEST_TIMEOUT_SEC
* each). Keep this low so HTTP /fragment/comments and browsers do not hit 60–90s proxy cuts.
*/
private const MAX_SEQUENTIAL_RELAY_URLS = 3;
/** When a logged-in user lists this relay, also use {@see self::AGGR_NOSTR_LAND} for comment + profile reads. */
private const NOSTR_LAND = 'wss://nostr.land';
/**
* Aggregated / subscription relay (not for anonymous visitors). Only added when the session user
* has {@see self::NOSTR_LAND} in their NIP-65-style relay list.
*/
private const AGGR_NOSTR_LAND = 'wss://aggr.nostr.land';
private RelaySet $defaultRelaySet;
/**
* @param list<string> $articleRelayUrls extra relays for the default set (default_relay is always first)
* @param list<string> $profileRelayUrls kind-0 / profile; merged for metadata (see {@see profileMetadataQueryRelayUrlList()})
*/
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly ArticleFactory $articleFactory,
private readonly TokenStorageInterface $tokenStorage,
private readonly LoggerInterface $logger,
private readonly string $defaultRelayUrl,
private readonly array $articleRelayUrls,
private readonly array $profileRelayUrls,
private readonly CacheInterface $relayQueryCache,
private readonly string $projectDir,
) {
$this->defaultRelaySet = $this->buildArticleRelaySet();
}
/**
* default_relay + article_relays (deduplicated) for publishing user comments.
*
* @return list<string>
*/
public function getArticleWriteRelayUrls(): array
{
return $this->configuredArticleRelayUrlList();
}
/**
* Relays to publish a kind-1111 reply: site defaults plus NIP-65 (kind-10002) for the
* article author and, when the direct parent is another pubkey (nested comment), that
* author’s relays as well.
*
* @param string $articleCoordinate kind:pubkey:identifier
* @param string $parentEventAuthorHex 64-char hex of the event being replied to
*
* @return list<string>
*/
public function getRelayUrlsForCommentPublish(string $articleCoordinate, string $parentEventAuthorHex): array
{
$base = $this->configuredArticleRelayUrlList();
$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->getAuthorNip65RelaysList($pk) as $wss) {
if (!\is_string($wss) || $wss === '' || isset($seen[$wss])) {
continue;
}
$seen[$wss] = true;
$out[] = $wss;
}
}
return $out;
}
/**
* default_relay + article_relays from config, in order, deduplicated. Used for the static
* default set and as the base when merging author/extra relay URLs in {@see createRelaySet()}.
*
* @return list<string>
*/
private function configuredArticleRelayUrlList(): array
{
$seen = [];
$out = [];
foreach (array_merge([$this->defaultRelayUrl], $this->articleRelayUrls) as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
if ($out === []) {
$out[] = $this->defaultRelayUrl;
}
return $out;
}
private function buildArticleRelaySet(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->configuredArticleRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* One relay for magazine 30040 lookups. {@see Request::send()} iterates every relay in the set
* sequentially; the full default set (5–6 wss) multiplies wall time — often 10s+ while a single
* relay returns in under 2s for the same filter.
*/
private function buildSingleRelaySet(string $wssUrl): RelaySet
{
$rs = new RelaySet();
$rs->addRelay(new Relay($wssUrl));
return $rs;
}
/**
* Host (or full URL) for log messages so console output shows which relay without opening context.
*/
private static function relayLogLabel(string $relayUrl): string
{
$host = parse_url($relayUrl, \PHP_URL_HOST);
if (\is_string($host) && $host !== '') {
return $host;
}
return $relayUrl;
}
private function newTimedRequest(RelaySet $relaySet, RequestMessage $requestMessage): Request
{
$request = new Request($relaySet, $requestMessage);
// 1.9.4+: Request::setTimeout() drives getResponseFromRelay(). Older: only WebSocket client on Relay.
if (method_exists($request, 'setTimeout')) {
$request->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC);
} else {
$this->applyRelaySocketTimeoutToSet($relaySet);
}
return $request;
}
/**
* Set per-relay WebSocket I/O cap. {@see RelaySet::send()} bypasses {@see Request}; use this there too.
*/
private function applyRelaySocketTimeoutToSet(RelaySet $relaySet): void
{
foreach ($relaySet->getRelays() as $relay) {
$client = $relay->getClient();
if (method_exists($client, 'setTimeout')) {
$client->setTimeout(self::RELAY_REQUEST_TIMEOUT_SEC);
}
}
}
/**
* Merges all configured article relays (default + article_relays) with the given URLs in order, deduped.
* Used for comment threads (getArticleDiscussion), per-author fetches, etc.
*/
private function createRelaySet(array $relayUrls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* 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->loggedInUserHasNostrLandInRelayList() ? 'a1' : '';
}
private function loggedInUserHasNostrLandInRelayList(): bool
{
$token = $this->tokenStorage->getToken();
if ($token === null) {
return false;
}
$user = $token->getUser();
if (!$user instanceof User) {
return false;
}
return $this->userRelayListContainsNostrLand($user->getRelays());
}
/**
* @param list<array{0?: string, 1?: string, 2?: string}>|array<array-key, mixed>|null $relays
*/
private function userRelayListContainsNostrLand(?array $relays): bool
{
if ($relays === null || $relays === []) {
return false;
}
$target = $this->normalizeWssUrlForNostrLandMatch(self::NOSTR_LAND);
foreach ($relays as $row) {
if (!\is_array($row) || !isset($row[1]) || !\is_string($row[1])) {
continue;
}
if ($this->normalizeWssUrlForNostrLandMatch($row[1]) === $target) {
return true;
}
}
return false;
}
private function normalizeWssUrlForNostrLandMatch(string $url): string
{
return rtrim(trim($url), '/');
}
/**
* Appends wss://aggr.nostr.land when the current user listed wss://nostr.land (session).
*
* @param list<string> $urls
* @return list<string>
*/
private function withAggrNostrLandIfUserSubscribesNostrLand(array $urls): array
{
if (!$this->loggedInUserHasNostrLandInRelayList()) {
return $urls;
}
$seen = array_fill_keys($urls, true);
if (isset($seen[self::AGGR_NOSTR_LAND])) {
return $urls;
}
$this->logger->debug('nostr.relay.append_aggr_nostr_land', [
'user_has_nostr_land' => true,
]);
$out = $urls;
$out[] = self::AGGR_NOSTR_LAND;
return $out;
}
/**
* @param list<string> $urls
*/
private function relaySetFromDistinctUrlList(array $urls): RelaySet
{
$relaySet = new RelaySet();
$seen = [];
foreach ($urls as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$relaySet->addRelay(new Relay($relayUrl));
}
return $relaySet;
}
/**
* Full NIP-65 (kind-10002) wss:// list for a hex pubkey, cached. Used for comment fetches; prefer
* {@see getTopReputableRelaysForAuthor} when you only need a few relays.
*
* @return list<string>
*/
private function getAuthorNip65RelaysList(string $pubkey): array
{
$cacheKey = 'nostr_kind10002_relays_v1_'.hash('sha256', $pubkey);
return $this->relayQueryCache->get($cacheKey, function (ItemInterface $item) use ($pubkey): array {
$item->expiresAfter(3600);
try {
$authorRelays = $this->getNpubRelays($pubkey);
} catch (\Exception $e) {
$this->logger->error('Error getting author NIP-65 relay list', [
'pubkey' => $pubkey,
'error' => $e->getMessage(),
]);
$authorRelays = [];
}
$authorRelays = array_values(array_filter(
is_array($authorRelays) ? $authorRelays : [],
static function ($relay): bool {
return \is_string($relay)
&& str_starts_with($relay, 'wss:')
&& !str_contains($relay, 'localhost');
}
));
if ($authorRelays === []) {
return [];
}
$seen = [];
$out = [];
foreach ($authorRelays as $u) {
if (isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$out[] = $u;
}
return $out;
});
}
/**
* A short prefix of the author NIP-65 list (or default relay) for queries that do not need every home relay.
*/
private function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array
{
$all = $this->getAuthorNip65RelaysList($pubkey);
if ($all === []) {
return [$this->defaultRelayUrl];
}
if ($limit < 1) {
$limit = 1;
}
return \array_values(\array_slice($all, 0, $limit));
}
/**
* @return list<string> Deduplicated profile relay URLs from config
*/
private function profileRelayUrlList(): array
{
$seen = [];
$out = [];
foreach ($this->profileRelayUrls as $url) {
if (!\is_string($url) || $url === '' || isset($seen[$url])) {
continue;
}
if (!str_starts_with($url, 'wss:')) {
continue;
}
$seen[$url] = true;
$out[] = $url;
}
return $out;
}
/**
* Profile (kind-0) queries: {@see profileRelayUrlList()} first (Damus, nos.lol, …), then default + article set.
* Order matters: {@see Request::send()} walks relays sequentially.
*
* @return list<string>
*/
private function profileMetadataQueryRelayUrlList(): array
{
$seen = [];
$ordered = [];
foreach (array_merge($this->profileRelayUrlList(), $this->configuredArticleRelayUrlList()) as $u) {
if (!\is_string($u) || $u === '' || isset($seen[$u])) {
continue;
}
$seen[$u] = true;
$ordered[] = $u;
}
if ($ordered === []) {
$ordered[] = $this->defaultRelayUrl;
}
return $this->withAggrNostrLandIfUserSubscribesNostrLand($ordered);
}
/**
* Same relays for kind-0 metadata, without mutating {@see $this->defaultRelaySet}.
*/
private function relaySetForProfileMetadataFetch(): RelaySet
{
$relaySet = new RelaySet();
foreach ($this->profileMetadataQueryRelayUrlList() as $url) {
$relaySet->addRelay(new Relay($url));
}
return $relaySet;
}
/**
* Batched kind-0 profile fetch: one Nostr REQ per chunk with multiple "authors" (hex pubkeys).
*
* @param list<string> $authorPubkeyHex
* @return array<string, \stdClass> Newest kind-0 JSON per pubkey, keyed by hex
*/
public function fetchKind0MetadataForAuthors(array $authorPubkeyHex, int $authorsPerRequest = 50): array
{
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === []) {
return [];
}
$authorsPerRequest = max(1, min(200, $authorsPerRequest));
$byPub = [];
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch();
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
foreach ($chunks as $i => $chunk) {
$t0 = microtime(true);
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
filters: ['authors' => $chunk],
relaySet: $relaySet
);
$events = $this->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),
]);
$newest = [];
foreach ($events as $ev) {
if (!\is_object($ev) || !isset($ev->pubkey, $ev->content)) {
continue;
}
$pk = (string) $ev->pubkey;
if (64 !== \strlen($pk)) {
continue;
}
$ts = (int) ($ev->created_at ?? 0);
if (isset($newest[$pk]) && $ts <= $newest[$pk]['t']) {
continue;
}
$newest[$pk] = ['ev' => $ev, 't' => $ts];
}
foreach ($newest as $pk => $row) {
$ev = $row['ev'];
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;
}
/**
* NIP-09 kind 5 deletion requests in $since..$until (unix), batched by author pubkey (hex).
*
* @param (callable(int, int, int): void)|null $afterChunk 1-based index, total chunks, pubkeys in chunk
* @param list<string> $authorPubkeyHex
* @return list<stdClass> Deduplicated by event `id` (highest {@see created_at} kept)
*/
public function fetchKind5DeletionEventsForAuthors(
array $authorPubkeyHex,
int $since,
int $until,
int $authorsPerRequest = 40,
?callable $afterChunk = null,
): array {
$authorPubkeyHex = \array_values(\array_unique(\array_filter(
$authorPubkeyHex,
static fn (mixed $h): bool => \is_string($h) && 64 === \strlen($h),
)));
if ($authorPubkeyHex === [] || $since >= $until) {
return [];
}
$authorsPerRequest = max(1, min(100, $authorsPerRequest));
$byId = [];
$chunks = array_chunk($authorPubkeyHex, $authorsPerRequest);
$numChunks = \count($chunks);
foreach ($chunks as $i => $chunk) {
$request = $this->createNostrRequest(
kinds: [KindsEnum::DELETION_REQUEST],
filters: [
'authors' => $chunk,
'since' => $since,
'until' => $until,
],
);
$t0 = microtime(true);
$events = $this->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;
}
$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->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch();
$this->logger->info(sprintf('Getting metadata for npub (relays: %s)', $relaysTriedStr), ['npub' => $npub, 'relays' => $relaysTried]);
$request = $this->createNostrRequest(
kinds: [KindsEnum::METADATA],
filters: ['authors' => [$npub]],
relaySet: $relaySet
);
$events = $this->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.')');
}
// Sort by date and return newest
usort($events, static fn ($a, $b) => (int) ($b->created_at ?? 0) <=> (int) ($a->created_at ?? 0));
return $events[0];
}
/**
* NIP-A3 kind 10133: payment target events (replaceable) with `["payto", type, authority, ...]` tags.
*
* @return list<object>
*/
public function getKind10133PaymentTargetEventsForNpub(string $npub, int $limit = 20): array
{
$relaysTried = $this->profileMetadataQueryRelayUrlList();
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
$relaySet = $this->relaySetForProfileMetadataFetch();
try {
$request = $this->createNostrRequest(
kinds: [KindsEnum::PAYMENT_TARGETS],
filters: ['authors' => [$npub], 'limit' => max(1, min(50, $limit))],
relaySet: $relaySet
);
$events = $this->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 (!\is_array($events) || $events === []) {
return [];
}
usort($events, static fn ($a, $b) => (int) ($b->created_at ?? 0) <=> (int) ($a->created_at ?? 0));
return array_values($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->newTimedRequest($relays, $requestMessage);
$wrappers = $this->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);
$relaySet = new RelaySet();
foreach ($relays as $relayWss) {
$relay = new Relay($relayWss);
$relaySet->addRelay($relay);
}
$relaySet->setMessage($eventMessage);
$this->applyRelaySocketTimeoutToSet($relaySet);
// TODO handle responses appropriately
return $relaySet->send();
}
/**
* 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->newTimedRequest($this->defaultRelaySet, $requestMessage);
$wrappers = $this->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->getTopReputableRelaysForAuthor($author);
$authorRelaySet = $this->createRelaySet($topAuthorRelays);
$relaysTried = $this->plannedRelayUrlsForSet($topAuthorRelays);
} else {
$authorRelaySet = $this->createRelaySet($relayList);
$relaysTried = $this->plannedRelayUrlsForSet($relayList);
}
$relaysTriedStr = implode(', ', array_map(self::relayLogLabel(...), $relaysTried));
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(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->createRelaySet($relays);
// Create request using the helper method
$request = $this->createNostrRequest(
kinds: [], // Leave empty to accept any kind
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];
}
/**
* 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;
}
/**
* 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
{
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
{
// Get relays
$request = $this->createNostrRequest(
kinds: [KindsEnum::RELAY_LIST],
filters: ['authors' => [$npub]],
relaySet: $this->defaultRelaySet
);
$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
return array_filter(array_unique($relays), function ($relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
});
}
/**
* NIP-22 kind 1111 thread, legacy kind 1 replies (pre-NIP-22 clients), and quote/repost-style references.
*
* @param string $coordinate kind:pubkey:d-identifier (e.g. longform address)
* @param null|string $rootEventHexId Published article event id (hex) for #e / #q matching
*
* @return array{thread: array<int, object>, quotes: array<int, object>}
*/
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->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->configuredArticleRelayUrlList();
$mergedForDiscussion = $this->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->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->capRelayUrlsForSequentialPath($plannedRelayUrls);
$response = $this->sendArticleDiscussionToRelaysSequential($forSeq, $requestMessage);
} else {
try {
$response = $this->sendArticleDiscussionToRelaysParallel($plannedRelayUrls, $requestMessage);
} catch (\Throwable $e) {
$this->logger->warning('nostr.article_discussion.parallel_failed', [
'message' => $e->getMessage(),
'exception_class' => \get_class($e),
]);
$forSeq = $this->capRelayUrlsForSequentialPath($plannedRelayUrls);
$this->logger->warning('nostr.article_discussion.sequential_fallback', [
'relays' => $forSeq,
]);
$response = $this->sendArticleDiscussionToRelaysSequential($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->logNostrWireResponseSummary('article_discussion', $response);
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'nostr.article_discussion.req_send_failed (relays: %s): %s',
implode(', ', array_map(self::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);
}
$tParse = microtime(true);
$this->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->eventIsNip22ArticleThreadReply($event, $coordinate)) {
$thread[] = $event;
$threadIds[(string) $event->id] = true;
continue;
}
if ($kind === KindsEnum::TEXT_NOTE->value && $this->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->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),
]);
return ['thread' => $thread, 'quotes' => $quotes];
}
/**
* @param list<string> $relayUrls
*
* @return list<string>
*/
private function capRelayUrlsForSequentialPath(array $relayUrls): array
{
if (\count($relayUrls) <= self::MAX_SEQUENTIAL_RELAY_URLS) {
return $relayUrls;
}
$this->logger->notice('nostr.article_discussion.sequential_relay_cap', [
'used' => self::MAX_SEQUENTIAL_RELAY_URLS,
'had' => \count($relayUrls),
]);
return \array_values(\array_slice($relayUrls, 0, self::MAX_SEQUENTIAL_RELAY_URLS));
}
/**
* One {@see Request} over all relays (library visits each wss:// in series).
*
* @param list<string> $relayUrls
*
* @return array<string, mixed> Same shape as {@see Request::send()} (relay url → message list)
*/
private function sendArticleDiscussionToRelaysSequential(array $relayUrls, RequestMessage $requestMessage): array
{
$relaySet = $this->relaySetFromDistinctUrlList($relayUrls);
$request = $this->newTimedRequest($relaySet, $requestMessage);
return $request->send();
}
/**
* One short-lived CLI worker per relay so WebSocket I/O is parallel (vs. a single in-process
* {@see Request::send() loop).
*
* @param list<string> $relayUrls
*
* @return array<string, mixed> Same shape as {@see Request::send()}
*/
private function sendArticleDiscussionToRelaysParallel(array $relayUrls, RequestMessage $requestMessage): array
{
$worker = $this->projectDir.'/bin/nostr_relay_request_worker.php';
$phpBinary = (new PhpExecutableFinder())->find() ?: 'php';
$timeout = self::RELAY_REQUEST_TIMEOUT_SEC + (int) self::DISCUSSION_WORKER_GRACE_SEC;
$rawPayload = serialize($requestMessage);
$tmp = tempnam(sys_get_temp_dir(), 'nrq_');
if ($tmp === false) {
throw new \RuntimeException('tempnam failed for Nostr discussion payload');
}
try {
if (file_put_contents($tmp, $rawPayload) === false) {
throw new \RuntimeException('Could not write Nostr discussion temp payload');
}
/** @var array<string, Process> $procs */
$procs = [];
foreach ($relayUrls as $wss) {
if (!\is_string($wss) || $wss === '') {
continue;
}
$p = new Process(
[$phpBinary, $worker, $wss, $tmp],
$this->projectDir,
null,
null,
(float) $timeout
);
$p->start();
$procs[$wss] = $p;
}
$merged = [];
foreach ($procs as $wss => $p) {
$p->wait();
if (!$p->isSuccessful()) {
$err = $p->getErrorOutput();
$this->logger->warning('nostr.article_discussion.relay_worker_failed', [
'relay' => $wss,
'exit_code' => $p->getExitCode(),
'stderr' => $err !== '' ? $err : null,
]);
$merged[$wss] = [];
continue;
}
$out = trim($p->getOutput());
if ($out === '') {
$merged[$wss] = [];
continue;
}
$decoded = base64_decode($out, true);
if ($decoded === false || $decoded === '') {
$merged[$wss] = [];
continue;
}
$chunk = unserialize($decoded, ['allowed_classes' => true]);
if (!\is_array($chunk)) {
$merged[$wss] = [];
continue;
}
$merged = array_replace($merged, $chunk);
}
return $merged;
} finally {
if (\is_file($tmp)) {
@unlink($tmp);
}
}
}
/**
* Same merge/dedupe rules as {@see createRelaySet()} — used only for logging planned relay URLs.
*
* @param array<int, string> $relayUrls
*
* @return list<string>
*/
private function plannedRelayUrlsForSet(array $relayUrls): array
{
$seen = [];
$out = [];
foreach (array_merge($this->configuredArticleRelayUrlList(), $relayUrls) as $relayUrl) {
if (!\is_string($relayUrl) || $relayUrl === '' || isset($seen[$relayUrl])) {
continue;
}
$seen[$relayUrl] = true;
$out[] = $relayUrl;
}
return $out;
}
/**
* One line per relay after {@see Request::send()}: errors vs message-type counts (EVENT, EOSE, …).
*
* @param array<string, mixed> $response
*/
private function logNostrWireResponseSummary(string $context, array $response): void
{
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->warning(sprintf(
'nostr.wire.relay_throwable [%s]: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'context' => $context,
'relay' => $relayUrl,
'message' => $relayRes->getMessage(),
'class' => \get_class($relayRes),
]);
continue;
}
if (!\is_iterable($relayRes)) {
$this->logger->warning(sprintf(
'nostr.wire.relay_not_iterable [%s]: %s',
self::relayLogLabel($relayUrl),
\get_debug_type($relayRes)
), [
'context' => $context,
'relay' => $relayUrl,
'php_type' => \get_debug_type($relayRes),
]);
continue;
}
$counts = [
'EVENT' => 0,
'EOSE' => 0,
'NOTICE' => 0,
'ERROR' => 0,
'AUTH' => 0,
'CLOSED' => 0,
'other' => 0,
];
foreach ($relayRes as $item) {
if (!\is_object($item)) {
++$counts['other'];
continue;
}
$t = (string) ($item->type ?? 'other');
if (\array_key_exists($t, $counts)) {
++$counts[$t];
} else {
++$counts['other'];
}
}
$this->logger->info(sprintf('nostr.wire.relay_messages [%s]', self::relayLogLabel($relayUrl)), [
'context' => $context,
'relay' => $relayUrl,
'counts' => $counts,
]);
}
}
private function eventIsNip22ArticleThreadReply(object $event, string $coordinate): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::COMMENTS->value) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
if (($name === 'a' || $name === 'A') && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
return false;
}
private function eventIsLegacyThreadReply(object $event, string $coordinate, ?string $rootEventHexId): bool
{
if ((int) ($event->kind ?? 0) !== KindsEnum::TEXT_NOTE->value) {
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
$val = (string) ($tag[1] ?? '');
if (($name === 'a' || $name === 'A') && $val === $coordinate) {
return true;
}
if ($rootEventHexId !== null && $rootEventHexId !== '' && $name === 'e' && $val === $rootEventHexId) {
return true;
}
}
return false;
}
private function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool
{
$kind = (int) ($event->kind ?? 0);
if ($kind === KindsEnum::COMMENTS->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'q') {
$val = (string) ($tag[1] ?? '');
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) {
return true;
}
}
}
return false;
}
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$name = (string) ($tag[0] ?? '');
$val = (string) ($tag[1] ?? '');
if ($name === 'q') {
if ($val === $coordinate || ($rootEventHexId !== null && $val === $rootEventHexId)) {
return true;
}
}
}
if ($kind === KindsEnum::GENERIC_REPOST->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
if (($tag[0] ?? '') === 'a' && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
}
if ($kind === KindsEnum::HIGHLIGHTS->value) {
foreach ($event->tags ?? [] as $tag) {
if (!\is_array($tag) || \count($tag) < 2) {
continue;
}
$n = (string) ($tag[0] ?? '');
if (($n === 'a' || $n === 'A') && (string) ($tag[1] ?? '') === $coordinate) {
return true;
}
}
}
return false;
}
/**
* @return array<int, Filter>
*/
private function createArticleDiscussionFilters(string $coordinate, ?string $rootEventHexId): array
{
$limThread = 100;
$limQuote = 80;
$filters = [];
$k1111 = KindsEnum::COMMENTS->value;
$f = new Filter();
$f->setKinds([$k1111]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$f = new Filter();
$f->setKinds([$k1111]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$k1 = KindsEnum::TEXT_NOTE->value;
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#A', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#a', [$coordinate]);
$f->setLimit($limThread);
$filters[] = $f;
if ($rootEventHexId !== null && $rootEventHexId !== '') {
$f = new Filter();
$f->setKinds([$k1]);
$f->setTag('#e', [$rootEventHexId]);
$f->setLimit($limThread);
$filters[] = $f;
}
$qKinds = [
KindsEnum::TEXT_NOTE->value,
KindsEnum::REPOST->value,
KindsEnum::GENERIC_REPOST->value,
KindsEnum::COMMENTS->value,
KindsEnum::HIGHLIGHTS->value,
];
$qVals = [$coordinate];
if ($rootEventHexId !== null && $rootEventHexId !== '') {
$qVals[] = $rootEventHexId;
}
$f = new Filter();
$f->setKinds($qKinds);
$f->setTag('#q', $qVals);
$f->setLimit($limQuote);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::GENERIC_REPOST->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit(50);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#a', [$coordinate]);
$f->setLimit(40);
$filters[] = $f;
$f = new Filter();
$f->setKinds([KindsEnum::HIGHLIGHTS->value]);
$f->setTag('#A', [$coordinate]);
$f->setLimit(40);
$filters[] = $f;
return $filters;
}
/**
* 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->getTopReputableRelaysForAuthor($pubkey);
$relaySet = $this->createRelaySet($authorRelays);
// Create request using the helper method
// Zaps are kind 9735
$request = $this->createNostrRequest(
kinds: [KindsEnum::ZAP],
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): 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);
}
// Create request using the helper method
$request = $this->createNostrRequest(
kinds: [KindsEnum::LONGFORM],
filters: [
'authors' => [$ident],
'limit' => 10
],
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);
});
}
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->newTimedRequest($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',
self::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->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticles: %s',
self::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',
self::relayLogLabel($relayUrl),
$item->type,
$msg !== '' ? $msg : '(no message)'
), ['relay' => $relayUrl, 'response' => $item]);
}
}
}
}
} catch (\Exception $e) {
$relaysTried = $this->configuredArticleRelayUrlList();
$relaysStr = implode(', ', array_map(self::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->newTimedRequest($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 $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 (empty($relayList)) {
$relayList = [];
}
// 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]);
$relaysForLog = $this->plannedRelayUrlsForSet($relayList);
$relaysLogStr = implode(', ', array_map(self::relayLogLabel(...), $relaysForLog));
try {
$request = $this->newTimedRequest($relaySet, $requestMessage);
$response = $request->send();
$found = false;
// Check responses from each relay
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticlesByCoordinates: %s',
self::relayLogLabel($relayUrl),
$value->getMessage()
), ['coordinate' => $coordinate, 'relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
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 = $this->newTimedRequest($this->defaultRelaySet, $requestMessage);
$response = $request->send();
foreach ($response as $relayUrl => $value) {
if ($value instanceof \Throwable) {
$this->logger->warning(sprintf(
'[%s] getArticlesByCoordinates: %s',
self::relayLogLabel($relayUrl),
$value->getMessage()
), ['coordinate' => $coordinate, 'relay' => $relayUrl]);
continue;
}
if (!\is_iterable($value)) {
continue;
}
foreach ($value as $item) {
if ($item->type === 'EVENT') {
$articlesMap[$coordinate] = $item->event;
break 2;
}
}
}
}
} 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;
}
private function createNostrRequest(array $kinds, array $filters = [], ?RelaySet $relaySet = null): Request
{
$subscription = new Subscription();
$subscriptionId = $subscription->setId();
$filter = new Filter();
$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);
}
}
}
$requestMessage = new RequestMessage($subscriptionId, [$filter]);
return $this->newTimedRequest($relaySet ?? $this->defaultRelaySet, $requestMessage);
}
private function processResponse(array $response, callable $eventHandler): array
{
$results = [];
foreach ($response as $relayUrl => $relayRes) {
if ($relayRes instanceof \Throwable) {
$this->logger->error(sprintf(
'Relay error at %s: %s',
self::relayLogLabel($relayUrl),
$relayRes->getMessage()
), [
'relay' => $relayUrl,
'error' => $relayRes->getMessage(),
]);
continue;
}
$itemEstimate = \is_countable($relayRes) ? \count($relayRes) : null;
$this->logger->debug(sprintf('Processing relay response from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl,
'item_count' => $itemEstimate,
]);
foreach ($relayRes as $item) {
try {
if (!is_object($item)) {
$this->logger->warning(sprintf(
'Invalid response item from %s',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl,
'item' => $item,
]);
continue;
}
switch ($item->type) {
case 'EVENT':
$this->logger->debug(sprintf('Processing event from %s', self::relayLogLabel($relayUrl)), [
'relay' => $relayUrl,
'event_id' => $item->event->id ?? 'unknown',
]);
$result = $eventHandler($item->event);
if ($result !== null) {
$results[] = $result;
}
break;
case 'AUTH':
$this->logger->warning(sprintf(
'Relay %s requires authentication',
self::relayLogLabel($relayUrl)
), [
'relay' => $relayUrl,
'response' => $item,
]);
break;
case 'ERROR':
case 'NOTICE':
$msg = (string) ($item->message ?? 'No message');
$this->logger->warning(sprintf(
'[%s] %s: %s',
self::relayLogLabel($relayUrl),
$item->type,
$msg
), [
'relay' => $relayUrl,
'type' => $item->type,
'message' => $msg,
]);
break;
}
} catch (\Exception $e) {
$this->logger->error(sprintf(
'Error processing event from relay %s: %s',
self::relayLogLabel($relayUrl),
$e->getMessage()
), [
'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 (!\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->createNostrRequest(
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->createNostrRequest(
kinds: [$kind],
filters: [
'authors' => [$pubkey],
'tag' => ['#d', [$identifier]],
],
relaySet: $this->defaultRelaySet
);
}
$events = $this->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 ?? '');
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.
*/
public function getMagazineIndex(mixed $npub, mixed $dTag): ?PublicationEventEntity
{
$entity = $this->queryMagazineIndex(
$npub,
$dTag,
$this->buildSingleRelaySet($this->defaultRelayUrl),
self::relayLogLabel($this->defaultRelayUrl)
);
if ($entity !== null) {
return $entity;
}
if (\count($this->configuredArticleRelayUrlList()) <= 1) {
$this->logger->warning(sprintf(
'No magazine index found (tried %s)',
self::relayLogLabel($this->defaultRelayUrl)
), ['npub' => $npub, 'dTag' => $dTag, 'relay' => $this->defaultRelayUrl]);
return null;
}
$this->logger->notice('Magazine index not on default relay, falling back to full relay set', [
'dTag' => $dTag,
]);
$fullListStr = implode(', ', array_map(self::relayLogLabel(...), $this->configuredArticleRelayUrlList()));
return $this->queryMagazineIndex($npub, $dTag, $this->defaultRelaySet, $fullListStr);
}
private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity
{
$request = $this->createNostrRequest(
[KindsEnum::PUBLICATION_INDEX],
['authors' => [(string) $npub], 'tag' => ['#d', [(string) $dTag]]],
$relaySet,
);
$this->logger->info(sprintf('Magazine index query (relays: %s)', $relaysForLog), [
'npub' => $npub,
'dTag' => $dTag,
'relays' => $relaysForLog,
]);
$response = $request->send();
$events = $this->processResponse($response, function ($received) {
return $received;
});
if (empty($events)) {
return null;
}
usort($events, static function ($a, $b): int {
return self::magazineEventCreatedAt($b) <=> self::magazineEventCreatedAt($a);
});
return self::magazineEventToPublicationEntity($events[0]);
}
/**
* Batch-fetch longform for category `a` coordinates that are not in the DB; one Nostr call per
* (author × kind) group, only the default relay (see {@see getMagazineIndex} rationale).
*
* @param list<string> $addresses kind:pubkey:identifier
*/
public function ingestMissingLongformForCategoryCoordinates(array $addresses): void
{
if ($addresses === []) {
return;
}
$groups = [];
foreach ($addresses as $c) {
$parts = explode(':', (string) $c, 3);
if (\count($parts) < 3) {
continue;
}
$kind = (int) $parts[0];
$pubkey = $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;
}
foreach ($groups as $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('Skipping category coordinate with unknown kind', ['kind' => $g['kind']]);
continue;
}
$request = $this->createNostrRequest(
[$kindEnum],
['authors' => [(string) $g['pubkey']], 'tag' => ['#d', $dTags]],
$this->buildSingleRelaySet($this->defaultRelayUrl),
);
try {
$this->processResponse($request->send(), function ($event) {
$article = $this->articleFactory->createFromLongFormContentEvent($event);
$this->saveEachArticleToTheDatabase($article);
return null;
});
} catch (\Throwable $e) {
$this->logger->error(sprintf(
'ingestMissingLongformForCategoryCoordinates [%s]: %s',
self::relayLogLabel($this->defaultRelayUrl),
$e->getMessage()
), [
'message' => $e->getMessage(),
'pubkey' => $g['pubkey'] ?? null,
'relay' => $this->defaultRelayUrl,
]);
}
}
}
private static function magazineEventCreatedAt(mixed $event): int
{
if ($event instanceof PublicationEventEntity) {
return $event->getCreatedAt();
}
if (\is_object($event) && isset($event->created_at)) {
return (int) $event->created_at;
}
return 0;
}
/**
* Normalize relay / library event objects to the app's Event entity (not persisted).
*/
private static function magazineEventToPublicationEntity(mixed $raw): ?PublicationEventEntity
{
if ($raw instanceof PublicationEventEntity) {
return $raw;
}
if (!\is_object($raw)) {
return null;
}
try {
/** @var array<string, mixed> $data */
$data = json_decode(json_encode($raw, \JSON_THROW_ON_ERROR), true, 512, \JSON_THROW_ON_ERROR);
} catch (\JsonException) {
return null;
}
if (!\is_array($data)) {
return null;
}
$entity = new PublicationEventEntity();
$entity->setId((string) ($data['id'] ?? ''));
$entity->setKind((int) ($data['kind'] ?? 0));
$entity->setPubkey((string) ($data['pubkey'] ?? ''));
$entity->setContent((string) ($data['content'] ?? ''));
$entity->setCreatedAt((int) ($data['created_at'] ?? 0));
$tags = $data['tags'] ?? [];
$entity->setTags(\is_array($tags) ? $tags : []);
$entity->setSig((string) ($data['sig'] ?? ''));
return $entity;
}
}