lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit 0de0fabe00914ad201b6a0b84f82ff0f39fe81e0
parent 734db3c665c65347dd4aec0ff9b9239096fd8c43
Author: triesap <tyson@radroots.org>
Date:   Sun,  5 Oct 2025 22:16:44 +0100

net-core: add typed post models and modular `NostrClientManager`

Diffstat:
Mcrates/events/src/lib.rs | 4++++
Acrates/events/src/post/models.rs | 24++++++++++++++++++++++++
Dcrates/net-core/src/nostr_client.rs | 289------------------------------------------------------------------------------
Acrates/net-core/src/nostr_client/connection.rs | 46++++++++++++++++++++++++++++++++++++++++++++++
Acrates/net-core/src/nostr_client/inner.rs | 28++++++++++++++++++++++++++++
Acrates/net-core/src/nostr_client/manager.rs | 20++++++++++++++++++++
Acrates/net-core/src/nostr_client/mod.rs | 10++++++++++
Acrates/net-core/src/nostr_client/posts.rs | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/net-core/src/nostr_client/profile.rs | 105+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/net-core/src/nostr_client/status.rs | 62++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/net-core/src/nostr_client/types.rs | 14++++++++++++++
11 files changed, 378 insertions(+), 289 deletions(-)

diff --git a/crates/events/src/lib.rs b/crates/events/src/lib.rs @@ -14,6 +14,10 @@ pub mod listing { pub mod models; } +pub mod post { + pub mod models; +} + pub mod profile { pub mod models; } diff --git a/crates/events/src/post/models.rs b/crates/events/src/post/models.rs @@ -0,0 +1,24 @@ +use crate::RadrootsNostrEvent; +use serde::{Deserialize, Serialize}; + +#[typeshare::typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RadrootsPostEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsPostEventMetadata, +} + +#[typeshare::typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RadrootsPostEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub post: RadrootsPost, +} + +#[typeshare::typeshare] +#[derive(Clone, Debug, Serialize, Deserialize)] +pub struct RadrootsPost { + pub content: String, +} diff --git a/crates/net-core/src/nostr_client.rs b/crates/net-core/src/nostr_client.rs @@ -1,289 +0,0 @@ -#![cfg(feature = "nostr-client")] - -use std::collections::HashMap; -use std::sync::{Arc, Mutex}; - -use nostr_sdk::prelude::*; -use radroots_events::profile::models::RadrootsProfile; -use tokio::runtime::Handle; -use tracing::{error, info}; - -use crate::error::{NetError, Result}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Light { - Red, - Yellow, - Green, -} - -#[derive(Debug, Clone)] -pub struct NostrConnectionSnapshot { - pub light: Light, - pub connected: usize, - pub connecting: usize, - pub last_error: Option<String>, -} - -#[derive(Clone)] -pub struct NostrClientManager { - inner: Arc<Inner>, -} - -struct Inner { - client: Client, - relays: Arc<Mutex<Vec<String>>>, - statuses: Arc<Mutex<HashMap<RelayUrl, RelayStatus>>>, - last_error: Arc<Mutex<Option<String>>>, - rt: Handle, -} - -impl NostrClientManager { - pub fn new(keys: nostr::Keys, rt: Handle) -> Self { - let monitor = Monitor::new(2048); - let client = Client::builder().signer(keys).monitor(monitor).build(); - - let inner = Arc::new(Inner { - client, - relays: Arc::new(Mutex::new(Vec::new())), - statuses: Arc::new(Mutex::new(HashMap::new())), - last_error: Arc::new(Mutex::new(None)), - rt, - }); - - let this = Self { - inner: inner.clone(), - }; - this.spawn_status_watcher(); - this - } - - pub fn set_relays(&self, urls: &[String]) { - if let Ok(mut guard) = self.inner.relays.lock() { - *guard = urls.to_vec(); - } - } - - pub fn connect(&self) -> Result<()> { - let inner = self.inner.clone(); - let urls = { - let g = inner.relays.lock().ok(); - g.map(|v| v.clone()).unwrap_or_default() - }; - - if urls.is_empty() { - if let Ok(mut e) = inner.last_error.lock() { - *e = Some("no relays configured".to_string()); - } - return Err(NetError::Msg("no relays configured".into())); - } - - let inner_for_task = inner.clone(); - let rt = inner.rt.clone(); - rt.spawn(async move { - for u in &urls { - match inner_for_task.client.add_relay(u.as_str()).await { - Ok(_) => {} - Err(e) => { - if let Ok(mut last) = inner_for_task.last_error.lock() { - *last = Some(format!("add_relay {}: {}", u, e)); - } - error!("add_relay failed for {}: {}", u, e); - } - } - } - inner_for_task.client.connect().await; - }); - - Ok(()) - } - - pub fn snapshot(&self) -> NostrConnectionSnapshot { - let map = self - .inner - .statuses - .lock() - .ok() - .map(|g| g.clone()) - .unwrap_or_default(); - - let mut connected = 0usize; - let mut connecting = 0usize; - for (_url, st) in map.iter() { - match st { - RelayStatus::Connected => connected += 1, - RelayStatus::Connecting => connecting += 1, - _ => {} - } - } - - let light = if connected > 0 { - Light::Green - } else if connecting > 0 { - Light::Yellow - } else { - Light::Red - }; - - let last_error = self.inner.last_error.lock().ok().and_then(|e| e.clone()); - NostrConnectionSnapshot { - light, - connected, - connecting, - last_error, - } - } - - pub async fn fetch_profile_kind0( - &self, - author: nostr::PublicKey, - ) -> Result<Option<RadrootsProfile>> { - let filter = Filter::new() - .authors(vec![author]) - .kind(Kind::Metadata) - .limit(1); - - let events = self - .inner - .client - .fetch_events(filter, std::time::Duration::from_secs(5)) - .await - .map_err(|e| NetError::Msg(e.to_string()))?; - - if let Some(ev) = events.into_iter().next() { - if let Ok(p) = serde_json::from_str::<RadrootsProfile>(&ev.content) { - return Ok(Some(p)); - } - if let Ok(md) = serde_json::from_str::<nostr::Metadata>(&ev.content) { - let p = RadrootsProfile { - name: md.name.unwrap_or_default(), - display_name: md.display_name, - nip05: md.nip05, - about: md.about, - website: md.website.map(|u| u.to_string()), - picture: md.picture.map(|u| u.to_string()), - banner: md.banner.map(|u| u.to_string()), - lud06: md.lud06, - lud16: md.lud16, - bot: None, - }; - return Ok(Some(p)); - } - return Err(NetError::Msg( - "failed to parse kind:0 metadata content".to_string(), - )); - } - - Ok(None) - } - - pub fn fetch_profile_kind0_blocking( - &self, - author: nostr::PublicKey, - ) -> Result<Option<RadrootsProfile>> { - let rt = self.inner.rt.clone(); - let inner_for_task = self.inner.clone(); - rt.block_on(async move { - let filter = Filter::new() - .authors(vec![author]) - .kind(Kind::Metadata) - .limit(1); - let events = inner_for_task - .client - .fetch_events(filter, std::time::Duration::from_secs(5)) - .await - .map_err(|e| NetError::Msg(e.to_string()))?; - if let Some(ev) = events.into_iter().next() { - if let Ok(p) = serde_json::from_str::<RadrootsProfile>(&ev.content) { - return Ok(Some(p)); - } - if let Ok(md) = serde_json::from_str::<nostr::Metadata>(&ev.content) { - let p = RadrootsProfile { - name: md.name.unwrap_or_default(), - display_name: md.display_name, - nip05: md.nip05, - about: md.about, - website: md.website.map(|u| u.to_string()), - picture: md.picture.map(|u| u.to_string()), - banner: md.banner.map(|u| u.to_string()), - lud06: md.lud06, - lud16: md.lud16, - bot: None, - }; - return Ok(Some(p)); - } - return Err(NetError::Msg( - "failed to parse kind:0 metadata content".to_string(), - )); - } - Ok(None) - }) - } - - pub fn set_profile_kind0_blocking( - &self, - name: Option<String>, - display_name: Option<String>, - nip05: Option<String>, - about: Option<String>, - ) -> Result<String> { - let rt = self.inner.rt.clone(); - let inner_for_task = self.inner.clone(); - rt.block_on(async move { - let mut md = nostr::Metadata::new(); - if let Some(v) = name { - md = md.name(v); - } - if let Some(v) = display_name { - md = md.display_name(v); - } - if let Some(v) = nip05 { - md = md.nip05(v); - } - if let Some(v) = about { - md = md.about(v); - } - inner_for_task - .client - .set_metadata(&md) - .await - .map_err(|e| NetError::Msg(e.to_string()))?; - Ok("ok".to_string()) - }) - } - - pub async fn publish_text_note(&self, content: String) -> Result<String> { - let out = self - .inner - .client - .send_event_builder(EventBuilder::text_note(content)) - .await - .map_err(|e| NetError::Msg(e.to_string()))?; - Ok(out.val.to_string()) - } - - pub fn publish_text_note_blocking(&self, content: String) -> Result<String> { - let rt = self.inner.rt.clone(); - let this = self.clone(); - rt.block_on(async move { this.publish_text_note(content).await }) - } - - fn spawn_status_watcher(&self) { - let inner = self.inner.clone(); - let rt = inner.rt.clone(); - let inner_for_task = inner.clone(); - rt.spawn(async move { - if let Some(m) = inner_for_task.client.monitor() { - let mut rx = m.subscribe(); - while let Ok(notification) = rx.recv().await { - let MonitorNotification::StatusChanged { relay_url, status } = notification; - { - let mut map = inner_for_task.statuses.lock().unwrap(); - map.insert(relay_url.clone(), status); - } - info!("relay status changed {} -> {:?}", relay_url, status); - } - } - }); - } -} diff --git a/crates/net-core/src/nostr_client/connection.rs b/crates/net-core/src/nostr_client/connection.rs @@ -0,0 +1,46 @@ +use crate::error::{NetError, Result}; +use tracing::error; + +use super::manager::NostrClientManager; + +impl NostrClientManager { + pub fn set_relays(&self, urls: &[String]) { + if let Ok(mut guard) = self.inner.relays.lock() { + *guard = urls.to_vec(); + } + } + + pub fn connect(&self) -> Result<()> { + let inner = self.inner.clone(); + let urls = { + let g = inner.relays.lock().ok(); + g.map(|v| v.clone()).unwrap_or_default() + }; + + if urls.is_empty() { + if let Ok(mut e) = inner.last_error.lock() { + *e = Some("no relays configured".to_string()); + } + return Err(NetError::Msg("no relays configured".into())); + } + + let inner_for_task = inner.clone(); + let rt = inner.rt.clone(); + rt.spawn(async move { + for u in &urls { + match inner_for_task.client.add_relay(u.as_str()).await { + Ok(_) => {} + Err(e) => { + if let Ok(mut last) = inner_for_task.last_error.lock() { + *last = Some(format!("add_relay {}: {}", u, e)); + } + error!("add_relay failed for {}: {}", u, e); + } + } + } + inner_for_task.client.connect().await; + }); + + Ok(()) + } +} diff --git a/crates/net-core/src/nostr_client/inner.rs b/crates/net-core/src/nostr_client/inner.rs @@ -0,0 +1,28 @@ +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; + +use nostr_sdk::prelude::*; +use tokio::runtime::Handle; + +pub(super) struct Inner { + pub client: Client, + pub relays: Arc<Mutex<Vec<String>>>, + pub statuses: Arc<Mutex<HashMap<RelayUrl, RelayStatus>>>, + pub last_error: Arc<Mutex<Option<String>>>, + pub rt: Handle, +} + +impl Inner { + pub fn new(keys: nostr::Keys, rt: Handle) -> Arc<Self> { + let monitor = Monitor::new(2048); + let client = Client::builder().signer(keys).monitor(monitor).build(); + + Arc::new(Self { + client, + relays: Arc::new(Mutex::new(Vec::new())), + statuses: Arc::new(Mutex::new(HashMap::new())), + last_error: Arc::new(Mutex::new(None)), + rt, + }) + } +} diff --git a/crates/net-core/src/nostr_client/manager.rs b/crates/net-core/src/nostr_client/manager.rs @@ -0,0 +1,20 @@ +use std::sync::Arc; +use tokio::runtime::Handle; + +use super::inner::Inner; + +#[derive(Clone)] +pub struct NostrClientManager { + pub(super) inner: Arc<Inner>, +} + +impl NostrClientManager { + pub fn new(keys: nostr::Keys, rt: Handle) -> Self { + let inner = Inner::new(keys, rt); + let this = Self { + inner: inner.clone(), + }; + this.spawn_status_watcher(); + this + } +} diff --git a/crates/net-core/src/nostr_client/mod.rs b/crates/net-core/src/nostr_client/mod.rs @@ -0,0 +1,10 @@ +mod connection; +mod inner; +mod manager; +mod posts; +mod profile; +mod status; +pub mod types; + +pub use manager::NostrClientManager; +pub use types::{Light, NostrConnectionSnapshot}; diff --git a/crates/net-core/src/nostr_client/posts.rs b/crates/net-core/src/nostr_client/posts.rs @@ -0,0 +1,65 @@ +use std::time::Duration; + +use crate::error::{NetError, Result}; +use radroots_events::post::models::{RadrootsPost, RadrootsPostEventMetadata}; + +use super::manager::NostrClientManager; + +impl NostrClientManager { + pub async fn publish_text_note(&self, content: String) -> Result<String> { + let out = self + .inner + .client + .send_event_builder(nostr_sdk::prelude::EventBuilder::text_note(content)) + .await + .map_err(|e| NetError::Msg(e.to_string()))?; + Ok(out.val.to_string()) + } + + pub fn publish_text_note_blocking(&self, content: String) -> Result<String> { + let rt = self.inner.rt.clone(); + let this = self.clone(); + rt.block_on(async move { this.publish_text_note(content).await }) + } + + pub async fn fetch_text_notes( + &self, + limit: u16, + since_unix: Option<u64>, + ) -> Result<Vec<RadrootsPostEventMetadata>> { + let mut filter = nostr_sdk::prelude::Filter::new() + .kind(nostr_sdk::prelude::Kind::TextNote) + .limit(limit.into()); + if let Some(s) = since_unix { + filter = filter.since(nostr_sdk::prelude::Timestamp::from(s)); + } + let events = self + .inner + .client + .fetch_events(filter, Duration::from_secs(10)) + .await + .map_err(|e| NetError::Msg(e.to_string()))?; + let out = events + .into_iter() + .map(|ev| RadrootsPostEventMetadata { + id: ev.id.to_string(), + author: ev.pubkey.to_string(), + published_at: ev.created_at.as_u64() as u32, + post: RadrootsPost { + content: ev.content, + }, + }) + .collect(); + Ok(out) + } + + pub fn fetch_text_notes_blocking( + &self, + limit: u16, + since_unix: Option<u64>, + ) -> Result<Vec<RadrootsPostEventMetadata>> { + let rt = self.inner.rt.clone(); + let this = self.clone(); + rt.block_on(async move { this.fetch_text_notes(limit, since_unix).await }) + } +} diff --git a/crates/net-core/src/nostr_client/profile.rs b/crates/net-core/src/nostr_client/profile.rs @@ -0,0 +1,105 @@ +use std::time::Duration; + +use crate::error::{NetError, Result}; +use radroots_events::profile::models::{RadrootsProfile, RadrootsProfileEventMetadata}; + +use super::manager::NostrClientManager; + +impl NostrClientManager { + pub async fn fetch_profile_kind0( + &self, + author: nostr::PublicKey, + ) -> Result<Option<RadrootsProfileEventMetadata>> { + let filter = nostr_sdk::prelude::Filter::new() + .authors(vec![author]) + .kind(nostr_sdk::prelude::Kind::Metadata) + .limit(1); + + let events = self + .inner + .client + .fetch_events(filter, Duration::from_secs(5)) + .await + .map_err(|e| NetError::Msg(e.to_string()))?; + + if let Some(ev) = events.into_iter().next() { + if let Ok(p) = serde_json::from_str::<RadrootsProfile>(&ev.content) { + let out = RadrootsProfileEventMetadata { + id: ev.id.to_string(), + author: ev.pubkey.to_string(), + published_at: ev.created_at.as_u64() as u32, + profile: p, + }; + return Ok(Some(out)); + } + if let Ok(md) = serde_json::from_str::<nostr::Metadata>(&ev.content) { + let p = RadrootsProfile { + name: md.name.unwrap_or_default(), + display_name: md.display_name, + nip05: md.nip05, + about: md.about, + website: md.website.map(|u| u.to_string()), + picture: md.picture.map(|u| u.to_string()), + banner: md.banner.map(|u| u.to_string()), + lud06: md.lud06, + lud16: md.lud16, + bot: None, + }; + let out = RadrootsProfileEventMetadata { + id: ev.id.to_string(), + author: ev.pubkey.to_string(), + published_at: ev.created_at.as_u64() as u32, + profile: p, + }; + return Ok(Some(out)); + } + return Err(NetError::Msg( + "failed to parse kind:0 metadata content".to_string(), + )); + } + + Ok(None) + } + + pub fn fetch_profile_kind0_blocking( + &self, + author: nostr::PublicKey, + ) -> Result<Option<RadrootsProfileEventMetadata>> { + let rt = self.inner.rt.clone(); + let this = self.clone(); + rt.block_on(async move { this.fetch_profile_kind0(author).await }) + } + + pub fn set_profile_kind0_blocking( + &self, + name: Option<String>, + display_name: Option<String>, + nip05: Option<String>, + about: Option<String>, + ) -> Result<String> { + let rt = self.inner.rt.clone(); + let inner_for_task = self.inner.clone(); + rt.block_on(async move { + let mut md = nostr::Metadata::new(); + if let Some(v) = name { + md = md.name(v); + } + if let Some(v) = display_name { + md = md.display_name(v); + } + if let Some(v) = nip05 { + md = md.nip05(v); + } + if let Some(v) = about { + md = md.about(v); + } + inner_for_task + .client + .set_metadata(&md) + .await + .map_err(|e| NetError::Msg(e.to_string()))?; + Ok::<(), NetError>(()) + })?; + Ok("ok".to_string()) + } +} diff --git a/crates/net-core/src/nostr_client/status.rs b/crates/net-core/src/nostr_client/status.rs @@ -0,0 +1,62 @@ +use nostr_sdk::prelude::MonitorNotification; +use tracing::info; + +use super::manager::NostrClientManager; +use super::types::{Light, NostrConnectionSnapshot}; + +impl NostrClientManager { + pub(super) fn spawn_status_watcher(&self) { + let inner = self.inner.clone(); + let rt = inner.rt.clone(); + let inner_for_task = inner.clone(); + rt.spawn(async move { + if let Some(m) = inner_for_task.client.monitor() { + let mut rx = m.subscribe(); + while let Ok(notification) = rx.recv().await { + let MonitorNotification::StatusChanged { relay_url, status } = notification; + { + let mut map = inner_for_task.statuses.lock().unwrap(); + map.insert(relay_url.clone(), status); + } + info!("relay status changed {} -> {:?}", relay_url, status); + } + } + }); + } + + pub fn snapshot(&self) -> NostrConnectionSnapshot { + let map = self + .inner + .statuses + .lock() + .ok() + .map(|g| g.clone()) + .unwrap_or_default(); + + let mut connected = 0usize; + let mut connecting = 0usize; + for (_url, st) in map.iter() { + match st { + nostr_sdk::prelude::RelayStatus::Connected => connected += 1, + nostr_sdk::prelude::RelayStatus::Connecting => connecting += 1, + _ => {} + } + } + + let light = if connected > 0 { + Light::Green + } else if connecting > 0 { + Light::Yellow + } else { + Light::Red + }; + + let last_error = self.inner.last_error.lock().ok().and_then(|e| e.clone()); + NostrConnectionSnapshot { + light, + connected, + connecting, + last_error, + } + } +} diff --git a/crates/net-core/src/nostr_client/types.rs b/crates/net-core/src/nostr_client/types.rs @@ -0,0 +1,14 @@ +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Light { + Red, + Yellow, + Green, +} + +#[derive(Debug, Clone)] +pub struct NostrConnectionSnapshot { + pub light: Light, + pub connected: usize, + pub connecting: usize, + pub last_error: Option<String>, +}