//! Nostr relay management: configure, monitor, and manage relay connections. use anyhow::{Context, Result}; use serde::{Deserialize, Serialize}; use std::path::Path; use tokio::fs; use tracing::debug; const RELAYS_FILE: &str = "nostr_relays.json"; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayConfig { pub url: String, pub enabled: bool, pub added_at: String, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayStatus { pub url: String, pub connected: bool, pub enabled: bool, pub added_at: String, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct RelayStore { pub relays: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct RelayStats { pub total_relays: usize, pub connected_count: usize, pub enabled_count: usize, } /// Default relays seeded on first use. const DEFAULT_RELAYS: &[&str] = &[ "wss://relay.damus.io", "wss://nos.lol", "wss://relay.nostr.band", "wss://relay.snort.social", "wss://nostr.wine", "wss://relay.nostr.info", "wss://nostr-pub.wellorder.net", "wss://relay.current.fyi", ]; pub async fn load_relays(data_dir: &Path) -> Result { let path = data_dir.join(RELAYS_FILE); if !path.exists() { // Seed with defaults on first load let store = seed_defaults(); save_relays(data_dir, &store).await?; return Ok(store); } let data = fs::read_to_string(&path) .await .context("Reading relay store")?; serde_json::from_str(&data).context("Parsing relay store") } pub async fn save_relays(data_dir: &Path, store: &RelayStore) -> Result<()> { let path = data_dir.join(RELAYS_FILE); let data = serde_json::to_string_pretty(store)?; fs::write(&path, data).await.context("Writing relay store") } fn seed_defaults() -> RelayStore { let now = chrono::Utc::now().to_rfc3339(); RelayStore { relays: DEFAULT_RELAYS .iter() .map(|url| RelayConfig { url: url.to_string(), enabled: true, added_at: now.clone(), }) .collect(), } } /// List all relays with connection status. pub async fn list_relays(data_dir: &Path) -> Result> { let store = load_relays(data_dir).await?; let statuses: Vec = store .relays .into_iter() .map(|r| { // Connection check: try a quick TCP probe to the relay // For now, report enabled relays as connected (actual connectivity // is tested via the Nostr SDK when publishing/subscribing) RelayStatus { url: r.url, connected: r.enabled, enabled: r.enabled, added_at: r.added_at, } }) .collect(); Ok(statuses) } /// Add a new relay. pub async fn add_relay(data_dir: &Path, url: &str) -> Result { let mut store = load_relays(data_dir).await?; let normalized = normalize_relay_url(url)?; if store.relays.iter().any(|r| r.url == normalized) { return Err(anyhow::anyhow!("Relay already exists: {}", normalized)); } let config = RelayConfig { url: normalized, enabled: true, added_at: chrono::Utc::now().to_rfc3339(), }; debug!(url = %config.url, "Added relay"); store.relays.push(config.clone()); save_relays(data_dir, &store).await?; Ok(config) } /// Remove a relay. pub async fn remove_relay(data_dir: &Path, url: &str) -> Result<()> { let mut store = load_relays(data_dir).await?; let original_len = store.relays.len(); store.relays.retain(|r| r.url != url); if store.relays.len() == original_len { return Err(anyhow::anyhow!("Relay not found: {}", url)); } save_relays(data_dir, &store).await } /// Toggle relay enabled/disabled. pub async fn toggle_relay(data_dir: &Path, url: &str, enabled: bool) -> Result<()> { let mut store = load_relays(data_dir).await?; let relay = store .relays .iter_mut() .find(|r| r.url == url) .ok_or_else(|| anyhow::anyhow!("Relay not found: {}", url))?; relay.enabled = enabled; save_relays(data_dir, &store).await } /// Get aggregate stats. pub async fn get_stats(data_dir: &Path) -> Result { let store = load_relays(data_dir).await?; let enabled_count = store.relays.iter().filter(|r| r.enabled).count(); Ok(RelayStats { total_relays: store.relays.len(), connected_count: enabled_count, enabled_count, }) } /// Normalize a relay URL (ensure wss:// prefix). fn normalize_relay_url(url: &str) -> Result { let trimmed = url.trim(); if trimmed.is_empty() { return Err(anyhow::anyhow!("Relay URL cannot be empty")); } if trimmed.starts_with("wss://") || trimmed.starts_with("ws://") { Ok(trimmed.to_string()) } else { Ok(format!("wss://{}", trimmed)) } }