diff --git a/.editorconfig b/.editorconfig index fe77564..12b8ad2 100644 --- a/.editorconfig +++ b/.editorconfig @@ -6,6 +6,6 @@ max_line_length = 120 trim_trailing_whitespace = true # 2 space indentation -[*.rb] +[{*.rb, *.mjs}] indent_style = space indent_size = 2 diff --git a/README.md b/README.md index 17b2f19..eb336d5 100644 --- a/README.md +++ b/README.md @@ -4,31 +4,30 @@ [![Maintainability](https://api.codeclimate.com/v1/badges/c7633eb2c89eb95ee7f2/maintainability)](https://codeclimate.com/github/wilsonsilva/nostr/maintainability) [![Test Coverage](https://api.codeclimate.com/v1/badges/c7633eb2c89eb95ee7f2/test_coverage)](https://codeclimate.com/github/wilsonsilva/nostr/test_coverage) -Asynchronous Nostr client. Please note that the API is likely to change as the gem is still in development and -has not yet reached a stable release. Use with caution. +Asynchronous Nostr client for Rubyists. ## Table of contents -- [Installation](#installation) -- [Usage](#usage) - * [Requiring the gem](#requiring-the-gem) - * [Generating a keypair](#generating-a-keypair) - * [Generating a private key and a public key](#generating-a-private-key-and-a-public-key) - * [Connecting to a Relay](#connecting-to-a-relay) - * [WebSocket events](#websocket-events) - * [Requesting for events / creating a subscription](#requesting-for-events--creating-a-subscription) - * [Stop previous subscriptions](#stop-previous-subscriptions) - * [Publishing an event](#publishing-an-event) - * [Creating/updating your contact list](#creatingupdating-your-contact-list) - * [Sending an encrypted direct message](#sending-an-encrypted-direct-message) -- [Implemented NIPs](#implemented-nips) -- [Development](#development) +- [Key features](#-key-features) +- [Installation](#-installation) +- [Quickstart](#-quickstart) +- [Documentation](#-documentation) +- [Implemented NIPs](#-implemented-nips) +- [Development](#-development) * [Type checking](#type-checking) -- [Contributing](#contributing) -- [License](#license) -- [Code of Conduct](#code-of-conduct) +- [Contributing](#-contributing) +- [License](#-license) +- [Code of Conduct](#-code-of-conduct) -## Installation +## ๐Ÿ”‘ Key features + +- Asynchronous +- Easy to use +- Fully documented +- Fully tested +- Fully typed + +## ๐Ÿ“ฆ Installation Install the gem and add to the application's Gemfile by executing: @@ -38,248 +37,96 @@ If bundler is not being used to manage dependencies, install the gem by executin $ gem install nostr -## Usage +## โšก๏ธ Quickstart -### Requiring the gem - -All examples below assume that the gem has been required. +Here is a quick example of how to use the gem. For more detailed documentation, please check the +[documentation website](https://nostr-omega.vercel.app). ```ruby +# Require the gem require 'nostr' -``` -### Generating a keypair - -```ruby -keygen = Nostr::Keygen.new -keypair = keygen.generate_key_pair - -keypair.private_key -keypair.public_key -``` - -### Generating a private key and a public key - -```ruby -keygen = Nostr::Keygen.new - -private_key = keygen.generate_private_key -public_key = keygen.extract_public_key(private_key) -``` - -### Connecting to a Relay - -Clients can connect to multiple Relays. In this version, a Client can only connect to a single Relay at a time. - -You may instantiate multiple Clients and multiple Relays. - -```ruby +# Instantiate a client client = Nostr::Client.new -relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') +# a) Use an existing keypair +keypair = Nostr::KeyPair.new( + private_key: 'add-your-private-key-here', + public_key: 'add-your-public-key-here', +) + +# b) Or create a new keypair +keygen = Nostr::Keygen.new +keypair = keygen.generate_keypair + +# Create a user with the keypair +user = Nostr::User.new(keypair: keypair) + +# Create a signed event +text_note_event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' +) + +# Connect asynchronously to a relay +relay = Nostr::Relay.new(url: 'wss://nostr.wine', name: 'Wine') client.connect(relay) -``` -### WebSocket events - -All communication between clients and relays happen in WebSockets. - -The `:connect` event is fired when a connection with a WebSocket is opened. You must call `Nostr::Client#connect` first. - -```ruby +# Listen asynchronously for the connect event client.on :connect do - # all the code goes here -end -``` + # Send the event to the Relay + client.publish(text_note_event) -The `:close` event is fired when a connection with a WebSocket has been closed because of an error. + # Create a filter to receive the first 20 text notes + # and encrypted direct messages from the relay that + # were created in the previous hour + filter = Nostr::Filter.new( + kinds: [ + Nostr::EventKind::TEXT_NOTE, + Nostr::EventKind::ENCRYPTED_DIRECT_MESSAGE + ], + since: Time.now.to_i - 3600, # 1 hour ago + until: Time.now.to_i, + limit: 20, + ) -```ruby -client.on :error do |error_message| - puts error_message + # Subscribe to events matching conditions of a filter + subscription = client.subscribe(filter) + + # Unsubscribe from events matching the filter above + client.unsubscribe(subscription.id) end -# > Network error: wss://rsslay.fiatjaf.com: Unable to verify the server certificate for 'rsslay.fiatjaf.com' -``` - -The `:message` event is fired when data is received through a WebSocket. - -```ruby +# Listen for incoming messages and print them client.on :message do |message| puts message end -# [ -# "EVENT", -# "d34107357089bfc9882146d3bfab0386", -# { -# "content":"", -# "created_at":1676456512, -# "id":"18f63550da74454c5df7caa2a349edc5b2a6175ea4c5367fa4b4212781e5b310", -# "kind":3, -# "pubkey":"117a121fa41dc2caa0b3d6c5b9f42f90d114f1301d39f9ee96b646ebfee75e36", -# "sig":"d171420bd62cf981e8f86f2dd8f8f86737ea2bbe2d98da88db092991d125535860d982139a3c4be39886188613a9912ef380be017686a0a8b74231dc6e0b03cb", -# "tags":[ -# ["p","1cc821cc2d47191b15fcfc0f73afed39a86ac6fb34fbfa7993ee3e0f0186ef7c"] -# ] -# } -# ] -``` +# Listen for error messages +client.on :error do |error_message| + # Handle the error +end -The `:close` event is fired when a connection with a WebSocket is closed. - -```ruby +# Listen for the close event client.on :close do |code, reason| - # you may attempt to reconnect - - client.connect(relay) + # You may attempt to reconnect to the relay here end ``` -### Requesting for events / creating a subscription +## ๐Ÿ“š Documentation -A client can request events and subscribe to new updates after it has established a connection with the Relay. +I made a detailed documentation for this gem and it's usage. The code is also fully documented using YARD. -You may use a `Nostr::Filter` instance with as many attributes as you wish: +- [Guide documentation](https://nostr-omega.vercel.app) +- [YARD documentation](https://rubydoc.info/gems/nostr) -```ruby -client.on :connect do - filter = Nostr::Filter.new( - ids: ['8535d5e2d7b9dc07567f676fbe70428133c9884857e1915f5b1cc6514c2fdff8'], - authors: ['ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577'], - kinds: [Nostr::EventKind::TEXT_NOTE], - e: ["f111593a72cc52a7f0978de5ecf29b4653d0cf539f1fa50d2168fc1dc8280e52"], - p: ["f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8"], - since: 1230981305, - until: 1292190341, - limit: 420, - ) - - subscription = client.subscribe('a_random_subscription_id', filter) -end -``` - -With just a few: - -```ruby -client.on :connect do - filter = Nostr::Filter.new(kinds: [Nostr::EventKind::TEXT_NOTE]) - subscription = client.subscribe('a_random_subscription_id', filter) -end -``` - -Or omit the filter: - -```ruby -client.on :connect do - subscription = client.subscribe('a_random_subscription_id') -end -``` - -Or even omit the subscription id: - -```ruby -client.on :connect do - subscription = client.subscribe('a_random_subscription_id') -end -``` - -### Stop previous subscriptions - -You can stop receiving messages from a subscription by calling `#unsubscribe`: - -```ruby -client.unsubscribe('your_subscription_id') -``` - -### Publishing an event - -To publish an event you need a keypair. - -```ruby -# Create a keypair -keypair = Nostr::KeyPair.new( - private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', - public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', -) - -# Add the keypair to the user facade -user = Nostr::User.new(keypair: keypair) - -# Create a signed event -event = user.create_event( - created_at: 1667422587, # optional, defaults to the current time - kind: Nostr::EventKind::TEXT_NOTE, - tags: [], # optional, defaults to [] - content: 'Your feedback is appreciated, now pay $8' -) - -# Send it to the Relay -client.publish(event) -``` - -### Creating/updating your contact list - -Every new contact list that gets published overwrites the past ones, so it should contain all entries. - -```ruby -# Creating a contact list event with 2 contacts -update_contacts_event = user.create_event( - kind: Nostr::EventKind::CONTACT_LIST, - tags: [ - [ - "p", # mandatory - "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", # public key of the user to add to the contacts - "wss://alicerelay.com/", # can be an empty string or can be omitted - "alice" # can be an empty string or can be omitted - ], - [ - "p", # mandatory - "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", # public key of the user to add to the contacts - "wss://bobrelay.com/nostr", # can be an empty string or can be omitted - "bob" # can be an empty string or can be omitted - ], - ], -) - -# Send it to the Relay -client.publish(update_contacts_event) -``` - -### Sending an encrypted direct message - -```ruby -sender_private_key = '3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757' - -encrypted_direct_message = Nostr::Events::EncryptedDirectMessage.new( - sender_private_key: sender_private_key, - recipient_public_key: '6c31422248998e300a1a457167565da7d15d0da96651296ee2791c29c11b6aa0', - plain_text: 'Your feedback is appreciated, now pay $8', - previous_direct_message: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' # optional -) - -encrypted_direct_message.sign(sender_private_key) - -# # - -# Send it to the Relay -client.publish(encrypted_direct_message) -```` - -## Implemented NIPs +## โœ… Implemented NIPs - [x] [NIP-01 - Client](https://github.com/nostr-protocol/nips/blob/master/01.md) - [x] [NIP-02 - Client](https://github.com/nostr-protocol/nips/blob/master/02.md) - [x] [NIP-04 - Client](https://github.com/nostr-protocol/nips/blob/master/04.md) -## Development +## ๐Ÿ”จ Development After checking out the repo, run `bin/setup` to install dependencies. @@ -323,17 +170,22 @@ used to provide type checking and autocompletion in your editor. Run `bundle exe an RBS definition for the given Ruby file. And validate all definitions using [Steep](https://github.com/soutaro/steep) with the command `bundle exec steep check`. -## Contributing +## ๐Ÿž Issues & Bugs + +If you find any issues or bugs, please report them [here](https://github.com/wilsonsilva/nostr/issues), I will be happy +to have a look at them and fix them as soon as possible. + +## ๐Ÿค Contributing Bug reports and pull requests are welcome on GitHub at https://github.com/wilsonsilva/nostr. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/wilsonsilva/nostr/blob/main/CODE_OF_CONDUCT.md). -## License +## ๐Ÿ“œ License The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT). -## Code of Conduct +## ๐Ÿ‘” Code of Conduct Everyone interacting in the Nostr project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/wilsonsilva/nostr/blob/main/CODE_OF_CONDUCT.md). diff --git a/docs/.vitepress/config.mjs b/docs/.vitepress/config.mjs index e4e9b69..e3e73ae 100644 --- a/docs/.vitepress/config.mjs +++ b/docs/.vitepress/config.mjs @@ -1,31 +1,106 @@ import { defineConfig } from 'vitepress' +import { withMermaid } from "vitepress-plugin-mermaid"; // https://vitepress.dev/reference/site-config -export default defineConfig({ +// https://www.npmjs.com/package/vitepress-plugin-mermaid +export default defineConfig(withMermaid({ title: "Nostr", - description: "Ruby gem documentation", + description: "Documentation of the Nostr Ruby gem", + // https://vitepress.dev/reference/site-config#head + head: [ + ['link', { rel: 'icon', href: '/favicon.ico' }] + ], themeConfig: { + // https://vitepress.dev/reference/default-theme-last-updated + lastUpdated: true, + // https://vitepress.dev/reference/default-theme-config nav: [ { text: 'Home', link: '/' }, - { text: 'Examples', link: '/markdown-examples' } + { text: 'Guide', link: '/getting-started/overview' } ], + // https://vitepress.dev/reference/default-theme-search + search: { + provider: 'local' + }, + + // https://vitepress.dev/reference/default-theme-sidebar sidebar: [ { - text: 'Examples', + text: 'Getting started', + link: '/getting-started', + collapsed: false, items: [ - { text: 'Markdown Examples', link: '/markdown-examples' }, - { text: 'Runtime API Examples', link: '/api-examples' } + { text: 'Overview', link: '/getting-started/overview' }, + { text: 'Installation', link: '/getting-started/installation' }, ] - } + }, + { + text: 'Core', + collapsed: false, + items: [ + { text: 'Client', link: '/core/client' }, + { text: 'Keys', link: '/core/keys' }, + { text: 'User', link: '/core/user' }, + ] + }, + { + text: 'Relays', + items: [ + { text: 'Connecting to a relay', link: '/relays/connecting-to-a-relay' }, + { text: 'Publishing events', link: '/relays/publishing-events' }, + { text: 'Receiving events', link: '/relays/receiving-events' }, + ] + }, + { + text: 'Subscriptions', + collapsed: false, + items: [ + { text: 'Creating a subscription', link: '/subscriptions/creating-a-subscription' }, + { text: 'Filtering subscription events', link: '/subscriptions/filtering-subscription-events' }, + { text: 'Updating a subscription', link: '/subscriptions/updating-a-subscription' }, + { text: 'Deleting a subscription', link: '/subscriptions/deleting-a-subscription' }, + ] + }, + { + text: 'Events', + link: '/events', + collapsed: false, + items: [ + { text: 'Set Metadata', link: '/events/set-metadata' }, + { text: 'Text Note', link: '/events/text-note' }, + { text: 'Recommend Server', link: '/events/recommend-server' }, + { text: 'Contact List', link: '/events/contact-list' }, + { text: 'Encrypted Direct Message', link: '/events/encrypted-direct-message' }, + ] + }, + { + text: 'Implemented NIPs', + link: '/implemented-nips', + }, ], + // https://vitepress.dev/reference/default-theme-config#sociallinks socialLinks: [ - { icon: 'github', link: 'https://github.com/vuejs/vitepress' } + { icon: 'github', link: 'https://github.com/wilsonsilva/nostr' } ], + + // https://vitepress.dev/reference/default-theme-edit-link + editLink: { + pattern: 'https://github.com/wilsonsilva/nostr/edit/main/docs/:path', + text: 'Edit this page on GitHub' + }, + + // https://vitepress.dev/reference/default-theme-footer + footer: { + message: 'Released under the MIT License.', + copyright: 'Copyright ยฉ 2023-present Wilson Silva' + } }, + + // https://vitepress.dev/reference/site-config#ignoredeadlinks ignoreDeadLinks: [ /^https?:\/\/localhost/ ], -}) +})) diff --git a/docs/core/client.md b/docs/core/client.md new file mode 100644 index 0000000..dacfb91 --- /dev/null +++ b/docs/core/client.md @@ -0,0 +1,108 @@ +# Client + +Clients establish a WebSocket connection to [relays](../relays/connecting-to-a-relay). Through this connection, clients +communicate and subscribe to a range of [Nostr events](../events) based on specified subscription filters. These filters +define the Nostr events a client wishes to receive updates about. + +::: info +Clients do not need to sign up or create an account to use Nostr. Upon connecting to a relay, a client provides +its subscription filters. The relay then streams events that match these filters to the client for the duration of the +connection. +::: + +## WebSocket events + +Communication between clients and relays happen via WebSockets. The client will emit WebSocket events when the +connection is __opened__, __closed__, when a __message__ is received or when there's an __error__. + +::: info +WebSocket events are not [Nostr events](https://nostr.com/the-protocol/events). They are events emitted by the +WebSocket connection. The WebSocket `:message` event, however, contains a Nostr event in its payload. +::: + +### connect + +The `:connect` event is fired when a connection with a WebSocket is opened. You must call `Nostr::Client#connect` first. + +```ruby +client = Nostr::Client.new +relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') + +client.on :connect do + # When this block executes, you're connected to the relay +end + +# Connect to a relay asynchronously +client.connect(relay) +``` + +Once the connection is open, you can send events to the relay, manage subscriptions, etc. + +::: tip +Define the connection event handler before calling +[`Nostr::Client#connect`](https://www.rubydoc.info/gems/nostr/Nostr/Client#connect-instance_method). Otherwise, +you may miss the event. +::: + +### error + +The `:error` event is fired when a connection with a WebSocket has been closed because of an error. + +```ruby +client.on :error do |error_message| + puts error_message +end + +# > Network error: wss://rsslay.fiatjaf.com: Unable to verify the +# server certificate for 'rsslay.fiatjaf.com' +``` + +### message + +The `:message` event is fired when data is received through a WebSocket. + +```ruby +client.on :message do |message| + puts message +end +``` + +The message will be one of these 4 types, which must also be JSON arrays, according to the following patterns: +- `["EVENT", , ]` +- `["OK", , , ]` +- `["EOSE", ]` +- `["NOTICE", ]` + +::: details Click me to see how a WebSocket message looks like +```ruby +[ + "EVENT", + "d34107357089bfc9882146d3bfab0386", + { + "content": "", + "created_at": 1676456512, + "id": "18f63550da74454c5df7caa2a349edc5b2a6175ea4c5367fa4b4212781e5b310", + "kind": 3, + "pubkey": "117a121fa41dc2caa0b3d6c5b9f42f90d114f1301d39f9ee96b646ebfee75e36", + "sig": "d171420bd62cf981e8f86f2dd8f8f86737ea2bbe2d98da88db092991d125535860d982139a3c4be39886188613a9912ef380be017686a0a8b74231dc6e0b03cb", + "tags":[ + ["p", "1cc821cc2d47191b15fcfc0f73afed39a86ac6fb34fbfa7993ee3e0f0186ef7c"] + ] + } +] +``` +::: + +### close + +The `:close` event is fired when a connection with a WebSocket is closed. + +```ruby +client.on :close do |code, reason| + puts "Error: #{code} - #{reason}" +end +``` + +::: tip +This handler is useful to attempt to reconnect to the relay. +::: diff --git a/docs/core/keys.md b/docs/core/keys.md new file mode 100644 index 0000000..cd53929 --- /dev/null +++ b/docs/core/keys.md @@ -0,0 +1,83 @@ +# Keys + +To [sign events](#signing-an-event), you need a **private key**. To verify signatures, you need a **public key**. The combination of a +private and a public key is called a **keypair**. + +There are a few ways to generate a keypair. + +## a) Generating a keypair + +If you don't have any keys, you can generate a keypair using the [`Nostr::Keygen`](https://www.rubydoc.info/gems/nostr/Nostr/Keygen) class: + +```ruby +keygen = Nostr::Keygen.new +keypair = keygen.generate_key_pair + +keypair.private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' +keypair.public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' +``` + +## b) Generating a private key and a public key + +Alternatively, if you have already generated a private key, you can extract the corresponding public key by calling +`Keygen#extract_public_key`: + +```ruby +keygen = Nostr::Keygen.new + +private_key = keygen.generate_private_key +public_key = keygen.extract_public_key(private_key) + +private_key # => '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900' +public_key # => '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558' +``` + +## c) Using existing keys + +If you already have a private key and a public key, you can create a keypair using the `Nostr::KeyPair` class: + +```ruby +keypair = Nostr::KeyPair.new( + private_key: '893c4cc8088924796b41dc788f7e2f746734497010b1a9f005c1faad7074b900', + public_key: '2d7661527d573cc8e84f665fa971dd969ba51e2526df00c149ff8e40a58f9558', +) +``` + +## Signing an event + +KeyPairs are used to sign [events](../events). To create a signed event, you need to instantiate a +[`Nostr::User`](https://www.rubydoc.info/gems/nostr/Nostr/User) with a keypair: + +```ruby{9,12-15} +# a) Use an existing keypair +keypair = Nostr::KeyPair.new(private_key: 'your-key', public_key: 'your-key') + +# b) Or generate a new keypair +keygen = Nostr::Keygen.new +keypair = keygen.generate_key_pair + +# Add the keypair to a user +user = Nostr::User.new(keypair: keypair) + +# Create signed events +text_note = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' +) +``` + +::: details Click me to view the text_note + +```ruby +# text_note.to_h +{ + id: '5feb10973dbcf5f210cfc1f0aa338fee62bed6a29696a67957713599b9baf0eb', + pubkey: 'b9b9821074d1b60b8fb4a3983632af3ef9669f55b20d515bf982cda5c439ad61', # from keypair + created_at: 1699847447, + kind: 1, # Nostr::EventKind::TEXT_NOTE, + tags: [], + content: 'Your feedback is appreciated, now pay $8', + sig: 'e30f2f08331f224e41a4099d16aefc780bf9f2d1191b71777e1e1789e6b51fdf7bb956f25d4ea9a152d1c66717a9d68c081ce6c89c298c3c5e794914013381ab' +} +``` +::: diff --git a/docs/core/user.md b/docs/core/user.md new file mode 100644 index 0000000..41eb7ba --- /dev/null +++ b/docs/core/user.md @@ -0,0 +1,43 @@ +# User + +The class [`Nostr::User`](https://www.rubydoc.info/gems/nostr/Nostr/User) is an abstraction to facilitate the creation +of signed events. It is not required to use it to create events, but it is recommended. + +Here's an example of how to create a signed event without the class `Nostr::User`: + +```ruby +event = Nostr::Event.new( + pubkey: keypair.public_key, + kind: Nostr::EventKind::TEXT_NOTE, + tags: [], + content: 'Your feedback is appreciated, now pay $8', +) +event.sign(keypair.private_key) +``` + +::: details Click me to view the event + +```ruby +# event.to_h +{ + id: '5feb10973dbcf5f210cfc1f0aa338fee62bed6a29696a67957713599b9baf0eb', + pubkey: 'b9b9821074d1b60b8fb4a3983632af3ef9669f55b20d515bf982cda5c439ad61', + created_at: 1699847447, + kind: 1, # Nostr::EventKind::TEXT_NOTE, + tags: [], + content: 'Your feedback is appreciated, now pay $8', + sig: 'e30f2f08331f224e41a4099d16aefc780bf9f2d1191b71777e1e1789e6b51fdf7bb956f25d4ea9a152d1c66717a9d68c081ce6c89c298c3c5e794914013381ab' +} +``` +::: + +And here's how to create it with the class `Nostr::User`: + +```ruby +user = Nostr::User.new(keypair: keypair) + +event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' +) +``` diff --git a/docs/events.md b/docs/events.md new file mode 100644 index 0000000..524d59e --- /dev/null +++ b/docs/events.md @@ -0,0 +1,11 @@ +# Events + +## Event Kinds + +| kind | description | NIP | +| ------------- |----------------------------------------------------------------| -------------------------------------------------------------- | +| `0` | [Metadata](./events/set-metadata) | [1](https://github.com/nostr-protocol/nips/blob/master/01.md) | +| `1` | [Short Text Note](./events/text-note) | [1](https://github.com/nostr-protocol/nips/blob/master/01.md) | +| `2` | [Recommend Relay](./events/recommend-server) | | +| `3` | [Contacts](./events/contact-list) | [2](https://github.com/nostr-protocol/nips/blob/master/02.md) | +| `4` | [Encrypted Direct Messages](./events/encrypted-direct-message) | [4](https://github.com/nostr-protocol/nips/blob/master/04.md) | diff --git a/docs/events/contact-list.md b/docs/events/contact-list.md new file mode 100644 index 0000000..edfb3f8 --- /dev/null +++ b/docs/events/contact-list.md @@ -0,0 +1,29 @@ +# Contact List + +## Creating/updating your contact list + +Every new contact list that gets published overwrites the past ones, so it should contain all entries. + +```ruby +# Creating a contact list event with 2 contacts +update_contacts_event = user.create_event( + kind: Nostr::EventKind::CONTACT_LIST, + tags: [ + [ + "p", # mandatory + "32e1827635450ebb3c5a7d12c1f8e7b2b514439ac10a67eef3d9fd9c5c68e245", # public key of the user to add to the contacts + "wss://alicerelay.com/", # can be an empty string or can be omitted + "alice" # can be an empty string or can be omitted + ], + [ + "p", # mandatory + "3efdaebb1d8923ebd99c9e7ace3b4194ab45512e2be79c1b7d68d9243e0d2681", # public key of the user to add to the contacts + "wss://bobrelay.com/nostr", # can be an empty string or can be omitted + "bob" # can be an empty string or can be omitted + ], + ], +) + +# Send it to the Relay +client.publish(update_contacts_event) +``` diff --git a/docs/events/encrypted-direct-message.md b/docs/events/encrypted-direct-message.md new file mode 100644 index 0000000..f1dd37b --- /dev/null +++ b/docs/events/encrypted-direct-message.md @@ -0,0 +1,28 @@ +# Encrypted Direct Message + +## Sending an encrypted direct message + +```ruby +sender_private_key = '3185a47e3802f956ca5a2b4ea606c1d51c7610f239617e8f0f218d55bdf2b757' + +encrypted_direct_message = Nostr::Events::EncryptedDirectMessage.new( + sender_private_key: sender_private_key, + recipient_public_key: '6c31422248998e300a1a457167565da7d15d0da96651296ee2791c29c11b6aa0', + plain_text: 'Your feedback is appreciated, now pay $8', + previous_direct_message: 'ccf9fdf3e1466d7c20969c71ec98defcf5f54aee088513e1b73ccb7bd770d460' # optional +) + +encrypted_direct_message.sign(sender_private_key) + +# # + +# Send it to the Relay +client.publish(encrypted_direct_message) +``` diff --git a/docs/events/recommend-server.md b/docs/events/recommend-server.md new file mode 100644 index 0000000..019ce1e --- /dev/null +++ b/docs/events/recommend-server.md @@ -0,0 +1,32 @@ +# Recommend Server + +The `Recommend Server` event, has a set of tags with the following structure `['e', , , ]` + +Where: + +- `` is the id of the event being referenced. +- `` is the URL of a recommended relay associated with the reference. Clients SHOULD add a valid `` +field, but may instead leave it as `''`. +- `` is optional and if present is one of `'reply'`, `'root'`, or `'mention'`. +Those marked with `'reply'` denote the id of the reply event being responded to. Those marked with `'root'` denote the +root id of the reply thread being responded to. For top level replies (those replying directly to the root event), +only the `'root'` marker should be used. Those marked with `'mention'` denote a quoted or reposted event id. + +A direct reply to the root of a thread should have a single marked `'e'` tag of type `'root'`. + +## Recommending a server + +```ruby +recommend_server_event = user.create_event( + kind: Nostr::EventKind::RECOMMEND_SERVER, + tags: [ + [ + 'e', + '461544014d87c9eaf3e76e021240007dff2c7afb356319f99c741b45749bf82f', + 'wss://relay.damus.io' + ], + ] +) + +client.publish(recommend_server_event) +``` diff --git a/docs/events/set-metadata.md b/docs/events/set-metadata.md new file mode 100644 index 0000000..95882fb --- /dev/null +++ b/docs/events/set-metadata.md @@ -0,0 +1,20 @@ +# Set Metadata + +In the `Metadata` event, the `content` is set to a stringified JSON object +`{name: , about: , picture: }` describing the [user](../core/user) who created the event. A relay may +delete older events once it gets a new one for the same pubkey. + +## Setting the user's metadata + +```ruby +metadata_event = user.create_event( + kind: Nostr::EventKind::SET_METADATA, + content: { + name: 'Wilson Silva', + about: 'Used to make hydrochloric acid bombs in high school.', + picture: 'https://thispersondoesnotexist.com/' + } +) + +client.publish(metadata_event) +``` diff --git a/docs/events/text-note.md b/docs/events/text-note.md new file mode 100644 index 0000000..d6640a4 --- /dev/null +++ b/docs/events/text-note.md @@ -0,0 +1,15 @@ +# Text Note + +In the `Text Note` event, the `content` is set to the plaintext content of a note (anything the user wants to say). +Content that must be parsed, such as Markdown and HTML, should not be used. + +## Sending a text note event + +```ruby +text_note_event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' +) + +client.publish(text_note_event) +``` diff --git a/docs/getting-started/installation.md b/docs/getting-started/installation.md new file mode 100644 index 0000000..3549598 --- /dev/null +++ b/docs/getting-started/installation.md @@ -0,0 +1,21 @@ +# Installation + +Install the gem and add to the application's Gemfile by executing: + +```shell +bundle add nostr +``` + +If bundler is not being used to manage dependencies, install the gem by executing: + +```shell +gem install nostr +``` + +## Requiring the gem + +All examples in this guide assume that the gem has been required: + +```ruby +require 'nostr' +``` diff --git a/docs/getting-started/overview.md b/docs/getting-started/overview.md new file mode 100644 index 0000000..444f742 --- /dev/null +++ b/docs/getting-started/overview.md @@ -0,0 +1,164 @@ +--- +editLink: true +--- + +# Getting started + +This gem abstracts the complexity that you would face when trying to connect to relays web sockets, send and receive +events, handle events callbacks and much more. + +## Visual overview + +Begin your journey with an overview of the essential functions. A visual representation below maps out the key +components we'll delve into in this section. + +```mermaid +classDiagram + class Client { + connect(relay) + publish(event) + subscribe(subscription_id, filter) + unsubscribe(subscription_id) + } + class Relay { + url + name + } + class Event { + pubkey + created_at + kind + tags + content + add_event_reference(event_id) + add_pubkey_reference(pubkey) + serialize() + to_h() + sign(private_key) + } + class Subscription { + id + filter + } + class Filter { + ids + authors + kinds + since + until + limit + to_h() + } + class EventKind { + <> + SET_METADATA + TEXT_NOTE + RECOMMEND_SERVER + CONTACT_LIST + ENCRYPTED_DIRECT_MESSAGE + } + class KeyPair { + private_key + public_key + } + class Keygen { + generate_key_pair() + generate_private_key() + extract_public_key(private_key) + } + class User { + keypair + create_event(event_attributes) + } + + Client --> Relay : connects via
WebSockets to + Client --> Event : uses WebSockets to
publish and receive + Client --> Subscription : receives Events via + Subscription --> Filter : uses + Event --> EventKind : is of kind + User --> KeyPair : has + User --> Event : creates and signs + User --> Keygen : uses to generate keys + Keygen --> KeyPair : generates +``` + +## Code overview + +Explore the provided code snippet to learn about initializing the Nostr [client](../core/client.md), generating +a [keypair](../core/keys), [publishing](../relays/publishing-events) an event, and +efficiently [managing event subscriptions](../subscriptions/creating-a-subscription) (including event reception, +filtering, and WebSocket event handling). + +```ruby +# Require the gem +require 'nostr' + +# Instantiate a client +client = Nostr::Client.new + +# a) Use an existing keypair +keypair = Nostr::KeyPair.new( + private_key: 'your-private-key', + public_key: 'your-public-key', +) + +# b) Or create a new keypair +keygen = Nostr::Keygen.new +keypair = keygen.generate_keypair + +# Create a user with the keypair +user = Nostr::User.new(keypair: keypair) + +# Create a signed event +text_note_event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' +) + +# Connect asynchronously to a relay +relay = Nostr::Relay.new(url: 'wss://nostr.wine', name: 'Wine') +client.connect(relay) + +# Listen asynchronously for the connect event +client.on :connect do + # Send the event to the Relay + client.publish(text_note_event) + + # Create a filter to receive the first 20 text notes + # and encrypted direct messages from the relay that + # were created in the previous hour + filter = Nostr::Filter.new( + kinds: [ + Nostr::EventKind::TEXT_NOTE, + Nostr::EventKind::ENCRYPTED_DIRECT_MESSAGE + ], + since: Time.now.to_i - 3600, # 1 hour ago + until: Time.now.to_i, + limit: 20, + ) + + # Subscribe to events matching conditions of a filter + subscription = client.subscribe(filter) + + # Unsubscribe from events matching the filter above + client.unsubscribe(subscription.id) +end + +# Listen for incoming messages and print them +client.on :message do |message| + puts message +end + +# Listen for error messages +client.on :error do |error_message| + # Handle the error +end + +# Listen for the close event +client.on :close do |code, reason| + # You may attempt to reconnect to the relay here +end +``` + +Beyond what's covered here, the Nostr protocol and this gem boast a wealth of additional functionalities. For an +in-depth exploration of these capabilities, proceed to the next page. diff --git a/docs/implemented-nips.md b/docs/implemented-nips.md new file mode 100644 index 0000000..b586dee --- /dev/null +++ b/docs/implemented-nips.md @@ -0,0 +1,8 @@ +# Implemented NIPs + +NIPs stand for Nostr Implementation Possibilities. They exist to document what may be implemented by Nostr-compatible +relay and client software. + +- [NIP-01: Basic protocol flow description](https://github.com/nostr-protocol/nips/blob/master/01.md) +- [NIP-02: Contact List and Petnames](https://github.com/nostr-protocol/nips/blob/master/02.md) +- [NIP-04: Encrypted Direct Message](https://github.com/nostr-protocol/nips/blob/master/04.md) diff --git a/docs/index.md b/docs/index.md index 1554164..0d34637 100644 --- a/docs/index.md +++ b/docs/index.md @@ -4,22 +4,41 @@ layout: home hero: name: "Nostr" - text: "Ruby gem documentation" - tagline: My great project tagline + text: "Ruby" + tagline: "The Nostr protocol implemented in a Ruby gem." actions: - theme: brand - text: Markdown Examples - link: /markdown-examples + text: Getting Started + link: /getting-started/overview - theme: alt - text: API Examples - link: /api-examples + text: View on Github + link: https://github.com/wilsonsilva/nostr + - theme: alt + text: View on RubyDoc + link: https://www.rubydoc.info/gems/nostr + - theme: alt + text: View on RubyGems + link: https://rubygems.org/gems/nostr features: - - title: Feature A - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit - - title: Feature B - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit - - title: Feature C - details: Lorem ipsum dolor sit amet, consectetur adipiscing elit + - title: Asynchronous + details: Non-blocking I/O for maximum performance. + icon: โšก + - title: Lightweight + details: Minimal runtime dependencies. + icon: ๐Ÿชถ + - title: Intuitive + details: The API mirrors the Nostr protocol specification domain language. + icon: ๐Ÿ’ก + - title: Fully documented + details: All code is documented from both the maintainer's as well as the consumer's perspective. + icon: ๐Ÿ“š + - title: Fully tested + details: All code is tested with 100% coverage. + icon: ๐Ÿงช + - title: Fully typed + details: All code is typed with RBS with the help of TypeProf. Type correctness is enforced by Steep. + icon: โœ… + --- diff --git a/docs/relays/connecting-to-a-relay.md b/docs/relays/connecting-to-a-relay.md new file mode 100644 index 0000000..f11ea6a --- /dev/null +++ b/docs/relays/connecting-to-a-relay.md @@ -0,0 +1,21 @@ +# Connecting to a Relay + +You must connect your nostr [Client](../core/client) to a relay in order to send and receive [Events](../events). +Instantiate a [`Nostr::Client`](https://www.rubydoc.info/gems/nostr/Nostr/Client) and a +[`Nostr::Relay`](https://www.rubydoc.info/gems/nostr/Nostr/Relay) giving it the `url` of your relay. The `name` +attribute is just descriptive. +Calling [`Client#connect`](https://www.rubydoc.info/gems/nostr/Nostr/Client#connect-instance_method) attempts to +establish a WebSocket connection between the Client and the Relay. + +```ruby +client = Nostr::Client.new +relay = Nostr::Relay.new(url: 'wss://relay.damus.io', name: 'Damus') + +# Listen for the connect event +client.on :connect do + # When this block executes, you're connected to the relay +end + +# Connect to a relay asynchronously +client.connect(relay) +``` diff --git a/docs/relays/publishing-events.md b/docs/relays/publishing-events.md new file mode 100644 index 0000000..c2a369f --- /dev/null +++ b/docs/relays/publishing-events.md @@ -0,0 +1,29 @@ +# Publishing events + +Create a [signed event](../core/keys) and call the method +[`Nostr::Client#publish`](https://www.rubydoc.info/gems/nostr/Nostr/Client#publish-instance_method) to send the +event to the relay. + +```ruby{4-8,17} +# Create a user with the keypair +user = Nostr::User.new(keypair: keypair) + +# Create a signed event +text_note_event = user.create_event( + kind: Nostr::EventKind::TEXT_NOTE, + content: 'Your feedback is appreciated, now pay $8' +) + +# Connect asynchronously to a relay +relay = Nostr::Relay.new(url: 'wss://nostr.wine', name: 'Wine') +client.connect(relay) + +# Listen asynchronously for the connect event +client.on :connect do + # Send the event to the relay + client.publish(text_note_event) +end +``` + +The relay will verify the signature of the event with the public key. If the signature is valid, the relay should +broadcast the event to all subscribers. diff --git a/docs/relays/receiving-events.md b/docs/relays/receiving-events.md new file mode 100644 index 0000000..1b76a5c --- /dev/null +++ b/docs/relays/receiving-events.md @@ -0,0 +1,6 @@ +# Receiving events + +To receive events from Relays, you must create a subscription on the relay. A subscription is a filter that defines the +events you want to receive. + +For more information, read the [Subscription](../subscriptions/creating-a-subscription.md) section. diff --git a/docs/subscriptions/creating-a-subscription.md b/docs/subscriptions/creating-a-subscription.md new file mode 100644 index 0000000..7e6f67e --- /dev/null +++ b/docs/subscriptions/creating-a-subscription.md @@ -0,0 +1,49 @@ +# Creating a subscription + +A client can request events and subscribe to new updates __after__ it has established a connection with the Relay. + +You may use a [`Nostr::Filter`](https://www.rubydoc.info/gems/nostr/Nostr/Filter) instance with as many attributes as +you wish: + +```ruby +client.on :connect do + filter = Nostr::Filter.new( + ids: ['8535d5e2d7b9dc07567f676fbe70428133c9884857e1915f5b1cc6514c2fdff8'], + authors: ['ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577'], + kinds: [Nostr::EventKind::TEXT_NOTE], + e: ["f111593a72cc52a7f0978de5ecf29b4653d0cf539f1fa50d2168fc1dc8280e52"], + p: ["f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8"], + since: 1230981305, + until: 1292190341, + limit: 420, + ) + + subscription = client.subscribe(subscription_id: 'an-id', filter: filter) +end +``` + +With just a few: + +```ruby +client.on :connect do + filter = Nostr::Filter.new(kinds: [Nostr::EventKind::TEXT_NOTE]) + subscription = client.subscribe(subscription_id: 'an-id', filter: filter) +end +``` + +Or omit the filter: + +```ruby +client.on :connect do + subscription = client.subscribe(subscription_id: 'an-id') +end +``` + +Or even omit the subscription id: + +```ruby +client.on :connect do + subscription = client.subscribe(filter: filter) + subscription.id # => "13736f08dee8d7b697222ba605c6fab2" (randomly generated) +end +``` diff --git a/docs/subscriptions/deleting-a-subscription.md b/docs/subscriptions/deleting-a-subscription.md new file mode 100644 index 0000000..0b90388 --- /dev/null +++ b/docs/subscriptions/deleting-a-subscription.md @@ -0,0 +1,10 @@ +# Stop previous subscriptions + +You can stop receiving messages from a subscription by calling +[`Nostr::Client#unsubscribe`](https://www.rubydoc.info/gems/nostr/Nostr/Client#unsubscribe-instance_method) with the +ID of the subscription you want to stop receiving messages from: + +```ruby +client.unsubscribe('your-subscription-id') +client.unsubscribe(subscription.id) +``` diff --git a/docs/subscriptions/filtering-subscription-events.md b/docs/subscriptions/filtering-subscription-events.md new file mode 100644 index 0000000..0d9bf52 --- /dev/null +++ b/docs/subscriptions/filtering-subscription-events.md @@ -0,0 +1,131 @@ +# Filtering events + +## Filtering by id + +You can filter events by their ids or prefixes: + +```ruby +filter = Nostr::Filter.new( + ids: [ + # matches events with these exact IDs + '8535d5e2d7b9dc07567f676fbe70428133c9884857e1915f5b1cc6514c2fdff8', + '461544014d87c9eaf3e76e021240007dff2c7afb356319f99c741b45749bf82f', + # and match events whose id start with '76fbe' + '76fbe', + # and match events whose id start with 'b' + 'b', + ] +) +subscription = client.subscribe(filter: filter) +``` + +## Filtering by author + +You can filter events by their author's pubkey or prefix: + +```ruby +filter = Nostr::Filter.new( + authors: [ + # matches events whose (authors) pubkey match these exact IDs + 'b698043170d580f8ae5bad4ac80b1fdb508e957f0bbffe97f2a8915fa8b34070', + '51f853ff4894b062950e46ebed8c1c7015160f8173994414a96dd286f65f0f49', + # and match events whose (authors) pubkey start with 'db508e957' + 'db508e957', + # and match events whose (authors) pubkey start with 'f' + 'f', + ] +) +subscription = client.subscribe(filter: filter) +``` + +## Filtering by kind + +You can filter events by their kind: + +```ruby +filter = Nostr::Filter.new( + kinds: [ + # matches events whose kind is TEXT_NOTE + Nostr::EventKind::TEXT_NOTE, + # and matches events whose kind is CONTACT_LIST + Nostr::EventKind::CONTACT_LIST, + ] +) +subscription = client.subscribe(filter: filter) +``` + +## Filtering by referenced event + +You can filter events by the events they reference (in their `e` tag): + +```ruby +filter = Nostr::Filter.new( + e: [ + # matches events that reference other events whose ids match these exact IDs + 'f111593a72cc52a7f0978de5ecf29b4653d0cf539f1fa50d2168fc1dc8280e52', + 'f1f9b0996d4ff1bf75e79e4cc8577c89eb633e68415c7faf74cf17a07bf80bd8', + # and match events that reference other events whose id starts with '76fbe' + '76fbe', + # and match events that reference other events whose id starts with 'b' + 'b', + ] +) +subscription = client.subscribe(filter: filter) +``` + +## Filtering by referenced pubkey + +You can filter events by the pubkeys they reference (in their `p` tag): + +```ruby +filter = Nostr::Filter.new( + p: [ + # matches events that reference other pubkeys that match these exact IDs + 'b698043170d580f8ae5bad4ac80b1fdb508e957f0bbffe97f2a8915fa8b34070', + '51f853ff4894b062950e46ebed8c1c7015160f8173994414a96dd286f65f0f49', + # and match events that reference other pubkeys that start with 'db508e957' + 'db508e957', + # and match events that reference other pubkeys that start with 'f' + 'f', + ] +) +subscription = client.subscribe(filter: filter) +``` + +## Filtering by timestamp + +You can filter events by their timestamp: + +```ruby +filter = Nostr::Filter.new( + since: 1230981305, # matches events that are newer than this timestamp + until: 1292190341, # matches events that are older than this timestamp +) +subscription = client.subscribe(filter: filter) +``` + +## Limiting the number of events + +You can limit the number of events received: + +```ruby +filter = Nostr::Filter.new( + limit: 420, # matches at most 420 events +) +subscription = client.subscribe(filter: filter) +``` + +## Combining filters + +You can combine filters. For example, to match `5` text note events that are newer than `1230981305` from the author +`ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577`: + +```ruby +filter = Nostr::Filter.new( + authors: ['ae00f88a885ce76afad5cbb2459ef0dcf0df0907adc6e4dac16e1bfbd7074577'], + kinds: [Nostr::EventKind::TEXT_NOTE], + since: 1230981305, + limit: 5, +) +subscription = client.subscribe(filter: filter) +``` diff --git a/docs/subscriptions/updating-a-subscription.md b/docs/subscriptions/updating-a-subscription.md new file mode 100644 index 0000000..e557771 --- /dev/null +++ b/docs/subscriptions/updating-a-subscription.md @@ -0,0 +1,4 @@ +# Updating a subscription + +Updating a subscription is done by creating a new subscription with the same id as the previous one. See +[creating a subscription](./creating-a-subscription.md) for more information.