13 changed files with 1117 additions and 1414 deletions
@ -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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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 @@ |
|||||||
|
<?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