cli

Command-line interface for Radroots
git clone https://radroots.dev/git/cli.git
Log | Files | Refs | README | LICENSE

commit edd28484dc6740fbfa84ceafdcb2315ddf29d50c
parent c0b700cbf5d3ec4f14bd1330cd9c1c33797620ac
Author: triesap <tyson@radroots.org>
Date:   Mon, 27 Apr 2026 23:54:36 +0000

cli: add direct relay fetch helper

- add a signerless direct relay fetch receipt and error boundary
- configure read relays through the existing rr-rs Nostr client
- keep missing relay, relay config, connection, and fetch failures distinct
- cover no-relay, bad relay, and connection failure cases in runtime tests

Diffstat:
Msrc/runtime/direct_relay.rs | 147+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
1 file changed, 145 insertions(+), 2 deletions(-)

diff --git a/src/runtime/direct_relay.rs b/src/runtime/direct_relay.rs @@ -3,10 +3,12 @@ use std::time::Duration; use radroots_events_codec::wire::WireEventParts; use radroots_identity::RadrootsIdentity; use radroots_nostr::prelude::{ - RadrootsNostrClient, RadrootsNostrError, RadrootsNostrOutput, radroots_nostr_build_event, + RadrootsNostrClient, RadrootsNostrError, RadrootsNostrEvent, RadrootsNostrFilter, + RadrootsNostrOutput, radroots_nostr_build_event, }; const RELAY_CONNECT_TIMEOUT: Duration = Duration::from_secs(10); +const RELAY_FETCH_TIMEOUT: Duration = Duration::from_secs(10); #[derive(Debug, Clone, PartialEq, Eq)] pub struct DirectRelayFailure { @@ -24,6 +26,14 @@ pub struct DirectRelayPublishReceipt { pub failed_relays: Vec<DirectRelayFailure>, } +#[derive(Debug, Clone)] +pub struct DirectRelayFetchReceipt { + pub target_relays: Vec<String>, + pub connected_relays: Vec<String>, + pub failed_relays: Vec<DirectRelayFailure>, + pub events: Vec<RadrootsNostrEvent>, +} + #[derive(Debug, thiserror::Error)] pub enum DirectRelayPublishError { #[error("direct relay publish requires at least one configured relay")] @@ -46,6 +56,24 @@ pub enum DirectRelayPublishError { Publish { event_id: String, reason: String }, } +#[derive(Debug, thiserror::Error)] +pub enum DirectRelayFetchError { + #[error("direct relay fetch requires at least one configured relay")] + MissingRelays, + #[error("failed to build async runtime for direct relay fetch: {0}")] + Runtime(String), + #[error("failed to configure relay `{relay}` for direct relay fetch: {source}")] + RelayConfig { + relay: String, + #[source] + source: RadrootsNostrError, + }, + #[error("direct relay connection failed: {0}")] + Connect(String), + #[error("direct relay fetch failed: {0}")] + Fetch(#[source] RadrootsNostrError), +} + pub fn publish_parts_with_identity( identity: &RadrootsIdentity, relay_urls: &[String], @@ -65,6 +93,77 @@ pub fn publish_parts_with_identity( )) } +pub fn fetch_events_from_relays( + relay_urls: &[String], + filter: RadrootsNostrFilter, +) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError> { + fetch_events_from_relays_with_timeout(relay_urls, filter, RELAY_FETCH_TIMEOUT) +} + +pub fn fetch_events_from_relays_with_timeout( + relay_urls: &[String], + filter: RadrootsNostrFilter, + fetch_timeout: Duration, +) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError> { + if relay_urls.is_empty() { + return Err(DirectRelayFetchError::MissingRelays); + } + + let runtime = tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|error| DirectRelayFetchError::Runtime(error.to_string()))?; + + runtime.block_on(fetch_events_from_relays_async( + relay_urls, + filter, + fetch_timeout, + RELAY_CONNECT_TIMEOUT, + )) +} + +async fn fetch_events_from_relays_async( + relay_urls: &[String], + filter: RadrootsNostrFilter, + fetch_timeout: Duration, + connect_timeout: Duration, +) -> Result<DirectRelayFetchReceipt, DirectRelayFetchError> { + let client = RadrootsNostrClient::new_signerless(); + + for relay_url in relay_urls { + client.add_read_relay(relay_url).await.map_err(|source| { + DirectRelayFetchError::RelayConfig { + relay: relay_url.clone(), + source, + } + })?; + } + + let connection_output = client.try_connect(connect_timeout).await; + let failed_relays = relay_failures_from_output(&connection_output); + if connection_output.success.is_empty() { + return Err(DirectRelayFetchError::Connect(summarize_failures( + &failed_relays, + ))); + } + + let events = client + .fetch_events(filter, fetch_timeout) + .await + .map_err(DirectRelayFetchError::Fetch)?; + + Ok(DirectRelayFetchReceipt { + target_relays: relay_urls.to_vec(), + connected_relays: connection_output + .success + .iter() + .map(ToString::to_string) + .collect(), + failed_relays, + events, + }) +} + async fn publish_parts_with_identity_async( identity: &RadrootsIdentity, relay_urls: &[String], @@ -157,10 +256,16 @@ fn event_created_at_u32(event: &radroots_nostr::prelude::RadrootsNostrEvent) -> #[cfg(test)] mod tests { + use std::time::Duration; + use radroots_events_codec::wire::WireEventParts; use radroots_identity::RadrootsIdentity; + use radroots_nostr::prelude::RadrootsNostrFilter; - use super::{DirectRelayPublishError, publish_parts_with_identity}; + use super::{ + DirectRelayFetchError, DirectRelayPublishError, fetch_events_from_relays_async, + fetch_events_from_relays_with_timeout, publish_parts_with_identity, + }; #[test] fn publish_parts_requires_relays_before_runtime_work() { @@ -178,4 +283,42 @@ mod tests { assert!(matches!(err, DirectRelayPublishError::MissingRelays)); } + + #[test] + fn fetch_events_requires_relays_before_runtime_work() { + let err = fetch_events_from_relays_with_timeout( + &[], + RadrootsNostrFilter::new(), + Duration::from_millis(1), + ) + .expect_err("missing relay error"); + + assert!(matches!(err, DirectRelayFetchError::MissingRelays)); + } + + #[test] + fn fetch_events_rejects_invalid_relay_urls() { + let err = fetch_events_from_relays_with_timeout( + &["not-a-relay".to_owned()], + RadrootsNostrFilter::new(), + Duration::from_millis(1), + ) + .expect_err("relay config error"); + + assert!(matches!(err, DirectRelayFetchError::RelayConfig { .. })); + } + + #[tokio::test] + async fn fetch_events_reports_connection_failure() { + let err = fetch_events_from_relays_async( + &["ws://127.0.0.1:9".to_owned()], + RadrootsNostrFilter::new(), + Duration::from_millis(1), + Duration::from_millis(50), + ) + .await + .expect_err("connection failure"); + + assert!(matches!(err, DirectRelayFetchError::Connect(_))); + } }