commit cf0a4605eca6cc735b03ea92b413d1b1ec9507b1
parent edb617171cbf78ee3eb05238f43e2f346c7552ec
Author: triesap <tyson@radroots.org>
Date: Fri, 3 Oct 2025 20:05:14 +0100
net-core: add `nostr_client` manager and relay configuration handling
Diffstat:
5 files changed, 205 insertions(+), 1 deletion(-)
diff --git a/Cargo.lock b/Cargo.lock
@@ -1701,6 +1701,7 @@ dependencies = [
"directories",
"hex",
"nostr",
+ "nostr-sdk",
"radroots-log",
"secrecy",
"serde",
diff --git a/crates/net-core/Cargo.toml b/crates/net-core/Cargo.toml
@@ -10,7 +10,14 @@ license.workspace = true
default = ["std"]
std = []
rt = ["dep:tokio"]
-nostr-client = ["dep:nostr", "dep:secrecy", "dep:hex", "dep:tempfile", "dep:serde_json"]
+nostr-client = [
+"dep:nostr",
+"dep:nostr-sdk",
+"dep:secrecy",
+"dep:hex",
+"dep:tempfile",
+"dep:serde_json"
+]
directories = ["dep:directories"]
fs-persistence = []
@@ -19,6 +26,7 @@ radroots-log = { workspace = true }
directories = { workspace = true, optional = true }
hex = { workspace = true, optional = true }
nostr = { workspace = true, optional = true }
+nostr-sdk = { workspace = true, optional = true }
secrecy = { workspace = true, optional = true }
serde = { workspace = true, features = ["derive"] }
serde_json = { workspace = true, optional = true }
diff --git a/crates/net-core/src/lib.rs b/crates/net-core/src/lib.rs
@@ -14,4 +14,7 @@ pub mod config;
#[cfg(feature = "nostr-client")]
pub mod keys;
+#[cfg(feature = "nostr-client")]
+mod nostr_client;
+
pub use net::{Net, NetHandle, NetInfo};
diff --git a/crates/net-core/src/net.rs b/crates/net-core/src/net.rs
@@ -4,6 +4,8 @@ use std::sync::{Arc, Mutex, MutexGuard};
use crate::error::{NetError, Result};
#[cfg(feature = "nostr-client")]
use crate::keys::KeysManager;
+#[cfg(feature = "nostr-client")]
+use crate::nostr_client::{NostrClientManager, NostrConnectionSnapshot};
#[derive(Debug, Clone, Serialize)]
pub struct BuildInfo {
@@ -31,6 +33,9 @@ pub struct Net {
#[cfg(feature = "nostr-client")]
pub keys: KeysManager,
+ #[cfg(feature = "nostr-client")]
+ pub nostr: Option<NostrClientManager>,
+
#[cfg(feature = "rt")]
pub rt: Option<tokio::runtime::Runtime>,
}
@@ -51,6 +56,8 @@ impl Net {
config: cfg,
#[cfg(feature = "nostr-client")]
keys: KeysManager::default(),
+ #[cfg(feature = "nostr-client")]
+ nostr: None,
#[cfg(feature = "rt")]
rt: None,
}
@@ -78,6 +85,45 @@ impl Net {
self.rt = Some(rt);
Ok(())
}
+
+ #[cfg(feature = "nostr-client")]
+ pub fn nostr_set_default_relays(&mut self, urls: &[String]) -> Result<()> {
+ if self.nostr.is_none() {
+ let keys = self.keys.require()?;
+ let rt = self
+ .rt
+ .as_ref()
+ .ok_or_else(|| NetError::msg("tokio runtime missing"))?;
+ self.nostr = Some(NostrClientManager::new(keys.clone(), rt.handle().clone()));
+ }
+ if let Some(n) = &self.nostr {
+ n.set_relays(urls);
+ }
+ Ok(())
+ }
+
+ #[cfg(feature = "nostr-client")]
+ pub fn nostr_connect_if_key_present(&mut self) -> Result<()> {
+ if self.keys.state.loaded {
+ let rt = self
+ .rt
+ .as_ref()
+ .ok_or_else(|| NetError::msg("tokio runtime missing"))?;
+ if self.nostr.is_none() {
+ let keys = self.keys.require()?;
+ self.nostr = Some(NostrClientManager::new(keys.clone(), rt.handle().clone()));
+ }
+ if let Some(n) = &self.nostr {
+ n.connect();
+ }
+ }
+ Ok(())
+ }
+
+ #[cfg(feature = "nostr-client")]
+ pub fn nostr_connection_snapshot(&self) -> Option<NostrConnectionSnapshot> {
+ self.nostr.as_ref().map(|n| n.snapshot())
+ }
}
#[derive(Clone)]
diff --git a/crates/net-core/src/nostr_client.rs b/crates/net-core/src/nostr_client.rs
@@ -0,0 +1,146 @@
+#[cfg(feature = "nostr-client")]
+use std::collections::HashMap;
+#[cfg(feature = "nostr-client")]
+use std::sync::{Arc, Mutex};
+
+#[cfg(feature = "nostr-client")]
+use nostr_sdk::prelude::*;
+#[cfg(feature = "nostr-client")]
+use tokio::runtime::Handle;
+#[cfg(feature = "nostr-client")]
+use tracing::{error, info};
+
+#[cfg(feature = "nostr-client")]
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum Light {
+ Red,
+ Yellow,
+ Green,
+}
+
+#[cfg(feature = "nostr-client")]
+#[derive(Debug, Clone)]
+pub struct NostrConnectionSnapshot {
+ pub light: Light,
+ pub connected: usize,
+ pub connecting: usize,
+ pub last_error: Option<String>,
+}
+
+#[cfg(feature = "nostr-client")]
+#[derive(Clone)]
+pub struct NostrClientManager {
+ inner: Arc<Inner>,
+}
+
+#[cfg(feature = "nostr-client")]
+struct Inner {
+ client: Client,
+ relays: Arc<Mutex<Vec<String>>>,
+ statuses: Arc<Mutex<HashMap<RelayUrl, RelayStatus>>>,
+ last_error: Arc<Mutex<Option<String>>>,
+ rt: Handle,
+}
+
+#[cfg(feature = "nostr-client")]
+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) {
+ let inner = self.inner.clone();
+ inner.rt.spawn(async move {
+ let urls = {
+ let g = inner.relays.lock().ok();
+ g.map(|v| v.clone()).unwrap_or_default()
+ };
+ if urls.is_empty() {
+ info!("no relays configured; using default wss://relay.damus.io");
+ }
+ let effective = if urls.is_empty() {
+ vec!["wss://relay.damus.io".to_string()]
+ } else {
+ urls
+ };
+
+ for u in &effective {
+ match inner.client.add_relay(u).await {
+ Ok(_) => {}
+ Err(e) => {
+ *inner.last_error.lock().unwrap() = Some(format!("add_relay {u}: {e}"));
+ error!("add_relay failed for {u}: {e}");
+ }
+ }
+ }
+
+ inner.client.connect().await;
+ });
+ }
+
+ pub fn snapshot(&self) -> NostrConnectionSnapshot {
+ let map = self.inner.statuses.lock().ok().cloned().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,
+ }
+ }
+
+ fn spawn_status_watcher(&self) {
+ let inner = self.inner.clone();
+ inner.rt.spawn(async move {
+ if let Some(m) = inner.client.monitor() {
+ let mut rx = m.subscribe();
+ while let Ok(notification) = rx.recv().await {
+ if let MonitorNotification::StatusChanged { relay_url, status } = notification {
+ {
+ let mut map = inner.statuses.lock().unwrap();
+ map.insert(relay_url.clone(), status);
+ }
+ info!("relay status changed {} -> {:?}", relay_url, status);
+ }
+ }
+ }
+ });
+ }
+}