8 changed files with 461 additions and 21 deletions
@ -0,0 +1,268 @@
@@ -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 @@
@@ -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