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:
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(_)));
+ }
}