diff --git a/compose.yaml b/compose.yaml index cfa2027..680ed14 100644 --- a/compose.yaml +++ b/compose.yaml @@ -89,6 +89,26 @@ services: DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}} NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-} + article_hydration_worker: + build: + context: . + dockerfile: Dockerfile + working_dir: /app + entrypoint: ["php"] # run PHP CLI, not Caddy/FrankenPHP + command: + - bin/console + - articles:subscribe-local-relay + - -vv + restart: unless-stopped + depends_on: + - php + - database + - strfry + environment: + APP_ENV: prod + DATABASE_URL: ${DATABASE_URL:-postgresql://${POSTGRES_USER:-app}:${POSTGRES_PASSWORD:-!ChangeMe!}@database:5432/${POSTGRES_DB:-app}?serverVersion=${POSTGRES_VERSION:-17}&charset=${POSTGRES_CHARSET:-utf8}} + NOSTR_DEFAULT_RELAY: ${NOSTR_DEFAULT_RELAY:-ws://strfry:7777} + ###> strfry relay ### strfry: image: dockurr/strfry:latest diff --git a/src/Command/CacheLatestArticlesCommand.php b/src/Command/CacheLatestArticlesCommand.php index 95ff542..7e79d09 100644 --- a/src/Command/CacheLatestArticlesCommand.php +++ b/src/Command/CacheLatestArticlesCommand.php @@ -4,112 +4,142 @@ declare(strict_types=1); namespace App\Command; +use App\Entity\Article; +use App\Repository\ArticleRepository; use App\Service\RedisCacheService; -use App\Util\NostrKeyUtil; -use FOS\ElasticaBundle\Finder\FinderInterface; +use App\Service\RedisViewStore; +use App\ReadModel\RedisView\RedisViewFactory; use Symfony\Component\Console\Attribute\AsCommand; use Symfony\Component\Console\Command\Command; use Symfony\Component\Console\Input\InputInterface; +use Symfony\Component\Console\Input\InputOption; use Symfony\Component\Console\Output\OutputInterface; -use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface; -use Elastica\Query\BoolQuery; -use Elastica\Query; -use Elastica\Collapse; -use Elastica\Query\Terms; -use Psr\Cache\CacheItemPoolInterface; use swentel\nostr\Key\Key; -#[AsCommand(name: 'app:cache_latest_articles', description: 'Cache the latest articles list')] +#[AsCommand( + name: 'app:cache_latest_articles', + description: 'Cache the latest articles list to Redis views' +)] class CacheLatestArticlesCommand extends Command { - private FinderInterface $finder; - private CacheItemPoolInterface $articlesCache; - private ParameterBagInterface $params; - private RedisCacheService $redisCacheService; - public function __construct( - FinderInterface $finder, - CacheItemPoolInterface $articlesCache, - ParameterBagInterface $params, - RedisCacheService $redisCacheService - ) - { + private readonly ArticleRepository $articleRepository, + private readonly RedisCacheService $redisCacheService, + private readonly RedisViewStore $viewStore, + private readonly RedisViewFactory $viewFactory, + ) { parent::__construct(); - $this->finder = $finder; - $this->articlesCache = $articlesCache; - $this->params = $params; - $this->redisCacheService = $redisCacheService; + } + + protected function configure(): void + { + $this->addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + 'Maximum number of articles to cache', + 50 + ); } protected function execute(InputInterface $input, OutputInterface $output): int { - $env = $this->params->get('kernel.environment'); - $cacheKey = 'latest_articles_list_' . $env; - $cacheItem = $this->articlesCache->getItem($cacheKey); + $limit = (int) $input->getOption('limit'); + // Define excluded pubkeys (bots, spam accounts) $key = new Key(); $excludedPubkeys = [ $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) - $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) - $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed 𝚋𝚘𝚝 (Just annoying) + $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz + $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW - $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs, job offers feed + $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs $key->convertToHex('npub1sztw66ap7gdrwyaag7js8m3h0vxw9uv8k0j68t5sngjmsjgpqx9sm9wyaq'), // Now Playing bot - ]; - // if (!$cacheItem->isHit()) { - $boolQuery = new BoolQuery(); - $boolQuery->addMustNot(new Terms('pubkey', $excludedPubkeys)); - - $query = new Query($boolQuery); - $query->setSize(50); - $query->setSort(['createdAt' => ['order' => 'desc']]); - - $collapse = new Collapse(); - $collapse->setFieldname('slug'); - $query->setCollapse($collapse); - - $collapse2 = new Collapse(); - $collapse2->setFieldname('pubkey'); - $query->setCollapse($collapse2); - - $articles = $this->finder->find($query); - - // Pre-fetch and cache author metadata for all articles - $authorPubkeys = []; - foreach ($articles as $article) { - // Debug: check what type of object we have - if ($article instanceof \App\Entity\Article) { - $pubkey = $article->getPubkey(); - if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) { - $authorPubkeys[] = $pubkey; - } - } elseif (is_object($article)) { - // Elastica result object - if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { - $authorPubkeys[] = $article->pubkey; - } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) { - $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub); - } - } + $output->writeln('Querying database for latest articles...'); + + // Query database directly - get latest published articles, one per author + // Using DQL to get the most recent article per pubkey + $qb = $this->articleRepository->createQueryBuilder('a'); + $qb->where('a.publishedAt IS NOT NULL') + ->andWhere('a.slug IS NOT NULL') + ->andWhere('a.title IS NOT NULL') + ->andWhere($qb->expr()->notIn('a.pubkey', ':excludedPubkeys')) + ->setParameter('excludedPubkeys', $excludedPubkeys) + ->orderBy('a.createdAt', 'DESC') + ->setMaxResults($limit * 2); // Get more initially, will dedupe by author + + /** @var Article[] $allArticles */ + $allArticles = $qb->getQuery()->getResult(); + + $output->writeln(sprintf('Found %d articles from database', count($allArticles))); + + // Deduplicate: Keep only the most recent article per author + $articlesByAuthor = []; + foreach ($allArticles as $article) { + $pubkey = $article->getPubkey(); + if (!$pubkey) { + continue; + } + + // Keep first (most recent) article per author + if (!isset($articlesByAuthor[$pubkey])) { + $articlesByAuthor[$pubkey] = $article; + } + } + + // Take only the requested limit + $articles = array_slice($articlesByAuthor, 0, $limit); + + $output->writeln(sprintf('Selected %d articles (one per author)', count($articles))); + + if (empty($articles)) { + $output->writeln('No articles found matching criteria'); + return Command::FAILURE; + } + + // Collect author pubkeys for metadata fetching + $authorPubkeys = array_keys($articlesByAuthor); + + $output->writeln(sprintf('Fetching metadata for %d authors...', count($authorPubkeys))); + $authorsMetadata = $this->redisCacheService->getMultipleMetadata($authorPubkeys); + $output->writeln(sprintf('✓ Fetched %d author profiles', count($authorsMetadata))); + + // Build Redis view objects - we now have exact Article entities! + $output->writeln('Building Redis view objects...'); + $baseObjects = []; + + foreach ($articles as $article) { + $authorMeta = $authorsMetadata[$article->getPubkey()] ?? null; + + try { + $baseObject = $this->viewFactory->articleBaseObject($article, $authorMeta); + $baseObjects[] = $baseObject; + } catch (\Exception $e) { + $output->writeln(sprintf( + 'Failed to build view object for article %s: %s', + $article->getSlug(), + $e->getMessage() + )); } - $authorPubkeys = array_unique($authorPubkeys); - - $output->writeln('Pre-fetching metadata for ' . count($authorPubkeys) . ' authors...'); - $authorsMetadata = $this->redisCacheService->getMultipleMetadata($authorPubkeys); - $output->writeln('Fetched ' . count($authorsMetadata) . ' author profiles.'); - - $cacheItem->set($articles); - $cacheItem->expiresAfter(3600); // Cache for 1 hour - $this->articlesCache->save($cacheItem); - $output->writeln('Cached ' . count($articles) . ' articles with author metadata.'); -// } else { -// $output->writeln('Cache already exists for key: ' . $cacheKey . ''); -// } + } + + if (empty($baseObjects)) { + $output->writeln('No view objects created'); + return Command::FAILURE; + } + + // Store to Redis views + $output->writeln('Storing to Redis...'); + $this->viewStore->storeLatestArticles($baseObjects); + + $output->writeln(''); + $output->writeln(sprintf('✓ Successfully cached %d articles to Redis views', count($baseObjects))); + $output->writeln(' Key: view:articles:latest'); return Command::SUCCESS; } diff --git a/src/Command/CacheLatestHighlightsCommand.php b/src/Command/CacheLatestHighlightsCommand.php new file mode 100644 index 0000000..7bcea87 --- /dev/null +++ b/src/Command/CacheLatestHighlightsCommand.php @@ -0,0 +1,148 @@ +addOption( + 'limit', + 'l', + InputOption::VALUE_OPTIONAL, + 'Maximum number of highlights to cache', + 50 + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $limit = (int) $input->getOption('limit'); + + $output->writeln("Fetching latest {$limit} highlights from database..."); + + try { + // Get latest highlights with their articles + $highlightsWithArticles = $this->highlightRepository->findLatestWithArticles($limit); + $output->writeln(sprintf('Found %d highlights', count($highlightsWithArticles))); + + if (empty($highlightsWithArticles)) { + $output->writeln('No highlights found in database'); + return Command::SUCCESS; + } + + // Collect unique pubkeys for batch metadata fetch + $pubkeys = []; + foreach ($highlightsWithArticles as $item) { + $highlight = $item['highlight']; + $article = $item['article']; + + if ($highlight->getPubkey()) { + $pubkeys[] = $highlight->getPubkey(); + } + if ($article && $article->getPubkey()) { + $pubkeys[] = $article->getPubkey(); + } + } + $pubkeys = array_unique(array_filter($pubkeys)); + + $output->writeln(sprintf('Pre-fetching metadata for %d unique authors...', count($pubkeys))); + $metadataMap = $this->redisCacheService->getMultipleMetadata($pubkeys); + $output->writeln(sprintf('Fetched %d author profiles', count($metadataMap))); + + // Build Redis view objects + $output->writeln('Building Redis view objects...'); + $baseObjects = []; + $skipped = 0; + + foreach ($highlightsWithArticles as $item) { + $highlight = $item['highlight']; + $article = $item['article']; + + // Skip if article not found + if (!$article) { + $skipped++; + $this->logger->debug('Skipping highlight - article not found', [ + 'highlight_id' => $highlight->getEventId(), + 'coordinate' => $highlight->getArticleCoordinate(), + ]); + continue; + } + + // Get metadata for both authors + $highlightAuthorMeta = $metadataMap[$highlight->getPubkey()] ?? null; + $articleAuthorMeta = $metadataMap[$article->getPubkey()] ?? null; + + // Build base object + try { + $baseObject = $this->viewFactory->highlightBaseObject( + $highlight, + $article, + $highlightAuthorMeta, + $articleAuthorMeta + ); + $baseObjects[] = $baseObject; + } catch (\Exception $e) { + $this->logger->error('Failed to build highlight base object', [ + 'highlight_id' => $highlight->getEventId(), + 'error' => $e->getMessage(), + ]); + $skipped++; + } + } + + if ($skipped > 0) { + $output->writeln(sprintf('Skipped %d highlights (missing articles or errors)', $skipped)); + } + + // Store to Redis views + if (!empty($baseObjects)) { + $this->viewStore->storeLatestHighlights($baseObjects); + $output->writeln(sprintf('✓ Stored %d highlights to Redis views (view:highlights:latest)', count($baseObjects))); + } else { + $output->writeln('No valid highlights to cache'); + return Command::FAILURE; + } + + return Command::SUCCESS; + + } catch (\Exception $e) { + $output->writeln(sprintf('Error: %s', $e->getMessage())); + $this->logger->error('Failed to cache highlights', [ + 'error' => $e->getMessage(), + 'trace' => $e->getTraceAsString(), + ]); + return Command::FAILURE; + } + } +} + diff --git a/src/Command/ProcessArticleHtmlCommand.php b/src/Command/ProcessArticleHtmlCommand.php new file mode 100644 index 0000000..c9fd789 --- /dev/null +++ b/src/Command/ProcessArticleHtmlCommand.php @@ -0,0 +1,126 @@ +addOption('force', 'f', InputOption::VALUE_NONE, 'Force reprocessing of all articles (including those with existing HTML)') + ->addOption('limit', 'l', InputOption::VALUE_REQUIRED, 'Limit the number of articles to process', null) + ->setHelp( + 'This command processes markdown content to HTML for articles and caches the result in the database. ' . + 'By default, it only processes articles that are missing processed HTML. ' . + 'Use --force to reprocess all articles.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + $force = $input->getOption('force'); + $limit = $input->getOption('limit'); + + $io->title('Article HTML Processing'); + + // Build query + $queryBuilder = $this->entityManager->createQueryBuilder() + ->select('a') + ->from(Article::class, 'a') + ->where('a.content IS NOT NULL'); + + if (!$force) { + $queryBuilder->andWhere('a.processedHtml IS NULL'); + } + + if ($limit) { + $queryBuilder->setMaxResults((int) $limit); + } + + $articles = $queryBuilder->getQuery()->getResult(); + $total = count($articles); + + if ($total === 0) { + $io->success('No articles to process.'); + return Command::SUCCESS; + } + + $io->info(sprintf('Found %d article(s) to process', $total)); + $io->newLine(); + + $progressBar = $io->createProgressBar($total); + $progressBar->setFormat('very_verbose'); + $progressBar->start(); + + $processed = 0; + $failed = 0; + $batchSize = 20; + + foreach ($articles as $index => $article) { + try { + $html = $this->converter->convertToHTML($article->getContent()); + $article->setProcessedHtml($html); + $processed++; + + // Flush in batches for better performance + if (($index + 1) % $batchSize === 0) { + $this->entityManager->flush(); + $this->entityManager->clear(); + } + } catch (\Exception $e) { + $failed++; + $io->writeln(''); + $io->warning(sprintf( + 'Failed to process article %s: %s', + $article->getEventId() ?? $article->getId(), + $e->getMessage() + )); + } + + $progressBar->advance(); + } + + // Final flush for remaining articles + $this->entityManager->flush(); + $this->entityManager->clear(); + + $progressBar->finish(); + $io->newLine(2); + + // Summary + $io->success(sprintf( + 'Processing complete: %d processed, %d failed', + $processed, + $failed + )); + + if ($failed > 0) { + $io->note('Some articles failed to process. Check the warnings above for details.'); + } + + return Command::SUCCESS; + } +} + diff --git a/src/Command/SubscribeLocalRelayCommand.php b/src/Command/SubscribeLocalRelayCommand.php new file mode 100644 index 0000000..2f9cd1f --- /dev/null +++ b/src/Command/SubscribeLocalRelayCommand.php @@ -0,0 +1,126 @@ +setHelp( + 'This command subscribes to the local Nostr relay for article events (kind 30023) ' . + 'and automatically persists them to the database. It runs as a long-lived daemon process.' + ); + } + + protected function execute(InputInterface $input, OutputInterface $output): int + { + $io = new SymfonyStyle($input, $output); + + $localRelay = $this->relayPool->getLocalRelay(); + if (!$localRelay) { + $io->error('Local relay not configured. Please set NOSTR_DEFAULT_RELAY environment variable.'); + return Command::FAILURE; + } + + $io->title('Article Hydration Worker'); + $io->info(sprintf('Subscribing to local relay: %s', $localRelay)); + $io->info('Listening for article events (kind 30023)...'); + $io->newLine(); + + try { + // Start the long-lived subscription + // This blocks forever and processes events via the callback + $this->relayPool->subscribeLocalArticles( + function (object $event, string $relayUrl) use ($io) { + $timestamp = date('Y-m-d H:i:s'); + $eventId = substr($event->id ?? 'unknown', 0, 16) . '...'; + $pubkey = substr($event->pubkey ?? 'unknown', 0, 16) . '...'; + $kind = $event->kind ?? 'unknown'; + $title = ''; + + // Extract title from tags if available + if (isset($event->tags) && is_array($event->tags)) { + foreach ($event->tags as $tag) { + if (is_array($tag) && isset($tag[0]) && $tag[0] === 'title' && isset($tag[1])) { + $title = mb_substr($tag[1], 0, 50); + if (mb_strlen($tag[1]) > 50) { + $title .= '...'; + } + break; + } + } + } + + // Log to console + $io->writeln(sprintf( + '[%s] Event received: %s (kind: %s, pubkey: %s)%s', + $timestamp, + $eventId, + $kind, + $pubkey, + $title ? ' - ' . $title : '' + )); + + // Project the event to the database + try { + $this->projector->projectArticleFromEvent($event, $relayUrl); + $io->writeln(sprintf( + '[%s] ✓ Article saved to database', + date('Y-m-d H:i:s') + )); + } catch (\InvalidArgumentException $e) { + // Invalid event (wrong kind, bad signature, etc.) + $io->writeln(sprintf( + '[%s] ⚠ Skipped invalid event: %s', + date('Y-m-d H:i:s'), + $e->getMessage() + )); + } catch (\Exception $e) { + // Database or other errors + $io->writeln(sprintf( + '[%s] ✗ Error saving article: %s', + date('Y-m-d H:i:s'), + $e->getMessage() + )); + } + + $io->newLine(); + } + ); + + // @phpstan-ignore-next-line - This line should never be reached (infinite loop in subscribeLocalArticles) + return Command::SUCCESS; + + } catch (\Exception $e) { + $io->error('Subscription failed: ' . $e->getMessage()); + $this->logger->error('Article hydration worker failed', [ + 'error' => $e->getMessage(), + 'exception' => get_class($e), + 'trace' => $e->getTraceAsString() + ]); + return Command::FAILURE; + } + } +} + diff --git a/src/Controller/ArticleController.php b/src/Controller/ArticleController.php index 3a43673..5dceaf6 100644 --- a/src/Controller/ArticleController.php +++ b/src/Controller/ArticleController.php @@ -128,8 +128,8 @@ class ArticleController extends AbstractController $slug, EntityManagerInterface $entityManager, RedisCacheService $redisCacheService, - CacheItemPoolInterface $articlesCache, Converter $converter, + LoggerInterface $logger, HighlightService $highlightService ): Response { @@ -143,12 +143,23 @@ class ArticleController extends AbstractController if (!$article) { throw $this->createNotFoundException('The article could not be found'); } - $cacheKey = 'article_' . $article->getEventId(); - $cacheItem = $articlesCache->getItem($cacheKey); - if (!$cacheItem->isHit()) { - $cacheItem->set($converter->convertToHTML($article->getContent())); - $articlesCache->save($cacheItem); + + // Use cached processedHtml from database if available + $htmlContent = $article->getProcessedHtml(); + $logger->info('Article content retrieval', [ + 'article_id' => $article->getId(), + 'slug' => $article->getSlug(), + 'pubkey' => $article->getPubkey(), + 'has_cached_html' => $htmlContent !== null + ]); + + if (!$htmlContent) { + // Fall back to converting on-the-fly and save for future requests + $htmlContent = $converter->convertToHTML($article->getContent()); + $article->setProcessedHtml($htmlContent); + $entityManager->flush(); } + $author = $redisCacheService->getMetadata($article->getPubkey()); $canEdit = false; $user = $this->getUser(); @@ -170,7 +181,7 @@ class ArticleController extends AbstractController 'article' => $article, 'author' => $author, 'npub' => $npub, - 'content' => $cacheItem->get(), + 'content' => $htmlContent, 'canEdit' => $canEdit, 'canonical' => $canonical, 'highlights' => $highlights diff --git a/src/Controller/AuthorController.php b/src/Controller/AuthorController.php index d0eb2d7..8f8aee7 100644 --- a/src/Controller/AuthorController.php +++ b/src/Controller/AuthorController.php @@ -232,26 +232,67 @@ class AuthorController extends AbstractController #[Route('/p/{npub}', name: 'author-profile', requirements: ['npub' => '^npub1.*'])] #[Route('/p/{npub}/articles', name: 'author-articles', requirements: ['npub' => '^npub1.*'])] public function index($npub, RedisCacheService $redisCacheService, FinderInterface $finder, - MessageBusInterface $messageBus): Response + MessageBusInterface $messageBus, RedisViewStore $viewStore, + RedisViewFactory $viewFactory): Response { $keys = new Key(); $pubkey = $keys->convertToHex($npub); $author = $redisCacheService->getMetadata($pubkey); - // Get articles using Elasticsearch with collapse on slug - $boolQuery = new BoolQuery(); - $boolQuery->addMust(new Term(['pubkey' => $pubkey])); - $query = new \Elastica\Query($boolQuery); - $query->setSort(['createdAt' => ['order' => 'desc']]); - $collapse = new Collapse(); - $collapse->setFieldname('slug'); - $query->setCollapse($collapse); - $articles = $finder->find($query); + // Try to get cached view first + $cachedArticles = $viewStore->fetchUserArticles($pubkey); + $fromCache = false; + + if ($cachedArticles !== null) { + // Redis view data already matches template - just extract articles + $articles = []; + foreach ($cachedArticles as $baseObject) { + if (isset($baseObject['article'])) { + $articles[] = (object) $baseObject['article']; + } + } + $fromCache = true; + } else { + // Cache miss - query from Elasticsearch + $boolQuery = new BoolQuery(); + $boolQuery->addMust(new Term(['pubkey' => $pubkey])); + $query = new \Elastica\Query($boolQuery); + $query->setSort(['createdAt' => ['order' => 'desc']]); + $collapse = new Collapse(); + $collapse->setFieldname('slug'); + $query->setCollapse($collapse); + $articles = $finder->find($query); + + // Build and cache Redis views for next time + if (!empty($articles)) { + try { + $baseObjects = []; + foreach ($articles as $article) { + if ($article instanceof Article) { + $baseObjects[] = $viewFactory->articleBaseObject($article, $author); + } + } + if (!empty($baseObjects)) { + $viewStore->storeUserArticles($pubkey, $baseObjects); + } + } catch (\Exception $e) { + // Log but don't fail the request + error_log('Failed to cache user articles view: ' . $e->getMessage()); + } + } + } // Get latest createdAt for dispatching fetch message if (!empty($articles)) { - $latest = $articles[0]->getCreatedAt()->getTimestamp(); + // Handle both Article entities and cached arrays + if (is_array($articles[0])) { + $latest = isset($articles[0]['article']['publishedAt']) + ? strtotime($articles[0]['article']['publishedAt']) + : time(); + } else { + $latest = $articles[0]->getCreatedAt()->getTimestamp(); + } // Dispatch async message to fetch new articles since latest + 1 $messageBus->dispatch(new FetchAuthorArticlesMessage($pubkey, $latest + 1)); } else { @@ -266,6 +307,7 @@ class AuthorController extends AbstractController 'pubkey' => $pubkey, 'articles' => $articles, 'is_author_profile' => true, + 'from_cache' => $fromCache, ]); } diff --git a/src/Controller/DefaultController.php b/src/Controller/DefaultController.php index 9d93b49..82609ae 100644 --- a/src/Controller/DefaultController.php +++ b/src/Controller/DefaultController.php @@ -9,6 +9,7 @@ use App\Entity\Event; use App\Enum\KindsEnum; use App\Service\NostrClient; use App\Service\RedisCacheService; +use App\Service\RedisViewStore; use App\Util\CommonMark\Converter; use App\Util\ForumTopics; use App\Util\NostrKeyUtil; @@ -32,7 +33,6 @@ use Psr\Log\LoggerInterface; class DefaultController extends AbstractController { - /** * @throws Exception */ @@ -58,67 +58,89 @@ class DefaultController extends AbstractController public function discover( FinderInterface $finder, RedisCacheService $redisCacheService, + RedisViewStore $viewStore, CacheItemPoolInterface $articlesCache ): Response { - $env = $this->getParameter('kernel.environment'); - // Reuse previous latest list cache key to show same set as old 'latest' - $cacheKey = 'latest_articles_list_' . $env; - $cacheItem = $articlesCache->getItem($cacheKey); - - if (!$cacheItem->isHit()) { - // Fallback: run query now if command hasn't populated cache yet - set_time_limit(300); - ini_set('max_execution_time', '300'); - - $key = new Key(); - $excludedPubkeys = [ - $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), - $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), - $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), - $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), - $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), - $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), - $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), - $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), - ]; - - $boolQuery = new BoolQuery(); - $boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys)); - $query = new Query($boolQuery); - $query->setSize(50); - $query->setSort(['createdAt' => ['order' => 'desc']]); - $collapseSlug = new Collapse(); - $collapseSlug->setFieldname('slug'); - $query->setCollapse($collapseSlug); - $articles = $finder->find($query); - $cacheItem->set($articles); - $cacheItem->expiresAfter(3600); // 1 hour to match command cache duration - $articlesCache->save($cacheItem); - } + // Fast path: Try to get from Redis views first (single GET) + $cachedView = $viewStore->fetchLatestArticles(); + $fromCache = false; + + if ($cachedView !== null) { + // Redis view data already matches template expectations! + // Just extract articles and profiles - NO MAPPING NEEDED + $articles = []; + $authorsMetadata = []; + + foreach ($cachedView as $baseObject) { + if (isset($baseObject['article'])) { + $articles[] = (object) $baseObject['article']; // Cast to object for template + } + if (isset($baseObject['profiles'])) { + foreach ($baseObject['profiles'] as $pubkey => $profile) { + $authorsMetadata[$pubkey] = (object) $profile; // Cast to object for template + } + } + } + $fromCache = true; + } else { + // Fallback path: Use old cache system if Redis view not available + $env = $this->getParameter('kernel.environment'); + $cacheKey = 'latest_articles_list_' . $env; + $cacheItem = $articlesCache->getItem($cacheKey); + + if (!$cacheItem->isHit()) { + // Fallback: run query now if command hasn't populated cache yet + set_time_limit(300); + ini_set('max_execution_time', '300'); - $articles = $cacheItem->get(); + $key = new Key(); + $excludedPubkeys = [ + $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), + $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), + $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), + $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), + $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), + $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), + $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), + $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), + ]; + + $boolQuery = new BoolQuery(); + $boolQuery->addMustNot(new Query\Terms('pubkey', $excludedPubkeys)); + $query = new Query($boolQuery); + $query->setSize(50); + $query->setSort(['createdAt' => ['order' => 'desc']]); + $collapseSlug = new Collapse(); + $collapseSlug->setFieldname('slug'); + $query->setCollapse($collapseSlug); + $articles = $finder->find($query); + $cacheItem->set($articles); + $cacheItem->expiresAfter(3600); + $articlesCache->save($cacheItem); + } else { + $articles = $cacheItem->get(); + } - // Fetch author metadata - this is now much faster because - // metadata is pre-cached by the CacheLatestArticlesCommand - $authorPubkeys = []; - foreach ($articles as $article) { - if ($article instanceof \App\Entity\Article) { - $pubkey = $article->getPubkey(); - if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) { - $authorPubkeys[] = $pubkey; - } - } elseif (is_object($article)) { - // Elastica result object fallback - if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { - $authorPubkeys[] = $article->pubkey; - } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) { - $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub); + // Fetch author metadata for fallback path + $authorPubkeys = []; + foreach ($articles as $article) { + if ($article instanceof \App\Entity\Article) { + $pubkey = $article->getPubkey(); + if ($pubkey && NostrKeyUtil::isHexPubkey($pubkey)) { + $authorPubkeys[] = $pubkey; + } + } elseif (is_object($article)) { + if (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { + $authorPubkeys[] = $article->pubkey; + } elseif (isset($article->npub) && NostrKeyUtil::isNpub($article->npub)) { + $authorPubkeys[] = NostrKeyUtil::npubToHex($article->npub); + } } } + $authorPubkeys = array_unique($authorPubkeys); + $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); } - $authorPubkeys = array_unique($authorPubkeys); - $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); // Build main topics key => display name map from ForumTopics constant $mainTopicsMap = []; @@ -131,6 +153,7 @@ class DefaultController extends AbstractController 'articles' => $articles, 'authorsMetadata' => $authorsMetadata, 'mainTopicsMap' => $mainTopicsMap, + 'from_redis_view' => $fromCache, ]); } @@ -140,52 +163,85 @@ class DefaultController extends AbstractController #[Route('/latest-articles', name: 'latest_articles')] public function latestArticles( RedisCacheService $redisCacheService, - NostrClient $nostrClient + NostrClient $nostrClient, + RedisViewStore $viewStore ): Response { - set_time_limit(300); // 5 minutes - ini_set('max_execution_time', '300'); - - // Direct feed: always fetch fresh from relay, no caching + // Define excluded pubkeys (needed for template) $key = new Key(); $excludedPubkeys = [ $key->convertToHex('npub1etsrcjz24fqewg4zmjze7t5q8c6rcwde5zdtdt4v3t3dz2navecscjjz94'), // Bitcoin Magazine (News Bot) $key->convertToHex('npub1m7szwpud3jh2k3cqe73v0fd769uzsj6rzmddh4dw67y92sw22r3sk5m3ys'), // No Bullshit Bitcoin (News Bot) $key->convertToHex('npub13wke9s6njrmugzpg6mqtvy2d49g4d6t390ng76dhxxgs9jn3f2jsmq82pk'), // TFTC (News Bot) $key->convertToHex('npub10akm29ejpdns52ca082skmc3hr75wmv3ajv4987c9lgyrfynrmdqduqwlx'), // Discreet Log (News Bot) - $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz (Just annoying) - $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace - feed bot + $key->convertToHex('npub13uvnw9qehqkds68ds76c4nfcn3y99c2rl9z8tr0p34v7ntzsmmzspwhh99'), // Batcoinz + $key->convertToHex('npub1fls5au5fxj6qj0t36sage857cs4tgfpla0ll8prshlhstagejtkqc9s2yl'), // AGORA Marketplace $key->convertToHex('npub1t5d8kcn0hu8zmt6dpkgatd5hwhx76956g7qmdzwnca6fzgprzlhqnqks86'), // NSFW $key->convertToHex('npub14l5xklll5vxzrf6hfkv8m6n2gqevythn5pqc6ezluespah0e8ars4279ss'), // LNgigs ]; - // Fetch raw latest articles (limit 50) directly from relay - $articles = $nostrClient->getLatestLongFormArticles(50); + // Fast path: Try Redis cache first (single GET - super fast!) + $cachedView = $viewStore->fetchLatestArticles(); + + if ($cachedView !== null) { + // Use cached articles - already filtered and with metadata + $articles = []; + $authorsMetadata = []; - // Filter out excluded pubkeys - $articles = array_filter($articles, function($article) use ($excludedPubkeys) { - if (method_exists($article, 'getPubkey')) { - return !in_array($article->getPubkey(), $excludedPubkeys, true); + foreach ($cachedView as $baseObject) { + if (isset($baseObject['article'])) { + $articles[] = (object) $baseObject['article']; + } + if (isset($baseObject['profiles'])) { + foreach ($baseObject['profiles'] as $pubkey => $profile) { + $authorsMetadata[$pubkey] = (object) $profile; + } + } } - return true; - }); - // Collect author pubkeys for metadata - $authorPubkeys = []; - foreach ($articles as $article) { - if (method_exists($article, 'getPubkey')) { - $authorPubkeys[] = $article->getPubkey(); - } elseif (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { - $authorPubkeys[] = $article->pubkey; + $fromCache = true; + } else { + // Cache miss: Fetch fresh from relay (slower but ensures we have data) + set_time_limit(300); + ini_set('max_execution_time', '300'); + + + try { + // Fetch fresh from relay + $articles = $nostrClient->getLatestLongFormArticles(50); + + // Filter out excluded pubkeys + $articles = array_filter($articles, function($article) use ($excludedPubkeys) { + if (method_exists($article, 'getPubkey')) { + return !in_array($article->getPubkey(), $excludedPubkeys, true); + } + return true; + }); + + // Collect author pubkeys for metadata + $authorPubkeys = []; + foreach ($articles as $article) { + if (method_exists($article, 'getPubkey')) { + $authorPubkeys[] = $article->getPubkey(); + } elseif (isset($article->pubkey) && NostrKeyUtil::isHexPubkey($article->pubkey)) { + $authorPubkeys[] = $article->pubkey; + } + } + $authorPubkeys = array_unique($authorPubkeys); + $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); + + $fromCache = false; + } catch (\Exception $e) { + // Relay fetch failed and no cache available + throw $e; } } - $authorPubkeys = array_unique($authorPubkeys); - $authorsMetadata = $redisCacheService->getMultipleMetadata($authorPubkeys); return $this->render('pages/latest-articles.html.twig', [ 'articles' => $articles, 'newsBots' => array_slice($excludedPubkeys, 0, 4), - 'authorsMetadata' => $authorsMetadata + 'authorsMetadata' => $authorsMetadata, + 'from_redis_view' => $fromCache ?? false, ]); } diff --git a/src/Entity/Article.php b/src/Entity/Article.php index 2feaf55..972f787 100644 --- a/src/Entity/Article.php +++ b/src/Entity/Article.php @@ -36,6 +36,10 @@ class Article #[ORM\Column(type: Types::TEXT, nullable: true)] private ?string $content = null; + + #[ORM\Column(type: Types::TEXT, nullable: true)] + private ?string $processedHtml = null; + #[ORM\Column(nullable: true, enumType: KindsEnum::class)] private ?KindsEnum $kind = null; @@ -130,6 +134,18 @@ class Article return $this; } + public function getProcessedHtml(): ?string + { + return $this->processedHtml; + } + + public function setProcessedHtml(?string $processedHtml): static + { + $this->processedHtml = $processedHtml; + + return $this; + } + public function getKind(): ?KindsEnum { return $this->kind; diff --git a/src/ReadModel/RedisView/RedisArticleView.php b/src/ReadModel/RedisView/RedisArticleView.php new file mode 100644 index 0000000..da90393 --- /dev/null +++ b/src/ReadModel/RedisView/RedisArticleView.php @@ -0,0 +1,26 @@ + + */ +final readonly class RedisBaseObject +{ + /** + * @param RedisArticleView|null $article Article data (if applicable) + * @param RedisHighlightView|null $highlight Highlight data (if applicable) + * @param RedisProfileView|null $author Primary author (article or highlight author) + * @param array $profiles Map of all referenced profiles (pubkey => profile) + * @param array $meta Extensible metadata (zaps, counts, etc.) + */ + public function __construct( + public ?RedisArticleView $article = null, + public ?RedisHighlightView $highlight = null, + public ?RedisProfileView $author = null, + public array $profiles = [], + public array $meta = [], + ) {} +} + diff --git a/src/ReadModel/RedisView/RedisHighlightView.php b/src/ReadModel/RedisView/RedisHighlightView.php new file mode 100644 index 0000000..27732df --- /dev/null +++ b/src/ReadModel/RedisView/RedisHighlightView.php @@ -0,0 +1,20 @@ +logger->debug('No metadata found for pubkey', ['pubkey' => $pubkey]); + return null; + } + + // Convert stdClass to array if needed + if ($metadata instanceof \stdClass) { + $metadata = json_decode(json_encode($metadata), true) ?? []; + } + + // Helper to extract string from array or return string/null + $getString = function($value): ?string { + if (is_array($value)) { + return !empty($value) ? (string)$value[0] : null; + } + return $value ? (string)$value : null; + }; + + return new RedisProfileView( + pubkey: $pubkey, + name: $metadata['name'] ?? $metadata['display_name'] ?? null, // PRIMARY name field + display_name: $metadata['display_name'] ?? null, + picture: $metadata['picture'] ?? null, // Match template expectation + nip05: $getString($metadata['nip05'] ?? null), + about: $metadata['about'] ?? null, + website: $metadata['website'] ?? null, + lud16: $getString($metadata['lud16'] ?? null), + banner: $metadata['banner'] ?? null, + ); + } + + /** + * Convert Article entity to RedisArticleView + */ + public function articleToView(Article $article): RedisArticleView + { + return new RedisArticleView( + id: (string) $article->getId(), + slug: $article->getSlug() ?? '', // Template expects: article.slug + title: $article->getTitle() ?? '', // Template expects: article.title + pubkey: $article->getPubkey() ?? '', // Template expects: article.pubkey + createdAt: $article->getCreatedAt(), // Template expects: article.createdAt + summary: $article->getSummary(), // Template expects: article.summary + image: $article->getImage(), // Template expects: article.image + eventId: $article->getEventId() ?? '', + contentHtml: $article->getProcessedHtml(), + publishedAt: $article->getPublishedAt(), + topics: $article->getTopics() ?? [], + ); + } + + /** + * Convert Highlight entity to RedisHighlightView + */ + public function highlightToView(Highlight $highlight): RedisHighlightView + { + // Convert Unix timestamp to DateTimeImmutable + $createdAt = $highlight->getCreatedAt(); + $createdAtDt = $createdAt instanceof \DateTimeImmutable + ? $createdAt + : new \DateTimeImmutable('@' . $createdAt); + + return new RedisHighlightView( + eventId: $highlight->getEventId() ?? '', + pubkey: $highlight->getPubkey() ?? '', + createdAt: $createdAtDt, + content: $highlight->getContent(), + context: $highlight->getContext(), + refs: [ + 'article_coordinate' => $highlight->getArticleCoordinate(), + ], + ); + } + + /** + * Build a complete RedisBaseObject for an article + * Fetches author profile from Redis metadata cache + * @param Article $article + * @param array|\stdClass|null $authorMetadata Author profile metadata (from RedisCacheService) + */ + public function articleBaseObject(Article $article, array|\stdClass|null $authorMetadata = null): RedisBaseObject + { + $articleView = $this->articleToView($article); + + // Fetch author metadata if not provided + if ($authorMetadata === null) { + $authorMetadata = $this->redisCacheService->getMetadata($article->getPubkey()); + } + + $authorView = $this->profileToView($authorMetadata, $article->getPubkey()); + + $profiles = []; + if ($authorView !== null) { + $profiles[$authorView->pubkey] = $authorView; + } + + return new RedisBaseObject( + article: $articleView, + highlight: null, + author: $authorView, + profiles: $profiles, + meta: [], + ); + } + + /** + * Build a complete RedisBaseObject for a highlight + * Requires the highlighted article and fetches both author profiles + * @param Highlight $highlight + * @param Article $article + * @param array|\stdClass|null $highlightAuthorMetadata Highlight author metadata + * @param array|\stdClass|null $articleAuthorMetadata Article author metadata + */ + public function highlightBaseObject( + Highlight $highlight, + Article $article, + array|\stdClass|null $highlightAuthorMetadata = null, + array|\stdClass|null $articleAuthorMetadata = null + ): RedisBaseObject { + $articleView = $this->articleToView($article); + $highlightView = $this->highlightToView($highlight); + + // Fetch metadata if not provided + if ($highlightAuthorMetadata === null) { + $highlightAuthorMetadata = $this->redisCacheService->getMetadata($highlight->getPubkey()); + } + if ($articleAuthorMetadata === null) { + $articleAuthorMetadata = $this->redisCacheService->getMetadata($article->getPubkey()); + } + + $highlightAuthorView = $this->profileToView($highlightAuthorMetadata, $highlight->getPubkey()); + $articleAuthorView = $this->profileToView($articleAuthorMetadata, $article->getPubkey()); + + $profiles = []; + if ($articleAuthorView !== null) { + $profiles[$articleAuthorView->pubkey] = $articleAuthorView; + } + if ($highlightAuthorView !== null) { + $profiles[$highlightAuthorView->pubkey] = $highlightAuthorView; + } + + return new RedisBaseObject( + article: $articleView, + highlight: $highlightView, + author: $highlightAuthorView, // Primary author is the highlight author + profiles: $profiles, + meta: [], + ); + } + + /** + * Normalize RedisBaseObject to array for JSON storage + */ + public function normalizeBaseObject(RedisBaseObject $obj): array + { + return [ + 'article' => $obj->article ? $this->normalizeArticleView($obj->article) : null, + 'highlight' => $obj->highlight ? $this->normalizeHighlightView($obj->highlight) : null, + 'author' => $obj->author ? $this->normalizeProfileView($obj->author) : null, + 'profiles' => array_map( + fn(RedisProfileView $profile) => $this->normalizeProfileView($profile), + $obj->profiles + ), + 'meta' => $obj->meta, + ]; + } + + /** + * Denormalize array back to RedisBaseObject + */ + public function denormalizeBaseObject(array $data): RedisBaseObject + { + $profiles = []; + foreach ($data['profiles'] ?? [] as $pubkey => $profileData) { + $profiles[$pubkey] = $this->denormalizeProfileView($profileData); + } + + return new RedisBaseObject( + article: isset($data['article']) ? $this->denormalizeArticleView($data['article']) : null, + highlight: isset($data['highlight']) ? $this->denormalizeHighlightView($data['highlight']) : null, + author: isset($data['author']) ? $this->denormalizeProfileView($data['author']) : null, + profiles: $profiles, + meta: $data['meta'] ?? [], + ); + } + + private function normalizeProfileView(RedisProfileView $view): array + { + return [ + 'pubkey' => $view->pubkey, + 'name' => $view->name, + 'display_name' => $view->display_name, + 'picture' => $view->picture, + 'nip05' => $view->nip05, + 'about' => $view->about, + 'website' => $view->website, + 'lud16' => $view->lud16, + 'banner' => $view->banner, + ]; + } + + private function denormalizeProfileView(array $data): RedisProfileView + { + return new RedisProfileView( + pubkey: $data['pubkey'], + name: $data['name'] ?? null, + display_name: $data['display_name'] ?? null, + picture: $data['picture'] ?? null, + nip05: $data['nip05'] ?? null, + about: $data['about'] ?? null, + website: $data['website'] ?? null, + lud16: $data['lud16'] ?? null, + banner: $data['banner'] ?? null, + ); + } + + private function normalizeArticleView(RedisArticleView $view): array + { + return [ + 'id' => $view->id, + 'slug' => $view->slug, + 'title' => $view->title, + 'pubkey' => $view->pubkey, + 'createdAt' => $view->createdAt?->format(\DateTimeInterface::ATOM), + 'summary' => $view->summary, + 'image' => $view->image, + 'eventId' => $view->eventId, + 'contentHtml' => $view->contentHtml, + 'publishedAt' => $view->publishedAt?->format(\DateTimeInterface::ATOM), + 'topics' => $view->topics, + ]; + } + + private function denormalizeArticleView(array $data): RedisArticleView + { + return new RedisArticleView( + id: $data['id'], + slug: $data['slug'], + title: $data['title'], + pubkey: $data['pubkey'], + createdAt: isset($data['createdAt']) ? new \DateTimeImmutable($data['createdAt']) : null, + summary: $data['summary'] ?? null, + image: $data['image'] ?? null, + eventId: $data['eventId'] ?? null, + contentHtml: $data['contentHtml'] ?? null, + publishedAt: isset($data['publishedAt']) ? new \DateTimeImmutable($data['publishedAt']) : null, + topics: $data['topics'] ?? [], + ); + } + + private function normalizeHighlightView(RedisHighlightView $view): array + { + return [ + 'eventId' => $view->eventId, + 'pubkey' => $view->pubkey, + 'createdAt' => $view->createdAt->format(\DateTimeInterface::ATOM), + 'content' => $view->content, + 'context' => $view->context, + 'refs' => $view->refs, + ]; + } + + private function denormalizeHighlightView(array $data): RedisHighlightView + { + return new RedisHighlightView( + eventId: $data['eventId'], + pubkey: $data['pubkey'], + createdAt: new \DateTimeImmutable($data['createdAt']), + content: $data['content'] ?? null, + context: $data['context'] ?? null, + refs: $data['refs'] ?? [], + ); + } +} + diff --git a/src/Repository/HighlightRepository.php b/src/Repository/HighlightRepository.php index 63069b8..be13a6a 100644 --- a/src/Repository/HighlightRepository.php +++ b/src/Repository/HighlightRepository.php @@ -94,5 +94,74 @@ class HighlightRepository extends ServiceEntityRepository ->getQuery() ->execute(); } + + /** + * Get latest highlights across all articles + * @param int $limit Maximum number of highlights to return + * @return array + */ + public function findLatest(int $limit = 50): array + { + return $this->createQueryBuilder('h') + ->orderBy('h.createdAt', 'DESC') + ->setMaxResults($limit) + ->getQuery() + ->getResult(); + } + + /** + * Get latest highlights with their corresponding articles + * Uses a join or separate query to efficiently fetch both + * @param int $limit Maximum number of highlights to return + * @return array + */ + public function findLatestWithArticles(int $limit = 50): array + { + // First get the highlights + $highlights = $this->findLatest($limit); + + // Extract article coordinates and fetch corresponding articles + $coordinates = array_unique(array_map( + fn(Highlight $h) => $h->getArticleCoordinate(), + $highlights + )); + + // Query articles by their coordinates + $articles = []; + if (!empty($coordinates)) { + $em = $this->getEntityManager(); + $articleRepo = $em->getRepository(\App\Entity\Article::class); + + // Build article coordinate map (kind:pubkey:identifier format) + foreach ($coordinates as $coordinate) { + // Parse coordinate: 30023:pubkey:identifier + $parts = explode(':', $coordinate, 3); + if (count($parts) === 3) { + [, $pubkey, $identifier] = $parts; + + // Find article by pubkey and slug (identifier) + $article = $articleRepo->findOneBy([ + 'pubkey' => $pubkey, + 'slug' => $identifier, + ]); + + if ($article) { + $articles[$coordinate] = $article; + } + } + } + } + + // Combine highlights with their articles + $result = []; + foreach ($highlights as $highlight) { + $result[] = [ + 'highlight' => $highlight, + 'article' => $articles[$highlight->getArticleCoordinate()] ?? null, + ]; + } + + return $result; + } } diff --git a/src/Service/ArticleEventProjector.php b/src/Service/ArticleEventProjector.php new file mode 100644 index 0000000..9cb8f6f --- /dev/null +++ b/src/Service/ArticleEventProjector.php @@ -0,0 +1,113 @@ +articleFactory->createFromLongFormContentEvent($event); + + // Process markdown content to HTML for performance optimization + if ($article->getContent()) { + try { + $processedHtml = $this->converter->convertToHTML($article->getContent()); + $article->setProcessedHtml($processedHtml); + + $this->logger->debug('Processed article HTML', [ + 'event_id' => $article->getEventId(), + 'content_length' => strlen($article->getContent()), + 'html_length' => strlen($processedHtml) + ]); + } catch (\Exception $e) { + // If HTML conversion fails, log but continue (HTML will be null) + $this->logger->warning('Failed to process article HTML', [ + 'event_id' => $article->getEventId(), + 'error' => $e->getMessage() + ]); + } + } + + // Check if article with same eventId already exists in the database + $existingArticle = $this->entityManager + ->getRepository(Article::class) + ->findOneBy(['eventId' => $article->getEventId()]); + + if (!$existingArticle) { + // New article - persist it + $this->logger->info('Persisting new article from relay', [ + 'event_id' => $article->getEventId(), + 'kind' => $article->getKind()?->value, + 'pubkey' => $article->getPubkey(), + 'title' => $article->getTitle(), + 'has_processed_html' => $article->getProcessedHtml() !== null, + 'relay' => $relayUrl + ]); + + $this->entityManager->persist($article); + $this->entityManager->flush(); + + $this->logger->info('Article successfully saved to database', [ + 'event_id' => $article->getEventId(), + 'db_id' => $article->getId() + ]); + } else { + $this->logger->debug('Article already exists in database, skipping', [ + 'event_id' => $article->getEventId(), + 'db_id' => $existingArticle->getId() + ]); + } + } catch (\InvalidArgumentException $e) { + // Invalid event (wrong kind, invalid signature, etc.) + $this->logger->warning('Invalid article event received', [ + 'event_id' => $event->id ?? 'unknown', + 'error' => $e->getMessage(), + 'relay' => $relayUrl + ]); + throw $e; + } catch (\Exception $e) { + // Database or other errors + $this->logger->error('Error projecting article event', [ + 'event_id' => $event->id ?? 'unknown', + 'error' => $e->getMessage(), + 'relay' => $relayUrl + ]); + + // Reset entity manager on error to prevent further issues + $this->managerRegistry->resetManager(); + throw $e; + } + } +} + diff --git a/src/Service/NostrRelayPool.php b/src/Service/NostrRelayPool.php index de4559a..c9aedd5 100644 --- a/src/Service/NostrRelayPool.php +++ b/src/Service/NostrRelayPool.php @@ -3,8 +3,12 @@ namespace App\Service; use Psr\Log\LoggerInterface; +use swentel\nostr\Filter\Filter; +use swentel\nostr\Message\RequestMessage; use swentel\nostr\Relay\Relay; use swentel\nostr\RelayResponse\RelayResponse; +use swentel\nostr\Subscription\Subscription; +use Symfony\Component\HttpClient\Exception\TimeoutException; use WebSocket\Exception\Exception; /** @@ -224,7 +228,7 @@ class NostrRelayPool continue; } - $relayResponse = \swentel\nostr\RelayResponse\RelayResponse::create($decoded); + $relayResponse = RelayResponse::create($decoded); $relayResponses[] = $relayResponse; // Check for EOSE (End of Stored Events) or CLOSED @@ -405,4 +409,221 @@ class NostrRelayPool return $relayUrls; } + + /** + * Subscribe to local relay for article events with long-lived connection + * This method blocks indefinitely and calls the callback for each received article event + * + * @param callable $onArticleEvent Callback function that receives (object $event, string $relayUrl) + * @throws \Exception|\Throwable If local relay is not configured or connection fails + */ + public function subscribeLocalArticles(callable $onArticleEvent): void + { + if (!$this->nostrDefaultRelay) { + throw new \Exception('Local relay not configured. Set NOSTR_DEFAULT_RELAY environment variable.'); + } + + $relayUrl = $this->normalizeRelayUrl($this->nostrDefaultRelay); + + $this->logger->info('Starting long-lived subscription to local relay for articles', [ + 'relay' => $relayUrl, + 'kind' => 30023 + ]); + + // Get relay connection + $relay = $this->getRelay($relayUrl); + + // Ensure relay is connected + if (!$relay->isConnected()) { + $relay->connect(); + } + + $client = $relay->getClient(); + + // Use reasonable timeout like TweakedRequest does (15 seconds per receive call) + // This allows for proper WebSocket handshake and message handling + $client->setTimeout(15); + + // Create subscription for article events (kind 30023) + $subscription = new Subscription(); + $subscriptionId = $subscription->setId(); + + $filter = new Filter(); + $filter->setKinds([30023]); // Longform article events only + + $requestMessage = new RequestMessage($subscriptionId, [$filter]); + $payload = $requestMessage->generate(); + + $this->logger->info('Sending REQ to local relay', [ + 'relay' => $relayUrl, + 'subscription_id' => $subscriptionId, + 'filter' => ['kinds' => [30023]] + ]); + + // Send the subscription request + $client->text($payload); + + $this->logger->info('Entering infinite receive loop for article events...'); + + // Track if we've received EOSE + $eoseReceived = false; + + // Infinite loop: receive and process messages + while (true) { + try { + $resp = $client->receive(); + + // Handle PING/PONG for keepalive (following TweakedRequest pattern) + if ($resp instanceof \WebSocket\Message\Ping) { + $client->text((new \WebSocket\Message\Pong())->getPayload()); + $this->logger->debug('Received PING, sent PONG'); + continue; + } + + // Only process text messages + if (!($resp instanceof \WebSocket\Message\Text)) { + continue; + } + + $content = $resp->getContent(); + $decoded = json_decode($content); // Decode as object for RelayResponse compatibility + + if (!$decoded) { + $this->logger->debug('Failed to decode message from relay', [ + 'relay' => $relayUrl, + 'content_preview' => substr($content, 0, 100) + ]); + continue; + } + + // Parse relay response + $relayResponse = RelayResponse::create($decoded); + + // Handle different response types + switch ($relayResponse->type) { + case 'EVENT': + // This is an article event - process it + if ($relayResponse instanceof \swentel\nostr\RelayResponse\RelayResponseEvent) { + $event = $relayResponse->event; + + $this->logger->info('Received article event from relay', [ + 'relay' => $relayUrl, + 'event_id' => $event->id ?? 'unknown', + 'kind' => $event->kind ?? 'unknown', + 'pubkey' => substr($event->pubkey ?? 'unknown', 0, 16) . '...' + ]); + + // Call the callback with the event + try { + $onArticleEvent($event, $relayUrl); + } catch (\Throwable $e) { + // Log callback errors but don't break the loop + $this->logger->error('Error in article event callback', [ + 'event_id' => $event->id ?? 'unknown', + 'error' => $e->getMessage(), + 'exception' => get_class($e) + ]); + } + } + break; + + case 'EOSE': + // End of stored events - all historical events received + // Unlike TweakedRequest, we DON'T close the subscription or disconnect + // We want to keep listening for new live events + $eoseReceived = true; + $this->logger->info('Received EOSE - all stored events processed, now listening for new events', [ + 'relay' => $relayUrl, + 'subscription_id' => $subscriptionId + ]); + // Continue the loop - this is the key difference from short-lived requests + break; + + case 'CLOSED': + // Subscription closed by relay + // Decode as array to access message by index + $decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true); + $this->logger->warning('Relay closed subscription', [ + 'relay' => $relayUrl, + 'subscription_id' => $subscriptionId, + 'message' => $decodedArray[2] ?? 'no message' + ]); + throw new \Exception('Subscription closed by relay: ' . ($decodedArray[2] ?? 'no message')); + + case 'NOTICE': + // Notice from relay - check if it's an error + $decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true); + $message = $decodedArray[1] ?? 'no message'; + if (str_starts_with($message, 'ERROR:')) { + $this->logger->error('Received ERROR NOTICE from relay', [ + 'relay' => $relayUrl, + 'message' => $message + ]); + throw new \Exception('Relay error: ' . $message); + } + $this->logger->info('Received NOTICE from relay', [ + 'relay' => $relayUrl, + 'message' => $message + ]); + break; + + case 'OK': + // Command result (usually for EVENT, not REQ) + $this->logger->debug('Received OK from relay', [ + 'relay' => $relayUrl, + 'message' => json_encode($decoded) + ]); + break; + + case 'AUTH': + // NIP-42 auth challenge - log but don't handle for now + // Most relays won't require auth for reading public events + $decodedArray = is_array($decoded) ? $decoded : json_decode(json_encode($decoded), true); + $this->logger->info('Received AUTH challenge from relay (ignoring for read-only subscription)', [ + 'relay' => $relayUrl, + 'challenge' => $decodedArray[1] ?? 'no challenge' + ]); + break; + + default: + $this->logger->debug('Received unknown message type from relay', [ + 'relay' => $relayUrl, + 'type' => $relayResponse->type ?? 'unknown', + 'content' => json_encode($decoded) + ]); + break; + } + + } catch (TimeoutException $e) { + // Timeout is expected when no new events arrive + // Just continue the loop to keep listening + if ($eoseReceived) { + $this->logger->debug('WebSocket timeout (normal - waiting for new events)', [ + 'relay' => $relayUrl + ]); + } + continue; + } catch (Exception $e) { + // WebSocket errors - log and rethrow to allow Docker restart + $this->logger->error('WebSocket error in subscription loop', [ + 'relay' => $relayUrl, + 'error' => $e->getMessage(), + 'exception' => get_class($e) + ]); + throw $e; + } catch (\Throwable $e) { + // Unexpected errors - log and rethrow + $this->logger->error('Unexpected error in subscription loop', [ + 'relay' => $relayUrl, + 'error' => $e->getMessage(), + 'exception' => get_class($e) + ]); + throw $e; + } + } + + // This line should never be reached (infinite loop above) + // But if it is, log it + $this->logger->warning('Subscription loop exited unexpectedly'); + } } diff --git a/src/Service/RedisCacheService.php b/src/Service/RedisCacheService.php index 153ac66..efbf093 100644 --- a/src/Service/RedisCacheService.php +++ b/src/Service/RedisCacheService.php @@ -15,15 +15,25 @@ use swentel\nostr\Key\Key; use Symfony\Component\HttpFoundation\Response; use Symfony\Contracts\Cache\ItemInterface; -readonly class RedisCacheService +class RedisCacheService { + private ?RedisViewStore $viewStore = null; + public function __construct( - private NostrClient $nostrClient, - private CacheItemPoolInterface $npubCache, - private EntityManagerInterface $entityManager, - private LoggerInterface $logger + private readonly NostrClient $nostrClient, + private readonly CacheItemPoolInterface $npubCache, + private readonly EntityManagerInterface $entityManager, + private readonly LoggerInterface $logger ) {} + /** + * Inject RedisViewStore (using setter to avoid circular dependency) + */ + public function setViewStore(RedisViewStore $viewStore): void + { + $this->viewStore = $viewStore; + } + /** * Generate the cache key for user metadata (hex pubkey only). */ @@ -487,5 +497,35 @@ readonly class RedisCacheService } } + /** + * Invalidate Redis views that contain this profile + * Called when profile metadata (kind 0) is updated + * + * @param string $pubkey Hex pubkey whose profile was updated + */ + public function invalidateProfileViews(string $pubkey): void + { + if ($this->viewStore === null) { + return; // ViewStore not injected, skip invalidation + } + + try { + // Invalidate user's articles view + $this->viewStore->invalidateUserArticles($pubkey); + + // For now, we invalidate all latest views since we don't track which profiles are in them + // In future, this could be more selective + $this->viewStore->invalidateAll(); + + $this->logger->debug('Invalidated Redis views for profile update', [ + 'pubkey' => $pubkey, + ]); + } catch (\Exception $e) { + $this->logger->error('Failed to invalidate Redis views', [ + 'pubkey' => $pubkey, + 'error' => $e->getMessage(), + ]); + } + } } diff --git a/src/Service/RedisViewStore.php b/src/Service/RedisViewStore.php new file mode 100644 index 0000000..5952617 --- /dev/null +++ b/src/Service/RedisViewStore.php @@ -0,0 +1,234 @@ + $baseObjects + */ + public function storeLatestArticles(array $baseObjects): void + { + $this->storeView(self::KEY_LATEST_ARTICLES, $baseObjects, self::DEFAULT_TTL); + } + + /** + * Fetch latest articles view + * @return array|null Array of denormalized base objects or null if not cached + */ + public function fetchLatestArticles(): ?array + { + return $this->fetchView(self::KEY_LATEST_ARTICLES); + } + + /** + * Store latest highlights view + * @param array $baseObjects + */ + public function storeLatestHighlights(array $baseObjects): void + { + $this->storeView(self::KEY_LATEST_HIGHLIGHTS, $baseObjects, self::DEFAULT_TTL); + } + + /** + * Fetch latest highlights view + * @return array|null Array of denormalized base objects or null if not cached + */ + public function fetchLatestHighlights(): ?array + { + return $this->fetchView(self::KEY_LATEST_HIGHLIGHTS); + } + + /** + * Store user articles view + * @param string $pubkey User's pubkey + * @param array $baseObjects + */ + public function storeUserArticles(string $pubkey, array $baseObjects): void + { + $key = sprintf(self::KEY_USER_ARTICLES, $pubkey); + $this->storeView($key, $baseObjects, self::DEFAULT_TTL); + } + + /** + * Fetch user articles view + * @param string $pubkey User's pubkey + * @return array|null Array of denormalized base objects or null if not cached + */ + public function fetchUserArticles(string $pubkey): ?array + { + $key = sprintf(self::KEY_USER_ARTICLES, $pubkey); + return $this->fetchView($key); + } + + /** + * Invalidate user articles cache + */ + public function invalidateUserArticles(string $pubkey): void + { + $key = sprintf(self::KEY_USER_ARTICLES, $pubkey); + try { + $this->redis->del($key); + $this->logger->debug('Invalidated user articles cache', ['pubkey' => $pubkey, 'key' => $key]); + } catch (\Exception $e) { + $this->logger->error('Failed to invalidate user articles cache', [ + 'pubkey' => $pubkey, + 'error' => $e->getMessage(), + ]); + } + } + + /** + * Invalidate all view caches + */ + public function invalidateAll(): void + { + try { + $this->redis->del(self::KEY_LATEST_ARTICLES); + $this->redis->del(self::KEY_LATEST_HIGHLIGHTS); + $this->logger->info('Invalidated all view caches'); + } catch (\Exception $e) { + $this->logger->error('Failed to invalidate all caches', ['error' => $e->getMessage()]); + } + } + + /** + * Store normalized view objects to Redis + * @param string $key Redis key + * @param array $baseObjects + * @param int|null $ttl Time to live in seconds + */ + private function storeView(string $key, array $baseObjects, ?int $ttl = null): void + { + try { + // Normalize all base objects to arrays + $normalizedObjects = array_map( + fn(RedisBaseObject $obj) => $this->factory->normalizeBaseObject($obj), + $baseObjects + ); + + // Encode to JSON + $json = json_encode($normalizedObjects, JSON_THROW_ON_ERROR); + + // Optionally compress for large payloads + $data = $this->shouldCompress($json) ? gzcompress($json, 6) : $json; + $isCompressed = $data !== $json; + + // Store in Redis + if ($ttl !== null) { + $this->redis->setex($key, $ttl, $data); + } else { + $this->redis->set($key, $data); + } + + // Store compression flag + if ($isCompressed) { + $this->redis->setex($key . ':compressed', $ttl ?? self::DEFAULT_TTL, '1'); + } + + $this->logger->debug('Stored view in Redis', [ + 'key' => $key, + 'count' => count($baseObjects), + 'size_bytes' => strlen($data), + 'compressed' => $isCompressed, + 'ttl' => $ttl, + ]); + } catch (\JsonException $e) { + $this->logger->error('JSON encoding failed', [ + 'key' => $key, + 'error' => $e->getMessage(), + ]); + throw $e; + } catch (\Exception $e) { + $this->logger->error('Failed to store view in Redis', [ + 'key' => $key, + 'error' => $e->getMessage(), + ]); + throw $e; + } + } + + /** + * Fetch and denormalize view objects from Redis + * @return array|null Array of arrays (not denormalized to objects) or null if not found + */ + private function fetchView(string $key): ?array + { + try { + $data = $this->redis->get($key); + + if ($data === false || $data === null) { + $this->logger->debug('View not found in Redis', ['key' => $key]); + return null; + } + + // Check if data is compressed + $isCompressed = $this->redis->get($key . ':compressed') !== false; + + // Decompress if needed + if ($isCompressed) { + $json = gzuncompress($data); + if ($json === false) { + $this->logger->error('Failed to decompress data', ['key' => $key]); + return null; + } + } else { + $json = $data; + } + + // Decode JSON + $normalized = json_decode($json, true, 512, JSON_THROW_ON_ERROR); + + $this->logger->debug('Fetched view from Redis', [ + 'key' => $key, + 'count' => count($normalized), + 'compressed' => $isCompressed, + ]); + + return $normalized; + } catch (\JsonException $e) { + $this->logger->error('JSON decoding failed', [ + 'key' => $key, + 'error' => $e->getMessage(), + ]); + return null; + } catch (\Exception $e) { + $this->logger->error('Failed to fetch view from Redis', [ + 'key' => $key, + 'error' => $e->getMessage(), + ]); + return null; + } + } + + /** + * Determine if data should be compressed + * Compress if larger than 10KB + */ + private function shouldCompress(string $data): bool + { + return strlen($data) > 10240; // 10KB + } +} +