diff --git a/README.md b/README.md index f88d0e7..60934e4 100644 --- a/README.md +++ b/README.md @@ -74,7 +74,7 @@ $eventMessage = new EventMessage($note); $message_string = $eventMessage->generate(); ``` -## Interacting with a relay +## Publish an event to a relay Publish an event with a note that has been prepared for sending to a relay. @@ -93,10 +93,115 @@ $signer->signEvent($note, $private_key); $eventMessage = new EventMessage($note); $relayUrl = 'wss://nostr-websocket.tld'; -$relay = new Relay($relayUrl, $eventMessage); +$relay = new Relay($relayUrl); +$relay->setMessage($eventMessage); $result = $relay->send(); ``` +If you would like to publish the event to multiple relays, you can use the `RelaySet` class. + +```php +$relay1 = new Relay(''wss://nostr-websocket1.tld''); +$relay2 = new Relay(''wss://nostr-websocket2.tld''); +$relay3 = new Relay(''wss://nostr-websocket3.tld''); +$relay4 = new Relay(''wss://nostr-websocket4.tld''); +$relaySet = new RelaySet(); +$relaySet->setRelays([$relay1, $relay2, $relay3, $relay4]); +$relaySet->setMessage($eventMessage); +$result = $relay->send(); +``` + +## Read events from a relay + +Fetch events from a relay. + +```php +$subscription = new Subscription(); +$subscriptionId = $subscription->setId(); + +$filter1 = new Filter(); +$filter1->setKinds([1, 3]); // You can add multiple kind numbers +$filter1->setLimit(25); // Limit to fetch only a maximum of 25 events +$filters = [$filter1]; // You can add multiple filters. + +$requestMessage = new RequestMessage($subscriptionId, $filters); + +$relayUrl = 'wss://nostr-websocket.tld'; +$relay = new Relay($relayUrl); +$relay->setMessage($requestMessage); + +$request = new Request($relay, $requestMessage); +$response = $request->send(); +``` + +`$response` is an multidimensional array with elements containing each a response message (JSON string) decoded to an array from the relay and sorted by the relay. +Output example: +```php +[ + 'wss://nostr-websocket.tld' => [ + 0 => [ + "EVENT", + "A8kWzjCVUHSD1rmuwGqyK2PxsolZMO9YXditbg05fch6p3Q4eT7vRFLEJINBna", + [ + 'id' => '1e8534623845629d40f7761c0577edf10f778c490e7b95a524845d9280c7c25a', + 'kind' => 1, + 'pubkey' => '06639a386c9c1014217622ccbcf40908c4f1a0c33e23f8d6d68f4abf655f8f71', + 'created_at' => 1718723787, + 'content' => 'Losing your social graph can feel the same for some I think 😮 ', + 'tags' => [ + ['e', 'f754a238947b7f32168f872650a8dd0b9376493e58005d7e0b8be52f6f229364', 'wss://nos.lol/', 'root'], + ['e', 'fe7dd6ba22fa0aa39370aa160226b8bc2413460621c8d67ce862205ad5a02c24', 'wss://nos.lol/', 'reply'], + ['p', 'fb1366abd5e4c92a8a950791bc72d51bde291a83555cb2c629a92fedd78068ac', '', 'mention'] + ], + 'sig' => '888c9b5d9e0b69eba3510dd2b5d03eddcf0a680ab0e7673820fb36a56448ad80701042a669c7ef9918593c5a41c8b3ccc1d82ade50f32b62dd843144f32df403' + ], + 1 => [ + "EVENT", + "A8kWzjCVUHSD1rmuwGqyK2PxsolZMO9YXditbg05fch6p3Q4eT7vRFLEJINBna", + [ + ...Nostr event + ] + ], + 2 => [ + ... + ], + 3 => [ + ... + ], + 4 => [ + ... + ] + ] +] + +``` + +## Read events from a set of relays + +Read events from a set of relays with the `RelaySet` class. +It's basically the same snippet as above with the difference you create a `RelaySet` class and pass it through the `Request` object. + +```php +$subscription = new Subscription(); +$subscriptionId = $subscription->setId(); + +$filter1 = new Filter(); +$filter1->setKinds([1]); +$filter1->setLimit(5); +$filters = [$filter1]; +$requestMessage = new RequestMessage($subscriptionId, $filters); +$relays = [ + new Relay('wss://nostr-websocket-1.tld'), + new Relay('wss://nostr-websocket-2.tld'), + new Relay('wss://nostr-websocket-3.tld'), +]; +$relaySet = new RelaySet(); +$relaySet->setRelays($relays); + +$request = new Request($relaySet, $requestMessage); +$response = $request->send(); +``` + ## Generating a private key and a public key ```php @@ -161,13 +266,20 @@ private key on command line. - [x] Event validation (issue [#17](https://github.com/swentel/nostr-php/issues/17)) - [ ] Support NIP-01 basic protocol flow description - [x] Publish events - - [ ] Request events (pr [#48](https://github.com/swentel/nostr-php/pull/48)) + - [x] Request events (issue [#55](https://github.com/nostrver-se/nostr-php/pull/55) credits to [kriptonix](https://github.com/kriptonix)) + - [ ] Implement all types of relay responses + - [ ] EVENT - sends events requested by the client + - [ ] OK - indicate an acceptance or denial of an EVENT message + - [ ] EOSE - end of stored events + - [ ] CLOSED - subscription is ended on the server side + - [ ] NOTICE - used to send human-readable messages (like errors) to clients - [ ] Improve handling relay responses - [ ] Support NIP-19 bech32-encoded identifiers - [ ] Support NIP-42 authentication of clients to relays - [ ] Support NIP-45 event counts - [ ] Support NIP-50 search capability -- [ ] Support multi-threading for handling requests simultaneously +- [ ] Support multi-threading (async concurrency) for handling requests simultaneously +- [ ] Support realtime (runtime) subscriptions with the `bin/nostr-php` CLI client to listen to new events from relays ## Community diff --git a/src/Filter/Filter.php b/src/Filter/Filter.php new file mode 100644 index 0000000..870a908 --- /dev/null +++ b/src/Filter/Filter.php @@ -0,0 +1,191 @@ +isLowercaseHex($key)) { + throw new \RuntimeException("Author pubkeys must be an array of 64-character lowercase hex values"); + } + } + $this->authors = $pubkeys; + return $this; + } + + /** + * Set the kinds for the Filter object. + * + * @param array $kinds The array of kinds to set. + */ + public function setKinds(array $kinds): static + { + $this->kinds = $kinds; + return $this; + } + + /** + * Set the #e tag for the Filter object. + * + * @param array $etag The array of tag to set. + */ + public function setLowercaseETags(array $etags): static + { + foreach($etags as $tag) { + if(!$this->isLowercaseHex($tag)) { + throw new \RuntimeException("#e tags must be an array of 64-character lowercase hex values"); + } + } + $this->etags = $etags; + return $this; + } + + /** + * Set the #p tag for the Filter object. + * + * @param array $ptag The array of tag to set. + */ + public function setLowercasePTags(array $ptags): static + { + // Check IF array contain exact 64-character lowercase hex values + foreach($ptags as $tag) { + if(!$this->isLowercaseHex($tag)) { + throw new \RuntimeException("#p tags must be an array of 64-character lowercase hex values"); + } + } + $this->ptags = $ptags; + return $this; + } + + /** + * Set the since for the Filter object. + * + * @param int $since The limit to set. + */ + public function setSince(int $since): static + { + $this->since = $since; + return $this; + } + + /** + * Set the until for the Filter object. + * + * @param int $until The limit to set. + */ + public function setUntil(int $until): static + { + $this->until = $until; + return $this; + } + + /** + * Set the limit for the Filter object. + * + * @param int $limit The limit to set. + */ + public function setLimit(int $limit): static + { + $this->limit = $limit; + return $this; + } + + /** + * Check if a given string is a 64-character lowercase hexadecimal value. + * + * @param string $string The string to check. + * @return bool True if the string is a 64-character lowercase hexadecimal value, false otherwise. + */ + public function isLowercaseHex($string): bool + { + // Regular expression to match 64-character lowercase hexadecimal value + $pattern = '/^[a-f0-9]{64}$/'; + // Check if the string matches the pattern + return preg_match($pattern, $string) === 1; + } + + /** + * Check if a given timestamp is valid. + * + * @param mixed $timestamp The timestamp to check. + * @return bool True if the timestamp is valid, false otherwise. + */ + public function isValidTimestamp($timestamp): bool + { + // Convert the timestamp to seconds + $timestamp = (int) $timestamp; + // Check if the timestamp is valid + return ($timestamp !== 0 && $timestamp !== false && $timestamp !== -1); + } + + /** + * Return an array representation of the object by iterating through its properties. + * + * @return array The array representation of the object. + */ + public function toArray(): array + { + $array = []; + foreach (get_object_vars($this) as $key => $val) { + if($key === 'etags') { + $array['#e'] = $val; + } elseif($key === 'ptags') { + $array['#p'] = $val; + } else { + $array[$key] = $val; + } + } + return $array; + } +} diff --git a/src/FilterInterface.php b/src/FilterInterface.php new file mode 100644 index 0000000..45a4f6d --- /dev/null +++ b/src/FilterInterface.php @@ -0,0 +1,80 @@ +subscriptionId = $subscriptionId; + $this->setType(MessageTypeEnum::CLOSE); + } + + /** + * Set message type. + * + * @param MessageTypeEnum $type + * @return void + */ + public function setType(MessageTypeEnum $type): void + { + $this->type = $type->value; + } + + /** + * {@inheritdoc} + */ + public function generate(): string + { + return '["' . $this->type . '", "' . $this->subscriptionId . '"]'; + } +} diff --git a/src/Message/EventMessage.php b/src/Message/EventMessage.php index 0172aff..77692b0 100644 --- a/src/Message/EventMessage.php +++ b/src/Message/EventMessage.php @@ -9,6 +9,13 @@ class EventMessage implements MessageInterface { + /** + * Message type. + * + * @var string + */ + private string $type; + /** * The event. * @@ -19,6 +26,17 @@ class EventMessage implements MessageInterface public function __construct(EventInterface $event) { $this->event = $event; + $this->setType(MessageTypeEnum::EVENT); + } + + public function setType(MessageTypeEnum $type): void + { + $this->type = $type->value; + } + + private function getType(): string + { + return $this->type; } /** @@ -26,6 +44,7 @@ public function __construct(EventInterface $event) */ public function generate(): string { - return '["EVENT", ' . json_encode($this->event->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE) . ']'; + $event = json_encode($this->event->toArray(), JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + return '["' . $this->type . '", ' . $event . ']'; } } diff --git a/src/Message/MessageTypeEnum.php b/src/Message/MessageTypeEnum.php new file mode 100644 index 0000000..97b59cb --- /dev/null +++ b/src/Message/MessageTypeEnum.php @@ -0,0 +1,15 @@ +subscriptionId = $subscriptionId; + $this->setType(MessageTypeEnum::REQUEST); + foreach ($filters as $filter) { + $this->filters[] = $filter->toArray(); + } + } + + /** + * Set message type. + * + * @param MessageTypeEnum $type + * @return void + */ + public function setType(MessageTypeEnum $type): void + { + $this->type = $type->value; + } + + /** + * Generates a JSON-encoded request array by merging the subscription ID and filters array. + * + * @return string The JSON-encoded request array + */ + public function generate(): string + { + $requestArray = array_merge([$this->type, $this->subscriptionId], $this->filters); + return json_encode($requestArray, JSON_UNESCAPED_SLASHES | JSON_UNESCAPED_UNICODE); + } +} diff --git a/src/MessageInterface.php b/src/MessageInterface.php index 849b336..f72b1c5 100644 --- a/src/MessageInterface.php +++ b/src/MessageInterface.php @@ -4,8 +4,16 @@ namespace swentel\nostr; +use swentel\nostr\Message\MessageTypeEnum; + interface MessageInterface { + /** + * @param MessageTypeEnum $type + * @return void + */ + public function setType(MessageTypeEnum $type): void; + /** * Generate the message ready to be sent to a relay. * diff --git a/src/Relay/Relay.php b/src/Relay/Relay.php index a5dc29e..93ebe9a 100644 --- a/src/Relay/Relay.php +++ b/src/Relay/Relay.php @@ -19,23 +19,37 @@ class Relay implements RelayInterface private string $url; /** - * the payload to send. + * The message to be sent. + * + * @var MessageInterface + */ + protected MessageInterface $message; + + /** + * The payload to be sent. * * @var string */ private string $payload; /** - * Constructs the Relay. + * Relay constructor. * * @param string $websocket * The socket URL. */ - public function __construct(string $websocket, MessageInterface $message) + public function __construct(string $websocket) { - // TODO validate URL. $this->url = $websocket; - $this->payload = $message->generate(); + } + + /** + * {@inheritdoc} + */ + public function setUrl(string $url): void + { + // TODO validate this URL which has to start with a prefix ws:// or wss://. + $this->url = $url; } /** @@ -46,6 +60,23 @@ public function getUrl(): string return $this->url; } + /** + * {@inheritdoc} + */ + public function setMessage(MessageInterface $message): void + { + $this->setPayload($message->generate()); + $this->message = $message; + } + + /** + * @param string $payload + */ + private function setPayload(string $payload): void + { + $this->payload = $payload; + } + /** * {@inheritdoc} */ @@ -60,7 +91,7 @@ public function send(): CommandResultInterface if ($response[0] === 'NOTICE') { throw new \RuntimeException($response[1]); } - } catch (WebSocket\ConnectionException $e) { + } catch (WebSocket\Exception\ClientException $e) { $response = [ 'ERROR', '', diff --git a/src/Relay/RelaySet.php b/src/Relay/RelaySet.php new file mode 100644 index 0000000..1f6cb3e --- /dev/null +++ b/src/Relay/RelaySet.php @@ -0,0 +1,141 @@ +relays = $relays; + } + + /** + * @inheritDoc + */ + public function getRelays(): array + { + return $this->relays; + } + + /** + * @inheritDoc + */ + public function addRelay(Relay $relay): void + { + $this->relays[] = $relay; + } + + /** + * @inheritDoc + */ + public function removeRelay(Relay $relay): void + { + // TODO: Implement removeRelay() method. + } + + /** + * @inheritDoc + */ + public function createFromUrls(array|string $urls): void + { + foreach ($urls as $url) { + $relay = new Relay($url); + $this->relays[] = $relay; + } + } + + /** + * @inheritDoc + */ + public function setMessage(MessageInterface $message): void + { + $this->message = $message; + } + + /** + * @inheritDoc + */ + public function connect(): bool + { + // TODO: Implement connect() method. + return $this->isConnected; + } + + /** + * @inheritDoc + */ + public function disconnect(): bool + { + // TODO: Implement disconnect() method. + return $this->isConnected; + } + + /** + * @inheritDoc + */ + public function isConnected(): bool + { + return $this->isConnected; + } + + /** + * @inheritDoc + */ + public function send(): CommandResultInterface + { + try { + // Send message to each relay defined in this set. + /** @var Relay $relay */ + foreach ($this->relays as $relay) { + $client = new WebSocket\Client($relay->getUrl()); + $payload = $this->message->generate(); + $client->text($payload); + $response = $client->receive(); + $client->disconnect(); + $response = json_decode($response->getContent()); + if ($response[0] === 'NOTICE') { + throw new \RuntimeException($response[1]); + } + } + } catch (WebSocket\Exception\ClientException $e) { + $response = [ + 'ERROR', + '', + false, + $e->getMessage(), + ]; + } + return new CommandResult($response); + } +} diff --git a/src/RelayInterface.php b/src/RelayInterface.php index 35b4ca6..949bd25 100644 --- a/src/RelayInterface.php +++ b/src/RelayInterface.php @@ -7,14 +7,29 @@ interface RelayInterface { /** - * Get url of the relay. + * Set URL of the relay. + * + * @param string $url + * @return void + */ + public function setUrl(string $url): void; + /** + * Get URL of the relay. * * @return string */ public function getUrl(): string; /** - * Send the message to the relay. + * Set message that will be sent to the relay. + * + * @param MessageInterface $message + * @return void + */ + public function setMessage(MessageInterface $message): void; + + /** + * Sends the message to the relay. * * @return CommandResultInterface */ diff --git a/src/RelaySetInterface.php b/src/RelaySetInterface.php new file mode 100644 index 0000000..eac3a43 --- /dev/null +++ b/src/RelaySetInterface.php @@ -0,0 +1,78 @@ +relays = $relay; + } else { + // Create RelaySet with a single relay. + $relaySet = new RelaySet(); + $relaySet->setRelays([$relay]); + $this->relays = $relaySet; + } + $this->payload = $message->generate(); + } + + /** + * @inheritDoc + */ + public function send(): array + { + try { + // TODO work out a nice solution with different RelayResponses. + $result = []; + // Send message to each relay defined in this set in $this->relays. + /** @var Relay $relay */ + foreach ($this->relays->getRelays() as $relay) { + $result[$relay->getUrl()] = $this->getResponseFromRelay($relay); + } + } catch (WebSocket\Exception\ClientException $e) { + $result[$relay->getUrl()][] = [ + 'ERROR', + '', + false, + $e->getMessage(), + ]; + } + + return $result; + } + + /** + * Method to send a request using WebSocket client, receive responses, and handle errors. + * + * @param Relay $relay + * @return array + */ + private function getResponseFromRelay(Relay $relay): array + { + $client = new WebSocket\Client($relay->getUrl()); + $client->text($this->payload); + $result = []; + /** + * When sending 'CLOSE' request to close a subscription, it is not guaranteed that we + * will receive a response confirming that the subscription with the given ID is closed + * as the protocol does not mandate a specific response for a "CLOSE" request + * We can handle this either by: + * - closing connection upon sending the request + * - waiting for a certain period to see if further events are received for that subscription ID + * - waiting for ping from server to close connection (in which case the server indicates the + * connection is still alive, but it does not confirm the closure of the subscription) + */ + while ($response = $client->receive()) { + if ($response instanceof WebSocket\Message\Ping) { + $client->disconnect(); + return $result; + } + if ($response instanceof WebSocket\Message\Text) { + $response = json_decode($response->getContent()); + if ($response[0] === 'NOTICE' || $response[0] === 'CLOSED') { + $client->disconnect(); + throw new \RuntimeException($response[0] === 'NOTICE' ? $response[1] : $response[2]); + } + if ($response[0] === 'EOSE') { + break; + } + $result[] = $response; + } + } + $client->disconnect(); + return $result; + } +} diff --git a/src/RequestInterface.php b/src/RequestInterface.php new file mode 100644 index 0000000..769076e --- /dev/null +++ b/src/RequestInterface.php @@ -0,0 +1,15 @@ +generatePrivateKey(); + + $note = new Event(); + $note->setContent('Hello world'); + + $signer = new Sign(); + $signer->signEvent($note, $private_key); + + $relay1 = new Relay('wss://example1.com'); + $relay2 = new Relay('wss://example2.com'); + $relay3 = new Relay('wss://example3.com'); + + $relaySet = $this->createMock(RelaySet::class); + $relaySet->setRelays([$relay1, $relay2, $relay3]); + $relaySet->expects($this->once()) + ->method('send') + ->willReturn(new CommandResult(['OK', $note->getId(), true, ''])); + + $response = $relaySet->send(); + $this->assertTrue( + $response->isSuccess(), + ); + } +} diff --git a/tests/RequestTest.php b/tests/RequestTest.php new file mode 100644 index 0000000..10bb81c --- /dev/null +++ b/tests/RequestTest.php @@ -0,0 +1,45 @@ +setId(); + + $filter = new Filter(); + $filter->setKinds([1]); + $filter->setLimit(3); + + $filters = [$filter]; + + $relay = new Relay($relayUrl); + + // Mocking the WebSocket\Client + $mockClient = $this->getMockBuilder(Client::class) + ->setConstructorArgs([$relay->getUrl()]) + ->getMock(); + + $requestMessage = new RequestMessage($subscriptionId, $filters); + $request = new Request($relay, $requestMessage, $mockClient); + + $result = $request->send(); + + $this->assertNotEmpty($result, 'Request send result should not be empty'); + } +}