radrootsd

JSON-RPC bridge for Radroots event publishing
git clone https://radroots.dev/git/radrootsd.git
Log | Files | Refs | README | LICENSE

commit dd6ab8320d1b918b7348cbb315bf3993a5e1eaec
parent 69cad08ce80797b7806f32048393a25be1817382
Author: triesap <triesap@radroots.dev>
Date:   Sat,  3 Jan 2026 23:18:53 +0000

events: add list_set list and publish RPC methods

- Register events.list_set.list and events.list_set.publish handlers
- Build filters for kinds/authors/d-tags with time bounds and timeout
- Decode list_set payload from tags and sort results by created_at desc
- Add tests for ordering and list_set entry decoding

Diffstat:
Asrc/api/jsonrpc/methods/events/list_set/list.rs | 229+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/list_set/mod.rs | 16++++++++++++++++
Asrc/api/jsonrpc/methods/events/list_set/publish.rs | 64++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/events/mod.rs | 1+
Msrc/api/jsonrpc/methods/mod.rs | 1+
5 files changed, 311 insertions(+), 0 deletions(-)

diff --git a/src/api/jsonrpc/methods/events/list_set/list.rs b/src/api/jsonrpc/methods/events/list_set/list.rs @@ -0,0 +1,229 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; +use std::time::Duration; + +use crate::api::jsonrpc::nostr::{event_tags, event_view_with_tags, NostrEventView}; +use crate::api::jsonrpc::params::{apply_time_bounds, limit_or, parse_pubkeys_opt, timeout_or}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::kinds::{is_nip51_list_set_kind, KIND_LIST_SET_GENERIC}; +use radroots_events::list_set::RadrootsListSet; +use radroots_events_codec::list_set::decode::list_set_from_tags; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Clone, Debug, Serialize)] +struct ListSetEventFlat { + #[serde(flatten)] + event: NostrEventView, + list_set: Option<RadrootsListSet>, +} + +#[derive(Clone, Debug, Serialize)] +struct ListSetListResponse { + list_sets: Vec<ListSetEventFlat>, +} + +#[derive(Debug, Default, Deserialize)] +struct ListSetListParams { + #[serde(default)] + authors: Option<Vec<String>>, + #[serde(default)] + limit: Option<u64>, + #[serde(default)] + since: Option<u64>, + #[serde(default)] + until: Option<u64>, + #[serde(default)] + timeout_secs: Option<u64>, + #[serde(default)] + kinds: Option<Vec<u32>>, + #[serde(default)] + d_tags: Option<Vec<String>>, +} + +fn list_set_kinds_or(kinds: Option<Vec<u32>>) -> Result<Vec<RadrootsNostrKind>, RpcError> { + let kinds = kinds.unwrap_or_else(|| vec![KIND_LIST_SET_GENERIC]); + if kinds.is_empty() { + return Err(RpcError::InvalidParams( + "list_set kinds cannot be empty".to_string(), + )); + } + let mut out = Vec::with_capacity(kinds.len()); + for kind in kinds { + if !is_nip51_list_set_kind(kind) { + return Err(RpcError::InvalidParams(format!( + "invalid list_set kind: {kind}" + ))); + } + let kind = u16::try_from(kind).map_err(|_| { + RpcError::InvalidParams(format!("list_set kind out of range: {kind}")) + })?; + out.push(RadrootsNostrKind::Custom(kind)); + } + Ok(out) +} + +fn build_list_set_rows<I>(events: I) -> Vec<ListSetEventFlat> +where + I: IntoIterator<Item = RadrootsNostrEvent>, +{ + let mut items = events + .into_iter() + .map(|ev| { + let tags = event_tags(&ev); + let kind = ev.kind.as_u16() as u32; + let list_set = list_set_from_tags(kind, ev.content.clone(), &tags).ok(); + ListSetEventFlat { + event: event_view_with_tags(&ev, tags), + list_set, + } + }) + .collect::<Vec<_>>(); + items.sort_by(|a, b| b.event.created_at.cmp(&a.event.created_at)); + items +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.list_set.list"); + m.register_async_method("events.list_set.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let ListSetListParams { + authors, + limit, + since, + until, + timeout_secs, + kinds, + d_tags, + } = params + .parse::<Option<ListSetListParams>>() + .map_err(|e| RpcError::InvalidParams(e.to_string()))? + .unwrap_or_default(); + + let limit = limit_or(limit); + let kinds = list_set_kinds_or(kinds)?; + + let mut filter = RadrootsNostrFilter::new().limit(limit).kinds(kinds); + + if let Some(authors) = parse_pubkeys_opt("author", authors)? { + filter = filter.authors(authors); + } else { + filter = filter.author(ctx.state.pubkey); + } + + if let Some(d_tags) = d_tags { + if !d_tags.is_empty() { + filter = filter.identifiers(d_tags); + } + } + + filter = apply_time_bounds(filter, since, until); + + let events = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(timeout_or(timeout_secs))) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let items = build_list_set_rows(events); + + Ok::<ListSetListResponse, RpcError>(ListSetListResponse { list_sets: items }) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_list_set_rows; + use radroots_events::kinds::KIND_LIST_SET_GENERIC; + use radroots_events::list::RadrootsListEntry; + use radroots_events::list_set::RadrootsListSet; + use radroots_events_codec::list_set::encode::list_set_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn list_set_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 12); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_LIST_SET_GENERIC, + "tags": tags, + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + fn sample_list_set(d_tag: &str, pubkey: &str) -> RadrootsListSet { + RadrootsListSet { + d_tag: d_tag.to_string(), + content: String::new(), + entries: vec![RadrootsListEntry { + tag: "p".to_string(), + values: vec![pubkey.to_string()], + }], + title: None, + description: None, + image: None, + } + } + + #[test] + fn list_set_list_sorts_by_created_at_desc() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let list_set = sample_list_set("member_of.farms", pubkey); + let content = list_set.content.clone(); + let tags = list_set_build_tags(&list_set).expect("tags"); + let older = list_set_event(&old_id, pubkey, 100, tags.clone(), &content); + let newer = list_set_event(&new_id, pubkey, 200, tags.clone(), &content); + + let list_sets = build_list_set_rows(vec![older, newer]); + + assert_eq!(list_sets.len(), 2); + assert_eq!(list_sets[0].event.id, new_id); + assert_eq!(list_sets[0].event.created_at, 200); + assert_eq!(list_sets[1].event.id, old_id); + assert_eq!(list_sets[1].event.created_at, 100); + } + + #[test] + fn list_set_list_decodes_entries() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let list_set = sample_list_set("member_of.farms", pubkey); + let content = list_set.content.clone(); + let tags = list_set_build_tags(&list_set).expect("tags"); + let id = format!("{:064x}", 3); + let event = list_set_event(&id, pubkey, 300, tags.clone(), &content); + + let list_sets = build_list_set_rows(vec![event]); + + assert_eq!(list_sets.len(), 1); + assert_eq!(list_sets[0].event.tags, tags); + let parsed = list_sets[0].list_set.as_ref().expect("list set"); + assert_eq!(parsed.d_tag, "member_of.farms"); + assert_eq!(parsed.entries.len(), 1); + assert_eq!(parsed.entries[0].tag, "p"); + assert_eq!(parsed.entries[0].values, vec![pubkey.to_string()]); + } +} diff --git a/src/api/jsonrpc/methods/events/list_set/mod.rs b/src/api/jsonrpc/methods/events/list_set/mod.rs @@ -0,0 +1,16 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod list; +pub mod publish; + +pub fn module(ctx: RpcContext, registry: MethodRegistry) -> Result<RpcModule<RpcContext>> { + let mut m = RpcModule::new(ctx); + list::register(&mut m, &registry)?; + publish::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/list_set/publish.rs b/src/api/jsonrpc/methods/events/list_set/publish.rs @@ -0,0 +1,64 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Deserialize; + +use crate::api::jsonrpc::nostr::{publish_response, PublishResponse}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::kinds::{is_nip51_list_set_kind, KIND_LIST_SET_GENERIC}; +use radroots_events::list_set::RadrootsListSet; +use radroots_events_codec::list_set::encode::list_set_build_tags; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishListSetParams { + list_set: RadrootsListSet, + #[serde(default)] + kind: Option<u32>, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.list_set.publish"); + m.register_async_method("events.list_set.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishListSetParams { + list_set, + kind, + tags, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let kind = kind.unwrap_or(KIND_LIST_SET_GENERIC); + if !is_nip51_list_set_kind(kind) { + return Err(RpcError::InvalidParams(format!( + "invalid list_set kind: {kind}" + ))); + } + + let content = list_set.content.clone(); + let mut tag_slices = + list_set_build_tags(&list_set).map_err(|e| RpcError::InvalidParams(e.to_string()))?; + if let Some(extra_tags) = tags { + tag_slices.extend(extra_tags); + } + + let builder = radroots_nostr_build_event(kind, content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build list_set event: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish list_set: {e}")))?; + + Ok::<PublishResponse, RpcError>(publish_response(output)) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs @@ -3,5 +3,6 @@ pub mod plot; pub mod resource_area; pub mod resource_cap; pub mod listing; +pub mod list_set; pub mod post; pub mod profile; diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs @@ -20,6 +20,7 @@ pub fn register_all( root.merge(events::profile::module(ctx.clone(), registry.clone())?)?; root.merge(events::post::module(ctx.clone(), registry.clone())?)?; root.merge(events::listing::module(ctx.clone(), registry.clone())?)?; + root.merge(events::list_set::module(ctx.clone(), registry.clone())?)?; root.merge(events::farm::module(ctx.clone(), registry.clone())?)?; root.merge(events::plot::module(ctx.clone(), registry.clone())?)?; root.merge(events::resource_area::module(ctx.clone(), registry.clone())?)?;