radrootsd

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

commit 499ad71168a923dc36cedb03e3789c2b8aea0631
parent 3f8a6cf6b1380cc3bd96b576604f54d42586687f
Author: triesap <triesap@radroots.dev>
Date:   Mon,  5 Jan 2026 18:13:19 +0000

events: add reaction RPC methods

- Register events.reaction module and merge into root registry
- Implement publish method to encode and send KIND_REACTION events
- Add list method with author/time filtering and created_at sorting
- Add get method to fetch latest reaction by event id

Diffstat:
Msrc/api/jsonrpc/methods/events/comment/list.rs | 2+-
Msrc/api/jsonrpc/methods/events/mod.rs | 1+
Asrc/api/jsonrpc/methods/events/reaction/get.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/reaction/list.rs | 199+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/reaction/mod.rs | 18++++++++++++++++++
Asrc/api/jsonrpc/methods/events/reaction/publish.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/mod.rs | 1+
7 files changed, 329 insertions(+), 1 deletion(-)

diff --git a/src/api/jsonrpc/methods/events/comment/list.rs b/src/api/jsonrpc/methods/events/comment/list.rs @@ -2,7 +2,7 @@ use anyhow::Result; use jsonrpsee::server::RpcModule; -use serde::Serialize; +use serde::{Deserialize, Serialize}; use std::time::Duration; use crate::api::jsonrpc::nostr::{event_tags, event_view_with_tags}; diff --git a/src/api/jsonrpc/methods/events/mod.rs b/src/api/jsonrpc/methods/events/mod.rs @@ -4,6 +4,7 @@ pub mod follow; pub mod dvm_feedback; pub mod dvm_request; pub mod dvm_result; +pub mod reaction; pub mod plot; pub mod resource_area; pub mod resource_cap; diff --git a/src/api/jsonrpc/methods/events/reaction/get.rs b/src/api/jsonrpc/methods/events/reaction/get.rs @@ -0,0 +1,58 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::{Deserialize, Serialize}; + +use crate::api::jsonrpc::{MethodRegistry, RpcContext, RpcError}; +use radroots_events::kinds::KIND_REACTION; +use radroots_nostr::prelude::{ + RadrootsNostrEventId, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +use super::list::{build_reaction_rows, ReactionRow}; +use crate::api::jsonrpc::methods::events::helpers::{ + fetch_latest_event, + require_non_empty, +}; + +#[derive(Debug, Deserialize)] +struct ReactionGetParams { + id: String, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct ReactionGetResponse { + reaction: Option<ReactionRow>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.reaction.get"); + m.register_async_method("events.reaction.get", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let ReactionGetParams { id, timeout_secs } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let id = require_non_empty("id", id)?; + let event_id = RadrootsNostrEventId::parse(&id) + .map_err(|e| RpcError::InvalidParams(format!("invalid id: {e}")))?; + + let filter = RadrootsNostrFilter::new() + .kind(RadrootsNostrKind::Custom(KIND_REACTION as u16)) + .id(event_id); + + let event = fetch_latest_event(&ctx.state.client, filter, timeout_secs).await?; + let reaction = event.and_then(|event| build_reaction_rows(vec![event]).into_iter().next()); + + Ok::<ReactionGetResponse, RpcError>(ReactionGetResponse { reaction }) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/reaction/list.rs b/src/api/jsonrpc/methods/events/reaction/list.rs @@ -0,0 +1,199 @@ +#![forbid(unsafe_code)] + +use anyhow::Result; +use jsonrpsee::server::RpcModule; +use serde::Serialize; +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::kinds::KIND_REACTION; +use radroots_events::reaction::RadrootsReaction; +use radroots_events_codec::reaction::decode::reaction_from_tags; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct ReactionRow { + id: String, + author: String, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, + reaction: Option<RadrootsReaction>, +} + +#[derive(Clone, Debug, Serialize)] +struct ReactionListResponse { + reactions: Vec<ReactionRow>, +} + +pub(crate) fn build_reaction_rows<I>(events: I) -> Vec<ReactionRow> +where + I: IntoIterator<Item = RadrootsNostrEvent>, +{ + let mut items = events + .into_iter() + .map(|ev| { + let tags = event_tags(&ev); + let reaction = parse_reaction_event(&ev, &tags); + let event = event_view_with_tags(&ev, tags); + ReactionRow { + id: event.id, + author: event.author, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + reaction, + } + }) + .collect::<Vec<_>>(); + items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + items +} + +fn parse_reaction_event(event: &RadrootsNostrEvent, tags: &[Vec<String>]) -> Option<RadrootsReaction> { + let kind = event.kind.as_u16() as u32; + reaction_from_tags(kind, tags, &event.content).ok() +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.reaction.list"); + m.register_async_method("events.reaction.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() + .limit(limit) + .kind(RadrootsNostrKind::Custom(KIND_REACTION as u16)); + + 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 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_reaction_rows(events); + + Ok::<ReactionListResponse, RpcError>(ReactionListResponse { reactions: items }) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_reaction_rows; + use radroots_events::kinds::{KIND_REACTION, KIND_POST}; + use radroots_events::reaction::RadrootsReaction; + use radroots_events::RadrootsNostrEventRef; + use radroots_events_codec::reaction::encode::reaction_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn reaction_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 11); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_REACTION, + "tags": tags, + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + fn sample_reaction(event_id: &str, author: &str, content: &str) -> RadrootsReaction { + let root = RadrootsNostrEventRef { + id: event_id.to_string(), + author: author.to_string(), + kind: KIND_POST, + d_tag: None, + relays: None, + }; + RadrootsReaction { + root, + content: content.to_string(), + } + } + + #[test] + fn reaction_list_sorts_by_created_at_desc() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let reaction = sample_reaction("root-1", pubkey, "+"); + let tags = reaction_build_tags(&reaction).expect("tags"); + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let older = reaction_event(&old_id, pubkey, 100, tags.clone(), &reaction.content); + let newer = reaction_event(&new_id, pubkey, 200, tags.clone(), &reaction.content); + + let reactions = build_reaction_rows(vec![older, newer]); + + assert_eq!(reactions.len(), 2); + assert_eq!(reactions[0].id, new_id); + assert_eq!(reactions[0].created_at, 200); + assert_eq!(reactions[1].id, old_id); + assert_eq!(reactions[1].created_at, 100); + } + + #[test] + fn reaction_list_decodes_reaction() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let reaction = sample_reaction("root-1", pubkey, "+"); + let tags = reaction_build_tags(&reaction).expect("tags"); + let id = format!("{:064x}", 3); + let event = reaction_event(&id, pubkey, 300, tags.clone(), &reaction.content); + + let reactions = build_reaction_rows(vec![event]); + + assert_eq!(reactions.len(), 1); + assert_eq!(reactions[0].tags, tags); + let parsed = reactions[0].reaction.as_ref().expect("reaction"); + assert_eq!(parsed.content, "+"); + assert_eq!(parsed.root.id, "root-1"); + } +} diff --git a/src/api/jsonrpc/methods/events/reaction/mod.rs b/src/api/jsonrpc/methods/events/reaction/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/reaction/publish.rs b/src/api/jsonrpc/methods/events/reaction/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::kinds::KIND_REACTION; +use radroots_events::reaction::RadrootsReaction; +use radroots_events_codec::reaction::encode::to_wire_parts; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishReactionParams { + reaction: RadrootsReaction, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.reaction.publish"); + m.register_async_method("events.reaction.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishReactionParams { reaction, tags } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let parts = to_wire_parts(&reaction) + .map_err(|e| RpcError::InvalidParams(format!("invalid reaction: {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_REACTION, parts.content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build reaction: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish reaction: {e}")))?; + + Ok::<PublishResponse, RpcError>(publish_response(output)) + })?; + + Ok(()) +} diff --git a/src/api/jsonrpc/methods/mod.rs b/src/api/jsonrpc/methods/mod.rs @@ -21,6 +21,7 @@ pub fn register_all( root.merge(events::follow::module(ctx.clone(), registry.clone())?)?; root.merge(events::post::module(ctx.clone(), registry.clone())?)?; root.merge(events::comment::module(ctx.clone(), registry.clone())?)?; + root.merge(events::reaction::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::dvm_request::module(ctx.clone(), registry.clone())?)?;