diff --git a/src/Event/List/DmRelaysList.php b/src/Event/List/DmRelaysList.php index 9234dc7..ec77c51 100644 --- a/src/Event/List/DmRelaysList.php +++ b/src/Event/List/DmRelaysList.php @@ -54,6 +54,11 @@ public function __construct() */ public function getRelays(string $pubkey, string $relayURL = 'wss://relay.nostr.band'): array { + /** + * @TODO + * Implements fetching the write relays where this pubkey writes to with RelayListMetadata class, + * so we than know where to possibly find the DM relays list. + */ $this->setPublicKey($pubkey); $subscription = new Subscription(); $filter = new Filter(); diff --git a/src/Event/List/RelayListMetadata.php b/src/Event/List/RelayListMetadata.php new file mode 100644 index 0000000..3cb7955 --- /dev/null +++ b/src/Event/List/RelayListMetadata.php @@ -0,0 +1,188 @@ +kind !== 10002) { + throw new \RuntimeException('You cannot set the kind number of ' . __CLASS__ . ' which is fixed to ' . $this->kind); + } + $this->setKind($this->kind); + $this->fetch($pubkey, $relayURL); + } + + /** + * Get all relays. + * + * @return array + */ + public function getRelays(): array + { + if (empty($this->relays)) { + throw new \RuntimeException('The relays property is empty of ' . __CLASS__); + } + foreach ($this->relays as $relay) { + if (str_starts_with($relay[1], 'wss://') === false) { + throw new \RuntimeException('The URL ' . $relay[1] . ' is not a valid websocket URL'); + } + } + return $this->relays; + } + + /** + * Get relays where the npub writes to. + * + * @return array + */ + public function getWriteRelays(): array + { + if (empty($this->relays)) { + throw new \RuntimeException('The relays property is empty of ' . __CLASS__); + } + $writeRelays = []; + foreach ($this->relays as $relay) { + if (str_starts_with($relay[1], 'wss://') === false) { + throw new \RuntimeException('The URL ' . $relay[1] . ' is not a valid websocket URL'); + } + if (!isset($relay[2]) && str_starts_with($relay[1], 'wss://')) { + $writeRelays[] = $relay[1]; + } + if (in_array('write', $relay, true)) { + $writeRelays[] = $relay[1]; + } + } + return $writeRelays; + } + + /** + * Get relays where the npub reads from. + * + * @return array + */ + public function getReadRelays(): array + { + if (empty($this->relays)) { + throw new \RuntimeException('The relays property is empty of ' . __CLASS__); + } + $readRelays = []; + foreach ($this->relays as $relay) { + if (str_starts_with($relay[1], 'wss://') === false) { + throw new \RuntimeException('The URL ' . $relay[1] . ' is not a valid websocket URL'); + } + if (!isset($relay[2]) && str_starts_with($relay[1], 'wss://')) { + $readRelays[] = $relay[1]; + } + if (in_array('read', $relay, true)) { + $readRelays[] = $relay[1]; + } + } + return $readRelays; + } + + /** + * Fetch all relays from a given pubkey and optional given relay URL. + * If the list (array) with relays is empty, other attempts are made with known public relays. + * + * @param string $pubkey + * @param string $relayURL + * @return array + */ + private function fetch(string $pubkey, string $relayURL = 'wss://purplepag.es'): void + { + $this->setPublicKey($pubkey); + $subscription = new Subscription(); + $filter = new Filter(); + $filter->setLimit(1); + $filter->setKinds([$this->kind]); + $filter->setAuthors([$pubkey]); + $requestMessage = new RequestMessage($subscription->getId(), [$filter]); + $relay = new Relay($relayURL); + $request = new Request($relay, $requestMessage); + $response = $request->send(); + foreach ($response as $relayResponses) { + foreach ($relayResponses as $relayResponse) { + if ($relayResponse instanceof RelayResponseEvent) { + $event = $relayResponse->event; + $this->setTags($event->tags); + $this->relays = $this->getTag('r'); + } + } + } + if (empty($this->relays)) { + // Fallback when no relays are found for given relay URL, let's query other relays. + $other_relays_to_query = $this->getKnownRelays(); + foreach ($other_relays_to_query as $relay_url) { + $subscription = new Subscription(); + $requestMessage = new RequestMessage($subscription->getId(), [$filter]); + $relay->setUrl($relay_url); + $request = new Request($relay, $requestMessage); + $response = $request->send(); + foreach ($response as $relayResponses) { + foreach ($relayResponses as $relayResponse) { + if ($relayResponse instanceof RelayResponseEose) { + break; + } + if ($relayResponse instanceof RelayResponseEvent) { + $event = $relayResponse->event; + $this->setTags($event->tags); + $this->relays = $this->getTag('r'); + } + } + } + if (!empty($this->relays)) { + break; + } + } + } + } + + /** + * Get a list of known (public) relays to query which indexes events with kind 10002. + * + * @return array List of relay URLs + */ + private function getKnownRelays(): array + { + // TODO: This would ideally come from configuration. + return [ + 'wss://profiles.nostrver.se', + 'wss://indexer.coracle.social', + 'wss://profiles.nostr1.com', + 'wss://relay.nostr.band', + ]; + } +} diff --git a/src/Event/Profile/Profile.php b/src/Event/Profile/Profile.php index 3da6b99..0864c0a 100644 --- a/src/Event/Profile/Profile.php +++ b/src/Event/Profile/Profile.php @@ -107,6 +107,7 @@ public function __construct() * @param string $relayURL * Relay to fetch from, defaults to wss://purplepag.es * @return $this + * @throws \JsonException */ public function fetch(string $pubkey, string $relayURL = 'wss://purplepag.es'): Profile { diff --git a/src/Examples/bootstrap-profile-with-pubkey.php b/src/Examples/bootstrap-profile-with-pubkey.php new file mode 100644 index 0000000..ac3ec32 --- /dev/null +++ b/src/Examples/bootstrap-profile-with-pubkey.php @@ -0,0 +1,90 @@ +getReadRelays(); + // This is the list of relays where the given pubkey writes (publishes) too. + $writeRelays = $relayListMetadata->getWriteRelays(); + /** + * Within the profile class, it will try to fetch as much us possible know metadata for this pubkey. + * Now we know to which relays the pubkey is writing to, we (assume to) know from which relay we can read the profile data from. + */ + $profile = new Profile(); + $profile->fetch($pubkey, $writeRelays[1] ?? null); + print_r($profile) . PHP_EOL; + + // Get follow list (kind 3) + $subscription = new Subscription(); + $filters = []; + $filter = new Filter(); + $filter->setKinds([3]); + $filter->setAuthors([$pubkey]); + $filter->setLimit(1); + $filters = [$filter]; + $relaySet = new RelaySet(); + foreach ($writeRelays as $relay_url) { + $relay = new Relay($relay_url); + $relaySet->addRelay($relay); + } + $requestMessage = new RequestMessage($subscription->getId(), $filters); + $request = new Request($relaySet, $requestMessage); + $response = $request->send(); + $following_lists = []; + foreach ($response as $relayUrl => $relayResponses) { + print 'Received ' . count($response[$relayUrl]) . ' message(s) received from relay ' . $relayUrl . PHP_EOL; + foreach ($relayResponses as $relayResponse) { + if ($relayResponse instanceof RelayResponseEvent && isset($relayResponse->event)) { + $following_list_event = new Event(); + $following_list_event->populate($relayResponse->event); + if (!isset($following_lists[$relayUrl][$following_list_event->getId()])) { + $following_lists[$relayUrl][$following_list_event->getId()] = $following_list_event; + $pTags = $following_list_event->getTag('p'); + print 'Found following list with ' . count($pTags) . ' pubkeys on relay ' . $relayUrl . PHP_EOL; + } + } + } + } + // We could now fetch the profile data for each of these pubkey and build a rich lists profiles followed by $pubkey. + //print_r($following_lists); + + // TODO Get mute list (kind 10000) + + // TODO Get pinned notes (kind 10001) + + /* + * TODO add other lists and sets too described on https://github.com/nostr-protocol/nips/blob/master/51.md + */ + +} catch (\Exception $e) { + print 'Exception error: ' . $e->getMessage() . PHP_EOL; +} diff --git a/src/Examples/fetch-relay-list-metadata.php b/src/Examples/fetch-relay-list-metadata.php new file mode 100644 index 0000000..75e906e --- /dev/null +++ b/src/Examples/fetch-relay-list-metadata.php @@ -0,0 +1,25 @@ +getRelays(); + print_r($relays) . PHP_EOL; + + $writeRelays = $relayListMetadata->getWriteRelays(); + print_r($writeRelays) . PHP_EOL; + + $readRelays = $relayListMetadata->getReadRelays(); + print_r($readRelays) . PHP_EOL; + +} catch (\Exception $e) { + print 'Exception error: ' . $e->getMessage() . PHP_EOL; +} diff --git a/tests/RelayListMetadataTest.php b/tests/RelayListMetadataTest.php new file mode 100644 index 0000000..25eaa3a --- /dev/null +++ b/tests/RelayListMetadataTest.php @@ -0,0 +1,177 @@ +assertEquals(10002, $relayList->getKind()); + } + + /** + * Test fetching relay list with empty response. + */ + public function testEmptyRelayList(): void + { + $mockRelay = $this->createMock(Relay::class); + $mockRequest = $this->createMock(Request::class); + $mockRequest->method('send')->willReturn([]); + + // Replace the Request constructor with our mock + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The relays property is empty of swentel\nostr\Event\List\RelayListMetadata'); + + $relayList = new RelayListMetadata(self::TEST_PUBKEY); + $relayList->getRelays(); + } + + /** + * Test getting write relays. + */ + public function testGetWriteRelays(): void + { + $relayList = $this->createRelayListWithMockData([ + ['r', 'wss://relay1.com', 'write'], + ['r', 'wss://relay2.com', 'read'], + ['r', 'wss://relay3.com'], // Both read and write + ['r', 'wss://relay4.com', 'write'], + ]); + + $writeRelays = $relayList->getWriteRelays(); + + $this->assertContains('wss://relay1.com', $writeRelays); + $this->assertNotContains('wss://relay2.com', $writeRelays); + $this->assertContains('wss://relay3.com', $writeRelays); + $this->assertContains('wss://relay4.com', $writeRelays); + $this->assertNotContains('http://invalid.com', $writeRelays); + } + + /** + * Test getting read relays. + */ + public function testGetReadRelays(): void + { + $relayList = $this->createRelayListWithMockData([ + ['r', 'wss://relay1.com', 'read'], + ['r', 'wss://relay2.com', 'write'], + ['r', 'wss://relay3.com'], // Both read and write + ['r', 'wss://relay4.com', 'read'], + ]); + + $readRelays = $relayList->getReadRelays(); + + $this->assertContains('wss://relay1.com', $readRelays); + $this->assertNotContains('wss://relay2.com', $readRelays); + $this->assertContains('wss://relay3.com', $readRelays); + $this->assertContains('wss://relay4.com', $readRelays); + $this->assertNotContains('http://invalid.com', $readRelays); + } + + /** + * Test that fallback relays are queried when primary relay returns no results. + */ + public function testFallbackRelayQuerying(): void + { + $relayList = $this->createRelayListWithMockData( + [['r', 'wss://fallback-relay.com', 'write']], + true, + ); + + $writeRelays = $relayList->getWriteRelays(); + $this->assertContains('wss://fallback-relay.com', $writeRelays); + } + + /** + * Test handling of malformed relay URLs. + */ + public function testMalformedRelayUrls(): void + { + $relayList = $this->createRelayListWithMockData([ + ['r', 'not-a-url', 'write'], + ['r', 'http://not-secure.com', 'write'], + ['r', 'wss://valid.com', 'write'], + ]); + + $this->expectException(\RuntimeException::class); + $relayList->getWriteRelays(); + + } + + /** + * Creates a RelayListMetadata instance with mock data. + */ + private function createRelayListWithMockData(array $tags, bool $useFallback = false): RelayListMetadata + { + // Create a mock event response + $mockEvent = new \stdClass(); + $mockEvent->tags = $tags; + + // Create a mock relay response + $mockRelayResponse = $this->createMock(RelayResponseEvent::class); + $mockRelayResponse->event = $mockEvent; + + // Create reflection class to modify private properties + $relayList = new RelayListMetadata(self::TEST_PUBKEY); + $reflection = new \ReflectionClass($relayList); + + $relaysProperty = $reflection->getProperty('relays'); + $relaysProperty->setAccessible(true); + $relaysProperty->setValue($relayList, $tags); + + return $relayList; + } + + /** + * Test that getKnownRelays returns expected relays. + */ + public function testGetKnownRelays(): void + { + $relayList = new RelayListMetadata(self::TEST_PUBKEY); + $reflection = new \ReflectionClass($relayList); + + $method = $reflection->getMethod('getKnownRelays'); + $method->setAccessible(true); + + $knownRelays = $method->invoke($relayList); + + $this->assertIsArray($knownRelays); + $this->assertNotEmpty($knownRelays); + foreach ($knownRelays as $relay) { + $this->assertStringStartsWith('wss://', $relay); + } + } + + /** + * Test that empty relays throw exception for all getter methods. + */ + public function testEmptyRelaysThrowExceptions(): void + { + $relayList = new RelayListMetadata(self::TEST_PUBKEY); + $reflection = new \ReflectionClass($relayList); + + $relaysProperty = $reflection->getProperty('relays'); + $relaysProperty->setAccessible(true); + $relaysProperty->setValue($relayList, []); + + $this->expectException(\RuntimeException::class); + $this->expectExceptionMessage('The relays property is empty of swentel\nostr\Event\List\RelayListMetadata'); + + $relayList->getRelays(); + $relayList->getReadRelays(); + $relayList->getWriteRelays(); + } +}