lib

Core libraries for Radroots
git clone https://radroots.dev/git/lib.git
Log | Files | Refs | README | LICENSE

commit dc59d83f9f86b933a53956e930ce60189992dd41
parent 321518045908b13eb534d951ee6461a447054e2e
Author: triesap <tyson@radroots.org>
Date:   Fri, 26 Dec 2025 13:15:37 +0000

message: add message event codec and types


- Add message module and wire integration (kind 14)
- Implement tag encode/decode for recipients, reply_to, and subject
- Add message tag builder implementation for RadrootsMessage
- Export message event/index/metadata types and add codec tests

Diffstat:
Mevents-codec/src/lib.rs | 1+
Aevents-codec/src/message/decode.rs | 155+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/message/encode.rs | 84+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/message/mod.rs | 4++++
Mevents-codec/src/tag_builders.rs | 10++++++++++
Aevents-codec/tests/message.rs | 156+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents/bindings/ts/src/types.ts | 8++++++++
Mevents/src/lib.rs | 1+
Aevents/src/message.rs | 52++++++++++++++++++++++++++++++++++++++++++++++++++++
9 files changed, 471 insertions(+), 0 deletions(-)

diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs @@ -12,6 +12,7 @@ pub mod wire; pub mod comment; pub mod follow; +pub mod message; pub mod post; pub mod reaction; diff --git a/events-codec/src/message/decode.rs b/events-codec/src/message/decode.rs @@ -0,0 +1,155 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, RadrootsNostrEventPtr, + message::{ + RadrootsMessage, RadrootsMessageEventIndex, RadrootsMessageEventMetadata, + RadrootsMessageRecipient, + }, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = 14; + +fn parse_recipient_tag(tag: &[String]) -> Result<RadrootsMessageRecipient, EventParseError> { + if tag.get(0).map(|s| s.as_str()) != Some("p") { + return Err(EventParseError::InvalidTag("p")); + } + let public_key = tag.get(1).ok_or(EventParseError::InvalidTag("p"))?; + if public_key.trim().is_empty() { + return Err(EventParseError::InvalidTag("p")); + } + let relay_url = match tag.get(2) { + Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("p")), + Some(value) => Some(value.clone()), + None => None, + }; + Ok(RadrootsMessageRecipient { + public_key: public_key.clone(), + relay_url, + }) +} + +fn parse_reply_tag(tag: &[String]) -> Result<RadrootsNostrEventPtr, EventParseError> { + if tag.get(0).map(|s| s.as_str()) != Some("e") { + return Err(EventParseError::InvalidTag("e")); + } + let id = tag.get(1).ok_or(EventParseError::InvalidTag("e"))?; + if id.trim().is_empty() { + return Err(EventParseError::InvalidTag("e")); + } + let relay = match tag.get(2) { + Some(value) if value.trim().is_empty() => return Err(EventParseError::InvalidTag("e")), + Some(value) => Some(value.clone()), + None => None, + }; + Ok(RadrootsNostrEventPtr { + id: id.clone(), + relays: relay, + }) +} + +fn parse_subject_tag(tag: &[String]) -> Result<String, EventParseError> { + if tag.get(0).map(|s| s.as_str()) != Some("subject") { + return Err(EventParseError::InvalidTag("subject")); + } + let subject = tag.get(1).ok_or(EventParseError::InvalidTag("subject"))?; + if subject.trim().is_empty() { + return Err(EventParseError::InvalidTag("subject")); + } + Ok(subject.clone()) +} + +pub fn message_from_tags( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsMessage, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "14", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + + let mut recipients = Vec::new(); + for tag in tags.iter().filter(|t| t.get(0).map(|s| s.as_str()) == Some("p")) { + recipients.push(parse_recipient_tag(tag)?); + } + if recipients.is_empty() { + return Err(EventParseError::MissingTag("p")); + } + + let reply_to = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("e")) + .map(|tag| parse_reply_tag(tag)) + .transpose()?; + + let subject = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some("subject")) + .map(|tag| parse_subject_tag(tag)) + .transpose()?; + + Ok(RadrootsMessage { + recipients, + content: content.to_string(), + reply_to, + subject, + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsMessageEventMetadata, EventParseError> { + let message = message_from_tags(kind, &tags, &content)?; + Ok(RadrootsMessageEventMetadata { + id, + author, + published_at, + kind, + message, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsMessageEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsMessageEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/message/encode.rs b/events-codec/src/message/encode.rs @@ -0,0 +1,84 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::message::{RadrootsMessage, RadrootsMessageRecipient}; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = 14; + +fn validate_recipient(recipient: &RadrootsMessageRecipient) -> Result<(), EventEncodeError> { + if recipient.public_key.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipients.public_key")); + } + if let Some(relay_url) = &recipient.relay_url { + if relay_url.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipients.relay_url")); + } + } + Ok(()) +} + +pub fn message_build_tags(message: &RadrootsMessage) -> Result<Vec<Vec<String>>, EventEncodeError> { + if message.recipients.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("recipients")); + } + + let mut tags = Vec::with_capacity( + message.recipients.len() + + usize::from(message.reply_to.is_some()) + + usize::from(message.subject.is_some()), + ); + + for recipient in &message.recipients { + validate_recipient(recipient)?; + let mut tag = Vec::with_capacity(3); + tag.push("p".to_string()); + tag.push(recipient.public_key.clone()); + if let Some(relay_url) = &recipient.relay_url { + tag.push(relay_url.clone()); + } + tags.push(tag); + } + + if let Some(reply_to) = &message.reply_to { + if reply_to.id.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("reply_to.id")); + } + let mut tag = Vec::with_capacity(3); + tag.push("e".to_string()); + tag.push(reply_to.id.clone()); + if let Some(relay) = &reply_to.relays { + if relay.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("reply_to.relays")); + } + tag.push(relay.clone()); + } + tags.push(tag); + } + + if let Some(subject) = &message.subject { + if subject.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("subject")); + } + let mut tag = Vec::with_capacity(2); + tag.push("subject".to_string()); + tag.push(subject.clone()); + tags.push(tag); + } + + Ok(tags) +} + +pub fn to_wire_parts(message: &RadrootsMessage) -> Result<WireEventParts, EventEncodeError> { + if message.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + let tags = message_build_tags(message)?; + Ok(WireEventParts { + kind: DEFAULT_KIND, + content: message.content.clone(), + tags, + }) +} diff --git a/events-codec/src/message/mod.rs b/events-codec/src/message/mod.rs @@ -0,0 +1,4 @@ +#![forbid(unsafe_code)] + +pub mod decode; +pub mod encode; diff --git a/events-codec/src/tag_builders.rs b/events-codec/src/tag_builders.rs @@ -12,6 +12,7 @@ use radroots_events::{ job_request::RadrootsJobRequest, job_result::RadrootsJobResult, listing::RadrootsListing, + message::RadrootsMessage, post::RadrootsPost, profile::RadrootsProfile, reaction::RadrootsReaction, @@ -25,6 +26,7 @@ use crate::job::feedback::encode::job_feedback_build_tags; use crate::job::request::encode::job_request_build_tags; use crate::job::result::encode::job_result_build_tags; use crate::listing::tags::listing_tags; +use crate::message::encode::message_build_tags; use crate::reaction::encode::reaction_build_tags; pub trait RadrootsEventTagBuilder { @@ -56,6 +58,14 @@ impl RadrootsEventTagBuilder for RadrootsReaction { } } +impl RadrootsEventTagBuilder for RadrootsMessage { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + message_build_tags(self) + } +} + impl RadrootsEventTagBuilder for RadrootsFollow { type Error = EventEncodeError; diff --git a/events-codec/tests/message.rs b/events-codec/tests/message.rs @@ -0,0 +1,156 @@ +use radroots_events::{ + RadrootsNostrEventPtr, + message::{RadrootsMessage, RadrootsMessageRecipient}, +}; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::message::decode::message_from_tags; +use radroots_events_codec::message::encode::{message_build_tags, to_wire_parts}; + +#[test] +fn message_build_tags_requires_recipients() { + let message = RadrootsMessage { + recipients: Vec::new(), + content: "hello".to_string(), + reply_to: None, + subject: None, + }; + + let err = message_build_tags(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("recipients") + )); +} + +#[test] +fn message_build_tags_requires_recipient_pubkey() { + let message = RadrootsMessage { + recipients: vec![RadrootsMessageRecipient { + public_key: " ".to_string(), + relay_url: None, + }], + content: "hello".to_string(), + reply_to: None, + subject: None, + }; + + let err = message_build_tags(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("recipients.public_key") + )); +} + +#[test] +fn message_to_wire_parts_requires_content() { + let message = RadrootsMessage { + recipients: vec![RadrootsMessageRecipient { + public_key: "pub".to_string(), + relay_url: None, + }], + content: " ".to_string(), + reply_to: None, + subject: None, + }; + + let err = to_wire_parts(&message).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("content") + )); +} + +#[test] +fn message_to_wire_parts_sets_tags() { + let message = RadrootsMessage { + recipients: vec![ + RadrootsMessageRecipient { + public_key: "pub1".to_string(), + relay_url: None, + }, + RadrootsMessageRecipient { + public_key: "pub2".to_string(), + relay_url: Some("wss://relay.example".to_string()), + }, + ], + content: "hello".to_string(), + reply_to: Some(RadrootsNostrEventPtr { + id: "reply".to_string(), + relays: Some("wss://reply.example".to_string()), + }), + subject: Some("topic".to_string()), + }; + + let parts = to_wire_parts(&message).unwrap(); + assert_eq!(parts.kind, 14); + assert_eq!(parts.content, "hello"); + assert_eq!( + parts.tags, + vec![ + vec!["p".to_string(), "pub1".to_string()], + vec![ + "p".to_string(), + "pub2".to_string(), + "wss://relay.example".to_string() + ], + vec![ + "e".to_string(), + "reply".to_string(), + "wss://reply.example".to_string() + ], + vec!["subject".to_string(), "topic".to_string()], + ] + ); +} + +#[test] +fn message_from_tags_requires_kind_content_and_recipients() { + let tags = vec![vec!["p".to_string(), "pub".to_string()]]; + let err = message_from_tags(1, &tags, "hello").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { expected: "14", got: 1 } + )); + + let err = message_from_tags(14, &tags, " ").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("content"))); + + let err = message_from_tags(14, &[], "hello").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag("p"))); +} + +#[test] +fn message_roundtrip_from_tags() { + let tags = vec![ + vec!["p".to_string(), "pub1".to_string()], + vec![ + "p".to_string(), + "pub2".to_string(), + "wss://relay.example".to_string(), + ], + vec![ + "e".to_string(), + "reply".to_string(), + "wss://reply.example".to_string(), + ], + vec!["subject".to_string(), "topic".to_string()], + ]; + + let message = message_from_tags(14, &tags, "hello").unwrap(); + + assert_eq!(message.recipients.len(), 2); + assert_eq!(message.recipients[0].public_key, "pub1"); + assert_eq!(message.recipients[0].relay_url, None); + assert_eq!(message.recipients[1].public_key, "pub2"); + assert_eq!( + message.recipients[1].relay_url, + Some("wss://relay.example".to_string()) + ); + assert_eq!(message.content, "hello"); + assert_eq!(message.reply_to.as_ref().map(|r| r.id.as_str()), Some("reply")); + assert_eq!( + message.reply_to.as_ref().and_then(|r| r.relays.as_deref()), + Some("wss://reply.example") + ); + assert_eq!(message.subject.as_deref(), Some("topic")); +} diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts @@ -76,6 +76,14 @@ export type RadrootsListingQuantity = { value: RadrootsCoreQuantity, label?: str export type RadrootsListingStatus = { "kind": "active" } | { "kind": "sold" } | { "kind": "other", "amount": { value: string, } }; +export type RadrootsMessage = { recipients: Array<RadrootsMessageRecipient>, content: string, reply_to?: RadrootsNostrEventPtr | null, subject?: string | null, }; + +export type RadrootsMessageEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsMessageEventMetadata, }; + +export type RadrootsMessageEventMetadata = { id: string, author: string, published_at: number, kind: number, message: RadrootsMessage, }; + +export type RadrootsMessageRecipient = { public_key: string, relay_url?: string | null, }; + export type RadrootsNostrEvent = { id: string, author: string, created_at: number, kind: number, tags: Array<Array<string>>, content: string, sig: string, }; export type RadrootsNostrEventPtr = { id: string, relays?: string | null, }; diff --git a/events/src/lib.rs b/events/src/lib.rs @@ -18,6 +18,7 @@ pub mod job_result; pub mod kinds; pub mod listing; pub mod app_data; +pub mod message; pub mod post; pub mod profile; pub mod reaction; diff --git a/events/src/message.rs b/events/src/message.rs @@ -0,0 +1,52 @@ +#![forbid(unsafe_code)] + +use crate::{RadrootsNostrEvent, RadrootsNostrEventPtr}; +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +#[cfg(not(feature = "std"))] +use alloc::{string::String, vec::Vec}; + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessageEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsMessageEventMetadata, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessageEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub message: RadrootsMessage, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessage { + pub recipients: Vec<RadrootsMessageRecipient>, + pub content: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "RadrootsNostrEventPtr | null"))] + pub reply_to: Option<RadrootsNostrEventPtr>, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub subject: Option<String>, +} + +#[cfg_attr(feature = "ts-rs", derive(TS))] +#[cfg_attr(feature = "ts-rs", ts(export, export_to = "types.ts"))] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[derive(Clone, Debug)] +pub struct RadrootsMessageRecipient { + pub public_key: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub relay_url: Option<String>, +}