8 changed files with 461 additions and 21 deletions
@ -0,0 +1,268 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Nostr; |
||||||
|
|
||||||
|
/** |
||||||
|
* Kind 1 (NIP-01 + NIP-10): only {@code e} (thread references) and {@code p} (thread participants) tags. |
||||||
|
* Marked {@code e} use {@code ["e", id, relay, marker, pubkey]}; relay may be "". |
||||||
|
* If the thread root id cannot be inferred, a single marked {@code e} with "reply" references the direct parent. |
||||||
|
*/ |
||||||
|
final class Nip10Kind1ArticleReplyTags |
||||||
|
{ |
||||||
|
/** |
||||||
|
* @param list<array<int, string|int|float>>|array<array> $parentRawTags |
||||||
|
* |
||||||
|
* @return list<list<string>> |
||||||
|
*/ |
||||||
|
public static function forReplyToKind1( |
||||||
|
string $parentEventIdHex, |
||||||
|
string $parentAuthorPubkeyHex, |
||||||
|
array $parentRawTags, |
||||||
|
string $articleCoordinate, |
||||||
|
?string $articleEventHexId |
||||||
|
): array { |
||||||
|
$parentEventIdHex = strtolower($parentEventIdHex); |
||||||
|
$parentAuthorPubkeyHex = strtolower($parentAuthorPubkeyHex); |
||||||
|
if (self::isInvalidHexId($parentEventIdHex) || self::isInvalidHexPubkey($parentAuthorPubkeyHex)) { |
||||||
|
throw new \InvalidArgumentException('Invalid parent id or pubkey'); |
||||||
|
} |
||||||
|
|
||||||
|
$rootId = self::inferRootEventId($parentRawTags, $articleEventHexId); |
||||||
|
|
||||||
|
$parts = explode(':', $articleCoordinate, 3); |
||||||
|
$articleAuthorHex = \count($parts) >= 2 ? strtolower((string) $parts[1]) : ''; |
||||||
|
if ($articleAuthorHex !== '' && (64 !== \strlen($articleAuthorHex) || !ctype_xdigit($articleAuthorHex))) { |
||||||
|
$articleAuthorHex = ''; |
||||||
|
} |
||||||
|
|
||||||
|
$articleEventNorm = self::normEventId($articleEventHexId); |
||||||
|
$tags = []; |
||||||
|
|
||||||
|
if (self::isInvalidHexId($rootId)) { |
||||||
|
$tags[] = ['e', $parentEventIdHex, '', 'reply', $parentAuthorPubkeyHex]; |
||||||
|
} else { |
||||||
|
$rootAuthorPk = self::pubkeyForRootEvent( |
||||||
|
$rootId, |
||||||
|
$parentEventIdHex, |
||||||
|
$parentAuthorPubkeyHex, |
||||||
|
$parentRawTags, |
||||||
|
$articleAuthorHex, |
||||||
|
$articleEventNorm |
||||||
|
); |
||||||
|
// NIP-10: direct reply to the thread root uses only a single "root" e tag, not root+reply to the same id. |
||||||
|
if (hash_equals($rootId, $parentEventIdHex)) { |
||||||
|
$tags[] = ['e', $rootId, '', 'root', $rootAuthorPk]; |
||||||
|
} else { |
||||||
|
$tags[] = ['e', $rootId, '', 'root', $rootAuthorPk]; |
||||||
|
$tags[] = ['e', $parentEventIdHex, '', 'reply', $parentAuthorPubkeyHex]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$seenP = []; |
||||||
|
$addP = static function (string $pk) use (&$tags, &$seenP): void { |
||||||
|
if (64 !== \strlen($pk) || !ctype_xdigit($pk) || isset($seenP[$pk])) { |
||||||
|
return; |
||||||
|
} |
||||||
|
$seenP[$pk] = true; |
||||||
|
$tags[] = ['p', $pk]; |
||||||
|
}; |
||||||
|
// NIP-10: p tags = author of E being replied to, then all p tags from E (in no particular order; we use author first). |
||||||
|
$addP($parentAuthorPubkeyHex); |
||||||
|
foreach (self::collectPFromParent($parentRawTags) as $pk) { |
||||||
|
$addP($pk); |
||||||
|
} |
||||||
|
|
||||||
|
return $tags; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<array<array-key, mixed>> $rawTags |
||||||
|
*/ |
||||||
|
private static function pubkeyForRootEvent( |
||||||
|
string $rootId, |
||||||
|
string $parentEventIdHex, |
||||||
|
string $parentAuthorPubkeyHex, |
||||||
|
array $parentRawTags, |
||||||
|
string $articleAuthorHex, |
||||||
|
?string $articleEventNorm |
||||||
|
): string { |
||||||
|
if (hash_equals($rootId, $parentEventIdHex)) { |
||||||
|
return $parentAuthorPubkeyHex; |
||||||
|
} |
||||||
|
if ($articleEventNorm !== null && hash_equals($rootId, $articleEventNorm) && $articleAuthorHex !== '') { |
||||||
|
return $articleAuthorHex; |
||||||
|
} |
||||||
|
$fromParent = self::eTagPubkeyForEventId($parentRawTags, $rootId); |
||||||
|
if ($fromParent !== '') { |
||||||
|
return $fromParent; |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Optional 5th field on marked e tags: pubkey of the author of the referenced event (NIP-10). |
||||||
|
* |
||||||
|
* @param list<array<array-key, mixed>> $rawTags |
||||||
|
*/ |
||||||
|
private static function eTagPubkeyForEventId(array $rawTags, string $eventIdHex): string |
||||||
|
{ |
||||||
|
$want = strtolower($eventIdHex); |
||||||
|
foreach ($rawTags as $row) { |
||||||
|
if (!\is_array($row) || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (strtolower((string) ($row[0] ?? '')) !== 'e') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$id = self::normEventId($row[1] ?? null); |
||||||
|
if ($id === null || !hash_equals($want, $id)) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (\count($row) >= 5) { |
||||||
|
$pk = self::normPubkey($row[4] ?? null); |
||||||
|
if ($pk !== '') { |
||||||
|
return $pk; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<array<array-key, mixed>> $rawTags |
||||||
|
* |
||||||
|
* @return list<string> |
||||||
|
*/ |
||||||
|
private static function collectPFromParent(array $rawTags): array |
||||||
|
{ |
||||||
|
$out = []; |
||||||
|
foreach ($rawTags as $row) { |
||||||
|
if (!\is_array($row) || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (strtolower((string) ($row[0] ?? '')) !== 'p') { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$pk = self::normPubkey($row[1] ?? null); |
||||||
|
if ($pk !== '') { |
||||||
|
$out[] = $pk; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $out; |
||||||
|
} |
||||||
|
|
||||||
|
private static function normPubkey(mixed $v): string |
||||||
|
{ |
||||||
|
if (!\is_string($v) && !\is_int($v) && !\is_float($v)) { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
$h = strtolower(trim((string) $v)); |
||||||
|
if (64 !== \strlen($h) || !ctype_xdigit($h)) { |
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
return $h; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* @param list<array<array-key, mixed>> $rawTags |
||||||
|
*/ |
||||||
|
private static function inferRootEventId(array $rawTags, ?string $articleEventHexId): string |
||||||
|
{ |
||||||
|
$articleHex = self::normEventId($articleEventHexId); |
||||||
|
|
||||||
|
$isE = static function (mixed $row): bool { |
||||||
|
if (!\is_array($row) || ($row[0] ?? null) === null) { |
||||||
|
return false; |
||||||
|
} |
||||||
|
$n = strtolower((string) $row[0]); |
||||||
|
|
||||||
|
return $n === 'e'; |
||||||
|
}; |
||||||
|
|
||||||
|
foreach ($rawTags as $row) { |
||||||
|
if (!\is_array($row) || !$isE($row) || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (($row[3] ?? '') === 'root') { |
||||||
|
$id = self::normEventId($row[1] ?? null); |
||||||
|
if ($id !== null) { |
||||||
|
return $id; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
if ($articleHex !== null) { |
||||||
|
foreach ($rawTags as $row) { |
||||||
|
if (!\is_array($row) || !$isE($row) || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$id = self::normEventId($row[1] ?? null); |
||||||
|
if ($id !== null && hash_equals($id, $articleHex)) { |
||||||
|
return $id; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
$eIds = []; |
||||||
|
foreach ($rawTags as $row) { |
||||||
|
if (!\is_array($row) || !$isE($row) || \count($row) < 2) { |
||||||
|
continue; |
||||||
|
} |
||||||
|
$id = self::normEventId($row[1] ?? null); |
||||||
|
if ($id !== null) { |
||||||
|
$eIds[] = $id; |
||||||
|
} |
||||||
|
} |
||||||
|
if ($eIds === [] && $articleHex !== null) { |
||||||
|
return $articleHex; |
||||||
|
} |
||||||
|
if (\count($eIds) === 1) { |
||||||
|
return $eIds[0]; |
||||||
|
} |
||||||
|
if ($eIds !== []) { |
||||||
|
if ($articleHex !== null) { |
||||||
|
foreach ($eIds as $id) { |
||||||
|
if (hash_equals($id, $articleHex)) { |
||||||
|
return $id; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return $eIds[0]; |
||||||
|
} |
||||||
|
if ($articleHex !== null) { |
||||||
|
return $articleHex; |
||||||
|
} |
||||||
|
|
||||||
|
return ''; |
||||||
|
} |
||||||
|
|
||||||
|
private static function normEventId(mixed $v): ?string |
||||||
|
{ |
||||||
|
if (!\is_string($v) && !\is_int($v) && !\is_float($v)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
$h = strtolower(trim((string) $v)); |
||||||
|
if (64 !== \strlen($h) || !ctype_xdigit($h)) { |
||||||
|
return null; |
||||||
|
} |
||||||
|
|
||||||
|
return $h; |
||||||
|
} |
||||||
|
|
||||||
|
private static function isInvalidHexId(string $h): bool |
||||||
|
{ |
||||||
|
return 64 !== \strlen($h) || !ctype_xdigit($h); |
||||||
|
} |
||||||
|
|
||||||
|
private static function isInvalidHexPubkey(string $h): bool |
||||||
|
{ |
||||||
|
return 64 !== \strlen($h) || !ctype_xdigit($h); |
||||||
|
} |
||||||
|
} |
||||||
@ -0,0 +1,106 @@ |
|||||||
|
<?php |
||||||
|
|
||||||
|
declare(strict_types=1); |
||||||
|
|
||||||
|
namespace App\Tests\Nostr; |
||||||
|
|
||||||
|
use App\Nostr\Nip10Kind1ArticleReplyTags; |
||||||
|
use PHPUnit\Framework\TestCase; |
||||||
|
|
||||||
|
final class Nip10Kind1ArticleReplyTagsTest extends TestCase |
||||||
|
{ |
||||||
|
public function testReplyUnderArticleUsesMarkedEWithPubkeys(): void |
||||||
|
{ |
||||||
|
$pk = str_repeat('a', 64); |
||||||
|
$aid = str_repeat('b', 64); |
||||||
|
$cid = str_repeat('c', 64); |
||||||
|
$coord = '30023:'.$pk.':slug'; |
||||||
|
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1( |
||||||
|
$cid, |
||||||
|
$pk, |
||||||
|
[['a', $coord]], |
||||||
|
$coord, |
||||||
|
$aid |
||||||
|
); |
||||||
|
$this->assertSame( |
||||||
|
[ |
||||||
|
['e', $aid, '', 'root', $pk], |
||||||
|
['e', $cid, '', 'reply', $pk], |
||||||
|
['p', $pk], |
||||||
|
], |
||||||
|
$tags |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
public function testDirectReplyToRootUsesSingleMarkedRootOnly(): void |
||||||
|
{ |
||||||
|
$author = str_repeat('f', 64); |
||||||
|
$rootId = str_repeat('e', 64); |
||||||
|
$coord = '30023:'.$author.':slug'; |
||||||
|
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1( |
||||||
|
$rootId, |
||||||
|
$author, |
||||||
|
[ |
||||||
|
['a', $coord], |
||||||
|
['e', $rootId, '', 'root', $author], |
||||||
|
], |
||||||
|
$coord, |
||||||
|
$rootId |
||||||
|
); |
||||||
|
$this->assertCount(2, $tags); |
||||||
|
$this->assertSame(['e', $rootId, '', 'root', $author], $tags[0]); |
||||||
|
$this->assertSame(['p', $author], $tags[1]); |
||||||
|
} |
||||||
|
|
||||||
|
public function testNestedCopiesParentPTagsAfterAuthor(): void |
||||||
|
{ |
||||||
|
$articlePk = str_repeat('1', 64); |
||||||
|
$root = str_repeat('2', 64); |
||||||
|
$parentNote = str_repeat('3', 64); |
||||||
|
$child = str_repeat('4', 64); |
||||||
|
$pExtra = str_repeat('6', 64); |
||||||
|
$coord = '30023:'.$articlePk.':x'; |
||||||
|
$parentTags = [ |
||||||
|
['a', $coord], |
||||||
|
['e', $root, '', 'root', str_repeat('5', 64)], |
||||||
|
['e', $parentNote, '', 'reply', $articlePk], |
||||||
|
['p', $articlePk], |
||||||
|
['p', $pExtra], |
||||||
|
]; |
||||||
|
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1( |
||||||
|
$child, |
||||||
|
$articlePk, |
||||||
|
$parentTags, |
||||||
|
$coord, |
||||||
|
$root |
||||||
|
); |
||||||
|
$this->assertSame($root, $tags[0][1]); |
||||||
|
$this->assertSame($articlePk, $tags[0][4]); |
||||||
|
$this->assertSame($child, $tags[1][1]); |
||||||
|
$this->assertSame($articlePk, $tags[1][4]); |
||||||
|
$this->assertSame(['p', $articlePk], $tags[2]); |
||||||
|
$this->assertSame(['p', $pExtra], $tags[3]); |
||||||
|
$this->assertCount(4, $tags); |
||||||
|
} |
||||||
|
|
||||||
|
public function testWhenRootInferenceFailsOnlyDirectParentE(): void |
||||||
|
{ |
||||||
|
$author = str_repeat('9', 64); |
||||||
|
$parentId = str_repeat('8', 64); |
||||||
|
$coord = '30023:'.$author.':x'; |
||||||
|
$tags = Nip10Kind1ArticleReplyTags::forReplyToKind1( |
||||||
|
$parentId, |
||||||
|
$author, |
||||||
|
[], |
||||||
|
$coord, |
||||||
|
null |
||||||
|
); |
||||||
|
$this->assertSame( |
||||||
|
[ |
||||||
|
['e', $parentId, '', 'reply', $author], |
||||||
|
['p', $author], |
||||||
|
], |
||||||
|
$tags |
||||||
|
); |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue