radrootsd

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

commit c2aa911a86a8fd5e505138e7a964b7ae75f43865
parent 3ebb7330bff8c99e85f2ee955e0e68f5e7ac09ca
Author: triesap <triesap@radroots.dev>
Date:   Mon,  5 Jan 2026 17:58:21 +0000

events: add comment RPC methods

- Register events.comment module and merge into root registry
- Implement publish method to encode and send KIND_COMMENT events
- Add list method with filters, sorting, and comment decoding
- Add get method to fetch latest comment by event id

Diffstat:
Asrc/api/jsonrpc/methods/events/comment/get.rs | 58++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/comment/list.rs | 208+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Asrc/api/jsonrpc/methods/events/comment/mod.rs | 18++++++++++++++++++
Asrc/api/jsonrpc/methods/events/comment/publish.rs | 51+++++++++++++++++++++++++++++++++++++++++++++++++++
Msrc/api/jsonrpc/methods/events/mod.rs | 1+
Msrc/api/jsonrpc/methods/mod.rs | 1+
6 files changed, 337 insertions(+), 0 deletions(-)

diff --git a/src/api/jsonrpc/methods/events/comment/get.rs b/src/api/jsonrpc/methods/events/comment/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_COMMENT; +use radroots_nostr::prelude::{ + RadrootsNostrEventId, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +use super::list::{build_comment_rows, CommentRow}; +use crate::api::jsonrpc::methods::events::helpers::{ + fetch_latest_event, + require_non_empty, +}; + +#[derive(Debug, Deserialize)] +struct CommentGetParams { + id: String, + #[serde(default)] + timeout_secs: Option<u64>, +} + +#[derive(Clone, Debug, Serialize)] +struct CommentGetResponse { + comment: Option<CommentRow>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.comment.get"); + m.register_async_method("events.comment.get", |params, ctx, _| async move { + if ctx.state.client.relays().await.is_empty() { + return Err(RpcError::NoRelays); + } + + let CommentGetParams { 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_COMMENT as u16)) + .id(event_id); + + let event = fetch_latest_event(&ctx.state.client, filter, timeout_secs).await?; + let comment = event.and_then(|event| build_comment_rows(vec![event]).into_iter().next()); + + Ok::<CommentGetResponse, RpcError>(CommentGetResponse { comment }) + })?; + Ok(()) +} diff --git a/src/api/jsonrpc/methods/events/comment/list.rs b/src/api/jsonrpc/methods/events/comment/list.rs @@ -0,0 +1,208 @@ +#![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::comment::RadrootsComment; +use radroots_events::kinds::KIND_COMMENT; +use radroots_events_codec::comment::decode::comment_from_tags; +use radroots_nostr::prelude::{ + RadrootsNostrEvent, + RadrootsNostrFilter, + RadrootsNostrKind, +}; + +#[derive(Clone, Debug, Serialize)] +pub(crate) struct CommentRow { + id: String, + author: String, + created_at: u64, + kind: u32, + tags: Vec<Vec<String>>, + content: String, + sig: String, + comment: Option<RadrootsComment>, +} + +#[derive(Clone, Debug, Serialize)] +struct CommentListResponse { + comments: Vec<CommentRow>, +} + +pub(crate) fn build_comment_rows<I>(events: I) -> Vec<CommentRow> +where + I: IntoIterator<Item = RadrootsNostrEvent>, +{ + let mut items = events + .into_iter() + .map(|ev| { + let tags = event_tags(&ev); + let comment = parse_comment_event(&ev, &tags); + let event = event_view_with_tags(&ev, tags); + CommentRow { + id: event.id, + author: event.author, + created_at: event.created_at, + kind: event.kind, + tags: event.tags, + content: event.content, + sig: event.sig, + comment, + } + }) + .collect::<Vec<_>>(); + items.sort_by(|a, b| b.created_at.cmp(&a.created_at)); + items +} + +fn parse_comment_event(event: &RadrootsNostrEvent, tags: &[Vec<String>]) -> Option<RadrootsComment> { + let kind = event.kind.as_u16() as u32; + comment_from_tags(kind, tags, &event.content).ok() +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.comment.list"); + m.register_async_method("events.comment.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_COMMENT 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_comment_rows(events); + + Ok::<CommentListResponse, RpcError>(CommentListResponse { comments: items }) + })?; + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::build_comment_rows; + use radroots_events::comment::RadrootsComment; + use radroots_events::kinds::{KIND_COMMENT, KIND_POST}; + use radroots_events::RadrootsNostrEventRef; + use radroots_events_codec::comment::encode::comment_build_tags; + use radroots_nostr::prelude::RadrootsNostrEvent; + use serde_json::json; + + fn comment_event( + id: &str, + pubkey: &str, + created_at: u64, + tags: Vec<Vec<String>>, + content: &str, + ) -> RadrootsNostrEvent { + let sig = format!("{:0128x}", 10); + let event_json = json!({ + "id": id, + "pubkey": pubkey, + "created_at": created_at, + "kind": KIND_COMMENT, + "tags": tags, + "content": content, + "sig": sig, + }); + serde_json::from_value(event_json).expect("event") + } + + fn sample_comment(root_id: &str, parent_id: &str, author: &str, content: &str) -> RadrootsComment { + let root = RadrootsNostrEventRef { + id: root_id.to_string(), + author: author.to_string(), + kind: KIND_POST, + d_tag: None, + relays: None, + }; + let parent = RadrootsNostrEventRef { + id: parent_id.to_string(), + author: author.to_string(), + kind: KIND_POST, + d_tag: None, + relays: None, + }; + RadrootsComment { + root, + parent, + content: content.to_string(), + } + } + + #[test] + fn comment_list_sorts_by_created_at_desc() { + let pubkey = "1bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let comment = sample_comment("root-1", "parent-1", pubkey, "hello"); + let tags = comment_build_tags(&comment).expect("tags"); + let old_id = format!("{:064x}", 1); + let new_id = format!("{:064x}", 2); + let older = comment_event(&old_id, pubkey, 100, tags.clone(), &comment.content); + let newer = comment_event(&new_id, pubkey, 200, tags.clone(), &comment.content); + + let comments = build_comment_rows(vec![older, newer]); + + assert_eq!(comments.len(), 2); + assert_eq!(comments[0].id, new_id); + assert_eq!(comments[0].created_at, 200); + assert_eq!(comments[1].id, old_id); + assert_eq!(comments[1].created_at, 100); + } + + #[test] + fn comment_list_decodes_comment() { + let pubkey = "2bdebe7b23fccb167fc8843280b789839dfa296ae9fd86cc9769b4813d76d8a4"; + let comment = sample_comment("root-1", "parent-1", pubkey, "hello"); + let tags = comment_build_tags(&comment).expect("tags"); + let id = format!("{:064x}", 3); + let event = comment_event(&id, pubkey, 300, tags.clone(), &comment.content); + + let comments = build_comment_rows(vec![event]); + + assert_eq!(comments.len(), 1); + assert_eq!(comments[0].tags, tags); + let parsed = comments[0].comment.as_ref().expect("comment"); + assert_eq!(parsed.content, "hello"); + assert_eq!(parsed.root.id, "root-1"); + assert_eq!(parsed.parent.id, "parent-1"); + } +} diff --git a/src/api/jsonrpc/methods/events/comment/mod.rs b/src/api/jsonrpc/methods/events/comment/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/comment/publish.rs b/src/api/jsonrpc/methods/events/comment/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::comment::RadrootsComment; +use radroots_events::kinds::KIND_COMMENT; +use radroots_events_codec::comment::encode::to_wire_parts; +use radroots_nostr::prelude::{radroots_nostr_build_event, radroots_nostr_send_event}; + +#[derive(Debug, Deserialize)] +struct PublishCommentParams { + comment: RadrootsComment, + #[serde(default)] + tags: Option<Vec<Vec<String>>>, +} + +pub fn register(m: &mut RpcModule<RpcContext>, registry: &MethodRegistry) -> Result<()> { + registry.track("events.comment.publish"); + m.register_async_method("events.comment.publish", |params, ctx, _| async move { + let relays = ctx.state.client.relays().await; + if relays.is_empty() { + return Err(RpcError::NoRelays); + } + + let PublishCommentParams { comment, tags } = params + .parse() + .map_err(|e| RpcError::InvalidParams(e.to_string()))?; + + let parts = to_wire_parts(&comment) + .map_err(|e| RpcError::InvalidParams(format!("invalid comment: {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_COMMENT, parts.content, tag_slices) + .map_err(|e| RpcError::Other(format!("failed to build comment: {e}")))?; + + let output = radroots_nostr_send_event(&ctx.state.client, builder) + .await + .map_err(|e| RpcError::Other(format!("failed to publish comment: {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,3 +1,4 @@ +pub mod comment; pub mod farm; pub mod follow; pub mod dvm_feedback; 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::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::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())?)?;