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:
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>,
+}