From c4f943530395b3feee64ea3a77b9fa4abaef1bcc Mon Sep 17 00:00:00 2001 From: Silberengel Date: Mon, 27 Apr 2026 21:56:33 +0200 Subject: [PATCH] more refactoring --- composer.json | 7 +- composer.lock | 586 +----------- config/services.yaml | 4 + phpstan-baseline.neon | 34 +- src/Service/NostrArticleDiscussionSupport.php | 169 ++++ src/Service/NostrAuthorRelayCache.php | 89 ++ src/Service/NostrClient.php | 853 ++---------------- src/Service/NostrKind5DeletionFilter.php | 54 ++ src/Service/NostrWireEventMerge.php | 450 +++++++++ .../NostrArticleDiscussionSupportTest.php | 85 ++ tests/Service/NostrAuthorRelayCacheTest.php | 60 ++ .../Service/NostrKind5DeletionFilterTest.php | 43 + tests/Service/NostrWireEventMergeTest.php | 97 ++ 13 files changed, 1117 insertions(+), 1414 deletions(-) create mode 100644 src/Service/NostrArticleDiscussionSupport.php create mode 100644 src/Service/NostrAuthorRelayCache.php create mode 100644 src/Service/NostrKind5DeletionFilter.php create mode 100644 src/Service/NostrWireEventMerge.php create mode 100644 tests/Service/NostrArticleDiscussionSupportTest.php create mode 100644 tests/Service/NostrAuthorRelayCacheTest.php create mode 100644 tests/Service/NostrKind5DeletionFilterTest.php create mode 100644 tests/Service/NostrWireEventMergeTest.php diff --git a/composer.json b/composer.json index 433ebd1..b22775b 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,8 @@ "doctrine/doctrine-migrations-bundle": "^3.3", "doctrine/orm": "^3.3", "embed/embed": "^4.4", - "laminas/laminas-diactoros": "^3.6", "league/commonmark": "^2.7", - "league/html-to-markdown": "*", - "phpdocumentor/reflection-docblock": "^5.6", - "phpstan/phpdoc-parser": "^2.0", + "league/html-to-markdown": "^5.1", "runtime/frankenphp-symfony": "^0.2.0", "swentel/nostr-php": "^1.9.4", "symfony/asset": "7.3.*", @@ -123,8 +120,6 @@ "phpstan/phpstan-doctrine": "^2.0", "phpstan/phpstan-symfony": "^2.0", "phpunit/phpunit": "^9.5", - "symfony/browser-kit": "7.3.*", - "symfony/css-selector": "7.3.*", "symfony/maker-bundle": "^1.63", "symfony/phpunit-bridge": "^7.2", "symfony/stopwatch": "7.3.*", diff --git a/composer.lock b/composer.lock index 857e40a..093a25c 100644 --- a/composer.lock +++ b/composer.lock @@ -4,7 +4,7 @@ "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies", "This file is @generated automatically" ], - "content-hash": "7921811e20bbf491efec9a7357aae9ab", + "content-hash": "27cf147fe3c8ebb58922656d107bd510", "packages": [ { "name": "bitwasp/bech32", @@ -1665,94 +1665,6 @@ }, "time": "2026-01-06T11:43:05+00:00" }, - { - "name": "laminas/laminas-diactoros", - "version": "3.8.0", - "source": { - "type": "git", - "url": "https://github.com/laminas/laminas-diactoros.git", - "reference": "60c182916b2749480895601649563970f3f12ec4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/laminas/laminas-diactoros/zipball/60c182916b2749480895601649563970f3f12ec4", - "reference": "60c182916b2749480895601649563970f3f12ec4", - "shasum": "" - }, - "require": { - "php": "~8.2.0 || ~8.3.0 || ~8.4.0 || ~8.5.0", - "psr/http-factory": "^1.1", - "psr/http-message": "^1.1 || ^2.0" - }, - "conflict": { - "amphp/amp": "<2.6.4" - }, - "provide": { - "psr/http-factory-implementation": "^1.0", - "psr/http-message-implementation": "^1.1 || ^2.0" - }, - "require-dev": { - "ext-curl": "*", - "ext-dom": "*", - "ext-gd": "*", - "ext-libxml": "*", - "http-interop/http-factory-tests": "^2.2.0", - "laminas/laminas-coding-standard": "~3.1.0", - "php-http/psr7-integration-tests": "^1.4.0", - "phpunit/phpunit": "^10.5.36", - "psalm/plugin-phpunit": "^0.19.5", - "vimeo/psalm": "^6.13" - }, - "type": "library", - "extra": { - "laminas": { - "module": "Laminas\\Diactoros", - "config-provider": "Laminas\\Diactoros\\ConfigProvider" - } - }, - "autoload": { - "files": [ - "src/functions/create_uploaded_file.php", - "src/functions/marshal_headers_from_sapi.php", - "src/functions/marshal_method_from_sapi.php", - "src/functions/marshal_protocol_version_from_sapi.php", - "src/functions/normalize_server.php", - "src/functions/normalize_uploaded_files.php", - "src/functions/parse_cookie_header.php" - ], - "psr-4": { - "Laminas\\Diactoros\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "BSD-3-Clause" - ], - "description": "PSR HTTP Message implementations", - "homepage": "https://laminas.dev", - "keywords": [ - "http", - "laminas", - "psr", - "psr-17", - "psr-7" - ], - "support": { - "chat": "https://laminas.dev/chat", - "docs": "https://docs.laminas.dev/laminas-diactoros/", - "forum": "https://discourse.laminas.dev", - "issues": "https://github.com/laminas/laminas-diactoros/issues", - "rss": "https://github.com/laminas/laminas-diactoros/releases.atom", - "source": "https://github.com/laminas/laminas-diactoros" - }, - "funding": [ - { - "url": "https://funding.communitybridge.org/projects/laminas-project", - "type": "community_bridge" - } - ], - "time": "2025-10-12T15:31:36+00:00" - }, { "name": "league/commonmark", "version": "2.8.2", @@ -2962,228 +2874,6 @@ }, "time": "2025-12-30T16:12:18+00:00" }, - { - "name": "phpdocumentor/reflection-common", - "version": "2.2.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionCommon.git", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionCommon/zipball/1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "reference": "1d01c49d4ed62f25aa84a747ad35d5a16924662b", - "shasum": "" - }, - "require": { - "php": "^7.2 || ^8.0" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-2.x": "2.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "Common reflection classes used by phpdocumentor to reflect the code structure", - "homepage": "http://www.phpdoc.org", - "keywords": [ - "FQSEN", - "phpDocumentor", - "phpdoc", - "reflection", - "static analysis" - ], - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionCommon/issues", - "source": "https://github.com/phpDocumentor/ReflectionCommon/tree/2.x" - }, - "time": "2020-06-27T09:03:43+00:00" - }, - { - "name": "phpdocumentor/reflection-docblock", - "version": "5.6.7", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/ReflectionDocBlock.git", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/ReflectionDocBlock/zipball/31a105931bc8ffa3a123383829772e832fd8d903", - "reference": "31a105931bc8ffa3a123383829772e832fd8d903", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.1", - "ext-filter": "*", - "php": "^7.4 || ^8.0", - "phpdocumentor/reflection-common": "^2.2", - "phpdocumentor/type-resolver": "^1.7", - "phpstan/phpdoc-parser": "^1.7|^2.0", - "webmozart/assert": "^1.9.1 || ^2" - }, - "require-dev": { - "mockery/mockery": "~1.3.5 || ~1.6.0", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-mockery": "^1.1", - "phpstan/phpstan-webmozart-assert": "^1.2", - "phpunit/phpunit": "^9.5", - "psalm/phar": "^5.26" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-master": "5.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - }, - { - "name": "Jaap van Otterdijk", - "email": "opensource@ijaap.nl" - } - ], - "description": "With this component, a library can provide support for annotations via DocBlocks or otherwise retrieve information that is embedded in a DocBlock.", - "support": { - "issues": "https://github.com/phpDocumentor/ReflectionDocBlock/issues", - "source": "https://github.com/phpDocumentor/ReflectionDocBlock/tree/5.6.7" - }, - "time": "2026-03-18T20:47:46+00:00" - }, - { - "name": "phpdocumentor/type-resolver", - "version": "1.12.0", - "source": { - "type": "git", - "url": "https://github.com/phpDocumentor/TypeResolver.git", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpDocumentor/TypeResolver/zipball/92a98ada2b93d9b201a613cb5a33584dde25f195", - "reference": "92a98ada2b93d9b201a613cb5a33584dde25f195", - "shasum": "" - }, - "require": { - "doctrine/deprecations": "^1.0", - "php": "^7.3 || ^8.0", - "phpdocumentor/reflection-common": "^2.0", - "phpstan/phpdoc-parser": "^1.18|^2.0" - }, - "require-dev": { - "ext-tokenizer": "*", - "phpbench/phpbench": "^1.2", - "phpstan/extension-installer": "^1.1", - "phpstan/phpstan": "^1.8", - "phpstan/phpstan-phpunit": "^1.1", - "phpunit/phpunit": "^9.5", - "rector/rector": "^0.13.9", - "vimeo/psalm": "^4.25" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-1.x": "1.x-dev" - } - }, - "autoload": { - "psr-4": { - "phpDocumentor\\Reflection\\": "src" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Mike van Riel", - "email": "me@mikevanriel.com" - } - ], - "description": "A PSR-5 based resolver of Class names, Types and Structural Element Names", - "support": { - "issues": "https://github.com/phpDocumentor/TypeResolver/issues", - "source": "https://github.com/phpDocumentor/TypeResolver/tree/1.12.0" - }, - "time": "2025-11-21T15:09:14+00:00" - }, - { - "name": "phpstan/phpdoc-parser", - "version": "2.3.2", - "source": { - "type": "git", - "url": "https://github.com/phpstan/phpdoc-parser.git", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/phpstan/phpdoc-parser/zipball/a004701b11273a26cd7955a61d67a7f1e525a45a", - "reference": "a004701b11273a26cd7955a61d67a7f1e525a45a", - "shasum": "" - }, - "require": { - "php": "^7.4 || ^8.0" - }, - "require-dev": { - "doctrine/annotations": "^2.0", - "nikic/php-parser": "^5.3.0", - "php-parallel-lint/php-parallel-lint": "^1.2", - "phpstan/extension-installer": "^1.0", - "phpstan/phpstan": "^2.0", - "phpstan/phpstan-phpunit": "^2.0", - "phpstan/phpstan-strict-rules": "^2.0", - "phpunit/phpunit": "^9.6", - "symfony/process": "^5.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "PHPStan\\PhpDocParser\\": [ - "src/" - ] - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "description": "PHPDoc parser with support for nullable, intersection and generic types", - "support": { - "issues": "https://github.com/phpstan/phpdoc-parser/issues", - "source": "https://github.com/phpstan/phpdoc-parser/tree/2.3.2" - }, - "time": "2026-01-25T14:56:51+00:00" - }, { "name": "phrity/comparison", "version": "1.4.1", @@ -9939,68 +9629,6 @@ } ], "time": "2026-03-17T21:31:11+00:00" - }, - { - "name": "webmozart/assert", - "version": "2.3.0", - "source": { - "type": "git", - "url": "https://github.com/webmozarts/assert.git", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/webmozarts/assert/zipball/eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "reference": "eb0d790f735ba6cff25c683a85a1da0eadeff9e4", - "shasum": "" - }, - "require": { - "ext-ctype": "*", - "ext-date": "*", - "ext-filter": "*", - "php": "^8.2" - }, - "suggest": { - "ext-intl": "", - "ext-simplexml": "", - "ext-spl": "" - }, - "type": "library", - "extra": { - "branch-alias": { - "dev-feature/2-0": "2.0-dev" - } - }, - "autoload": { - "psr-4": { - "Webmozart\\Assert\\": "src/" - } - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Bernhard Schussek", - "email": "bschussek@gmail.com" - }, - { - "name": "Woody Gilk", - "email": "woody.gilk@gmail.com" - } - ], - "description": "Assertions to validate method input/output with nice error messages.", - "keywords": [ - "assert", - "check", - "validate" - ], - "support": { - "issues": "https://github.com/webmozarts/assert/issues", - "source": "https://github.com/webmozarts/assert/tree/2.3.0" - }, - "time": "2026-04-11T10:33:05+00:00" } ], "packages-dev": [ @@ -11884,218 +11512,6 @@ ], "time": "2020-09-28T06:39:44+00:00" }, - { - "name": "symfony/browser-kit", - "version": "v7.3.10", - "source": { - "type": "git", - "url": "https://github.com/symfony/browser-kit.git", - "reference": "a1e115df7c86200f210814867a61694e6d304256" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/browser-kit/zipball/a1e115df7c86200f210814867a61694e6d304256", - "reference": "a1e115df7c86200f210814867a61694e6d304256", - "shasum": "" - }, - "require": { - "php": ">=8.2", - "symfony/dom-crawler": "^6.4|^7.0" - }, - "require-dev": { - "symfony/css-selector": "^6.4|^7.0", - "symfony/http-client": "^6.4|^7.0", - "symfony/mime": "^6.4|^7.0", - "symfony/process": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\BrowserKit\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Simulates the behavior of a web browser, allowing you to make requests, click on links and submit forms programmatically", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/browser-kit/tree/v7.3.10" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-13T10:28:39+00:00" - }, - { - "name": "symfony/css-selector", - "version": "v7.3.6", - "source": { - "type": "git", - "url": "https://github.com/symfony/css-selector.git", - "reference": "84321188c4754e64273b46b406081ad9b18e8614" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/css-selector/zipball/84321188c4754e64273b46b406081ad9b18e8614", - "reference": "84321188c4754e64273b46b406081ad9b18e8614", - "shasum": "" - }, - "require": { - "php": ">=8.2" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\CssSelector\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Jean-François Simon", - "email": "jeanfrancois.simon@sensiolabs.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Converts CSS selectors to XPath expressions", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/css-selector/tree/v7.3.6" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2025-10-29T17:24:25+00:00" - }, - { - "name": "symfony/dom-crawler", - "version": "v7.3.10", - "source": { - "type": "git", - "url": "https://github.com/symfony/dom-crawler.git", - "reference": "8d9b47c994701cd50d3507062501c1ac2b428aaf" - }, - "dist": { - "type": "zip", - "url": "https://api.github.com/repos/symfony/dom-crawler/zipball/8d9b47c994701cd50d3507062501c1ac2b428aaf", - "reference": "8d9b47c994701cd50d3507062501c1ac2b428aaf", - "shasum": "" - }, - "require": { - "masterminds/html5": "^2.6", - "php": ">=8.2", - "symfony/polyfill-ctype": "~1.8", - "symfony/polyfill-mbstring": "~1.0" - }, - "require-dev": { - "symfony/css-selector": "^6.4|^7.0" - }, - "type": "library", - "autoload": { - "psr-4": { - "Symfony\\Component\\DomCrawler\\": "" - }, - "exclude-from-classmap": [ - "/Tests/" - ] - }, - "notification-url": "https://packagist.org/downloads/", - "license": [ - "MIT" - ], - "authors": [ - { - "name": "Fabien Potencier", - "email": "fabien@symfony.com" - }, - { - "name": "Symfony Community", - "homepage": "https://symfony.com/contributors" - } - ], - "description": "Eases DOM navigation for HTML and XML documents", - "homepage": "https://symfony.com", - "support": { - "source": "https://github.com/symfony/dom-crawler/tree/v7.3.10" - }, - "funding": [ - { - "url": "https://symfony.com/sponsor", - "type": "custom" - }, - { - "url": "https://github.com/fabpot", - "type": "github" - }, - { - "url": "https://github.com/nicolas-grekas", - "type": "github" - }, - { - "url": "https://tidelift.com/funding/github/packagist/symfony/symfony", - "type": "tidelift" - } - ], - "time": "2026-01-05T08:45:46+00:00" - }, { "name": "symfony/maker-bundle", "version": "v1.67.0", diff --git a/config/services.yaml b/config/services.yaml index 1462a13..5d6beda 100644 --- a/config/services.yaml +++ b/config/services.yaml @@ -46,6 +46,10 @@ services: $defaultRelayUrl: '%default_relay%' $articleRelayUrls: '%article_relays%' $profileRelayUrls: '%profile_relays%' + App\Service\NostrAuthorRelayCache: + lazy: true + arguments: + $relayQueryCache: '@cache.app' App\Service\NostrClient: arguments: $projectDir: '%kernel.project_dir%' diff --git a/phpstan-baseline.neon b/phpstan-baseline.neon index 1041dcb..0c1d458 100644 --- a/phpstan-baseline.neon +++ b/phpstan-baseline.neon @@ -504,22 +504,10 @@ parameters: count: 1 path: src/Service/NostrClient.php - - - message: '#^Call to function is_array\(\) with array\ will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/Service/NostrClient.php - - - - message: '#^Call to function is_array\(\) with list\ will always evaluate to true\.$#' - identifier: function.alreadyNarrowedType - count: 1 - path: src/Service/NostrClient.php - - message: '#^Call to function is_object\(\) with object will always evaluate to true\.$#' identifier: function.alreadyNarrowedType - count: 9 + count: 6 path: src/Service/NostrClient.php - @@ -534,12 +522,6 @@ parameters: count: 1 path: src/Service/NostrClient.php - - - message: '#^Negated boolean expression is always true\.$#' - identifier: booleanNot.alwaysTrue - count: 1 - path: src/Service/NostrClient.php - - message: '#^Offset ''dTags'' on array\{pubkey\: string, kind\: int\<1, max\>, dTags\: non\-empty\-list\\} on left side of \?\? always exists and is not nullable\.$#' identifier: nullCoalesce.offset @@ -573,7 +555,7 @@ parameters: - message: '#^Parameter \#1 \$array \(non\-empty\-list\\) of array_values is already a list, call has no effect\.$#' identifier: arrayValues.list - count: 2 + count: 1 path: src/Service/NostrClient.php - @@ -582,24 +564,12 @@ parameters: count: 1 path: src/Service/NostrClient.php - - - message: '#^Strict comparison using \!\=\= between non\-empty\-list\ and array\{\} will always evaluate to true\.$#' - identifier: notIdentical.alwaysTrue - count: 1 - path: src/Service/NostrClient.php - - message: '#^Strict comparison using \=\=\= between non\-empty\-list\ and array\{\} will always evaluate to false\.$#' identifier: identical.alwaysFalse count: 1 path: src/Service/NostrClient.php - - - message: '#^Strict comparison using \=\=\= between non\-empty\-list\ and array\{\} will always evaluate to false\.$#' - identifier: identical.alwaysFalse - count: 1 - path: src/Service/NostrClient.php - - message: '#^PHPDoc tag @param for parameter \$event with type ArrayObject\\|list\ is not subtype of native type object\.$#' identifier: parameter.phpDocType diff --git a/src/Service/NostrArticleDiscussionSupport.php b/src/Service/NostrArticleDiscussionSupport.php new file mode 100644 index 0000000..77f433c --- /dev/null +++ b/src/Service/NostrArticleDiscussionSupport.php @@ -0,0 +1,169 @@ + + */ + 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; + } +} diff --git a/src/Service/NostrAuthorRelayCache.php b/src/Service/NostrAuthorRelayCache.php new file mode 100644 index 0000000..db584da --- /dev/null +++ b/src/Service/NostrAuthorRelayCache.php @@ -0,0 +1,89 @@ + + */ + 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 + */ + 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); + } +} diff --git a/src/Service/NostrClient.php b/src/Service/NostrClient.php index 70c845b..7d0320c 100644 --- a/src/Service/NostrClient.php +++ b/src/Service/NostrClient.php @@ -19,8 +19,6 @@ use swentel\nostr\Relay\RelaySet; use swentel\nostr\Request\Request; use swentel\nostr\Subscription\Subscription; use Symfony\Component\Security\Core\Authentication\Token\Storage\TokenStorageInterface; -use Symfony\Contracts\Cache\CacheInterface; -use Symfony\Contracts\Cache\ItemInterface; /** * Main integration point for swentel/nostr against configured relays: long-form fetch, kind-0 profile @@ -29,7 +27,11 @@ use Symfony\Contracts\Cache\ItemInterface; * `nostr_relay_request_timeout_sec` (see `config/unfold.yaml`). Shared building blocks: * {@see NostrRelayRequestFactory} (timeouts), {@see NostrRelayQuery} (REQ + response fan-in), * {@see NostrRelayFanoutTransport} (sequential vs parallel multi-relay REQ), {@see NostrRelayListFactory} - * (config relay lists, merge/dedupe, {@link RelaySet} for profile fetches, Nostr Land + aggr). + * (config relay lists, merge/dedupe, {@link RelaySet} for profile fetches, Nostr Land + aggr), + * {@see NostrAuthorRelayCache} (cached NIP-65 kind-10002 author relay lists), + * {@see NostrWireEventMerge} (NIP-33 / kind-0 merge, #d tags, npub→hex for wire objects), + * {@see NostrArticleDiscussionSupport} (article thread REQ filters and tag classifiers), + * {@see NostrKind5DeletionFilter} (NIP-09 kind-5 relevance for stored row kinds). */ class NostrClient { @@ -57,12 +59,15 @@ class NostrClient private readonly ArticleFactory $articleFactory, private readonly TokenStorageInterface $tokenStorage, private readonly LoggerInterface $logger, - private readonly CacheInterface $relayQueryCache, private readonly string $projectDir, private readonly NostrRelayRequestFactory $relayRequestFactory, private readonly NostrRelayQuery $nostrRelayQuery, private readonly NostrRelayFanoutTransport $relayFanout, private readonly NostrRelayListFactory $relayListFactory, + private readonly NostrAuthorRelayCache $authorRelayCache, + private readonly NostrWireEventMerge $wireMerge, + private readonly NostrArticleDiscussionSupport $articleDiscussion, + private readonly NostrKind5DeletionFilter $kind5DeletionFilter, ) { $this->defaultRelaySet = $this->relayListFactory->getDefaultArticleRelaySet(); } @@ -110,7 +115,7 @@ class NostrClient $seen = array_fill_keys($base, true); $out = $base; foreach ($pubkeys as $pk) { - foreach ($this->getAuthorNip65RelaysList($pk) as $wss) { + foreach ($this->authorRelayCache->getAuthorNip65RelaysList($pk) as $wss) { if (!\is_string($wss) || $wss === '' || isset($seen[$wss])) { continue; } @@ -132,181 +137,6 @@ class NostrClient return $this->relayListFactory->getNostrLandAggrReaderCacheSuffix(); } - /** - * Full NIP-65 (kind-10002) wss:// list for a hex pubkey, cached. Used for comment fetches; prefer - * {@see getTopReputableRelaysForAuthor} when you only need a few relays. - * - * @return list - */ - private 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->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( - is_array($authorRelays) ? $authorRelays : [], - static function ($relay): bool { - return \is_string($relay) - && 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 relay) for queries that do not need every home relay. - */ - private 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_values(\array_slice($all, 0, $limit)); - } - - /** - * NIP kind-range convention: kind 0, 3, and 10_000–19_999 are replaceable by (kind, pubkey) only; - * 30_000–39_999 are addressable by (kind, pubkey, d). On equal {@see created_at}, the - * lexicographically lowest id is kept. - */ - private static function isReplaceableByKindAndPubkeyNip(int $kind): bool - { - return $kind === 0 - || $kind === 3 - || ($kind >= 10_000 && $kind < 20_000); - } - - private static function replaceableKindPubkeyAddressFromWire(mixed $e): ?string - { - if (!\is_object($e)) { - return null; - } - $k = (int) ($e->kind ?? 0); - if (!self::isReplaceableByKindAndPubkeyNip($k)) { - return null; - } - $pk = (string) ($e->pubkey ?? ''); - if (64 !== \strlen($pk) || !ctype_xdigit($pk)) { - return null; - } - - return (string) $k.':'.strtolower($pk); - } - - private static function isValidNostrEventIdString(string $id): bool - { - return 64 === \strlen($id) && ctype_xdigit($id); - } - - /** - * Whether $candidate is the NIP-preferred live revision over $incumbent: higher created_at, or - * same created_at and lower (lexicographically first) id. Events without a valid 64-hex id - * lose to valid ones (avoids an empty id “winning” a tie and hiding real content). - */ - private static function wireEventSupersedes(mixed $candidate, mixed $incumbent): bool - { - $c = self::magazineEventCreatedAt($candidate); - $i = self::magazineEventCreatedAt($incumbent); - if ($c !== $i) { - return $c > $i; - } - $idC = self::magazineEventId($candidate); - $idI = self::magazineEventId($incumbent); - $vC = self::isValidNostrEventIdString($idC); - $vI = self::isValidNostrEventIdString($idI); - if ($vC && !$vI) { - return true; - } - if (!$vC && $vI) { - return false; - } - if (!$vC && !$vI) { - if ($idC === $idI) { - return false; - } - - return $idC < $idI; - } - if ($idC === $idI) { - return false; - } - - return $idC < $idI; - } - - /** - * NIP-01: kind-0 profile metadata is replaceable; the live document is addressed by `0:pubkey` - * (not by event id). Multiple relay copies collapse per {@see wireEventSupersedes}. - */ - private static 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 static function kind0ReplaceableIsNewer(mixed $candidate, mixed $incumbent): bool - { - return self::wireEventSupersedes($candidate, $incumbent); - } - - /** - * @param list $events - * - * @return array Keyed by `0:` + 64 hex (lowercase); one winning kind-0 event per key - */ - private static function mergeKind0EventsByReplaceableAddress(array $events): array - { - $byAddress = []; - foreach ($events as $ev) { - $addr = self::kind0Nip01ReplaceableAddress($ev); - if ($addr === null) { - continue; - } - if (!isset($byAddress[$addr]) || self::kind0ReplaceableIsNewer($ev, $byAddress[$addr])) { - $byAddress[$addr] = $ev; - } - } - - return $byAddress; - } - /** * Batched kind-0 profile fetch: one Nostr REQ per chunk with multiple "authors" (hex pubkeys). * @@ -348,7 +178,7 @@ class NostrClient 'relays' => $relaysTriedStr, 'ms' => (int) round((microtime(true) - $t0) * 1000), ]); - foreach (self::mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) { + foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) { if (!\is_object($ev) || !isset($ev->content)) { continue; } @@ -398,7 +228,7 @@ class NostrClient $request->send(), static fn ($ev) => $ev, ); - foreach (self::mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) { + foreach ($this->wireMerge->mergeKind0EventsByReplaceableAddress($events) as $addr => $ev) { if (!\is_object($ev)) { continue; } @@ -464,7 +294,7 @@ class NostrClient if (!\is_object($ev) || (int) ($ev->kind ?? 0) !== KindsEnum::DELETION_REQUEST->value) { continue; } - if (!self::kind5DeletionRelevantToStoredDbData($ev)) { + if (!$this->kind5DeletionFilter->isRelevantToStoredDbData($ev)) { continue; } $id = (string) ($ev->id ?? ''); @@ -482,45 +312,6 @@ class NostrClient return array_values($byId); } - /** - * Keep only kind-5 events that (claim to) delete kinds we keep in MySQL: profile, relay list, payto, - * long-form, magazine index. Omits thread/reply/comment deletions to shrink relay responses. - */ - private static function kind5DeletionRelevantToStoredDbData(object $ev): bool - { - static $kinds; - if ($kinds === null) { - $kinds = [ - KindsEnum::METADATA->value, - KindsEnum::RELAY_LIST->value, - KindsEnum::PAYMENT_TARGETS->value, - KindsEnum::LONGFORM->value, - KindsEnum::LONGFORM_DRAFT->value, - KindsEnum::PUBLICATION_INDEX->value, - ]; - } - 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 ($parts !== [] && \in_array((int) $parts[0], $kinds, true)) { - return true; - } - } - } - - return false; - } - /** * @throws \Exception */ @@ -549,8 +340,8 @@ class NostrClient if (empty($events)) { throw new \Exception('No metadata for npub '.$npub.' (relays: '.$relaysTriedStr.')'); } - $byAddr = self::mergeKind0EventsByReplaceableAddress($events); - $authorHex = self::npubToHexPubkey($npub); + $byAddr = $this->wireMerge->mergeKind0EventsByReplaceableAddress($events); + $authorHex = $this->wireMerge->npubToHexPubkey($npub); if ($authorHex === null) { throw new \Exception('Invalid npub for metadata: '.$npub); } @@ -598,7 +389,7 @@ class NostrClient return []; } - return self::mergeNip33ParameterizedWireEvents($events); + return $this->wireMerge->mergeNip33ParameterizedWireEvents($events); } public function getNpubLongForm($npub): void @@ -725,7 +516,7 @@ class NostrClient public function getLongFormFromNaddr($slug, $relayList, $author, $kind): void { if (empty($relayList)) { - $topAuthorRelays = $this->getTopReputableRelaysForAuthor($author); + $topAuthorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($author); $authorRelaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($topAuthorRelays); $relaysTried = $this->plannedRelayUrlsForSet($topAuthorRelays); } else { @@ -753,9 +544,9 @@ class NostrClient if (!empty($events)) { $kindI = (int) $kind; - $authorH = self::authorIdentToHexLower($author); - $event = self::isNip33ParameterizedKind($kindI) && $authorH !== null - ? self::pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, (string) $slug) + $authorH = $this->wireMerge->authorIdentToHexLower($author); + $event = $this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null + ? $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, (string) $slug) : null; if ($event === null) { $event = $events[0]; @@ -833,7 +624,7 @@ class NostrClient } // Try author's relays first - $authorRelays = empty($relays) ? $this->getTopReputableRelaysForAuthor($pubkey) : $relays; + $authorRelays = empty($relays) ? $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey) : $relays; $relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays); // Create request using the helper method @@ -893,7 +684,7 @@ class NostrClient $events[] = $wrapper->event; } } - foreach (self::mergeNip33ParameterizedWireEvents($events) as $event) { + foreach ($this->wireMerge->mergeNip33ParameterizedWireEvents($events) as $event) { $article = $this->articleFactory->createFromLongFormContentEvent($event); $this->saveEachArticleToTheDatabase($article); } @@ -916,7 +707,7 @@ class NostrClient if (empty($response)) { return null; } - $merged = self::mergeNip33ParameterizedWireEvents($response); + $merged = $this->wireMerge->mergeNip33ParameterizedWireEvents($response); $k10002 = (int) KindsEnum::RELAY_LIST->value; foreach ($merged as $e) { if (\is_object($e) && (int) ($e->kind ?? 0) === $k10002) { @@ -989,7 +780,7 @@ class NostrClient $pubkey = $parts[1]; $tRelays = microtime(true); - $authorRelays = $this->getAuthorNip65RelaysList($pubkey); + $authorRelays = $this->authorRelayCache->getAuthorNip65RelaysList($pubkey); $this->logger->info('nostr.article_discussion.author_relays_ready', [ 'elapsed_ms' => (int) round((microtime(true) - $tRelays) * 1000), 'author_relay_count' => \count($authorRelays), @@ -1007,7 +798,7 @@ class NostrClient ]); } - $filters = $this->createArticleDiscussionFilters($coordinate, $rootEventHexId); + $filters = $this->articleDiscussion->createArticleDiscussionFilters($coordinate, $rootEventHexId); $subscription = new Subscription(); $subscriptionId = $subscription->setId(); $requestMessage = new RequestMessage($subscriptionId, $filters); @@ -1091,13 +882,13 @@ class NostrClient foreach ($all as $event) { $kind = (int) ($event->kind ?? 0); - if ($kind === KindsEnum::COMMENTS->value && $this->eventIsNip22ArticleThreadReply($event, $coordinate)) { + if ($kind === KindsEnum::COMMENTS->value && $this->articleDiscussion->eventIsNip22ArticleThreadReply($event, $coordinate)) { $thread[] = $event; $threadIds[(string) $event->id] = true; continue; } - if ($kind === KindsEnum::TEXT_NOTE->value && $this->eventIsLegacyThreadReply($event, $coordinate, $rootEventHexId)) { + if ($kind === KindsEnum::TEXT_NOTE->value && $this->articleDiscussion->eventIsLegacyThreadReply($event, $coordinate, $rootEventHexId)) { $thread[] = $event; $threadIds[(string) $event->id] = true; } @@ -1109,7 +900,7 @@ class NostrClient if ($id === '' || isset($threadIds[$id])) { continue; } - if ($this->eventIsArticleQuote($event, $coordinate, $rootEventHexId)) { + if ($this->articleDiscussion->eventIsArticleQuote($event, $coordinate, $rootEventHexId)) { $quotes[] = $event; } } @@ -1151,7 +942,7 @@ class NostrClient $pubkey = $parts[1]; $tRelays = microtime(true); - $authorRelays = $this->getAuthorNip65RelaysList($pubkey); + $authorRelays = $this->authorRelayCache->getAuthorNip65RelaysList($pubkey); $this->logger->info('nostr.highlight_relay_list', [ 'elapsed_ms' => (int) round((microtime(true) - $tRelays) * 1000), 'author_relay_count' => \count($authorRelays), @@ -1259,161 +1050,6 @@ class NostrClient return $out; } - private 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; - } - - private 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; - } - - private function eventIsArticleQuote(object $event, string $coordinate, ?string $rootEventHexId): bool - { - $kind = (int) ($event->kind ?? 0); - if ($kind === KindsEnum::HIGHLIGHTS->value) { - // Highlights are stored in `article_highlight`, not the discussion/quote list. - 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; - } - - /** - * @return array - */ - private 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; - } - /** * Get zap events for a specific event * @@ -1433,7 +1069,7 @@ class NostrClient $pubkey = $parts[1]; // Get author's relays for better chances of finding zaps - $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); + $authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey); $relaySet = $this->relayListFactory->createRelaySetMergedWithArticleList($authorRelays); // Create request using the helper method @@ -1457,7 +1093,7 @@ class NostrClient */ public function getLongFormContentForPubkey(string $ident): array { - $authorRelays = $this->getTopReputableRelaysForAuthor($ident); + $authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($ident); $base = $this->relayListFactory->getConfiguredArticleRelayUrlList(); $merged = $authorRelays !== [] ? array_merge($base, $authorRelays) : $base; $seen = []; @@ -1487,7 +1123,7 @@ class NostrClient $request->send(), static fn (object $event) => $event, ); - foreach (self::mergeNip33ParameterizedWireEvents($events) as $event) { + foreach ($this->wireMerge->mergeNip33ParameterizedWireEvents($events) as $event) { if (!\is_object($event)) { continue; } @@ -1603,7 +1239,7 @@ class NostrClient } } - return self::mergeNip33ParameterizedWireEvents(array_values($articles)); + return $this->wireMerge->mergeNip33ParameterizedWireEvents(array_values($articles)); } /** @@ -1634,7 +1270,7 @@ class NostrClient $relayList = []; try { // Get relays where the author publishes - $authorRelays = $this->getTopReputableRelaysForAuthor($pubkey); + $authorRelays = $this->authorRelayCache->getTopReputableRelaysForAuthor($pubkey); if (!empty($authorRelays)) { $relayList = $authorRelays; } @@ -1670,7 +1306,7 @@ class NostrClient $request->send(), static fn (object $event) => $event, ); - $ev = $this->pickEventForNip33OrFirst($events, $kind, (string) $pubkey, (string) $slug); + $ev = $this->wireMerge->pickEventForNip33OrFirst($events, $kind, (string) $pubkey, (string) $slug); if ($ev !== null) { $articlesMap[$coordinate] = $ev; } @@ -1684,7 +1320,7 @@ class NostrClient $request2->send(), static fn (object $event) => $event, ); - $ev2 = $this->pickEventForNip33OrFirst($events2, $kind, (string) $pubkey, (string) $slug); + $ev2 = $this->wireMerge->pickEventForNip33OrFirst($events2, $kind, (string) $pubkey, (string) $slug); if ($ev2 !== null) { $articlesMap[$coordinate] = $ev2; } @@ -1744,7 +1380,7 @@ class NostrClient if ($incumbent === null) { $this->logger->info('[longform_ingest] saveEachArticle: persist new row (no DB row for author+slug)', [ 'eventId' => $newId, - 'address' => $pubkey.':…:'.self::longformIngestShortSlug($slug), + 'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug), ]); $this->persistNewArticle($article, 'no_db_row_for_nip33_address'); @@ -1760,11 +1396,11 @@ class NostrClient return; } $iWire = self::longFormWireStubFromArticle($incumbent); - $cTs = self::magazineEventCreatedAt($candidate); - $iTs = self::magazineEventCreatedAt($iWire); - if (self::wireEventSupersedes($candidate, $iWire)) { + $cTs = $this->wireMerge->magazineEventCreatedAt($candidate); + $iTs = $this->wireMerge->magazineEventCreatedAt($iWire); + if ($this->wireMerge->wireEventSupersedes($candidate, $iWire)) { $this->logger->info('[longform_ingest] saveEachArticle: NIP-33 update — candidate wins, flushing DB row', [ - 'address' => $pubkey.':…:'.self::longformIngestShortSlug($slug), + 'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug), 'from_event_id' => $incumbent->getEventId(), 'to_event_id' => $newId, 'db_row_id' => $incumbent->getId(), @@ -1784,9 +1420,9 @@ class NostrClient return; } - if (self::wireEventSupersedes($iWire, $candidate)) { + if ($this->wireMerge->wireEventSupersedes($iWire, $candidate)) { $this->logger->info('[longform_ingest] saveEachArticle: keep DB — merged relay result is not newer (incumbent wins)', [ - 'address' => $pubkey.':…:'.self::longformIngestShortSlug($slug), + 'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug), 'dbEventId' => $incumbent->getEventId(), 'seenEventId' => $newId, 'db_row_id' => $incumbent->getId(), @@ -1795,7 +1431,7 @@ class NostrClient ]); } elseif ((string) $incumbent->getEventId() !== $newId) { $this->logger->notice('[longform_ingest] saveEachArticle: inconclusive supersedes (different ids) — check relays / d-tag match', [ - 'address' => $pubkey.':…:'.self::longformIngestShortSlug($slug), + 'address' => $pubkey.':…:'.$this->wireMerge->longformIngestShortSlug($slug), 'dbEventId' => $incumbent->getEventId(), 'seenEventId' => $newId, 'db_row_id' => $incumbent->getId(), @@ -1811,7 +1447,7 @@ class NostrClient $this->logger->info('[longform_ingest] persistNewArticle', [ 'reason' => $reason, 'eventId' => $article->getEventId(), - 'slug' => self::longformIngestShortSlug((string) ($article->getSlug() ?? '')), + 'slug' => $this->wireMerge->longformIngestShortSlug((string) ($article->getSlug() ?? '')), ]); $this->entityManager->persist($article); $this->entityManager->flush(); @@ -1824,33 +1460,6 @@ class NostrClient } } - private static 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} - */ - private static function longformIngestEventWireSummary(object $e): array - { - $d = self::eventDTagValue($e); - $nip = self::nip33ParameterizedReplaceableAddress($e); - - return [ - 'kind' => (int) ($e->kind ?? 0), - 'id' => (string) ($e->id ?? ''), - 'created_at' => (int) ($e->created_at ?? 0), - 'd' => $d !== null && $d !== '' ? self::longformIngestShortSlug($d, 80) : '', - 'nip33' => $nip, - ]; - } - private function findLatestLongFormArticleByAuthorAndSlug(string $pubkey, string $slug): ?Article { $pubkey = strtolower($pubkey); @@ -1869,7 +1478,7 @@ class NostrClient } /** - * Minimal Nostr event shape for {@see self::wireEventSupersedes} when `raw` is not a full wire object. + * Minimal Nostr event shape for {@see NostrWireEventMerge::wireEventSupersedes()} when `raw` is not a full wire object. */ private static function longFormWireStubFromArticle(Article $a): object { @@ -1985,9 +1594,9 @@ class NostrClient $wantD = (string) ($data->identifier ?? ''); $kindI = (int) ($data->kind ?? KindsEnum::LONGFORM->value); - $authorH = self::authorIdentToHexLower($data->pubkey ?? null); - if (self::isNip33ParameterizedKind($kindI) && $authorH !== null) { - $picked = self::pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, $wantD); + $authorH = $this->wireMerge->authorIdentToHexLower($data->pubkey ?? null); + if ($this->wireMerge->isNip33ParameterizedKind($kindI) && $authorH !== null) { + $picked = $this->wireMerge->pickLatestNip33ParameterizedForQuery($events, $kindI, $authorH, $wantD); if ($picked !== null) { return $picked; } @@ -2019,188 +1628,6 @@ class NostrClient return false; } - /** - * One wire event for a (kind, author, #d) coordinate after merging relay results. - * - * @param list $events - */ - private function pickEventForNip33OrFirst(array $events, int $kind, string $authorIdent, string $dTag): ?object - { - if ($events === []) { - return null; - } - if (self::isNip33ParameterizedKind($kind)) { - $h = self::authorIdentToHexLower($authorIdent); - if ($h !== null) { - $picked = self::pickLatestNip33ParameterizedForQuery($events, $kind, $h, $dTag); - if ($picked !== null && \is_object($picked)) { - return $picked; - } - } - $merged = self::mergeNip33ParameterizedWireEvents($events); - $first = $merged[0] ?? null; - - return \is_object($first) ? $first : null; - } - if (self::isReplaceableByKindAndPubkeyNip($kind)) { - $h = self::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 || self::wireEventSupersedes($e, $best)) { - $best = $e; - } - } - if ($best !== null) { - return $best; - } - } - foreach (self::mergeNip33ParameterizedWireEvents($events) as $e) { - if (\is_object($e) && (int) ($e->kind ?? 0) === $kind) { - return $e; - } - } - - return null; - } - $e0 = $events[0] ?? null; - - return \is_object($e0) ? $e0 : null; - } - - /** NIP-33: kinds 30_000–39_999 (parameterized replaceable) use `kind:pubkey:d` as address. */ - private const NIP33_PARAMETERIZED_KIND_MIN = 30_000; - private const NIP33_PARAMETERIZED_KIND_MAX = 39_999; - - private static function isNip33ParameterizedKind(int $kind): bool - { - return $kind >= self::NIP33_PARAMETERIZED_KIND_MIN - && $kind <= self::NIP33_PARAMETERIZED_KIND_MAX; - } - - /** - * NIP-33: `kind:pubkey_hex:d` (d from tags; d may include colons). Kinds 30000–39999 only. - */ - private static function nip33ParameterizedReplaceableAddress(mixed $event): ?string - { - $k = self::magazineEventKind($event); - if (!self::isNip33ParameterizedKind($k)) { - return null; - } - $pk = self::magazineEventPubkeyHex($event); - if ($pk === '' || 64 !== \strlen($pk) || !ctype_xdigit($pk)) { - return null; - } - $d = self::eventDTagValue($event); - if ($d === null || $d === '') { - return null; - } - - return (string) $k.':'.strtolower($pk).':'.$d; - } - - /** - * NIP-33: from merged relay results, the event at the replaceable address kind:pubkeyLower:d. - * Uses {@see mergeNip33ParameterizedWireEvents} so every relay’s copies collapse to the live - * revision the same way everywhere; then we match the requested address only. - * - * (Older logic reimplemented “merge” by hand and had a fallback that could return a **different** - * 30040 (wrong #d) when the expected address key did not line up, which surfaced as “stale” - * category indices even when a newer note existed on a relay such as TheForest.) - * - * @param list $events - */ - private static function pickLatestNip33ParameterizedForQuery( - array $events, - int $expectedKind, - string $authorHexLower, - string $dTag - ): mixed { - if (!self::isNip33ParameterizedKind($expectedKind)) { - return null; - } - $wantD = trim($dTag); - $expectedAddr = (string) $expectedKind.':'.$authorHexLower.':'.$wantD; - - $merged = self::mergeNip33ParameterizedWireEvents($events); - foreach ($merged as $e) { - if (!\is_object($e)) { - continue; - } - if (self::magazineEventKind($e) !== $expectedKind) { - continue; - } - if (strtolower(self::magazineEventPubkeyHex($e)) !== $authorHexLower) { - continue; - } - $addr = self::nip33ParameterizedReplaceableAddress($e); - if ($addr === $expectedAddr) { - return $e; - } - } - - return null; - } - - /** - * Merge relay results: 30_000–39_999 by `kind:pubkey:d`; kind 0, 3, 10_000–19_999 by `kind:pubkey`; - * others by event id. Uses {@see wireEventSupersedes} for the winning revision in each bucket. - * - * @param list $events - * - * @return list - */ - private static function mergeNip33ParameterizedWireEvents(array $events): array - { - $byNip33Address = []; - $byKindPubkey = []; - $byId = []; - foreach ($events as $e) { - if (!\is_object($e)) { - continue; - } - $k = (int) ($e->kind ?? 0); - if (self::isNip33ParameterizedKind($k)) { - $a = self::nip33ParameterizedReplaceableAddress($e); - if ($a === null) { - continue; - } - if (!isset($byNip33Address[$a]) || self::wireEventSupersedes($e, $byNip33Address[$a])) { - $byNip33Address[$a] = $e; - } - } elseif (self::isReplaceableByKindAndPubkeyNip($k)) { - $a = self::replaceableKindPubkeyAddressFromWire($e); - if ($a === null) { - continue; - } - if (!isset($byKindPubkey[$a]) || self::wireEventSupersedes($e, $byKindPubkey[$a])) { - $byKindPubkey[$a] = $e; - } - } else { - $id = (string) ($e->id ?? ''); - if ($id === '') { - continue; - } - if (!isset($byId[$id]) || self::wireEventSupersedes($e, $byId[$id])) { - $byId[$id] = $e; - } - } - } - - return array_values(array_merge($byId, $byKindPubkey, $byNip33Address)); - } - - private static function authorIdentToHexLower(mixed $ident): ?string - { - return self::npubToHexPubkey($ident); - } - /** * Latest kind 30040 index for this author and #d tag, as {@see PublicationEventEntity} * so callers can use {@see PublicationEventEntity::getTags()} (relay payloads are otherwise stdClass). @@ -2232,7 +1659,7 @@ class NostrClient private function queryMagazineIndex(mixed $npub, mixed $dTag, RelaySet $relaySet, string $relaysForLog): ?PublicationEventEntity { - $authorHex = self::npubToHexPubkey($npub); + $authorHex = $this->wireMerge->npubToHexPubkey($npub); if ($authorHex === null) { $this->logger->warning('Magazine index: could not resolve npub to hex pubkey', [ 'npub' => $npub, @@ -2259,7 +1686,7 @@ class NostrClient if (empty($events)) { return null; } - $raw = self::pickLatestNip33ParameterizedForQuery( + $raw = $this->wireMerge->pickLatestNip33ParameterizedForQuery( $events, KindsEnum::PUBLICATION_INDEX->value, $authorHex, @@ -2276,7 +1703,7 @@ class NostrClient return null; } - return self::magazineEventToPublicationEntity($raw); + return $this->wireMerge->magazineEventToPublicationEntity($raw); } /** @@ -2311,7 +1738,7 @@ class NostrClient $request->send(), static fn (object $event) => $event, ); - $ev = $this->pickEventForNip33OrFirst($events, $kind, $pubkey, $slug); + $ev = $this->wireMerge->pickEventForNip33OrFirst($events, $kind, $pubkey, $slug); if ($ev !== null) { return $ev; } @@ -2333,14 +1760,14 @@ class NostrClient if (strtolower((string) ($ev2->pubkey ?? '')) !== $pubkey) { continue; } - $d = self::eventDTagValue($ev2); + $d = $this->wireMerge->eventDTagValue($ev2); if ($d === null || trim((string) $d) !== $slug) { continue; } $matched[] = $ev2; } - return $matched === [] ? null : $this->pickEventForNip33OrFirst($matched, $kind, $pubkey, $slug); + return $matched === [] ? null : $this->wireMerge->pickEventForNip33OrFirst($matched, $kind, $pubkey, $slug); } catch (\Throwable) { } @@ -2413,7 +1840,7 @@ class NostrClient 'author_hex64_prefix' => substr((string) $g['pubkey'], 0, 12), 'd_tag_count' => \count($dTags), 'd_tags' => array_map( - fn (string $dt): string => self::longformIngestShortSlug($dt, 72), + fn (string $dt): string => $this->wireMerge->longformIngestShortSlug($dt, 72), $dTags ), ]); @@ -2435,7 +1862,7 @@ class NostrClient continue; } if ($si < 25) { - $rawSample[] = self::longformIngestEventWireSummary($ev); + $rawSample[] = $this->wireMerge->longformIngestEventWireSummary($ev); } ++$si; } @@ -2470,7 +1897,7 @@ class NostrClient if ($evPubkey !== $expectedPubkey) { continue; } - $evD = self::eventDTagValue($ev); + $evD = $this->wireMerge->eventDTagValue($ev); if ($evD === null || !isset($expectedD[$evD])) { continue; } @@ -2527,7 +1954,7 @@ class NostrClient if ($evPubkey !== $expectedPubkeyPf) { continue; } - $evD = self::eventDTagValue($ev); + $evD = $this->wireMerge->eventDTagValue($ev); if ($evD === null || !isset($expectedDPf[$evD])) { continue; } @@ -2545,13 +1972,13 @@ class NostrClient } } } - $merged = self::mergeNip33ParameterizedWireEvents($events); + $merged = $this->wireMerge->mergeNip33ParameterizedWireEvents($events); $mergedDetail = []; foreach ($merged as $ev) { if (!\is_object($ev)) { continue; } - $mergedDetail[] = self::longformIngestEventWireSummary($ev); + $mergedDetail[] = $this->wireMerge->longformIngestEventWireSummary($ev); } $this->logger->info('[longform_ingest] ingestLongform: after mergeNip33ParameterizedWireEvents', [ 'merged_count' => \count($merged), @@ -2568,7 +1995,7 @@ class NostrClient if (!\is_object($event)) { continue; } - $addr = self::nip33ParameterizedReplaceableAddress($event); + $addr = $this->wireMerge->nip33ParameterizedReplaceableAddress($event); if ($addr !== null) { $seenAddresses[$addr] = true; } @@ -2611,160 +2038,4 @@ class NostrClient } $this->logger->info('[longform_ingest] ingestLongform: done (all groups)'); } - - private static 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 static 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 static 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 static 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 ''; - } - - /** - * Nostr wire tag as a name-first sequence (e.g. ["d", "ident"]). Handles both indexed arrays - * and object-shaped tag rows from JSON. - * - * @return list|null - */ - private static 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 === [] || $seq[0] === '') { - return null; - } - - return $seq; - } - - /** - * First "d" tag value from raw relay or {@see PublicationEventEntity} tag arrays (trimmed). - */ - private static 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 = self::normalizeNostrTagRowToSequence($t); - if ($seq === null || ($seq[0] ?? '') !== 'd' || !isset($seq[1]) || (string) $seq[1] === '') { - continue; - } - - return trim((string) $seq[1]); - } - - return null; - } - - private static 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 = (new NostrKeyHelper())->convertToHex($s); - - return $hex !== '' && 64 === \strlen($hex) && ctype_xdigit($hex) ? strtolower($hex) : null; - } - - return null; - } - - /** - * Normalize relay / library event objects to the app's Event entity (not persisted). - */ - private static function magazineEventToPublicationEntity(mixed $raw): ?PublicationEventEntity - { - if ($raw instanceof PublicationEventEntity) { - return $raw; - } - if (!\is_object($raw)) { - return null; - } - - try { - /** @var array $data */ - $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; - } } diff --git a/src/Service/NostrKind5DeletionFilter.php b/src/Service/NostrKind5DeletionFilter.php new file mode 100644 index 0000000..a53db55 --- /dev/null +++ b/src/Service/NostrKind5DeletionFilter.php @@ -0,0 +1,54 @@ +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 + */ + 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, + ]; + } +} diff --git a/src/Service/NostrWireEventMerge.php b/src/Service/NostrWireEventMerge.php new file mode 100644 index 0000000..497d65c --- /dev/null +++ b/src/Service/NostrWireEventMerge.php @@ -0,0 +1,450 @@ += 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 $events + * + * @return array + */ + 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 $events + * + * @return list + */ + 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 $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 $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|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 ''; + } +} diff --git a/tests/Service/NostrArticleDiscussionSupportTest.php b/tests/Service/NostrArticleDiscussionSupportTest.php new file mode 100644 index 0000000..864a6fd --- /dev/null +++ b/tests/Service/NostrArticleDiscussionSupportTest.php @@ -0,0 +1,85 @@ +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)); + } +} diff --git a/tests/Service/NostrAuthorRelayCacheTest.php b/tests/Service/NostrAuthorRelayCacheTest.php new file mode 100644 index 0000000..3af32d1 --- /dev/null +++ b/tests/Service/NostrAuthorRelayCacheTest.php @@ -0,0 +1,60 @@ +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)); + } +} diff --git a/tests/Service/NostrKind5DeletionFilterTest.php b/tests/Service/NostrKind5DeletionFilterTest.php new file mode 100644 index 0000000..5a58ba3 --- /dev/null +++ b/tests/Service/NostrKind5DeletionFilterTest.php @@ -0,0 +1,43 @@ + 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)); + } +} diff --git a/tests/Service/NostrWireEventMergeTest.php b/tests/Service/NostrWireEventMergeTest.php new file mode 100644 index 0000000..31e27f9 --- /dev/null +++ b/tests/Service/NostrWireEventMergeTest.php @@ -0,0 +1,97 @@ +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)); + } +}