Browse Source

refactor complete

imwald
Silberengel 17 hours ago
parent
commit
111cb0df9c
  1. 3
      src/Service/CacheService.php
  2. 130
      src/Service/NostrClient.php
  3. 116
      src/Service/NostrLongformArticleStore.php
  4. 36
      src/Service/NostrNip65RelayUrls.php
  5. 26
      tests/Service/NostrNip65RelayUrlsTest.php

3
src/Service/CacheService.php

@ -18,6 +18,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
private EventRepository $eventRepository, private EventRepository $eventRepository,
private LoggerInterface $logger, private LoggerInterface $logger,
private NostrKeyHelper $nostrKeyHelper, private NostrKeyHelper $nostrKeyHelper,
private NostrNip65RelayUrls $nip65RelayUrls,
) { ) {
} }
@ -111,7 +112,7 @@ readonly class CacheService implements HighlightAuthorMetadataProvider
} }
$this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire); $this->replaceByCoreKey($key, Event::STORAGE_RELAY_LIST_10002, $wire);
return NostrClient::relayWssListFromNip65Object($wire); return $this->nip65RelayUrls->wssListFromKind10002Wire($wire);
} }
/** /**

130
src/Service/NostrClient.php

@ -4,7 +4,6 @@ namespace App\Service;
use App\Entity\Article; use App\Entity\Article;
use App\Entity\Event as PublicationEventEntity; use App\Entity\Event as PublicationEventEntity;
use App\Enum\EventStatusEnum;
use App\Enum\KindsEnum; use App\Enum\KindsEnum;
use App\Factory\ArticleFactory; use App\Factory\ArticleFactory;
use Doctrine\ORM\EntityManagerInterface; use Doctrine\ORM\EntityManagerInterface;
@ -31,7 +30,9 @@ use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInt
* {@see NostrAuthorRelayCache} (cached NIP-65 kind-10002 author relay lists), * {@see NostrAuthorRelayCache} (cached NIP-65 kind-10002 author relay lists),
* {@see NostrWireEventMerge} (NIP-33 / kind-0 merge, #d tags, npub→hex for wire objects), * {@see NostrWireEventMerge} (NIP-33 / kind-0 merge, #d tags, npub→hex for wire objects),
* {@see NostrArticleDiscussionSupport} (article thread REQ filters and tag classifiers), * {@see NostrArticleDiscussionSupport} (article thread REQ filters and tag classifiers),
* {@see NostrKind5DeletionFilter} (NIP-09 kind-5 relevance for stored row kinds). * {@see NostrKind5DeletionFilter} (NIP-09 kind-5 relevance for stored row kinds),
* {@see NostrNip65RelayUrls} (NIP-65 `r` → wss list from kind-10002 wire),
* {@see NostrLongformArticleStore} (DB upsert for long-form / NIP-23 article rows).
*/ */
class NostrClient class NostrClient
{ {
@ -68,6 +69,8 @@ class NostrClient
private readonly NostrWireEventMerge $wireMerge, private readonly NostrWireEventMerge $wireMerge,
private readonly NostrArticleDiscussionSupport $articleDiscussion, private readonly NostrArticleDiscussionSupport $articleDiscussion,
private readonly NostrKind5DeletionFilter $kind5DeletionFilter, private readonly NostrKind5DeletionFilter $kind5DeletionFilter,
private readonly NostrNip65RelayUrls $nip65RelayUrls,
private readonly NostrLongformArticleStore $longformArticleStore,
) { ) {
$this->defaultRelaySet = $this->relayListFactory->getDefaultArticleRelaySet(); $this->defaultRelaySet = $this->relayListFactory->getDefaultArticleRelaySet();
} }
@ -718,32 +721,6 @@ class NostrClient
return null; return null;
} }
/**
* NIP-65: `r` values as wss URLs, excluding localhost.
*
* @return list<string>
*/
public static function relayWssListFromNip65Object(object $wire): array
{
$relays = [];
foreach ($wire->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'r') {
$relays[] = (string) $r[1];
}
}
return array_values(array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
}));
}
/** /**
* @return list<string> * @return list<string>
*/ */
@ -754,7 +731,7 @@ class NostrClient
return []; return [];
} }
return self::relayWssListFromNip65Object($use); return $this->nip65RelayUrls->wssListFromKind10002Wire($use);
} }
/** /**
@ -1355,8 +1332,7 @@ class NostrClient
return; return;
} }
$repo = $this->entityManager->getRepository(Article::class); if ($this->longformArticleStore->isEventIdAlreadyStored($newId)) {
if ($repo->findOneBy(['eventId' => $newId]) !== null) {
$this->logger->info('[longform_ingest] saveEachArticle: skip, DB already has this exact event id (no work)', [ $this->logger->info('[longform_ingest] saveEachArticle: skip, DB already has this exact event id (no work)', [
'eventId' => $newId, 'eventId' => $newId,
'slug' => $article->getSlug(), 'slug' => $article->getSlug(),
@ -1372,17 +1348,17 @@ class NostrClient
'pubkey_empty' => $pubkey === '', 'pubkey_empty' => $pubkey === '',
'slug' => $slug, 'slug' => $slug,
]); ]);
$this->persistNewArticle($article, 'missing_pubkey_or_slug_on_entity'); $this->longformArticleStore->persistNew($article, 'missing_pubkey_or_slug_on_entity');
return; return;
} }
$incumbent = $this->findLatestLongFormArticleByAuthorAndSlug($pubkey, $slug); $incumbent = $this->longformArticleStore->findLatestByAuthorAndSlug($pubkey, $slug);
if ($incumbent === null) { if ($incumbent === null) {
$this->logger->info('[longform_ingest] saveEachArticle: persist new row (no DB row for author+slug)', [ $this->logger->info('[longform_ingest] saveEachArticle: persist new row (no DB row for author+slug)', [
'eventId' => $newId, 'eventId' => $newId,
'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug), 'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug),
]); ]);
$this->persistNewArticle($article, 'no_db_row_for_nip33_address'); $this->longformArticleStore->persistNew($article, 'no_db_row_for_nip33_address');
return; return;
} }
@ -1391,11 +1367,11 @@ class NostrClient
$this->logger->warning('[longform_ingest] saveEachArticle: new Article has no raw wire; trying insert as new', [ $this->logger->warning('[longform_ingest] saveEachArticle: new Article has no raw wire; trying insert as new', [
'eventId' => $newId, 'eventId' => $newId,
]); ]);
$this->persistNewArticle($article, 'no_raw_on_incoming_article'); $this->longformArticleStore->persistNew($article, 'no_raw_on_incoming_article');
return; return;
} }
$iWire = self::longFormWireStubFromArticle($incumbent); $iWire = $this->longformArticleStore->longFormWireStubFromArticle($incumbent);
$cTs = $this->wireMerge->magazineEventCreatedAt($candidate); $cTs = $this->wireMerge->magazineEventCreatedAt($candidate);
$iTs = $this->wireMerge->magazineEventCreatedAt($iWire); $iTs = $this->wireMerge->magazineEventCreatedAt($iWire);
if ($this->wireMerge->wireEventSupersedes($candidate, $iWire)) { if ($this->wireMerge->wireEventSupersedes($candidate, $iWire)) {
@ -1407,7 +1383,7 @@ class NostrClient
'incumbent_created_at' => $iTs, 'incumbent_created_at' => $iTs,
'candidate_created_at' => $cTs, 'candidate_created_at' => $cTs,
]); ]);
$this->applyLongFormArticleOnto($article, $incumbent); $this->longformArticleStore->applySourceOntoTarget($article, $incumbent);
if ($incumbent->getPubkey() !== $pubkey) { if ($incumbent->getPubkey() !== $pubkey) {
$incumbent->setPubkey($pubkey); $incumbent->setPubkey($pubkey);
} }
@ -1441,86 +1417,6 @@ class NostrClient
} }
} }
private function persistNewArticle(Article $article, string $reason = 'unspecified'): void
{
try {
$this->logger->info('[longform_ingest] persistNewArticle', [
'reason' => $reason,
'eventId' => $article->getEventId(),
'slug' => $this->wireMerge->longformIngestShortSlug((string) ($article->getSlug() ?? '')),
]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [
'reason' => $reason,
'eventId' => $article->getEventId(),
]);
$this->managerRegistry->resetManager();
}
}
private function findLatestLongFormArticleByAuthorAndSlug(string $pubkey, string $slug): ?Article
{
$pubkey = strtolower($pubkey);
/** @var ?Article $row */
$row = $this->entityManager->getRepository(Article::class)->createQueryBuilder('a')
->where('LOWER(a.pubkey) = :pk')
->andWhere('a.slug = :sl')
->setParameter('pk', $pubkey)
->setParameter('sl', $slug)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
return $row;
}
/**
* Minimal Nostr event shape for {@see NostrWireEventMerge::wireEventSupersedes()} when `raw` is not a full wire object.
*/
private static function longFormWireStubFromArticle(Article $a): object
{
$raw = $a->getRaw();
if (\is_object($raw) && isset($raw->id) && (isset($raw->created_at) || isset($raw->createdAt))) {
return $raw;
}
$o = new \stdClass();
$o->id = (string) ($a->getEventId() ?? '');
$ca = $a->getCreatedAt();
$o->created_at = $ca !== null ? $ca->getTimestamp() : 0;
$o->pubkey = (string) ($a->getPubkey() ?? '');
$k = $a->getKind();
$o->kind = $k !== null ? $k->value : KindsEnum::LONGFORM->value;
return $o;
}
private function applyLongFormArticleOnto(Article $source, Article $target): void
{
$target->setEventId((string) $source->getEventId());
$target->setContent($source->getContent());
$target->setTitle($source->getTitle());
$target->setSummary($source->getSummary());
$target->setImage($source->getImage());
if ($source->getCreatedAt() !== null) {
$target->setCreatedAt($source->getCreatedAt());
}
$target->setSig($source->getSig());
if ($source->getPublishedAt() !== null) {
$target->setPublishedAt($source->getPublishedAt());
}
$target->setTopics($source->getTopics());
if ($source->getKind() !== null) {
$target->setKind($source->getKind());
}
$es = $source->getEventStatus();
$target->setEventStatus($es ?? EventStatusEnum::PUBLISHED);
$target->setRaw($source->getRaw());
}
/** /**
* @param mixed $descriptor * @param mixed $descriptor
* @return Event|null * @return Event|null

116
src/Service/NostrLongformArticleStore.php

@ -0,0 +1,116 @@
<?php
declare(strict_types=1);
namespace App\Service;
use App\Entity\Article;
use App\Enum\EventStatusEnum;
use App\Enum\KindsEnum;
use Doctrine\ORM\EntityManagerInterface;
use Doctrine\Persistence\ManagerRegistry;
use Psr\Log\LoggerInterface;
/**
* MySQL `article` row updates for NIP-23 / long-form ingest: find-by-(pubkey,slug), merge wire onto row,
* persist. Orchestrated by {@see NostrClient::saveEachArticleToTheDatabase()}.
*/
final class NostrLongformArticleStore
{
public function __construct(
private readonly EntityManagerInterface $entityManager,
private readonly ManagerRegistry $managerRegistry,
private readonly LoggerInterface $logger,
private readonly NostrWireEventMerge $wireMerge,
) {
}
public function isEventIdAlreadyStored(string $eventId): bool
{
if ($eventId === '') {
return false;
}
return $this->entityManager->getRepository(Article::class)->findOneBy(['eventId' => $eventId]) !== null;
}
public function findLatestByAuthorAndSlug(string $pubkey, string $slug): ?Article
{
$pubkey = strtolower($pubkey);
/** @var ?Article $row */
$row = $this->entityManager->getRepository(Article::class)->createQueryBuilder('a')
->where('LOWER(a.pubkey) = :pk')
->andWhere('a.slug = :sl')
->setParameter('pk', $pubkey)
->setParameter('sl', $slug)
->orderBy('a.createdAt', 'DESC')
->setMaxResults(1)
->getQuery()
->getOneOrNullResult();
return $row;
}
/**
* Minimal Nostr event shape for {@see NostrWireEventMerge::wireEventSupersedes()} when `raw` is not a full wire object.
*/
public function longFormWireStubFromArticle(Article $a): object
{
$raw = $a->getRaw();
if (\is_object($raw) && isset($raw->id) && (isset($raw->created_at) || isset($raw->createdAt))) {
return $raw;
}
$o = new \stdClass();
$o->id = (string) ($a->getEventId() ?? '');
$ca = $a->getCreatedAt();
$o->created_at = $ca !== null ? $ca->getTimestamp() : 0;
$o->pubkey = (string) ($a->getPubkey() ?? '');
$k = $a->getKind();
$o->kind = $k !== null ? $k->value : KindsEnum::LONGFORM->value;
return $o;
}
public function applySourceOntoTarget(Article $source, Article $target): void
{
$target->setEventId((string) $source->getEventId());
$target->setContent($source->getContent());
$target->setTitle($source->getTitle());
$target->setSummary($source->getSummary());
$target->setImage($source->getImage());
if ($source->getCreatedAt() !== null) {
$target->setCreatedAt($source->getCreatedAt());
}
$target->setSig($source->getSig());
if ($source->getPublishedAt() !== null) {
$target->setPublishedAt($source->getPublishedAt());
}
$target->setTopics($source->getTopics());
if ($source->getKind() !== null) {
$target->setKind($source->getKind());
}
$es = $source->getEventStatus();
$target->setEventStatus($es ?? EventStatusEnum::PUBLISHED);
$target->setRaw($source->getRaw());
}
public function persistNew(Article $article, string $reason = 'unspecified'): void
{
try {
$this->logger->info('[longform_ingest] persistNewArticle', [
'reason' => $reason,
'eventId' => $article->getEventId(),
'slug' => $this->wireMerge->longformIngestShortSlug((string) ($article->getSlug() ?? '')),
]);
$this->entityManager->persist($article);
$this->entityManager->flush();
} catch (\Exception $e) {
$this->logger->error('[longform_ingest] persistNewArticle failed: '.$e->getMessage(), [
'reason' => $reason,
'eventId' => $article->getEventId(),
]);
$this->managerRegistry->resetManager();
}
}
}

36
src/Service/NostrNip65RelayUrls.php

@ -0,0 +1,36 @@
<?php
declare(strict_types=1);
namespace App\Service;
/**
* NIP-65 kind-10002: collect `r` tag values as relay URLs (wss, excluding localhost). Used for author
* relay lists from wire and from {@see NostrClient::getNpubRelays()}.
*/
final class NostrNip65RelayUrls
{
/**
* @return list<string>
*/
public function wssListFromKind10002Wire(object $wire): array
{
$relays = [];
foreach ($wire->tags ?? [] as $tag) {
if (!\is_array($tag) && !\is_object($tag)) {
continue;
}
$r = \is_object($tag) ? array_values((array) $tag) : $tag;
if (!isset($r[0], $r[1])) {
continue;
}
if ((string) $r[0] === 'r') {
$relays[] = (string) $r[1];
}
}
return array_values(array_filter(array_unique($relays), static function (string $relay) {
return str_starts_with($relay, 'wss:') && !str_contains($relay, 'localhost');
}));
}
}

26
tests/Service/NostrNip65RelayUrlsTest.php

@ -0,0 +1,26 @@
<?php
declare(strict_types=1);
namespace App\Tests\Service;
use App\Service\NostrNip65RelayUrls;
use PHPUnit\Framework\TestCase;
final class NostrNip65RelayUrlsTest extends TestCase
{
public function testCollectsWssAndDropsLocalhostAndNonWss(): void
{
$s = new NostrNip65RelayUrls();
$wire = (object) [
'tags' => [
['r', 'wss://relay.example.com'],
['r', 'wss://localhost:8080'],
['r', 'http://not-tls.example.com'],
['p', 'ignored'],
],
];
$out = $s->wssListFromKind10002Wire($wire);
$this->assertSame(['wss://relay.example.com'], $out);
}
}
Loading…
Cancel
Save