radrootsd

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

commit fc075ee8aba0b682d0d7a24a643267b5df837920
parent 593cf9810aa05c87714a94048dc93af92610adc2
Author: triesap <triesap@radroots.dev>
Date:   Mon,  5 Jan 2026 17:15:56 +0000

events.follow: add JSON-RPC get/list/publish methods

- Add events.follow.get for latest contact list by author
- Add events.follow.list with time bounds, limit, and relay fetch
- Add events.follow.publish to encode and send follow events
- Wire follow module into events and root method registry

Diffstat:
Asrc/api/jsonrpc/methods/events/follow/get.rs | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/follow/list.rs | 214+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/follow/mod.rs | 18++++++++++++++++++
Asrc/api/jsonrpc/methods/events/follow/publish.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/events/mod.rs | 1+
Msrc/api/jsonrpc/methods/mod.rs | 1+
6 files changed, 341 insertions(+), 0 deletions(-)

diff --git a/src/api/jsonrpc/methods/events/follow/get.rs b/src/api/jsonrpc/methods/events/follow/get.rs @@ -0,0 +1,56 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_nostr::prelude::{RadrootsNostrFilter, RadrootsNostrKind}; + +use super::list::{build_follow_rows, FollowRow}; +use crate::api::jsonrpc::methods::events::helpers::{ + fetch_latest_event, + parse_author_or_default, +}; + +#[derive(Debug, Deserialize)] +struct FollowGetParams { + #[serde(default)] + author: Option<String>, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct FollowGetResponse { + follow: Option<FollowRow>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.follow.get"); + m.register_async_method("events.follow.get", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let FollowGetParams { + author, + timeout_secs, + } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let author = parse_author_or_default(author, ctx.state.pubkey)?; + + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::ContactList) + .author(author); + + let event = fetch_latest_event(&ctx.state.client, filter, timeout_secs).await?; + let follow = event.and_then(|event| build_follow_rows(vec![event]).into_iter().next()); + + Ok::<FollowGetResponse, RpcError>(FollowGetResponse { follow }) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/follow/list.rs b/src/api/jsonrpc/methods/events/follow/list.rs @@ -0,0 +1,214 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Serialize; +use std::collections::HashMap; +use std::time::Duration; + +use crate::api::jsonrpc::nostr::{event_tags, event_view_with_tags}; +use crate::api::jsonrpc::params::{ + apply_time_bounds, + limit_or, + parse_pubkeys_opt, + timeout_or, + EventListParams, +}; +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::follow::RadrootsFollow; +use radroots_events_codec::follow::decode::follow_from_tags; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, + RadrootsNostrPublicKey, +}; + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct FollowRow { + id: String, + author: String, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, + follow: Option<RadrootsFollow>, +} + +#[derive(Clone, Debug, Serialize)] +struct FollowListResponse { + follows: Vec<FollowRow>, +} + +pub(crate) fn build_follow_rows<I>(events: I) -> Vec<FollowRow> +where + I: IntoIterator<Item = RadrootsNostrEvent>, +{ + let mut latest_by_author: HashMap<RadrootsNostrPublicKey, RadrootsNostrEvent> = HashMap::new(); + for event in events { + match latest_by_author.get(&event.pubkey) { + Some(cur) if event.created_at <= cur.created_at => {} + _ => { + latest_by_author.insert(event.pubkey, event); + } + } + } + + let mut items = latest_by_author + .into_values() + .map(|ev| { + let tags = event_tags(&ev); + let follow = parse_follow_event(&ev, &tags); + let event = event_view_with_tags(&ev, tags); + FollowRow { + id: event.id, + author: event.author, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + follow, + } + }) + .collect::<Vec<_>>(); + + items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + items +} + +fn parse_follow_event(event: &RadrootsNostrEvent, tags: &[Vec<String>]) -> Option<RadrootsFollow> { + let kind = event.kind.as_u16() as u32; + let published_at = u32::try_from(event.created_at.as_secs()).ok()?; + follow_from_tags(kind, tags, published_at).ok() +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.follow.list"); + m.register_async_method("events.follow.list", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let EventListParams { + authors, + limit, + since, + until, + timeout_secs, + } = params + .parse::<Option<EventListParams>>() + .map_err(|e| RpcError::InvalidParams(e.to_string()))? + .unwrap_or_default(); + + let limit = limit_or(limit); + + let mut filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::ContactList) + .limit(limit); + + if let Some(authors) = parse_pubkeys_opt("author", authors)? { + filter = filter.authors(authors); + } else { + filter = filter.author(ctx.state.pubkey); + } + + filter = apply_time_bounds(filter, since, until); + + let stored = ctx + .state + .client + .database() + .query(filter.clone()) + .await + .map_err(|e| RpcError::Other(format!("query failed: {e}")))?; + let fetched = ctx + .state + .client + .fetch_events(filter, Duration::from_secs(timeout_or(timeout_secs))) + .await + .map_err(|e| RpcError::Other(format!("fetch failed: {e}")))?; + + let mut items = build_follow_rows(stored.into_iter().chain(fetched.into_iter())); + if items.len() > limit { + items.truncate(limit); + } + + Ok::<FollowListResponse, RpcError>(FollowListResponse { follows: items }) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_follow_rows; + use radroots_events::follow::{RadrootsFollow, RadrootsFollowProfile}; + use radroots_events::kinds::KIND_FOLLOW; + use radroots_events_codec::follow::encode::follow_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn follow_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 9); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_FOLLOW, + "tags": tags, + "content": "", + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + #[test] + fn follow_list_picks_latest_per_author() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let tags = vec![vec!["p".to_string(), "target".to_string()]]; + let older = follow_event(&old_id, pubkey, 100, tags.clone()); + let newer = follow_event(&new_id, pubkey, 200, tags.clone()); + + let follows = build_follow_rows(vec![older, newer]); + + assert_eq!(follows.len(), 1); + assert_eq!(follows[0].id, new_id); + assert_eq!(follows[0].created_at, 200); + } + + #[test] + fn follow_list_decodes_follow_entries() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let follow = RadrootsFollow { + list: vec![RadrootsFollowProfile { + published_at: 0, + public_key: "pubkey".to_string(), + relay_url: Some("wss://relay".to_string()), + contact_name: Some("alice".to_string()), + }], + }; + let tags = follow_build_tags(&follow).expect("tags"); + let id = format!("{:064x}", 3); + let event = follow_event(&id, pubkey, 300, tags.clone()); + + let follows = build_follow_rows(vec![event]); + + assert_eq!(follows.len(), 1); + assert_eq!(follows[0].tags, tags); + let parsed = follows[0].follow.as_ref().expect("follow"); + assert_eq!(parsed.list.len(), 1); + assert_eq!(parsed.list[0].public_key, "pubkey"); + assert_eq!(parsed.list[0].relay_url.as_deref(), Some("wss://relay")); + assert_eq!(parsed.list[0].contact_name.as_deref(), Some("alice")); + assert_eq!(parsed.list[0].published_at, 300); + } +} diff --git a/src/api/jsonrpc/methods/events/follow/mod.rs b/src/api/jsonrpc/methods/events/follow/mod.rs @@ -0,0 +1,18 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext}; + +pub mod get; +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)?; + get::register(&mut m, &registry)?; + Ok(m) +} diff --git a/src/api/jsonrpc/methods/events/follow/publish.rs b/src/api/jsonrpc/methods/events/follow/publish.rs @@ -0,0 +1,51 @@ +#![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::follow::RadrootsFollow; +use radroots_events::kinds::KIND_FOLLOW; +use radroots_events_codec::follow::encode::to_wire_parts; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishFollowParams { + follow: RadrootsFollow, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.follow.publish"); + m.register_async_method("events.follow.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishFollowParams { follow, tags } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let parts = to_wire_parts(&follow) + .map_err(|e| RpcError::InvalidParams(format!("invalid follow: {e}")))?; + let mut tag_slices = parts.tags; + if let Some(extra_tags) = tags { + tag_slices.extend(extra_tags); + } + + let builder = radroots_nostr_build_event(KIND_FOLLOW, parts.content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build follow: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish follow: {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 @@ -1,4 +1,5 @@ pub mod farm; +pub mod follow; pub mod dvm_feedback; pub mod dvm_request; pub mod dvm_result; diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs @@ -18,6 +18,7 @@ pub fn register_all( root.merge(system::module(ctx.clone(), registry.clone())?)?; root.merge(relays::module(ctx.clone(), registry.clone())?)?; root.merge(events::profile::module(ctx.clone(), registry.clone())?)?; + root.merge(events::follow::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())?)?;