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.
414 lines
14 KiB
414 lines
14 KiB
<?php |
|
|
|
declare(strict_types=1); |
|
|
|
namespace App\Service; |
|
|
|
use App\Entity\Event; |
|
use App\Nostr\MagazineEventKeys; |
|
use App\Repository\EventRepository; |
|
use Doctrine\ORM\EntityManagerInterface; |
|
use Psr\Log\LoggerInterface; |
|
use Symfony\Contracts\Service\ResetInterface; |
|
|
|
final class CacheService implements HighlightAuthorMetadataProvider, ResetInterface |
|
{ |
|
/** |
|
* @var array<string, array{content: \stdClass, kind0_tags: list<list<string>>, nip30_custom_emojis: list<array{shortcode: string, url: string, set?: string}>}> |
|
*/ |
|
private array $requestBundlesByHex = []; |
|
|
|
/** @var array<string, string> lowercase hex pubkey => npub */ |
|
private array $pendingHexToNpub = []; |
|
|
|
private int $metadataBatchDepth = 0; |
|
|
|
public function __construct( |
|
private NostrClient $nostrClient, |
|
private EntityManagerInterface $entityManager, |
|
private EventRepository $eventRepository, |
|
private LoggerInterface $logger, |
|
private NostrKeyHelper $nostrKeyHelper, |
|
private Nip30EmojiCatalogBuilder $nip30EmojiCatalogBuilder, |
|
) { |
|
} |
|
|
|
public function reset(): void |
|
{ |
|
$this->requestBundlesByHex = []; |
|
$this->pendingHexToNpub = []; |
|
$this->metadataBatchDepth = 0; |
|
} |
|
|
|
public function getMetadata(string $npub): \stdClass |
|
{ |
|
return $this->getMetadataBundle($npub)['content']; |
|
} |
|
|
|
/** |
|
* @return array{content: \stdClass, kind0_tags: list<list<string>>, nip30_custom_emojis: list<array{shortcode: string, url: string, set?: string}>} |
|
*/ |
|
public function getMetadataBundle(string $npub): array |
|
{ |
|
$authorHex = $this->npubToAuthorHex64($npub); |
|
if ($authorHex === null) { |
|
return $this->placeholderMetadataBundle($npub); |
|
} |
|
if (isset($this->requestBundlesByHex[$authorHex])) { |
|
return $this->requestBundlesByHex[$authorHex]; |
|
} |
|
$row = $this->eventRepository->findOneByCoreRowKey(MagazineEventKeys::profileKind0($authorHex)); |
|
if ($row !== null) { |
|
$bundle = $this->bundleFromKind0EventRow($row, $npub); |
|
$this->requestBundlesByHex[$authorHex] = $bundle; |
|
|
|
return $bundle; |
|
} |
|
$this->pendingHexToNpub[$authorHex] = $npub; |
|
$this->runPendingMetadataBatch(); |
|
|
|
return $this->requestBundlesByHex[$authorHex] ?? $this->placeholderMetadataBundle($npub); |
|
} |
|
|
|
/** |
|
* @param list<string> $npubs |
|
*/ |
|
public function prefetchMetadataForNpubs(array $npubs): void |
|
{ |
|
foreach ($npubs as $npub) { |
|
if (!\is_string($npub) || $npub === '') { |
|
continue; |
|
} |
|
$authorHex = $this->npubToAuthorHex64($npub); |
|
if ($authorHex === null || isset($this->requestBundlesByHex[$authorHex])) { |
|
continue; |
|
} |
|
$this->pendingHexToNpub[$authorHex] = $npub; |
|
} |
|
$this->runPendingMetadataBatch(); |
|
} |
|
|
|
/** |
|
* @param list<string> $pubkeyHex 64-char hex pubkeys (any case) |
|
*/ |
|
public function prefetchMetadataForPubkeyHexes(array $pubkeyHex): void |
|
{ |
|
$npubs = []; |
|
foreach ($pubkeyHex as $hex) { |
|
if (!\is_string($hex) || 64 !== \strlen($hex) || !ctype_xdigit($hex)) { |
|
continue; |
|
} |
|
try { |
|
$npubs[] = $this->nostrKeyHelper->convertPublicKeyToBech32(strtolower($hex)); |
|
} catch (\Throwable) { |
|
} |
|
} |
|
$this->prefetchMetadataForNpubs($npubs); |
|
} |
|
|
|
private function runPendingMetadataBatch(): void |
|
{ |
|
if ($this->pendingHexToNpub === []) { |
|
return; |
|
} |
|
++$this->metadataBatchDepth; |
|
try { |
|
do { |
|
if ($this->pendingHexToNpub === []) { |
|
break; |
|
} |
|
$this->flushPendingMetadataFetches(); |
|
} while ($this->pendingHexToNpub !== []); |
|
} finally { |
|
--$this->metadataBatchDepth; |
|
} |
|
} |
|
|
|
private function flushPendingMetadataFetches(): void |
|
{ |
|
if ($this->pendingHexToNpub === []) { |
|
return; |
|
} |
|
$pending = $this->pendingHexToNpub; |
|
$this->pendingHexToNpub = []; |
|
|
|
$keys = []; |
|
foreach (array_keys($pending) as $hex) { |
|
$keys[] = MagazineEventKeys::profileKind0($hex); |
|
} |
|
$rowsByKey = $this->eventRepository->findByCoreRowKeys($keys); |
|
|
|
$relayHex = []; |
|
foreach ($pending as $hex => $npub) { |
|
$key = MagazineEventKeys::profileKind0($hex); |
|
if (isset($rowsByKey[$key])) { |
|
$this->requestBundlesByHex[$hex] = $this->bundleFromKind0EventRow($rowsByKey[$key], $npub); |
|
continue; |
|
} |
|
$relayHex[] = $hex; |
|
} |
|
if ($relayHex === []) { |
|
return; |
|
} |
|
|
|
try { |
|
$fetched = $this->nostrClient->fetchProfilePrewarmWireBundlesForAuthors($relayHex); |
|
$this->putPrewarmMetadataBatch($relayHex, $fetched); |
|
} catch (\Throwable $e) { |
|
$this->logger->warning('Profile metadata batch fetch failed.', [ |
|
'authors' => \count($relayHex), |
|
'exception' => $e, |
|
]); |
|
} |
|
|
|
$rowsAfterRelay = $this->eventRepository->findByCoreRowKeys(array_map( |
|
static fn (string $hex): string => MagazineEventKeys::profileKind0($hex), |
|
$relayHex, |
|
)); |
|
foreach ($relayHex as $hex) { |
|
$npub = $pending[$hex]; |
|
$key = MagazineEventKeys::profileKind0($hex); |
|
if (isset($rowsAfterRelay[$key])) { |
|
$this->requestBundlesByHex[$hex] = $this->bundleFromKind0EventRow($rowsAfterRelay[$key], $npub); |
|
continue; |
|
} |
|
$this->logger->warning('Profile metadata fetch failed; using npub placeholder.', [ |
|
'npub' => $npub, |
|
]); |
|
$this->requestBundlesByHex[$hex] = $this->placeholderMetadataBundle($npub); |
|
} |
|
} |
|
|
|
/** |
|
* Prewarm: batch upsert of kind-0 profile rows in {@see Event} with merged NIP-30 emoji catalog. |
|
* |
|
* @param list<string> $authorPubkeyHex |
|
* @param array<string, array<string, mixed>> $bundlesByLowerHex from {@see NostrClient::fetchProfilePrewarmWireBundlesForAuthors} |
|
*/ |
|
public function putPrewarmMetadataBatch(array $authorPubkeyHex, array $bundlesByLowerHex): int |
|
{ |
|
$n = 0; |
|
foreach ($authorPubkeyHex as $hex) { |
|
if (64 !== \strlen($hex) || !ctype_xdigit($hex)) { |
|
continue; |
|
} |
|
$h = strtolower($hex); |
|
if (!isset($bundlesByLowerHex[$h]) || !\is_array($bundlesByLowerHex[$h])) { |
|
continue; |
|
} |
|
$bundle = $bundlesByLowerHex[$h]; |
|
$k0 = $bundle['kind0'] ?? null; |
|
if (!\is_object($k0)) { |
|
continue; |
|
} |
|
$emojiList = $bundle['emoji_list'] ?? null; |
|
if (!\is_object($emojiList)) { |
|
$emojiList = null; |
|
} |
|
$statuses = $bundle['statuses'] ?? []; |
|
if (!\is_array($statuses)) { |
|
$statuses = []; |
|
} |
|
$nip30 = $this->nip30EmojiCatalogBuilder->buildMergedCatalog($k0, $emojiList, $statuses); |
|
$this->replaceByCoreKey( |
|
MagazineEventKeys::profileKind0($h), |
|
Event::STORAGE_PROFILE_KIND0, |
|
$k0, |
|
$nip30, |
|
); |
|
++$n; |
|
} |
|
|
|
return $n; |
|
} |
|
|
|
/** |
|
* @return list<list<string>> |
|
*/ |
|
private static function normalizeEventTagsList(mixed $tags): array |
|
{ |
|
if (!\is_array($tags)) { |
|
return []; |
|
} |
|
$out = []; |
|
foreach ($tags as $row) { |
|
if (!\is_array($row) && !\is_object($row)) { |
|
continue; |
|
} |
|
$seq = \is_object($row) ? get_object_vars($row) : $row; |
|
if ($seq === []) { |
|
continue; |
|
} |
|
$r = array_values( |
|
array_map( |
|
static fn (mixed $v): string => (string) $v, |
|
array_values($seq) |
|
) |
|
); |
|
if ($r !== [] && (string) ($r[0] ?? '') !== '') { |
|
$out[] = $r; |
|
} |
|
} |
|
|
|
return $out; |
|
} |
|
|
|
private function npubToAuthorHex64(string $npub): ?string |
|
{ |
|
if (64 === \strlen($npub) && ctype_xdigit($npub)) { |
|
return strtolower($npub); |
|
} |
|
if (str_starts_with($npub, 'npub1')) { |
|
try { |
|
$h = $this->nostrKeyHelper->convertToHex($npub); |
|
} catch (\Throwable) { |
|
$h = ''; |
|
} |
|
if (64 === \strlen((string) $h) && ctype_xdigit((string) $h)) { |
|
return strtolower($h); |
|
} |
|
} |
|
|
|
return null; |
|
} |
|
|
|
/** |
|
* @param list<array{shortcode: string, url: string, set?: string}>|null $nip30Catalog profile rows only |
|
*/ |
|
private function replaceByCoreKey(string $coreKey, string $storageRole, object $rawWire, ?array $nip30Catalog = null): void |
|
{ |
|
$entity = $this->wireToEventEntity($rawWire); |
|
if ($entity === null) { |
|
return; |
|
} |
|
$entity->setCoreRowKey($coreKey); |
|
$entity->setStorageRole($storageRole); |
|
if ($storageRole === Event::STORAGE_PROFILE_KIND0) { |
|
$entity->setNip30CustomEmoji($nip30Catalog ?? $this->nip30EmojiCatalogBuilder->buildMergedCatalog($rawWire, null, [])); |
|
} else { |
|
$entity->setNip30CustomEmoji(null); |
|
} |
|
if ($entity->getEventId() === null) { |
|
$entity->setEventId($entity->getId()); |
|
} |
|
$prev = $this->eventRepository->findOneByCoreRowKey($coreKey); |
|
if ($prev !== null && $prev->getId() === $entity->getId()) { |
|
$prev->setKind($entity->getKind()); |
|
$prev->setPubkey($entity->getPubkey()); |
|
$prev->setContent($entity->getContent()); |
|
$prev->setCreatedAt($entity->getCreatedAt()); |
|
$prev->setTags($entity->getTags()); |
|
$prev->setSig($entity->getSig()); |
|
$prev->setCoreRowKey($coreKey); |
|
$prev->setStorageRole($storageRole); |
|
if ($storageRole === Event::STORAGE_PROFILE_KIND0) { |
|
$prev->setNip30CustomEmoji($entity->getNip30CustomEmoji()); |
|
} else { |
|
$prev->setNip30CustomEmoji(null); |
|
} |
|
if ($entity->getEventId() !== null) { |
|
$prev->setEventId($entity->getEventId()); |
|
} |
|
$this->entityManager->flush(); |
|
|
|
return; |
|
} |
|
if ($prev !== null) { |
|
$this->entityManager->remove($prev); |
|
$this->entityManager->flush(); |
|
} |
|
$this->entityManager->persist($entity); |
|
$this->entityManager->flush(); |
|
} |
|
|
|
private function wireToEventEntity(object $raw): ?Event |
|
{ |
|
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; |
|
} |
|
$id = (string) ($data['id'] ?? ''); |
|
if (64 !== \strlen($id) || !ctype_xdigit($id)) { |
|
return null; |
|
} |
|
$e = new Event(); |
|
$e->setId(strtolower($id)); |
|
$e->setEventId(strtolower($id)); |
|
$e->setKind((int) ($data['kind'] ?? 0)); |
|
$e->setPubkey(strtolower((string) ($data['pubkey'] ?? ''))); |
|
$e->setContent((string) ($data['content'] ?? '')); |
|
$e->setCreatedAt((int) ($data['created_at'] ?? 0)); |
|
$tags = $data['tags'] ?? []; |
|
$e->setTags(\is_array($tags) ? $tags : []); |
|
$e->setSig((string) ($data['sig'] ?? '')); |
|
|
|
return $e; |
|
} |
|
|
|
private function bundleFromKind0EventRow(Event $row, string $npub): array |
|
{ |
|
$content = $this->decodeKind0ContentString($row->getContent()); |
|
if (!\is_object($content) || $this->isPlaceholderContent($content, $npub)) { |
|
$content = $this->namePlaceholderNpubObject($npub); |
|
} |
|
|
|
$nip30 = $row->getNip30CustomEmoji(); |
|
if (!\is_array($nip30) || $nip30 === []) { |
|
$nip30 = $this->nip30EmojiCatalogBuilder->catalogFromTagsOnly($row->getTags()); |
|
} |
|
|
|
return [ |
|
'content' => $content, |
|
'kind0_tags' => self::normalizeEventTagsList($row->getTags()), |
|
'nip30_custom_emojis' => $nip30, |
|
]; |
|
} |
|
|
|
private function decodeKind0ContentObject(object $ev): \stdClass |
|
{ |
|
return $this->decodeKind0ContentString((string) ($ev->content ?? '')); |
|
} |
|
|
|
private function decodeKind0ContentString(string $raw): \stdClass |
|
{ |
|
try { |
|
$data = \json_decode($raw, false, 512, \JSON_THROW_ON_ERROR); |
|
} catch (\JsonException) { |
|
return new \stdClass(); |
|
} |
|
if (!\is_object($data)) { |
|
return new \stdClass(); |
|
} |
|
|
|
return $data; |
|
} |
|
|
|
private function isPlaceholderContent(\stdClass $content, string $npub): bool |
|
{ |
|
$n = (string) ($content->name ?? ''); |
|
|
|
return $n === substr($npub, 0, 8).'…'.substr($npub, -4); |
|
} |
|
|
|
private function namePlaceholderNpubObject(string $npub): \stdClass |
|
{ |
|
$c = new \stdClass(); |
|
$c->name = substr($npub, 0, 8).'…'.substr($npub, -4); |
|
|
|
return $c; |
|
} |
|
|
|
private function placeholderMetadataBundle(string $npub): array |
|
{ |
|
return [ |
|
'content' => $this->namePlaceholderNpubObject($npub), |
|
'kind0_tags' => [], |
|
'nip30_custom_emojis' => [], |
|
]; |
|
} |
|
|
|
}
|
|
|