commit 39099b8a3873adc1556a7238d3c2fa1efce7bd9c
parent 6d4ee6ed2ccee2e81a771967191ec198ed8c9e7d
Author: Thomas <31560900+0xtlt@users.noreply.github.com>
Date: Sat, 5 Nov 2022 18:30:50 +0100
First commit!
Diffstat:
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)),
+ }
+ }
+}