nostr_rust

My fork of nostr_rust
git clone git://jb55.com/nostr_rust
Log | Files | Refs | README

commit 39099b8a3873adc1556a7238d3c2fa1efce7bd9c
parent 6d4ee6ed2ccee2e81a771967191ec198ed8c9e7d
Author: Thomas <31560900+0xtlt@users.noreply.github.com>
Date:   Sat,  5 Nov 2022 18:30:50 +0100

First commit!

Diffstat:
A.github/workflows/ci.yml | 114+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
A.github/workflows/publish.yml | 31+++++++++++++++++++++++++++++++
A.gitignore | 6++++++
ACHANGELOG.md | 33+++++++++++++++++++++++++++++++++
ACargo.toml | 27+++++++++++++++++++++++++++
MREADME.md | 136+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Asrc/events.rs | 149+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/keys.rs | 63+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/lib.rs | 49+++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/nips/mod.rs | 1+
Asrc/nips/nip1.rs | 115+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/nostr_client.rs | 221+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/req.rs | 106+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/utils.rs | 30++++++++++++++++++++++++++++++
Asrc/websocket.rs | 39+++++++++++++++++++++++++++++++++++++++
15 files changed, 1118 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml @@ -0,0 +1,114 @@ +name: CI + +on: + pull_request: + workflow_call: + push: + branches: + - main + +env: + RUST_BACKTRACE: 1 + SECRET_KEY: "${{ secrets.SECRET_KEY }}" + PUBLIC_KEY: "${{ secrets.PUBLIC_KEY }}" + +jobs: + ci-pass: + name: CI is green + runs-on: ubuntu-latest + needs: + - style + - test + - docs + steps: + - run: exit 0 + + style: + name: Check Style + + runs-on: ubuntu-latest + + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + components: rustfmt + profile: minimal + override: true + + - name: cargo fmt -- --check + uses: actions-rs/cargo@v1 + with: + command: fmt + args: -- --check + + - name: temporary workaround - fmt all files under src + run: cargo fmt -- --check $(find . -name '*.rs' -print) + + test: + name: ${{ matrix.name }} + needs: [style] + + runs-on: ${{ matrix.os || 'ubuntu-latest' }} + + strategy: + matrix: + name: + - linux / stable + # - macOS / stable + + include: + - name: linux / stable + # - name: macOS / stable + # os: macOS-latest + + steps: + - name: Checkout + uses: actions/checkout@v1 + + - name: Install rust + uses: actions-rs/toolchain@v1 + with: + toolchain: ${{ matrix.rust || 'stable' }} + target: ${{ matrix.target }} + profile: minimal + override: true + + - name: Build + uses: actions-rs/cargo@v1 + with: + command: build + args: ${{ matrix.features }} + + - name: Test + uses: actions-rs/cargo@v1 + with: + command: test + args: ${{ matrix.features }} ${{ matrix.test-features }} -- --test-threads=1 + + docs: + name: Docs + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v2 + + - name: Install Rust + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + profile: minimal + override: true + + - name: Check documentation + env: + RUSTDOCFLAGS: -D warnings + uses: actions-rs/cargo@v1 + with: + command: doc + args: --no-deps --document-private-items --all-features diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml @@ -0,0 +1,31 @@ +name: Release + +on: + push: + tags: + - "*" + +jobs: + call-ci: + uses: ./.github/workflows/ci.yml + secrets: inherit + + publish: + name: Publish + runs-on: ubuntu-latest + needs: + - call-ci + steps: + - name: Checkout sources + uses: actions/checkout@v2 + + - name: Install stable toolchain + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + override: true + + - run: cargo publish --token ${CRATES_TOKEN} + env: + CRATES_TOKEN: ${{ secrets.CRATES_TOKEN }} diff --git a/.gitignore b/.gitignore @@ -0,0 +1,5 @@ +/target +/Cargo.lock +.DS_Store +Makefile +main.rs+ \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md @@ -0,0 +1,33 @@ +# 0.1.0 - NIP-01 Support + +- Added: `Client` structure +- Added: `Client::new` function +- Added: `Client.add_relay` function +- Added: `Client.listen` function +- Added: `Client.subscribe` function +- Added: `Client.subscribe_with_id` function +- Added: `Client.unsubscribe` function +- Added: `Identity` structure +- Added: `Identity::from_str` function with hex private key support +- Added: `random_hash` function +- Added: `get_timestamp` function +- Added: `get_random_secret_key` function +- Added: `secret_key_from_str` function +- Added: `get_public_key_from_secret` function +- Added: `get_str_keys_from_secret` function +- Added: `EventPrepare` structure +- Added: `EventPrepare.get_content` function +- Added: `EventPrepare.get_content_id` function +- Added: `EventPrepare.to_event` function +- Added: `Event` structure +- Added: `Event.to_string` function +- Added `Req` structure +- Added: `Req::new` function +- Added: `Req.get_close_event` function +- Added: `Req.to_string()` function +- Added: `ReqFilter` structure +- Added: `ReqFilter.to_json` function +- Added: `SimplifiedWS` structure +- Added: `SimplifiedWS::new` function +- Added: `SimplifiedWS.send_message` function +- Added: `SimplifiedWS.read_message` function diff --git a/Cargo.toml b/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "nostr_rust" +description = "A Rust implementation of the Nostr protocol" +documentation = "https://docs.rs/nostr_rust" +readme = "README.md" +repository = "https://github.com/0xtlt/nostr_rust" +keywords = ["nostr", "rust", "protocol", "encryption", "decryption"] +categories = ["api-bindings"] +license = "MIT" +authors = ["Thomas Tastet"] +version = "0.1.0" +edition = "2021" + +# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html + +[dependencies] +serde_json = { version = "1", default-features = false, features = ["std"] } +serde = { version = "1", default-features = false, features = ["derive"] } +serde_derive = "1" +sha256 = "1" +hex = "0.4" +# bech32 = "0.9" // TODO: use bech32 for encoding and decoding +rand = "0.8" +secp256k1 = { version = "0.24", features = ["bitcoin_hashes", "global-context", "rand-std"] } +tungstenite = { version = "0.17", default-features = false, features = ["rustls-tls-native-roots"] } +url = "2.3"+ \ No newline at end of file diff --git a/README.md b/README.md @@ -1 +1,134 @@ -# nostr_rust- \ No newline at end of file +# nostr_rust + +[![crates.io](https://img.shields.io/crates/v/nostr_rust.svg)](https://crates.io/crates/nostr_rust) +[![Documentation](https://docs.rs/nostr_rust/badge.svg)](https://docs.rs/nostr_rust) +[![MIT/Apache-2 licensed](https://img.shields.io/crates/l/nostr_rust.svg)](./LICENSE.txt) +[![CI](https://github.com/0xtlt/nostr_rust/actions/workflows/ci.yml/badge.svg)](https://github.com/0xtlt/nostr_rust/actions/workflows/ci.yml) +[![Issues](https://img.shields.io/github/issues/0xtlt/nostr_rust)](https://img.shields.io/github/issues/0xtlt/nostr_rust) + +An ergonomic, Nostr API Client for Rust. + +- [Changelog](CHANGELOG.md) + +## Example + +This example uses [Tungstenite](https://crates.io/crates/tungstenite) for event handling, so your `Cargo.toml` could look like this: + +```toml +[dependencies] +nostr_rust = "0.1" +tungstenite = "0.17" +``` + +And then the code: + +```rust,norun +use std::{ + str::FromStr, + sync::{Arc, Mutex}, + thread, +}; +use tungstenite::Message; + +use nostr_rust::{nostr_client::Client, req::ReqFilter, Identity}; + +fn handle_message(relay_url: String, message: Message) -> Result<(), String> { + println!("Received message from {}: {:?}", { relay_url }, message); + + Ok(()) +} + +fn main() { + let my_identity = + Identity::from_str("your private key as hex string") + .unwrap(); + + let nostr_client = Arc::new(Mutex::new( + Client::new(vec!["wss://relay.nostr.info"]).unwrap(), + )); + + // Run a new thread to handle messages + let nostr_clone = nostr_client.clone(); + let handle_thread = thread::spawn(move || { + println!("Listening..."); + nostr_clone.lock().unwrap().listen(handle_message).unwrap(); + }); + + // Change metadata + nostr_client + .lock() + .unwrap() + .set_metadata( + &my_identity, + Some("Rust Nostr Client test account"), + Some("Hello Nostr! #5"), + None, + ) + .unwrap(); + + // Subscribe to my last text note + let subscription_id = nostr_client + .lock() + .unwrap() + .subscribe( + vec![ReqFilter { + ids: None, + authors: Some(vec![ + "884704bd421721e292edbff42eb77547fe115c6ff9825b08fc366be4cd69e9f6".to_string(), + ]), + kinds: None, + e: None, + p: None, + since: None, + until: None, + limit: Some(1), + }], + ) + .unwrap(); + + // Unsubscribe + nostr_client + .lock() + .unwrap() + .unsubscribe(&subscription_id) + .unwrap(); + + // Publish a text note + nostr_client + .lock() + .unwrap() + .publish_text_note(&my_identity, "Hello Nostr! :)", &[]) + .unwrap(); + + // Wait for the thread to finish + handle_thread.join().unwrap(); +} +``` + +## NIPs Supported + +| NIP | Supported | Client Version | Description | +| --- | --------- | -------------- | ------------------------------------------------------------ | +| 01 | ✅ | 0.1.0 | Basic protocol flow description | +| 02 | ❌ | Not supported | Contact List and Petnames | +| 03 | ❌ | Not supported | OpenTimestamps Attestations for Events | +| 04 | ❌ | Not supported | Encrypted Direct Message | +| 05 | ❌ | Not supported | Mapping Nostr keys to DNS-based internet identifiers | +| 06 | ❌ | Not supported | Basic key derivation from mnemonic seed phrase | +| 07 | ❌ | Not supported | window.nostr capability for web browsers | +| 08 | ❌ | Not supported | Handling Mentions | +| 09 | ❌ | Not supported | Event Deletion | +| 10 | ❌ | Not supported | Conventions for clients' use of e and p tags in text events. | +| 11 | ❌ | Not supported | Relay Information Document | +| 12 | ❌ | Not supported | Generic Tag Queries | +| 13 | ❌ | Not supported | Proof of Work | +| 14 | ❌ | Not supported | Subject tag in text events. | +| 15 | ❌ | Not supported | End of Stored Events Notice | +| 16 | ❌ | Not supported | Event Treatment | +| 22 | ❌ | Not supported | Event created_at Limits | +| 25 | ❌ | Not supported | Reactions | +| 28 | ❌ | Not supported | Public Chat | + +## License + +Licensed under MIT license ([LICENSE-MIT](LICENSE-MIT) or <http://opensource.org/licenses/MIT>) diff --git a/src/events.rs b/src/events.rs @@ -0,0 +1,149 @@ +use std::fmt; + +use secp256k1::{KeyPair, SECP256K1}; +use serde_derive::{Deserialize, Serialize}; +use serde_json::json; + +use crate::Identity; + +/// EventPrepare is the struct used to prepare an event before publishing it (signing it and assigning it an id) +#[derive(Serialize, Deserialize, Debug)] +pub struct EventPrepare { + /// 32-bytes hex-encoded public key of the event creator + #[serde(rename = "pubkey")] + pub pub_key: String, + /// unix timestamp in seconds + pub created_at: u64, + /// integer + /// 0: NostrEvent + pub kind: u8, + /// Tags + pub tags: Vec<Vec<String>>, + /// arbitrary string + pub content: String, +} + +impl EventPrepare { + /// get_content returns the content of the event to be signed + /// # Example + /// ```rust + /// use nostr_rust::{events::EventPrepare, utils::get_timestamp}; + /// + /// let actual_time = get_timestamp(); + /// + /// let event = EventPrepare { + /// pub_key: env!("PUBLIC_KEY").to_string(), + /// created_at: get_timestamp(), + /// kind: 0, + /// tags: vec![], + /// content: "content".to_string(), + /// }; + /// + /// assert_eq!(event.get_content(), format!("[0,\"c5aec31e83bdf980939b5ef7c6bcaa2be8bd39d38667da58ba6dba240eb8b69d\",{},0,[],\"content\"]", actual_time)); + /// ``` + pub fn get_content(&self) -> String { + json!([ + 0, + self.pub_key, + self.created_at, + self.kind, + self.tags, + self.content + ]) + .to_string() + } + + /// Get the id of the event which is the sha256 hash of the content + /// # Example + /// ```rust + /// use nostr_rust::{events::EventPrepare}; + /// + /// let event = EventPrepare { + /// pub_key: env!("PUBLIC_KEY").to_string(), + /// created_at: 0, // Don't use this in production + /// kind: 0, + /// tags: vec![], + /// content: "content".to_string(), + /// }; + /// + /// assert_eq!(event.get_content_id(), "4a57aad22fc0fd374e8ceeaaaf8817fa6cb661ca2229c66309d7dba69dfe2359"); + /// ``` + pub fn get_content_id(&self) -> String { + sha256::digest(self.get_content()) + } + + /// Transform the event to NostrEvent + /// # Example + /// ```rust + /// use std::str::FromStr; + /// use nostr_rust::{events::EventPrepare, Identity}; + /// + /// let event = EventPrepare { + /// pub_key: env!("PUBLIC_KEY").to_string(), + /// created_at: 0, // Don't use this in production + /// kind: 0, + /// tags: vec![], + /// content: "content".to_string(), + /// }; + /// + /// let identity = Identity::from_str(env!("SECRET_KEY")).unwrap(); + /// let nostr_event = event.to_event(&identity); + /// assert_eq!(nostr_event.id, "4a57aad22fc0fd374e8ceeaaaf8817fa6cb661ca2229c66309d7dba69dfe2359"); + /// assert_eq!(nostr_event.content, "content"); + /// assert_eq!(nostr_event.kind, 0); + /// assert_eq!(nostr_event.tags.len(), 0); + /// assert_eq!(nostr_event.created_at, 0); + /// assert_eq!(nostr_event.pub_key, env!("PUBLIC_KEY")); + /// assert_eq!(nostr_event.sig.len(), 128); + /// ``` + pub fn to_event(&self, secret_key: &Identity) -> Event { + let message = secp256k1::Message::from_hashed_data::<secp256k1::hashes::sha256::Hash>( + self.get_content().as_bytes(), + ); + + let signature = SECP256K1 + .sign_schnorr( + &message, + &KeyPair::from_secret_key(SECP256K1, &secret_key.secret_key), + ) + .to_string(); + + Event { + id: self.get_content_id(), + pub_key: self.pub_key.clone(), + created_at: self.created_at, + kind: self.kind, + tags: self.tags.clone(), + content: self.content.clone(), + sig: signature, + } + } +} + +/// Event is the struct used to represent a Nostr event +#[derive(Serialize, Deserialize, Debug)] +pub struct Event { + /// 32-bytes sha256 of the the serialized event data + pub id: String, + /// 32-bytes hex-encoded public key of the event creator + #[serde(rename = "pubkey")] + pub pub_key: String, + /// unix timestamp in seconds + pub created_at: u64, + /// integer + /// 0: NostrEvent + pub kind: u8, + /// Tags + pub tags: Vec<Vec<String>>, + /// arbitrary string + pub content: String, + /// 64-bytes signature of the sha256 hash of the serialized event data, which is the same as the "id" field + pub sig: String, +} + +impl fmt::Display for Event { + /// Return the serialized event + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + write!(f, "{}", serde_json::to_string(&self).unwrap()) + } +} diff --git a/src/keys.rs b/src/keys.rs @@ -0,0 +1,63 @@ +use rand::rngs::OsRng; +use secp256k1::{PublicKey, SecretKey, SECP256K1}; + +// TODO: implement bech32 keys + +/// Get a random secret key +/// # Example +/// ``` +/// use nostr_rust::keys::get_random_secret_key; +/// let (secret_key, public_key) = get_random_secret_key(); +/// ``` +pub fn get_random_secret_key() -> (SecretKey, PublicKey) { + SECP256K1.generate_keypair(&mut OsRng) +} + +/// Get a secret key from a hex string +/// # Example +/// ```rust +/// use nostr_rust::keys::secret_key_from_str; +/// let secret_key = secret_key_from_str(env!("SECRET_KEY")); +/// assert!(secret_key.is_ok()); +/// ``` +pub fn secret_key_from_str(s: &str) -> Result<SecretKey, String> { + let decoded_hex = &hex::decode(s); + match decoded_hex { + Ok(decoded_hex) => match SecretKey::from_slice(decoded_hex) { + Ok(secret_key) => Ok(secret_key), + Err(_) => Err("Invalid secret key".to_string()), + }, + Err(_) => Err("Invalid hex format".to_string()), + } +} + +/// Get a public key from a secret key +/// # Example +/// ```rust +/// use nostr_rust::keys::{secret_key_from_str, get_public_key_from_secret}; +/// +/// let secret_key = secret_key_from_str(env!("SECRET_KEY")).unwrap(); +/// let public_key = get_public_key_from_secret(&secret_key); +/// ``` +pub fn get_public_key_from_secret(secret_key: &SecretKey) -> PublicKey { + PublicKey::from_secret_key(SECP256K1, secret_key) +} + +/// Generate a hex secret key and a hex public key from a secret key +/// # Example +/// ```rust +/// use nostr_rust::keys::{secret_key_from_str, get_str_keys_from_secret}; +/// +/// let secret_key = secret_key_from_str(env!("SECRET_KEY")).unwrap(); +/// let (secret_key_str, public_key_str) = get_str_keys_from_secret(&secret_key); +/// +/// assert_eq!(secret_key_str, env!("SECRET_KEY")); +/// assert_eq!(public_key_str, env!("PUBLIC_KEY")); +/// ``` +pub fn get_str_keys_from_secret(secret_key: &SecretKey) -> (String, String) { + ( + secret_key.display_secret().to_string(), + // Remove the 2 first characters because they are "0X" and useless + get_public_key_from_secret(secret_key).to_string()[2..].to_string(), + ) +} diff --git a/src/lib.rs b/src/lib.rs @@ -0,0 +1,49 @@ +use secp256k1::{PublicKey, SecretKey}; +use std::str::FromStr; + +pub mod events; +pub mod keys; +pub mod nips; +pub mod nostr_client; +pub mod req; +pub mod utils; +pub mod websocket; + +/// Nostr Identity with secret and public keys +pub struct Identity { + pub secret_key: SecretKey, + pub public_key: PublicKey, + pub public_key_str: String, + pub address: String, +} + +impl FromStr for Identity { + type Err = String; + + /// Create an Identity from a secret key as a hex string + /// # Example + /// ``` + /// use nostr_rust::Identity; + /// use std::str::FromStr; + /// + /// // Working format + /// let identity = Identity::from_str(env!("SECRET_KEY")); + /// assert!(identity.is_ok()); + /// + /// // Invalid format + /// let identity = Identity::from_str("aeaeaeaeae"); + /// assert!(identity.is_err()); + /// ``` + fn from_str(secret_key: &str) -> Result<Self, Self::Err> { + let secret_key = keys::secret_key_from_str(secret_key)?; + let public_key = keys::get_public_key_from_secret(&secret_key); + let address = keys::get_str_keys_from_secret(&secret_key).1; + + Ok(Self { + secret_key, + public_key, + public_key_str: address.to_string(), + address, + }) + } +} diff --git a/src/nips/mod.rs b/src/nips/mod.rs @@ -0,0 +1 @@ +pub mod nip1; diff --git a/src/nips/nip1.rs b/src/nips/nip1.rs @@ -0,0 +1,115 @@ +use crate::{events::EventPrepare, nostr_client::Client, utils::get_timestamp, Identity}; +use serde_json::json; + +// Implementation of the NIP1 protocol +// https://github.com/nostr-protocol/nips/blob/master/01.md + +impl Client { + /// Set the metadata of the identity + /// # Example + /// # Example + /// ```rust + /// use nostr_rust::{nostr_client::Client, Identity}; + /// use std::str::FromStr; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// let identity = Identity::from_str(env!("SECRET_KEY")).unwrap(); + /// + /// // Here we set the metadata of the identity but not the profile picture one + /// client.set_metadata(&identity, Some("Rust Nostr Client"), Some("Automated account for Rust Nostr Client tests :)"), None).unwrap(); + /// ``` + pub fn set_metadata( + &mut self, + identity: &Identity, + name: Option<&str>, + about: Option<&str>, + picture: Option<&str>, + ) -> Result<(), String> { + let mut json_body = json!({}); + + if name.is_none() && about.is_none() && picture.is_none() { + return Err("No metadata provided".to_string()); + } + + if let Some(name) = name { + json_body["name"] = json!(name); + } + + if let Some(about) = about { + json_body["about"] = json!(about); + } + + if let Some(picture) = picture { + json_body["picture"] = json!(picture); + } + + let event = EventPrepare { + pub_key: identity.public_key_str.clone(), + created_at: get_timestamp(), + kind: 0, + tags: vec![], + content: json_body.to_string(), + } + .to_event(identity); + + self.publish_event(&event)?; + Ok(()) + } + + /// Publish a text note (text_note) event + /// # Example + /// ```rust + /// use nostr_rust::{nostr_client::Client, Identity, utils::get_timestamp}; + /// use std::str::FromStr; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// let identity = Identity::from_str(env!("SECRET_KEY")).unwrap(); + /// let message = format!("Hello Nostr! {}", get_timestamp()); + /// client.publish_text_note(&identity, &message, &vec![]).unwrap(); + /// ``` + pub fn publish_text_note( + &mut self, + identity: &Identity, + content: &str, + tags: &[Vec<String>], + ) -> Result<(), String> { + let event = EventPrepare { + pub_key: identity.public_key_str.clone(), + created_at: get_timestamp(), + kind: 1, + tags: tags.to_vec(), + content: content.to_string(), + } + .to_event(identity); + + self.publish_event(&event)?; + Ok(()) + } + + /// Add recommended relay server + /// # Example + /// ```rust + /// use nostr_rust::{nostr_client::Client, Identity}; + /// use std::str::FromStr; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// let identity = Identity::from_str(env!("SECRET_KEY")).unwrap(); + /// + /// // Here we set the recommended relay server to the one hosted by Wellorder + /// client.add_recommended_relay(&identity, "wss://relay.damus.io").unwrap(); + /// ``` + pub fn add_recommended_relay( + &mut self, + identity: &Identity, + relay: &str, + ) -> Result<(), String> { + let event = EventPrepare { + pub_key: identity.public_key_str.clone(), + created_at: get_timestamp(), + kind: 2, + tags: vec![], + content: relay.to_string(), + } + .to_event(identity); + + self.publish_event(&event)?; + Ok(()) + } +} diff --git a/src/nostr_client.rs b/src/nostr_client.rs @@ -0,0 +1,221 @@ +use crate::events::Event; +use crate::req::{Req, ReqFilter}; +use crate::websocket::SimplifiedWS; +use serde_json::json; +use tungstenite::Message; + +/// Relay Type contains the relay address and the websocket connection +pub type Relay = (String, SimplifiedWS); + +/// Nostr Client +pub struct Client { + pub relays: Vec<Relay>, +} + +impl Client { + /// Create a new client with a list of default relays + /// + /// # Example + /// ```rust + /// use nostr_rust::nostr_client::Client; + /// let client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// ``` + pub fn new(default_relays: Vec<&str>) -> Result<Self, String> { + let mut client = Self { relays: vec![] }; + + for relay in default_relays { + client.add_relay(relay)?; + } + + Ok(client) + } +} + +impl Client { + /// Add a relay to the client + /// # Example + /// ```rust + /// use nostr_rust::nostr_client::Client; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// client.add_relay("wss://nostr-pub.wellorder.net").unwrap(); + /// ``` + pub fn add_relay(&mut self, relay: &str) -> Result<(), String> { + let client = match SimplifiedWS::new(relay) { + Ok(client) => client, + Err(err) => return Err(format!("Error connecting to relay: {}", err)), + }; + + self.relays.push((relay.to_string(), client)); + + Ok(()) + } + + /// Publish a Nostr event + pub fn publish_event(&mut self, event: &Event) -> Result<(), String> { + let json_stringified = json!(["EVENT", event]).to_string(); + let message = Message::text(json_stringified); + match self.relays[0].1.send_message(&message) { + Ok(_) => Ok(()), + Err(_) => Err("Unable to send message".to_string()), + } + } + + /// Listen for data from the relays + /// # Example + /// ```rust + /// use std::{ + /// sync::{Arc, Mutex}, + /// thread, + /// }; + /// use tungstenite::Message; + /// use nostr_rust::{nostr_client::Client, req::ReqFilter}; + /// + /// fn handle_message(relay_url: String, message: Message) -> Result<(), String> { + /// println!("Received message: {:?}", message); + /// + /// Ok(()) + /// } + /// + /// let mut client = Arc::new(Mutex::new(Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap())); + /// + /// // Run a new thread to listen + /// let nostr_clone = client.clone(); + /// let nostr_thread = thread::spawn(move || { + /// println!("Listening..."); + /// nostr_clone.lock().unwrap().listen(handle_message).unwrap(); + /// }); + /// + /// // Subscribe to the most beautiful Nostr profile event + /// client + /// .lock() + /// .unwrap() + /// .subscribe(vec![ReqFilter { + /// ids: None, + /// authors: Some(vec![ + /// "884704bd421721e292edbff42eb77547fe115c6ff9825b08fc366be4cd69e9f6".to_string(), + /// ]), + /// kinds: None, + /// e: None, + /// p: None, + /// since: None, + /// until: None, + /// limit: Some(1), + /// }]) + /// .unwrap(); + /// + /// // Wait 2s for the thread to finish + /// std::thread::sleep(std::time::Duration::from_secs(2)); + /// ``` + pub fn listen<F, E>(&mut self, callback: F) -> Result<(), E> + where + F: Fn(String, Message) -> Result<(), E>, + { + for relay in &mut self.relays { + let client = &mut relay.1; + println!("Listening for messages from relay {}", relay.0); + loop { + match client.read_message() { + Ok(message) => callback(relay.0.clone(), message), + Err(err) => { + println!("Error reading message: {}", err); + continue; + } + }?; + } + } + + Ok(()) + } + + /// Subscribe + /// # Example + /// ```rust + /// use nostr_rust::{nostr_client::Client, req::ReqFilter}; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// client + /// .subscribe(vec![ReqFilter { // None means generate a random ID + /// ids: None, + /// authors: Some(vec![ + /// "884704bd421721e292edbff42eb77547fe115c6ff9825b08fc366be4cd69e9f6".to_string(), + /// ]), + /// kinds: None, + /// e: None, + /// p: None, + /// since: None, + /// until: None, + /// limit: Some(1), + /// }]) + /// .unwrap(); + /// ``` + pub fn subscribe(&mut self, filters: Vec<ReqFilter>) -> Result<String, String> { + let req = Req::new(None, filters); + let message = Message::text(req.to_string()); + match self.relays[0].1.send_message(&message) { + Ok(_) => Ok(req.subscription_id), + Err(_) => Err("Unable to send message".to_string()), + } + } + + /// Subscribe with a specific ID + /// + /// # Example + /// ```rust + /// use nostr_rust::{nostr_client::Client, req::ReqFilter}; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// client + /// .subscribe_with_id("my_subscription_id", vec![ReqFilter { + /// ids: None, + /// authors: Some(vec![ + /// "884704bd421721e292edbff42eb77547fe115c6ff9825b08fc366be4cd69e9f6".to_string(), + /// ]), + /// kinds: None, + /// e: None, + /// p: None, + /// since: None, + /// until: None, + /// limit: Some(1), + /// }]) + /// .unwrap(); + /// ``` + pub fn subscribe_with_id( + &mut self, + subscription_id: &str, + filters: Vec<ReqFilter>, + ) -> Result<(), String> { + let req = Req::new(Some(subscription_id), filters); + let message = Message::text(req.to_string()); + match self.relays[0].1.send_message(&message) { + Ok(_) => Ok(()), + Err(_) => Err("Unable to send message".to_string()), + } + } + + /// Unsubscribe + /// # Example + /// ```rust + /// use nostr_rust::{nostr_client::Client, req::ReqFilter}; + /// let mut client = Client::new(vec!["wss://nostr-pub.wellorder.net"]).unwrap(); + /// let subscription_id = client + /// .subscribe(vec![ReqFilter { + /// ids: None, + /// authors: Some(vec![ + /// "884704bd421721e292edbff42eb77547fe115c6ff9825b08fc366be4cd69e9f6".to_string(), + /// ]), + /// kinds: None, + /// e: None, + /// p: None, + /// since: None, + /// until: None, + /// limit: Some(1), + /// }]) + /// .unwrap(); + /// client.unsubscribe(&subscription_id).unwrap(); + /// ``` + pub fn unsubscribe(&mut self, subscription_id: &str) -> Result<(), String> { + let message = Message::text(json!(["CLOSE", subscription_id]).to_string()); + match self.relays[0].1.send_message(&message) { + Ok(_) => Ok(()), + Err(_) => Err("Unable to send message".to_string()), + } + } +} diff --git a/src/req.rs b/src/req.rs @@ -0,0 +1,106 @@ +use crate::utils::random_hash; +use serde::{Deserialize, Serialize}; +use serde_json::json; +use std::fmt; + +/// Req struct is used to request events and subscribe to new updates. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct Req { + /// `<subscription_id>` is a random string that should be used to represent a subscription. + pub subscription_id: String, + /// `<filters>` is a JSON object that determines what events will be sent in that subscription, it can have the following attributes: + pub filters: Vec<ReqFilter>, +} + +/// ReqFilter is a JSON object that determines what events will be sent in that subscription. +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ReqFilter { + /// a list of event ids or prefixes + pub ids: Option<Vec<String>>, + /// a list of pubkeys or prefixes, the pubkey of an event must be one of these + pub authors: Option<Vec<String>>, + /// a list of a kind numbers + pub kinds: Option<Vec<u8>>, + /// a list of event ids that are referenced in an "e" tag + #[serde(rename = "#e")] + pub e: Option<Vec<String>>, + /// a list of pubkeys that are referenced in a "p" tag + #[serde(rename = "#p")] + pub p: Option<Vec<String>>, + /// a timestamp, events must be newer than this to pass + pub since: Option<u64>, + /// a timestamp, events must be older than this to pass + pub until: Option<u64>, + /// maximum number of events to be returned in the initial query + pub limit: Option<u64>, +} + +impl ReqFilter { + /// Return a clean json object (Value) + pub fn to_json(&self) -> serde_json::Value { + let mut json = json!({}); + + if let Some(ids) = &self.ids { + json["ids"] = json!(ids); + } + + if let Some(authors) = &self.authors { + json["authors"] = json!(authors); + } + + if let Some(kinds) = &self.kinds { + json["kinds"] = json!(kinds); + } + + if let Some(e) = &self.e { + json["#e"] = json!(e); + } + + if let Some(p) = &self.p { + json["#p"] = json!(p); + } + + if let Some(since) = &self.since { + json["since"] = json!(since); + } + + if let Some(until) = &self.until { + json["until"] = json!(until); + } + + if let Some(limit) = &self.limit { + json["limit"] = json!(limit); + } + + json + } +} + +impl Req { + pub fn new(subscription_id: Option<&str>, filters: Vec<ReqFilter>) -> Self { + Self { + subscription_id: subscription_id.unwrap_or(&random_hash()).to_string(), + filters, + } + } + + pub fn get_close_event(&self) -> String { + json!({ + "subscription_id": self.subscription_id, + "close": true + }) + .to_string() + } +} + +impl fmt::Display for Req { + /// Return the serialized event + fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { + let mut req = json!(["REQ", self.subscription_id]); + for filter in &self.filters { + req.as_array_mut().unwrap().push(filter.to_json()); + } + + write!(f, "{}", serde_json::to_string(&req).unwrap()) + } +} diff --git a/src/utils.rs b/src/utils.rs @@ -0,0 +1,30 @@ +use rand::Rng; +use std::time::{SystemTime, UNIX_EPOCH}; + +/// Get actual timestamp in seconds +/// # Example +/// ```rust +/// use nostr_rust::utils::get_timestamp; +/// +/// let timestamp = get_timestamp(); +/// assert!(timestamp > 0); +/// ``` +pub fn get_timestamp() -> u64 { + let now = SystemTime::now(); + let since_the_epoch = now.duration_since(UNIX_EPOCH).expect("Time went backwards"); + since_the_epoch.as_secs() +} + +/// Random sha256 hash +/// # Example +/// ```rust +/// use nostr_rust::utils::random_hash; +/// let hash = random_hash(); +/// assert_eq!(hash.len(), 64); +/// ``` +pub fn random_hash() -> String { + let mut rng = rand::thread_rng(); + let mut bytes = [0u8; 32]; + rng.fill(&mut bytes); + sha256::digest(&bytes) +} diff --git a/src/websocket.rs b/src/websocket.rs @@ -0,0 +1,39 @@ +// Simplified websocket implementation +use std::net::TcpStream; +use tungstenite::{connect, stream::MaybeTlsStream, Message, WebSocket}; +use url::Url; + +pub struct SimplifiedWS { + pub url: Url, + pub socket: WebSocket<MaybeTlsStream<TcpStream>>, +} + +impl SimplifiedWS { + pub fn new(url: &str) -> Result<Self, String> { + let url = match Url::parse(url) { + Ok(url) => url, + Err(err) => return Err(format!("Error parsing url: {}", err)), + }; + + let (socket, _) = match connect(&url) { + Ok((socket, response)) => (socket, response), + Err(err) => return Err(format!("Error connecting to websocket: {}", err)), + }; + + Ok(Self { url, socket }) + } + + pub fn send_message(&mut self, message: &Message) -> Result<(), String> { + match self.socket.write_message(message.clone()) { + Ok(_) => Ok(()), + Err(err) => Err(format!("Error sending message: {}", err)), + } + } + + pub fn read_message(&mut self) -> Result<Message, String> { + match self.socket.read_message() { + Ok(message) => Ok(message), + Err(err) => Err(format!("Error reading message: {}", err)), + } + } +}