use std::collections::HashSet;
use std::sync::Arc;
use std::time::Duration;
use nostr_sdk::prelude::*;
use crate::core::constants::{tags, RELAY_LIST_METADATA_KIND};
use crate::core::error::Result;
use crate::relay::{RelayPool, RelayPoolTrait};
#[derive(Debug, Clone, PartialEq)]
pub struct RelayListEntry {
pub url: String,
pub marker: Option<String>,
}
pub fn select_operational_relay_urls(entries: &[RelayListEntry]) -> Vec<String> {
let unmarked: Vec<&str> = entries
.iter()
.filter(|e| e.marker.is_none() && !e.url.is_empty())
.map(|e| e.url.as_str())
.collect();
if !unmarked.is_empty() {
return dedup(unmarked);
}
let read_write: Vec<&str> = entries
.iter()
.filter(|e| {
!e.url.is_empty() && matches!(e.marker.as_deref(), Some("read") | Some("write"))
})
.map(|e| e.url.as_str())
.collect();
dedup(read_write)
}
fn dedup(urls: Vec<&str>) -> Vec<String> {
let mut seen = HashSet::new();
urls.into_iter()
.filter(|u| seen.insert(*u))
.map(|u| u.to_string())
.collect()
}
pub async fn fetch_server_relay_list(
server_pubkey: &PublicKey,
relay_urls: &[String],
signer: Arc<dyn NostrSigner>,
timeout: Duration,
) -> Result<Vec<RelayListEntry>> {
let pool = RelayPool::new(signer).await?;
pool.connect(relay_urls).await?;
let result = fetch_relay_list_from_pool(server_pubkey, &pool, timeout).await;
let _ = pool.disconnect().await;
result
}
pub(crate) async fn fetch_relay_list_from_pool(
server_pubkey: &PublicKey,
relay_pool: &dyn RelayPoolTrait,
timeout: Duration,
) -> Result<Vec<RelayListEntry>> {
let filter = Filter::new()
.kind(Kind::Custom(RELAY_LIST_METADATA_KIND))
.author(*server_pubkey);
let mut events = relay_pool.fetch_events(vec![filter], timeout).await?;
if events.is_empty() {
return Ok(vec![]);
}
events.sort_by_key(|e| std::cmp::Reverse(e.created_at));
let latest = &events[0];
let entries: Vec<RelayListEntry> = latest
.tags
.iter()
.filter_map(|tag| {
let parts = tag.clone().to_vec();
if parts.first().map(|s| s.as_str()) != Some(tags::RELAY) {
return None;
}
let url = parts.get(1)?.clone();
if url.is_empty() {
return None;
}
let marker = parts.get(2).cloned();
Some(RelayListEntry { url, marker })
})
.collect();
Ok(entries)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::relay::MockRelayPool;
#[test]
fn select_all_unmarked_returns_all_urls() {
let entries = vec![
RelayListEntry {
url: "wss://relay1.example.com".to_string(),
marker: None,
},
RelayListEntry {
url: "wss://relay2.example.com".to_string(),
marker: None,
},
];
let result = select_operational_relay_urls(&entries);
assert_eq!(result.len(), 2);
assert!(result.contains(&"wss://relay1.example.com".to_string()));
assert!(result.contains(&"wss://relay2.example.com".to_string()));
}
#[test]
fn select_mixed_markers_prefers_unmarked() {
let entries = vec![
RelayListEntry {
url: "wss://unmarked.example.com".to_string(),
marker: None,
},
RelayListEntry {
url: "wss://read.example.com".to_string(),
marker: Some("read".to_string()),
},
RelayListEntry {
url: "wss://write.example.com".to_string(),
marker: Some("write".to_string()),
},
];
let result = select_operational_relay_urls(&entries);
assert_eq!(result, vec!["wss://unmarked.example.com"]);
}
#[test]
fn select_only_read_write_returns_union() {
let entries = vec![
RelayListEntry {
url: "wss://read.example.com".to_string(),
marker: Some("read".to_string()),
},
RelayListEntry {
url: "wss://write.example.com".to_string(),
marker: Some("write".to_string()),
},
];
let result = select_operational_relay_urls(&entries);
assert_eq!(result.len(), 2);
assert!(result.contains(&"wss://read.example.com".to_string()));
assert!(result.contains(&"wss://write.example.com".to_string()));
}
#[test]
fn select_empty_input_returns_empty() {
let result = select_operational_relay_urls(&[]);
assert!(result.is_empty());
}
#[test]
fn select_deduplicates_urls() {
let entries = vec![
RelayListEntry {
url: "wss://relay.example.com".to_string(),
marker: None,
},
RelayListEntry {
url: "wss://relay.example.com".to_string(),
marker: None,
},
];
let result = select_operational_relay_urls(&entries);
assert_eq!(result, vec!["wss://relay.example.com"]);
}
#[test]
fn select_filters_empty_strings() {
let entries = vec![
RelayListEntry {
url: String::new(),
marker: None,
},
RelayListEntry {
url: "wss://relay.example.com".to_string(),
marker: None,
},
];
let result = select_operational_relay_urls(&entries);
assert_eq!(result, vec!["wss://relay.example.com"]);
}
fn build_relay_list_event(keys: &Keys, tags: Vec<Tag>, created_at: u64) -> Event {
let builder = EventBuilder::new(Kind::Custom(RELAY_LIST_METADATA_KIND), "")
.tags(tags)
.custom_created_at(Timestamp::from(created_at));
builder.sign_with_keys(keys).unwrap()
}
#[tokio::test]
async fn fetch_returns_parsed_entries_from_injected_event() {
let pool = MockRelayPool::new();
let server_keys = Keys::generate();
let tags = vec![
Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://relay1.example.com"],
),
Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://relay2.example.com"],
),
];
let event = build_relay_list_event(&server_keys, tags, 1000);
pool.inject_event(event).await;
let entries =
fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].url, "wss://relay1.example.com");
assert!(entries[0].marker.is_none());
assert_eq!(entries[1].url, "wss://relay2.example.com");
assert!(entries[1].marker.is_none());
}
#[tokio::test]
async fn fetch_no_events_returns_empty() {
let pool = MockRelayPool::new();
let server_keys = Keys::generate();
let entries =
fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5))
.await
.unwrap();
assert!(entries.is_empty());
}
#[tokio::test]
async fn fetch_multiple_events_returns_latest() {
let pool = MockRelayPool::new();
let server_keys = Keys::generate();
let old_tags = vec![Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://old.example.com"],
)];
let old_event = build_relay_list_event(&server_keys, old_tags, 1000);
pool.inject_event(old_event).await;
let new_tags = vec![Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://new.example.com"],
)];
let new_event = build_relay_list_event(&server_keys, new_tags, 2000);
pool.inject_event(new_event).await;
let entries =
fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].url, "wss://new.example.com");
}
#[tokio::test]
async fn fetch_extracts_marker_from_third_tag_element() {
let pool = MockRelayPool::new();
let server_keys = Keys::generate();
let tags = vec![
Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://read.example.com", "read"],
),
Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://write.example.com", "write"],
),
Tag::custom(
TagKind::Custom(tags::RELAY.into()),
vec!["wss://both.example.com"],
),
];
let event = build_relay_list_event(&server_keys, tags, 1000);
pool.inject_event(event).await;
let entries =
fetch_relay_list_from_pool(&server_keys.public_key(), &pool, Duration::from_secs(5))
.await
.unwrap();
assert_eq!(entries.len(), 3);
assert_eq!(
entries[0],
RelayListEntry {
url: "wss://read.example.com".to_string(),
marker: Some("read".to_string()),
}
);
assert_eq!(
entries[1],
RelayListEntry {
url: "wss://write.example.com".to_string(),
marker: Some("write".to_string()),
}
);
assert_eq!(
entries[2],
RelayListEntry {
url: "wss://both.example.com".to_string(),
marker: None,
}
);
}
}