diff --git a/core/archipelago/src/api/rpc/identity/handlers.rs b/core/archipelago/src/api/rpc/identity/handlers.rs index 0bb96e84..65bb45ef 100644 --- a/core/archipelago/src/api/rpc/identity/handlers.rs +++ b/core/archipelago/src/api/rpc/identity/handlers.rs @@ -727,7 +727,10 @@ impl RpcHandler { Ok(serde_json::json!({ "ok": true })) } - /// Publish kind 0 (metadata) profile to the local Nostr relay. + /// Publish kind 0 (metadata) profile to every enabled Nostr relay + /// configured in Manage Relays. Callers can override the default + /// list by passing `relays: [..]` in params (e.g. to publish to a + /// single relay for testing). pub(in crate::api::rpc) async fn handle_identity_publish_profile( &self, params: Option, @@ -739,18 +742,38 @@ impl RpcHandler { .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?; validate_identity_id(id)?; - let relay_url = params - .get("relay") - .and_then(|v| v.as_str()) - .unwrap_or("ws://localhost:18081"); + let relay_urls: Vec = if let Some(arr) = params.get("relays").and_then(|v| v.as_array()) { + arr.iter() + .filter_map(|v| v.as_str()) + .map(|s| s.to_string()) + .collect() + } else if let Some(single) = params.get("relay").and_then(|v| v.as_str()) { + vec![single.to_string()] + } else { + // Default: every enabled relay in the user's Manage Relays list. + let statuses = crate::nostr_relays::list_relays(&self.config.data_dir) + .await + .unwrap_or_default(); + statuses + .into_iter() + .filter(|s| s.enabled) + .map(|s| s.url) + .collect() + }; + + if relay_urls.is_empty() { + anyhow::bail!("No enabled relays configured; add one under Manage Relays"); + } let manager = IdentityManager::new(&self.config.data_dir).await?; - let event_id = manager.publish_profile(id, relay_url).await?; + let outcome = manager.publish_profile(id, &relay_urls).await?; Ok(serde_json::json!({ - "event_id": event_id, - "relay": relay_url, - "published": true, + "event_id": outcome.event_id, + "accepted": outcome.accepted, + "rejected": outcome.rejected, + "relays_attempted": relay_urls.len(), + "published": !outcome.accepted.is_empty(), })) } diff --git a/core/archipelago/src/identity_manager.rs b/core/archipelago/src/identity_manager.rs index 766f7623..71f83201 100644 --- a/core/archipelago/src/identity_manager.rs +++ b/core/archipelago/src/identity_manager.rs @@ -99,6 +99,26 @@ pub struct IdentityManager { identities_dir: PathBuf, } +/// Result of a multi-relay profile broadcast. +#[derive(Debug, Clone, serde::Serialize)] +pub struct ProfilePublishOutcome { + pub event_id: String, + pub accepted: Vec, + pub rejected: Vec<(String, String)>, +} + +/// Relay URL equality that tolerates minor normalization differences +/// (trailing slash, case). nostr-sdk canonicalises URLs internally and +/// we compare on the surface strings, so be liberal about what matches. +fn relay_url_matches(a: &str, b: &str) -> bool { + let norm = |s: &str| { + s.trim_end_matches('/') + .trim() + .to_ascii_lowercase() + }; + norm(a) == norm(b) +} + impl IdentityManager { pub async fn new(data_dir: &Path) -> Result { let identities_dir = data_dir.join(IDENTITIES_DIR); @@ -512,12 +532,26 @@ impl IdentityManager { Ok(()) } - /// Publish kind 0 (metadata) event to a Nostr relay. - pub async fn publish_profile(&self, id: &str, relay_url: &str) -> Result { + /// Publish kind 0 (metadata) event to one or more Nostr relays. + /// + /// Connects all relays in parallel, broadcasts the signed event to + /// every one of them, and reports back the event id plus per-relay + /// acceptance status. At least one successful relay is required — + /// if every relay rejects the event, this returns an error so the + /// UI can surface "publish failed" instead of silently succeeding. + pub async fn publish_profile( + &self, + id: &str, + relay_urls: &[String], + ) -> Result { let record = self.get(id).await?; let keys = self.load_nostr_keys(id).await?; let profile = record.profile.unwrap_or_default(); + if relay_urls.is_empty() { + anyhow::bail!("No relays configured — add a relay under Manage Relays first"); + } + // Build kind 0 content JSON (NIP-01 + NIP-24) let mut content = serde_json::Map::new(); content.insert("name".to_string(), serde_json::json!(record.name)); @@ -547,26 +581,78 @@ impl IdentityManager { serde_json::to_string(&content).context("Failed to serialize profile content")?; let client = nostr_sdk::Client::new(keys); - client - .add_relay(relay_url) - .await - .context("Failed to add relay")?; - if tokio::time::timeout(Duration::from_secs(10), client.connect()) + for url in relay_urls { + if let Err(e) = client.add_relay(url).await { + tracing::warn!(relay = %url, error = %e, "Failed to add relay; continuing"); + } + } + // 15s gives each relay a reasonable chance to hand-shake before we + // fire the publish. nostr-sdk's send_event_builder to "all relays" + // will only reach relays that have connected by then — some slow + // relays can miss the first publish but subsequent publishes hit + // them once the connection has settled. + if tokio::time::timeout(Duration::from_secs(15), client.connect()) .await .is_err() { - tracing::warn!("Nostr relay connection timed out after 10s, continuing anyway"); + tracing::warn!("Nostr relay connection timed out after 15s, continuing anyway"); } let builder = nostr_sdk::prelude::EventBuilder::new(nostr_sdk::prelude::Kind::Metadata, &content_str); - let output = client - .send_event_builder(builder) - .await - .context("Failed to publish profile")?; + let output = match client.send_event_builder(builder).await { + Ok(o) => o, + Err(e) => { + client.disconnect().await; + return Err(anyhow::anyhow!("Publish failed on every relay: {}", e)); + } + }; + + let event_id = output.id().to_hex(); + // `Output` has `success: HashSet` + `failed: HashMap`. + // Normalise to string comparisons (RelayUrl trims trailing slashes etc.). + let success_strs: std::collections::HashSet = output + .success + .iter() + .map(|u| u.to_string()) + .collect(); + let failed_strs: std::collections::HashMap = output + .failed + .iter() + .map(|(u, msg)| (u.to_string(), msg.clone())) + .collect(); + let mut accepted: Vec = Vec::new(); + let mut rejected: Vec<(String, String)> = Vec::new(); + for url in relay_urls { + let match_url = success_strs + .iter() + .any(|s| relay_url_matches(s, url)); + if match_url { + accepted.push(url.clone()); + } else if let Some((_, reason)) = failed_strs + .iter() + .find(|(s, _)| relay_url_matches(s, url)) + { + rejected.push((url.clone(), reason.clone())); + } else { + rejected.push((url.clone(), "(no ack from relay)".to_string())); + } + } client.disconnect().await; - Ok(output.id().to_hex()) + if accepted.is_empty() { + anyhow::bail!( + "Profile published on 0 relays — {} attempted. Failures: {:?}", + relay_urls.len(), + rejected + ); + } + + Ok(ProfilePublishOutcome { + event_id, + accepted, + rejected, + }) } /// Export all keys for an identity (SENSITIVE — only call after password verification).