13 changed files with 1117 additions and 1414 deletions
@ -0,0 +1,169 @@
@@ -0,0 +1,169 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Enum\KindsEnum; |
||||
use swentel\nostr\Filter\Filter; |
||||
|
||||
/** |
||||
* REQ {@link Filter}s and tag-matching rules for long-form article discussion (NIP-22 kind 1111, legacy kind 1, quotes). |
||||
* Used by {@see NostrClient::getArticleDiscussion()}. |
||||
*/ |
||||
final class NostrArticleDiscussionSupport |
||||
{ |
||||
/** |
||||
* @return array<int, Filter> |
||||
*/ |
||||
public 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, |
||||
]; |
||||
$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; |
||||
|
||||
return $filters; |
||||
} |
||||
|
||||
public 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; |
||||
} |
||||
|
||||
public 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; |
||||
} |
||||
|
||||
public function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool |
||||
{ |
||||
$kind = (int) ($event->kind ?? 0); |
||||
if ($kind === KindsEnum::HIGHLIGHTS->value) { |
||||
return false; |
||||
} |
||||
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; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
} |
||||
@ -0,0 +1,89 @@
@@ -0,0 +1,89 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use Psr\Log\LoggerInterface; |
||||
use Symfony\Contracts\Cache\CacheInterface; |
||||
use Symfony\Contracts\Cache\ItemInterface; |
||||
|
||||
/** |
||||
* Kind-10002 (NIP-65) wss:// lists per author hex pubkey, cached. Fetches wire data via |
||||
* {@see NostrClient::getNpubRelays()}; {@see NostrClient} is injected lazily to avoid a container cycle. |
||||
* |
||||
* Intentionally not `final` so Symfony can generate a lazy proxy for this service. |
||||
*/ |
||||
class NostrAuthorRelayCache |
||||
{ |
||||
public function __construct( |
||||
private readonly CacheInterface $relayQueryCache, |
||||
private readonly LoggerInterface $logger, |
||||
private readonly NostrRelayListFactory $relayListFactory, |
||||
private readonly NostrClient $nostrClient, |
||||
) { |
||||
} |
||||
|
||||
/** |
||||
* Full NIP-65 wss:// list for a hex pubkey, cached. Prefer {@see getTopReputableRelaysForAuthor} when |
||||
* only a few relays are needed. |
||||
* |
||||
* @return list<string> |
||||
*/ |
||||
public 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->nostrClient->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( |
||||
$authorRelays, |
||||
static function (string $relay): bool { |
||||
return 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 site relay) for queries that do not need every home relay. |
||||
* |
||||
* @return list<string> |
||||
*/ |
||||
public function getTopReputableRelaysForAuthor(string $pubkey, int $limit = 3): array |
||||
{ |
||||
$all = $this->getAuthorNip65RelaysList($pubkey); |
||||
if ($all === []) { |
||||
return [$this->relayListFactory->getDefaultRelayUrl()]; |
||||
} |
||||
if ($limit < 1) { |
||||
$limit = 1; |
||||
} |
||||
|
||||
return \array_slice($all, 0, $limit); |
||||
} |
||||
} |
||||
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Enum\KindsEnum; |
||||
|
||||
/** |
||||
* NIP-09 kind-5: keep only deletion events that target kinds persisted in MySQL (profile, relay list, payto, |
||||
* long-form, magazine). Skips thread/reply deletions to reduce relay payload. |
||||
*/ |
||||
final class NostrKind5DeletionFilter |
||||
{ |
||||
public function isRelevantToStoredDbData(object $ev): bool |
||||
{ |
||||
$kinds = $this->storedKindValues(); |
||||
foreach ($ev->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] === 'k' && \in_array((int) $r[1], $kinds, true)) { |
||||
return true; |
||||
} |
||||
if ((string) $r[0] === 'a') { |
||||
$parts = explode(':', (string) $r[1], 3); |
||||
if (\in_array((int) $parts[0], $kinds, true)) { |
||||
return true; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return false; |
||||
} |
||||
|
||||
/** |
||||
* @return list<int> |
||||
*/ |
||||
private function storedKindValues(): array |
||||
{ |
||||
return [ |
||||
KindsEnum::METADATA->value, |
||||
KindsEnum::RELAY_LIST->value, |
||||
KindsEnum::PAYMENT_TARGETS->value, |
||||
KindsEnum::LONGFORM->value, |
||||
KindsEnum::LONGFORM_DRAFT->value, |
||||
KindsEnum::PUBLICATION_INDEX->value, |
||||
]; |
||||
} |
||||
} |
||||
@ -0,0 +1,450 @@
@@ -0,0 +1,450 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Service; |
||||
|
||||
use App\Entity\Event as PublicationEventEntity; |
||||
use App\Enum\KindsEnum; |
||||
|
||||
/** |
||||
* NIP-33 / NIP-01 wire event merge, #d tags, npub→hex. See {@see NostrClient} call sites. |
||||
*/ |
||||
final readonly class NostrWireEventMerge |
||||
{ |
||||
private const NIP33_PARAMETERIZED_KIND_MIN = 30_000; |
||||
private const NIP33_PARAMETERIZED_KIND_MAX = 39_999; |
||||
|
||||
public function __construct( |
||||
private NostrKeyHelper $keyHelper, |
||||
) { |
||||
} |
||||
|
||||
public function isReplaceableByKindAndPubkeyNip(int $kind): bool |
||||
{ |
||||
return $kind === 0 |
||||
|| $kind === 3 |
||||
|| ($kind >= 10_000 && $kind < 20_000); |
||||
} |
||||
|
||||
public function isNip33ParameterizedKind(int $kind): bool |
||||
{ |
||||
return $kind >= self::NIP33_PARAMETERIZED_KIND_MIN |
||||
&& $kind <= self::NIP33_PARAMETERIZED_KIND_MAX; |
||||
} |
||||
|
||||
private function replaceableKindPubkeyAddressFromWire(mixed $e): ?string |
||||
{ |
||||
if (!\is_object($e)) { |
||||
return null; |
||||
} |
||||
$k = (int) ($e->kind ?? 0); |
||||
if (!$this->isReplaceableByKindAndPubkeyNip($k)) { |
||||
return null; |
||||
} |
||||
$pk = (string) ($e->pubkey ?? ''); |
||||
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||
return null; |
||||
} |
||||
|
||||
return (string) $k.':'.strtolower($pk); |
||||
} |
||||
|
||||
private function isValidNostrEventIdString(string $id): bool |
||||
{ |
||||
return 64 === \strlen($id) && ctype_xdigit($id); |
||||
} |
||||
|
||||
public function wireEventSupersedes(mixed $candidate, mixed $incumbent): bool |
||||
{ |
||||
$c = $this->magazineEventCreatedAt($candidate); |
||||
$i = $this->magazineEventCreatedAt($incumbent); |
||||
if ($c !== $i) { |
||||
return $c > $i; |
||||
} |
||||
$idC = $this->magazineEventId($candidate); |
||||
$idI = $this->magazineEventId($incumbent); |
||||
$vC = $this->isValidNostrEventIdString($idC); |
||||
$vI = $this->isValidNostrEventIdString($idI); |
||||
if ($vC !== $vI) { |
||||
return $vC && !$vI; |
||||
} |
||||
if (!$vC) { |
||||
if ($idC === $idI) { |
||||
return false; |
||||
} |
||||
|
||||
return $idC < $idI; |
||||
} |
||||
if ($idC === $idI) { |
||||
return false; |
||||
} |
||||
|
||||
return $idC < $idI; |
||||
} |
||||
|
||||
private function kind0Nip01ReplaceableAddress(mixed $ev): ?string |
||||
{ |
||||
if (!\is_object($ev) || (int) ($ev->kind ?? -1) !== KindsEnum::METADATA->value) { |
||||
return null; |
||||
} |
||||
$pk = (string) ($ev->pubkey ?? ''); |
||||
if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||
return null; |
||||
} |
||||
|
||||
return '0:'.strtolower($pk); |
||||
} |
||||
|
||||
private function kind0ReplaceableIsNewer(mixed $candidate, mixed $incumbent): bool |
||||
{ |
||||
return $this->wireEventSupersedes($candidate, $incumbent); |
||||
} |
||||
|
||||
/** |
||||
* @param list<mixed> $events |
||||
* |
||||
* @return array<string, object> |
||||
*/ |
||||
public function mergeKind0EventsByReplaceableAddress(array $events): array |
||||
{ |
||||
$byAddress = []; |
||||
foreach ($events as $ev) { |
||||
$addr = $this->kind0Nip01ReplaceableAddress($ev); |
||||
if ($addr === null) { |
||||
continue; |
||||
} |
||||
if (!isset($byAddress[$addr]) || $this->kind0ReplaceableIsNewer($ev, $byAddress[$addr])) { |
||||
$byAddress[$addr] = $ev; |
||||
} |
||||
} |
||||
|
||||
return $byAddress; |
||||
} |
||||
|
||||
public function nip33ParameterizedReplaceableAddress(mixed $event): ?string |
||||
{ |
||||
$k = $this->magazineEventKind($event); |
||||
if (!$this->isNip33ParameterizedKind($k)) { |
||||
return null; |
||||
} |
||||
$pk = $this->magazineEventPubkeyHex($event); |
||||
if ($pk === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { |
||||
return null; |
||||
} |
||||
$d = $this->eventDTagValue($event); |
||||
if ($d === null || $d === '') { |
||||
return null; |
||||
} |
||||
|
||||
return (string) $k.':'.strtolower($pk).':'.$d; |
||||
} |
||||
|
||||
/** |
||||
* @param list<mixed> $events |
||||
* |
||||
* @return list<object> |
||||
*/ |
||||
public function mergeNip33ParameterizedWireEvents(array $events): array |
||||
{ |
||||
$byNip33Address = []; |
||||
$byKindPubkey = []; |
||||
$byId = []; |
||||
foreach ($events as $e) { |
||||
if (!\is_object($e)) { |
||||
continue; |
||||
} |
||||
$k = (int) ($e->kind ?? 0); |
||||
if ($this->isNip33ParameterizedKind($k)) { |
||||
$a = $this->nip33ParameterizedReplaceableAddress($e); |
||||
if ($a === null) { |
||||
continue; |
||||
} |
||||
if (!isset($byNip33Address[$a]) || $this->wireEventSupersedes($e, $byNip33Address[$a])) { |
||||
$byNip33Address[$a] = $e; |
||||
} |
||||
} elseif ($this->isReplaceableByKindAndPubkeyNip($k)) { |
||||
$a = $this->replaceableKindPubkeyAddressFromWire($e); |
||||
if ($a === null) { |
||||
continue; |
||||
} |
||||
if (!isset($byKindPubkey[$a]) || $this->wireEventSupersedes($e, $byKindPubkey[$a])) { |
||||
$byKindPubkey[$a] = $e; |
||||
} |
||||
} else { |
||||
$id = (string) ($e->id ?? ''); |
||||
if ($id === '') { |
||||
continue; |
||||
} |
||||
if (!isset($byId[$id]) || $this->wireEventSupersedes($e, $byId[$id])) { |
||||
$byId[$id] = $e; |
||||
} |
||||
} |
||||
} |
||||
|
||||
return array_values(array_merge($byId, $byKindPubkey, $byNip33Address)); |
||||
} |
||||
|
||||
/** |
||||
* @param list<mixed> $events |
||||
*/ |
||||
public function pickLatestNip33ParameterizedForQuery( |
||||
array $events, |
||||
int $expectedKind, |
||||
string $authorHexLower, |
||||
string $dTag |
||||
): mixed { |
||||
if (!$this->isNip33ParameterizedKind($expectedKind)) { |
||||
return null; |
||||
} |
||||
$wantD = trim($dTag); |
||||
$expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD; |
||||
|
||||
$merged = $this->mergeNip33ParameterizedWireEvents($events); |
||||
foreach ($merged as $e) { |
||||
if ($this->magazineEventKind($e) !== $expectedKind) { |
||||
continue; |
||||
} |
||||
if (strtolower($this->magazineEventPubkeyHex($e)) !== $authorHexLower) { |
||||
continue; |
||||
} |
||||
$addr = $this->nip33ParameterizedReplaceableAddress($e); |
||||
if ($addr === $expectedAddr) { |
||||
return $e; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @param list<mixed> $events |
||||
*/ |
||||
public function pickEventForNip33OrFirst(array $events, int $kind, string $authorIdent, string $dTag): ?object |
||||
{ |
||||
if ($events === []) { |
||||
return null; |
||||
} |
||||
if ($this->isNip33ParameterizedKind($kind)) { |
||||
$h = $this->authorIdentToHexLower($authorIdent); |
||||
if ($h !== null) { |
||||
$picked = $this->pickLatestNip33ParameterizedForQuery($events, $kind, $h, $dTag); |
||||
if ($picked !== null && \is_object($picked)) { |
||||
return $picked; |
||||
} |
||||
} |
||||
$merged = $this->mergeNip33ParameterizedWireEvents($events); |
||||
$first = $merged[0] ?? null; |
||||
|
||||
return \is_object($first) ? $first : null; |
||||
} |
||||
if ($this->isReplaceableByKindAndPubkeyNip($kind)) { |
||||
$h = $this->authorIdentToHexLower($authorIdent); |
||||
if ($h !== null) { |
||||
$best = null; |
||||
foreach ($events as $e) { |
||||
if (!\is_object($e) || (int) ($e->kind ?? 0) !== $kind) { |
||||
continue; |
||||
} |
||||
if (strtolower((string) ($e->pubkey ?? '')) !== $h) { |
||||
continue; |
||||
} |
||||
if ($best === null || $this->wireEventSupersedes($e, $best)) { |
||||
$best = $e; |
||||
} |
||||
} |
||||
if ($best !== null) { |
||||
return $best; |
||||
} |
||||
} |
||||
foreach ($this->mergeNip33ParameterizedWireEvents($events) as $e) { |
||||
if ((int) ($e->kind ?? 0) === $kind) { |
||||
return $e; |
||||
} |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
$e0 = $events[0] ?? null; |
||||
|
||||
return \is_object($e0) ? $e0 : null; |
||||
} |
||||
|
||||
public function authorIdentToHexLower(mixed $ident): ?string |
||||
{ |
||||
return $this->npubToHexPubkey($ident); |
||||
} |
||||
|
||||
public function npubToHexPubkey(mixed $npub): ?string |
||||
{ |
||||
$s = trim((string) $npub); |
||||
if ($s === '') { |
||||
return null; |
||||
} |
||||
if (64 === \strlen($s) && ctype_xdigit($s)) { |
||||
return strtolower($s); |
||||
} |
||||
if (str_starts_with($s, 'npub')) { |
||||
$hex = $this->keyHelper->convertToHex($s); |
||||
|
||||
return $hex !== '' && 64 === \strlen($hex) && ctype_xdigit($hex) ? strtolower($hex) : null; |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
public function eventDTagValue(mixed $event): ?string |
||||
{ |
||||
$tags = null; |
||||
if ($event instanceof PublicationEventEntity) { |
||||
$tags = $event->getTags(); |
||||
} elseif (\is_object($event) && isset($event->tags) && \is_array($event->tags)) { |
||||
$tags = $event->tags; |
||||
} |
||||
if (!\is_array($tags)) { |
||||
return null; |
||||
} |
||||
foreach ($tags as $t) { |
||||
$seq = $this->normalizeNostrTagRowToSequence($t); |
||||
if ($seq === null || ($seq[0] ?? '') !== 'd' || !isset($seq[1]) || (string) $seq[1] === '') { |
||||
continue; |
||||
} |
||||
|
||||
return trim((string) $seq[1]); |
||||
} |
||||
|
||||
return null; |
||||
} |
||||
|
||||
/** |
||||
* @return list<string>|null |
||||
*/ |
||||
private function normalizeNostrTagRowToSequence(mixed $row): ?array |
||||
{ |
||||
if ($row === null) { |
||||
return null; |
||||
} |
||||
if (\is_object($row)) { |
||||
$row = get_object_vars($row); |
||||
} |
||||
if (!\is_array($row) || $row === []) { |
||||
return null; |
||||
} |
||||
$seq = array_values( |
||||
array_map( |
||||
static fn (mixed $v): string => (string) $v, |
||||
$row |
||||
) |
||||
); |
||||
if ($seq[0] === '') { |
||||
return null; |
||||
} |
||||
|
||||
return $seq; |
||||
} |
||||
|
||||
public function longformIngestShortSlug(string $slug, int $max = 100): string |
||||
{ |
||||
$t = trim($slug); |
||||
if (strlen($t) > $max) { |
||||
return substr($t, 0, $max - 1).'…'; |
||||
} |
||||
|
||||
return $t; |
||||
} |
||||
|
||||
/** |
||||
* @return array{kind: int, id: string, created_at: int, d: string, nip33: ?string} |
||||
*/ |
||||
public function longformIngestEventWireSummary(object $e): array |
||||
{ |
||||
$d = $this->eventDTagValue($e); |
||||
$nip = $this->nip33ParameterizedReplaceableAddress($e); |
||||
|
||||
return [ |
||||
'kind' => (int) ($e->kind ?? 0), |
||||
'id' => (string) ($e->id ?? ''), |
||||
'created_at' => (int) ($e->created_at ?? 0), |
||||
'd' => $d !== null && $d !== '' ? $this->longformIngestShortSlug($d, 80) : '', |
||||
'nip33' => $nip, |
||||
]; |
||||
} |
||||
|
||||
public function magazineEventToPublicationEntity(mixed $raw): ?PublicationEventEntity |
||||
{ |
||||
if ($raw instanceof PublicationEventEntity) { |
||||
return $raw; |
||||
} |
||||
if (!\is_object($raw)) { |
||||
return null; |
||||
} |
||||
|
||||
try { |
||||
$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; |
||||
} |
||||
|
||||
public 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; |
||||
} |
||||
|
||||
private function magazineEventId(mixed $event): string |
||||
{ |
||||
if ($event instanceof PublicationEventEntity) { |
||||
return $event->getId(); |
||||
} |
||||
if (\is_object($event) && isset($event->id)) { |
||||
return (string) $event->id; |
||||
} |
||||
|
||||
return ''; |
||||
} |
||||
|
||||
private function magazineEventKind(mixed $event): int |
||||
{ |
||||
if ($event instanceof PublicationEventEntity) { |
||||
return $event->getKind(); |
||||
} |
||||
if (\is_object($event) && isset($event->kind)) { |
||||
return (int) $event->kind; |
||||
} |
||||
|
||||
return 0; |
||||
} |
||||
|
||||
private function magazineEventPubkeyHex(mixed $event): string |
||||
{ |
||||
if ($event instanceof PublicationEventEntity) { |
||||
return (string) $event->getPubkey(); |
||||
} |
||||
if (\is_object($event) && isset($event->pubkey)) { |
||||
return (string) $event->pubkey; |
||||
} |
||||
|
||||
return ''; |
||||
} |
||||
} |
||||
@ -0,0 +1,85 @@
@@ -0,0 +1,85 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Service; |
||||
|
||||
use App\Enum\KindsEnum; |
||||
use App\Service\NostrArticleDiscussionSupport; |
||||
use PHPUnit\Framework\TestCase; |
||||
|
||||
final class NostrArticleDiscussionSupportTest extends TestCase |
||||
{ |
||||
private NostrArticleDiscussionSupport $s; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->s = new NostrArticleDiscussionSupport(); |
||||
} |
||||
|
||||
public function testCreateArticleDiscussionFilterCountWithoutRoot(): void |
||||
{ |
||||
$c = '30023:'.str_repeat('b', 64).':my-slug'; |
||||
$filters = $this->s->createArticleDiscussionFilters($c, null); |
||||
$this->assertCount(6, $filters); |
||||
} |
||||
|
||||
public function testCreateArticleDiscussionFilterCountWithRoot(): void |
||||
{ |
||||
$c = '30023:'.str_repeat('b', 64).':my-slug'; |
||||
$root = str_repeat('c', 64); |
||||
$filters = $this->s->createArticleDiscussionFilters($c, $root); |
||||
$this->assertCount(7, $filters); |
||||
} |
||||
|
||||
public function testNip22ThreadReplyByAtag(): void |
||||
{ |
||||
$coord = '30023:'.str_repeat('d', 64).':x'; |
||||
$e = (object) [ |
||||
'kind' => KindsEnum::COMMENTS->value, |
||||
'tags' => [['A', $coord]], |
||||
]; |
||||
$this->assertTrue($this->s->eventIsNip22ArticleThreadReply($e, $coord)); |
||||
} |
||||
|
||||
public function testLegacyThreadByAtag(): void |
||||
{ |
||||
$coord = '30023:'.str_repeat('d', 64).':x'; |
||||
$e = (object) [ |
||||
'kind' => KindsEnum::TEXT_NOTE->value, |
||||
'tags' => [['a', $coord]], |
||||
]; |
||||
$this->assertTrue($this->s->eventIsLegacyThreadReply($e, $coord, null)); |
||||
} |
||||
|
||||
public function testLegacyThreadByEtagWhenRootGiven(): void |
||||
{ |
||||
$coord = '30023:'.str_repeat('d', 64).':x'; |
||||
$root = str_repeat('e', 64); |
||||
$e = (object) [ |
||||
'kind' => KindsEnum::TEXT_NOTE->value, |
||||
'tags' => [['e', $root]], |
||||
]; |
||||
$this->assertTrue($this->s->eventIsLegacyThreadReply($e, $coord, $root)); |
||||
} |
||||
|
||||
public function testArticleQuoteByQtag(): void |
||||
{ |
||||
$coord = '30023:'.str_repeat('d', 64).':x'; |
||||
$e = (object) [ |
||||
'kind' => KindsEnum::TEXT_NOTE->value, |
||||
'tags' => [['q', $coord]], |
||||
]; |
||||
$this->assertTrue($this->s->eventIsArticleQuote($e, $coord, null)); |
||||
} |
||||
|
||||
public function testHighlightsAreNotQuotes(): void |
||||
{ |
||||
$coord = '30023:'.str_repeat('d', 64).':x'; |
||||
$e = (object) [ |
||||
'kind' => KindsEnum::HIGHLIGHTS->value, |
||||
'tags' => [['q', $coord]], |
||||
]; |
||||
$this->assertFalse($this->s->eventIsArticleQuote($e, $coord, null)); |
||||
} |
||||
} |
||||
@ -0,0 +1,60 @@
@@ -0,0 +1,60 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Service; |
||||
|
||||
use App\Service\NostrAuthorRelayCache; |
||||
use App\Service\NostrClient; |
||||
use App\Service\NostrRelayListFactory; |
||||
use PHPUnit\Framework\TestCase; |
||||
use Psr\Log\NullLogger; |
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter; |
||||
use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; |
||||
|
||||
final class NostrAuthorRelayCacheTest extends TestCase |
||||
{ |
||||
public function testGetAuthorNip65RelaysListDedupesAndCaches(): void |
||||
{ |
||||
$pk = str_repeat('b', 64); |
||||
$nostr = $this->createMock(NostrClient::class); |
||||
$nostr->expects($this->once()) |
||||
->method('getNpubRelays') |
||||
->with($pk) |
||||
->willReturn(['wss://r1', 'wss://r2', 'wss://r1', 'http://ignored', 'wss://localhost:1']); |
||||
|
||||
$ts = $this->createMock(TokenStorageInterface::class); |
||||
$ts->method('getToken')->willReturn(null); |
||||
$listFactory = new NostrRelayListFactory('wss://default', [], [], $ts, new NullLogger()); |
||||
|
||||
$c = new NostrAuthorRelayCache( |
||||
new ArrayAdapter(), |
||||
new NullLogger(), |
||||
$listFactory, |
||||
$nostr |
||||
); |
||||
|
||||
$this->assertSame(['wss://r1', 'wss://r2'], $c->getAuthorNip65RelaysList($pk)); |
||||
$this->assertSame(['wss://r1', 'wss://r2'], $c->getAuthorNip65RelaysList($pk), 'second call should use cache, not re-fetch'); |
||||
} |
||||
|
||||
public function testGetTopReputableRelaysForAuthorFallsBackToDefaultRelayWhenEmpty(): void |
||||
{ |
||||
$pk = str_repeat('c', 64); |
||||
$nostr = $this->createMock(NostrClient::class); |
||||
$nostr->method('getNpubRelays')->willReturn([]); |
||||
|
||||
$ts = $this->createMock(TokenStorageInterface::class); |
||||
$ts->method('getToken')->willReturn(null); |
||||
$listFactory = new NostrRelayListFactory('wss://main', [], [], $ts, new NullLogger()); |
||||
|
||||
$c = new NostrAuthorRelayCache( |
||||
new ArrayAdapter(), |
||||
new NullLogger(), |
||||
$listFactory, |
||||
$nostr |
||||
); |
||||
|
||||
$this->assertSame(['wss://main'], $c->getTopReputableRelaysForAuthor($pk, 2)); |
||||
} |
||||
} |
||||
@ -0,0 +1,43 @@
@@ -0,0 +1,43 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Service; |
||||
|
||||
use App\Enum\KindsEnum; |
||||
use App\Service\NostrKind5DeletionFilter; |
||||
use PHPUnit\Framework\TestCase; |
||||
|
||||
final class NostrKind5DeletionFilterTest extends TestCase |
||||
{ |
||||
public function testKindTagMatchingStoredLongformIsRelevant(): void |
||||
{ |
||||
$f = new NostrKind5DeletionFilter(); |
||||
$ev = (object) [ |
||||
'kind' => 5, |
||||
'tags' => [['k', (string) KindsEnum::LONGFORM->value]], |
||||
]; |
||||
$this->assertTrue($f->isRelevantToStoredDbData($ev)); |
||||
} |
||||
|
||||
public function testKindTagForTextNoteIsNotRelevant(): void |
||||
{ |
||||
$f = new NostrKind5DeletionFilter(); |
||||
$ev = (object) [ |
||||
'kind' => 5, |
||||
'tags' => [['k', (string) KindsEnum::TEXT_NOTE->value]], |
||||
]; |
||||
$this->assertFalse($f->isRelevantToStoredDbData($ev)); |
||||
} |
||||
|
||||
public function testAddressTagWithStoredKindIsRelevant(): void |
||||
{ |
||||
$f = new NostrKind5DeletionFilter(); |
||||
$pk = str_repeat('a', 64); |
||||
$ev = (object) [ |
||||
'kind' => 5, |
||||
'tags' => [['a', KindsEnum::LONGFORM->value.':'.$pk.':slug']], |
||||
]; |
||||
$this->assertTrue($f->isRelevantToStoredDbData($ev)); |
||||
} |
||||
} |
||||
@ -0,0 +1,97 @@
@@ -0,0 +1,97 @@
|
||||
<?php |
||||
|
||||
declare(strict_types=1); |
||||
|
||||
namespace App\Tests\Service; |
||||
|
||||
use App\Service\NostrKeyHelper; |
||||
use App\Service\NostrWireEventMerge; |
||||
use PHPUnit\Framework\TestCase; |
||||
|
||||
final class NostrWireEventMergeTest extends TestCase |
||||
{ |
||||
private NostrWireEventMerge $m; |
||||
|
||||
protected function setUp(): void |
||||
{ |
||||
$this->m = new NostrWireEventMerge(new NostrKeyHelper()); |
||||
} |
||||
|
||||
public function testAuthorIdentToHexLowerAcceptsHex(): void |
||||
{ |
||||
$h = str_repeat('a', 64); |
||||
$this->assertSame($h, $this->m->authorIdentToHexLower($h)); |
||||
} |
||||
|
||||
public function testWireEventSupersedesByCreatedAt(): void |
||||
{ |
||||
$older = (object) ['kind' => 1, 'id' => str_repeat('1', 64), 'created_at' => 100, 'pubkey' => str_repeat('b', 64)]; |
||||
$newer = (object) ['kind' => 1, 'id' => str_repeat('2', 64), 'created_at' => 200, 'pubkey' => str_repeat('b', 64)]; |
||||
|
||||
$this->assertTrue($this->m->wireEventSupersedes($newer, $older)); |
||||
$this->assertFalse($this->m->wireEventSupersedes($older, $newer)); |
||||
} |
||||
|
||||
public function testWireEventSupersedesTieBreakByIdWhenSameCreatedAt(): void |
||||
{ |
||||
$t = 50; |
||||
$a = (object) ['kind' => 1, 'id' => str_repeat('a', 64), 'created_at' => $t, 'pubkey' => str_repeat('b', 64)]; |
||||
$b = (object) ['kind' => 1, 'id' => str_repeat('b', 64), 'created_at' => $t, 'pubkey' => str_repeat('b', 64)]; |
||||
|
||||
$this->assertTrue($this->m->wireEventSupersedes($a, $b), 'a < b lexicographically so a supersedes when created_at equal'); |
||||
$this->assertFalse($this->m->wireEventSupersedes($b, $a)); |
||||
} |
||||
|
||||
public function testMergeNip33ParameterizedWireEventsKeepsNewerByAddress(): void |
||||
{ |
||||
$k = 30_040; |
||||
$pk = str_repeat('c', 64); |
||||
$d = 'my-article'; |
||||
$tags = [['d', $d]]; |
||||
|
||||
$old = (object) [ |
||||
'kind' => $k, |
||||
'id' => str_repeat('1', 64), |
||||
'pubkey' => $pk, |
||||
'created_at' => 10, |
||||
'tags' => $tags, |
||||
]; |
||||
$new = (object) [ |
||||
'kind' => $k, |
||||
'id' => str_repeat('2', 64), |
||||
'pubkey' => $pk, |
||||
'created_at' => 20, |
||||
'tags' => $tags, |
||||
]; |
||||
|
||||
$merged = $this->m->mergeNip33ParameterizedWireEvents([$old, $new]); |
||||
$this->assertCount(1, $merged); |
||||
$this->assertSame(20, (int) $merged[0]->created_at); |
||||
} |
||||
|
||||
public function testMergeKind0ByReplaceableAddress(): void |
||||
{ |
||||
$pk = str_repeat('d', 64); |
||||
$a = (object) ['kind' => 0, 'id' => str_repeat('1', 64), 'pubkey' => $pk, 'created_at' => 1]; |
||||
$b = (object) ['kind' => 0, 'id' => str_repeat('2', 64), 'pubkey' => $pk, 'created_at' => 2]; |
||||
|
||||
$out = $this->m->mergeKind0EventsByReplaceableAddress([$a, $b]); |
||||
$this->assertCount(1, $out); |
||||
$this->assertSame(2, (int) array_values($out)[0]->created_at); |
||||
} |
||||
|
||||
public function testIsReplaceableByKindAndPubkeyNip(): void |
||||
{ |
||||
$this->assertTrue($this->m->isReplaceableByKindAndPubkeyNip(0)); |
||||
$this->assertTrue($this->m->isReplaceableByKindAndPubkeyNip(10_000)); |
||||
$this->assertFalse($this->m->isReplaceableByKindAndPubkeyNip(1)); |
||||
} |
||||
|
||||
public function testIsNip33ParameterizedKindRange(): void |
||||
{ |
||||
$this->assertTrue($this->m->isNip33ParameterizedKind(30_000)); |
||||
$this->assertTrue($this->m->isNip33ParameterizedKind(39_999)); |
||||
$this->assertFalse($this->m->isNip33ParameterizedKind(29_999)); |
||||
$this->assertFalse($this->m->isNip33ParameterizedKind(40_000)); |
||||
} |
||||
} |
||||
Loading…
Reference in new issue