5 changed files with 193 additions and 118 deletions
@ -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(); |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
@ -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'); |
||||||
|
})); |
||||||
|
} |
||||||
|
} |
||||||
@ -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…
Reference in new issue