commit f6fbab7e1dc5f27528ced08256ee09cc0cbffc77
parent 4a1c0e87de4d04b5e326ce69b456dc71bf13d318
Author: triesap <tyson@radroots.org>
Date: Fri, 12 Jun 2026 03:10:33 -0700
events_codec: add social production codecs
- add repost, generic repost, report, calendar collection, and RSVP codec modules
- preserve RSVP event revision ids and NIP-99 listing published_at metadata
- validate NIP-65 relay-list entries through the existing RadrootsList codec
- cover production social codecs, listing drafts, and relay-list evidence with serde_json tests
Diffstat:
19 files changed, 2060 insertions(+), 29 deletions(-)
diff --git a/crates/events/src/calendar.rs b/crates/events/src/calendar.rs
@@ -110,6 +110,11 @@ pub struct RadrootsCalendarTimeEvent {
pub struct RadrootsCalendarEventRsvp {
pub d_tag: String,
pub event: RadrootsSocialTarget,
+ #[cfg_attr(
+ feature = "serde",
+ serde(default, skip_serializing_if = "Option::is_none")
+ )]
+ pub event_id: Option<String>,
pub status: RadrootsCalendarEventRsvpStatus,
#[cfg_attr(
feature = "serde",
@@ -214,6 +219,7 @@ mod tests {
event_kind: Some(31923),
relays: None,
},
+ event_id: Some("b".repeat(64)),
status: RadrootsCalendarEventRsvpStatus::Tentative,
free_busy: Some(RadrootsCalendarEventFreeBusy::Busy),
note: Some("depends on harvest".to_string()),
@@ -221,6 +227,10 @@ mod tests {
};
assert_eq!(rsvp.status, RadrootsCalendarEventRsvpStatus::Tentative);
+ assert_eq!(
+ rsvp.event_id.as_deref(),
+ Some("bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb")
+ );
assert_eq!(rsvp.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy));
}
}
diff --git a/crates/events_codec/src/calendar/decode.rs b/crates/events_codec/src/calendar/decode.rs
@@ -3,18 +3,29 @@ use alloc::{string::ToString, vec::Vec};
use radroots_events::{
RadrootsNostrEvent,
- calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent},
- kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT},
- social::RadrootsCalendarDateValue,
+ calendar::{
+ RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
+ RadrootsCalendarTimeEvent,
+ },
+ kinds::{
+ KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT,
+ },
+ social::{
+ RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
+ RadrootsSocialTarget,
+ },
tags::{
- TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_IMAGE, TAG_START, TAG_START_TZID, TAG_SUMMARY,
- TAG_TITLE,
+ TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_IMAGE, TAG_START,
+ TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
},
};
use crate::d_tag::validate_d_tag_tag;
use crate::error::EventParseError;
-use crate::field_helpers::{optional_tag_value, required_tag_value, tag_values};
+use crate::field_helpers::{
+ optional_tag_value, parse_address_tag, required_tag_value, tag_values,
+ validate_lowercase_hex_64_tag,
+};
use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
use crate::social_helpers::{
location_from_tags, participants_from_tags, validate_date_tag, validate_end_after_start,
@@ -22,6 +33,8 @@ use crate::social_helpers::{
const EXPECTED_DATE_KIND: &str = "31922";
const EXPECTED_TIME_KIND: &str = "31923";
+const EXPECTED_CALENDAR_KIND: &str = "31924";
+const EXPECTED_RSVP_KIND: &str = "31925";
pub fn calendar_date_event_from_event(
kind: u32,
@@ -104,6 +117,73 @@ pub fn calendar_time_event_from_event(
})
}
+pub fn calendar_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsCalendar, EventParseError> {
+ if kind != KIND_CALENDAR {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_CALENDAR_KIND,
+ got: kind,
+ });
+ }
+ if !content.is_empty() {
+ return Err(EventParseError::InvalidJson("content"));
+ }
+ let d_tag = required_tag_value(tags, TAG_D)?;
+ validate_d_tag_tag(&d_tag, TAG_D)?;
+ let title = required_tag_value(tags, TAG_TITLE)?;
+ let events = calendar_event_targets_from_tags(tags)?;
+ if events.is_empty() {
+ return Err(EventParseError::MissingTag(TAG_A));
+ }
+ Ok(RadrootsCalendar {
+ d_tag,
+ title,
+ events,
+ summary: optional_tag_value(tags, TAG_SUMMARY)?,
+ image: optional_tag_value(tags, TAG_IMAGE)?,
+ })
+}
+
+pub fn rsvp_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsCalendarEventRsvp, EventParseError> {
+ if kind != KIND_CALENDAR_EVENT_RSVP {
+ return Err(EventParseError::InvalidKind {
+ expected: EXPECTED_RSVP_KIND,
+ got: kind,
+ });
+ }
+ let d_tag = required_tag_value(tags, TAG_D)?;
+ validate_d_tag_tag(&d_tag, TAG_D)?;
+ let event = calendar_event_target_from_required_tag(tags)?;
+ let event_id = optional_tag_value(tags, TAG_E)?;
+ if let Some(event_id) = event_id.as_deref() {
+ validate_lowercase_hex_64_tag(event_id, TAG_E)?;
+ }
+ let status = parse_rsvp_status(&required_tag_value(tags, TAG_STATUS)?)?;
+ let free_busy = optional_tag_value(tags, TAG_FREE_BUSY)?
+ .map(|value| parse_free_busy(&value))
+ .transpose()?;
+ Ok(RadrootsCalendarEventRsvp {
+ d_tag,
+ event,
+ event_id,
+ status,
+ free_busy,
+ note: if content.is_empty() {
+ None
+ } else {
+ Some(content.to_string())
+ },
+ participants: participants_from_tags(tags),
+ })
+}
+
pub fn date_data_from_event(
id: String,
author: String,
@@ -140,6 +220,42 @@ pub fn time_data_from_event(
))
}
+pub fn calendar_data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsCalendar>, EventParseError> {
+ let calendar = calendar_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ calendar,
+ ))
+}
+
+pub fn rsvp_data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsCalendarEventRsvp>, EventParseError> {
+ let rsvp = rsvp_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ rsvp,
+ ))
+}
+
pub fn date_parsed_from_event(
id: String,
author: String,
@@ -202,6 +318,68 @@ pub fn time_parsed_from_event(
})
}
+pub fn calendar_parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsCalendar>, EventParseError> {
+ let data = calendar_data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+pub fn rsvp_parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsCalendarEventRsvp>, EventParseError> {
+ let data = rsvp_data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
fn parse_required_u64(tags: &[Vec<String>], key: &'static str) -> Result<u64, EventParseError> {
required_tag_value(tags, key)?
.parse::<u64>()
@@ -228,3 +406,74 @@ fn non_empty_vec<T>(values: Vec<T>) -> Option<Vec<T>> {
Some(values)
}
}
+
+fn calendar_event_targets_from_tags(
+ tags: &[Vec<String>],
+) -> Result<Vec<RadrootsSocialTarget>, EventParseError> {
+ tags.iter()
+ .filter(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
+ .map(|tag| calendar_event_target_from_tag(tag))
+ .collect()
+}
+
+fn calendar_event_target_from_required_tag(
+ tags: &[Vec<String>],
+) -> Result<RadrootsSocialTarget, EventParseError> {
+ let tag = tags
+ .iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_A))
+ .ok_or(EventParseError::MissingTag(TAG_A))?;
+ calendar_event_target_from_tag(tag)
+}
+
+fn calendar_event_target_from_tag(tag: &[String]) -> Result<RadrootsSocialTarget, EventParseError> {
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_A))?;
+ let address = parse_address_tag(&value, TAG_A)?;
+ if !is_calendar_event_kind(address.kind) {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ Ok(RadrootsSocialTarget::Address {
+ address: value,
+ author: Some(address.pubkey),
+ event_kind: Some(address.kind),
+ relays: relays_from_tag(tag, 2),
+ })
+}
+
+fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> {
+ let relays = tag
+ .iter()
+ .skip(start)
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+ .collect::<Vec<_>>();
+ if relays.is_empty() {
+ None
+ } else {
+ Some(relays)
+ }
+}
+
+fn is_calendar_event_kind(kind: u32) -> bool {
+ matches!(kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT)
+}
+
+fn parse_rsvp_status(value: &str) -> Result<RadrootsCalendarEventRsvpStatus, EventParseError> {
+ match value {
+ "accepted" => Ok(RadrootsCalendarEventRsvpStatus::Accepted),
+ "declined" => Ok(RadrootsCalendarEventRsvpStatus::Declined),
+ "tentative" => Ok(RadrootsCalendarEventRsvpStatus::Tentative),
+ _ => Err(EventParseError::InvalidTag(TAG_STATUS)),
+ }
+}
+
+fn parse_free_busy(value: &str) -> Result<RadrootsCalendarEventFreeBusy, EventParseError> {
+ match value {
+ "free" => Ok(RadrootsCalendarEventFreeBusy::Free),
+ "busy" => Ok(RadrootsCalendarEventFreeBusy::Busy),
+ _ => Err(EventParseError::InvalidTag(TAG_FREE_BUSY)),
+ }
+}
diff --git a/crates/events_codec/src/calendar/encode.rs b/crates/events_codec/src/calendar/encode.rs
@@ -1,18 +1,29 @@
#[cfg(not(feature = "std"))]
-use alloc::{string::ToString, vec::Vec};
+use alloc::{format, string::ToString, vec, vec::Vec};
use radroots_events::{
- calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent},
- kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT},
+ calendar::{
+ RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
+ RadrootsCalendarTimeEvent,
+ },
+ kinds::{
+ KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP, KIND_CALENDAR_TIME_EVENT,
+ },
+ social::{
+ RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus, RadrootsSocialTarget,
+ },
tags::{
- TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_IMAGE, TAG_START, TAG_START_TZID, TAG_SUMMARY,
- TAG_TITLE,
+ TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_IMAGE, TAG_START,
+ TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
},
};
use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
-use crate::field_helpers::{push_optional_tag, push_tag, validate_non_empty_field};
+use crate::field_helpers::{
+ parse_address_tag, push_optional_tag, push_tag, push_tag_values, validate_lowercase_hex_64,
+ validate_non_empty_field,
+};
use crate::social_helpers::{
push_location_tags, push_participants, validate_date, validate_date_end_after_start,
validate_end_after_start,
@@ -65,6 +76,50 @@ pub fn calendar_time_event_build_tags(
Ok(tags)
}
+pub fn calendar_collection_build_tags(
+ calendar: &RadrootsCalendar,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_calendar_collection(calendar)?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, calendar.d_tag.as_str());
+ push_tag(&mut tags, TAG_TITLE, calendar.title.as_str());
+ push_optional_tag(&mut tags, TAG_SUMMARY, calendar.summary.as_deref());
+ push_optional_tag(&mut tags, TAG_IMAGE, calendar.image.as_deref());
+ for event in &calendar.events {
+ push_calendar_event_address(&mut tags, event, "events")?;
+ }
+ Ok(tags)
+}
+
+pub fn rsvp_build_tags(
+ rsvp: &RadrootsCalendarEventRsvp,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_rsvp(rsvp)?;
+ let mut tags = Vec::new();
+ push_tag(&mut tags, TAG_D, rsvp.d_tag.as_str());
+ push_calendar_event_address(&mut tags, &rsvp.event, "event")?;
+ if let Some(event_id) = rsvp.event_id.as_deref() {
+ let mut tag = vec![TAG_E.to_string(), event_id.to_string()];
+ if let RadrootsSocialTarget::Address { relays, .. } = &rsvp.event {
+ if let Some(relays) = relays.as_ref() {
+ tag.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ }
+ }
+ tags.push(tag);
+ }
+ push_tag(&mut tags, TAG_STATUS, rsvp_status_as_str(&rsvp.status));
+ if let Some(free_busy) = rsvp.free_busy.as_ref() {
+ push_tag(&mut tags, TAG_FREE_BUSY, free_busy_as_str(free_busy));
+ }
+ push_participants(&mut tags, rsvp.participants.as_ref());
+ Ok(tags)
+}
+
pub fn date_to_wire_parts(
event: &RadrootsCalendarDateEvent,
) -> Result<WireEventParts, EventEncodeError> {
@@ -77,6 +132,18 @@ pub fn time_to_wire_parts(
time_to_wire_parts_with_kind(event, KIND_CALENDAR_TIME_EVENT)
}
+pub fn calendar_to_wire_parts(
+ calendar: &RadrootsCalendar,
+) -> Result<WireEventParts, EventEncodeError> {
+ calendar_to_wire_parts_with_kind(calendar, KIND_CALENDAR)
+}
+
+pub fn rsvp_to_wire_parts(
+ rsvp: &RadrootsCalendarEventRsvp,
+) -> Result<WireEventParts, EventEncodeError> {
+ rsvp_to_wire_parts_with_kind(rsvp, KIND_CALENDAR_EVENT_RSVP)
+}
+
pub fn date_to_wire_parts_with_kind(
event: &RadrootsCalendarDateEvent,
kind: u32,
@@ -105,6 +172,34 @@ pub fn time_to_wire_parts_with_kind(
})
}
+pub fn calendar_to_wire_parts_with_kind(
+ calendar: &RadrootsCalendar,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_CALENDAR {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: empty_content(),
+ tags: calendar_collection_build_tags(calendar)?,
+ })
+}
+
+pub fn rsvp_to_wire_parts_with_kind(
+ rsvp: &RadrootsCalendarEventRsvp,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_CALENDAR_EVENT_RSVP {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: rsvp.note.clone().unwrap_or_default(),
+ tags: rsvp_build_tags(rsvp)?,
+ })
+}
+
fn validate_date_event(event: &RadrootsCalendarDateEvent) -> Result<(), EventEncodeError> {
validate_d_tag(&event.d_tag, "d_tag")?;
validate_non_empty_field(&event.title, "title")?;
@@ -122,3 +217,89 @@ fn validate_time_event(event: &RadrootsCalendarTimeEvent) -> Result<(), EventEnc
validate_end_after_start(event.start, event.end, "end")?;
Ok(())
}
+
+fn validate_calendar_collection(calendar: &RadrootsCalendar) -> Result<(), EventEncodeError> {
+ validate_d_tag(&calendar.d_tag, "d_tag")?;
+ validate_non_empty_field(&calendar.title, "title")?;
+ if calendar.events.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("events"));
+ }
+ Ok(())
+}
+
+fn validate_rsvp(rsvp: &RadrootsCalendarEventRsvp) -> Result<(), EventEncodeError> {
+ validate_d_tag(&rsvp.d_tag, "d_tag")?;
+ validate_calendar_event_address(&rsvp.event, "event")?;
+ if let Some(event_id) = rsvp.event_id.as_deref() {
+ validate_lowercase_hex_64(event_id, "event_id")?;
+ }
+ Ok(())
+}
+
+fn push_calendar_event_address(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ let RadrootsSocialTarget::Address {
+ address,
+ event_kind,
+ relays,
+ ..
+ } = target
+ else {
+ return Err(EventEncodeError::InvalidField(field));
+ };
+ let address =
+ parse_address_tag(address, field).map_err(|_| EventEncodeError::InvalidField(field))?;
+ if !is_calendar_event_kind(address.kind) {
+ return Err(EventEncodeError::InvalidField(field));
+ }
+ if let Some(event_kind) = event_kind {
+ if *event_kind != address.kind {
+ return Err(EventEncodeError::InvalidField(field));
+ }
+ }
+ let value = format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag);
+ if let Some(relays) = relays.as_ref() {
+ let mut values = Vec::with_capacity(1 + relays.len());
+ values.push(value);
+ values.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ push_tag_values(tags, TAG_A, values);
+ } else {
+ push_tag(tags, TAG_A, value);
+ }
+ Ok(())
+}
+
+fn validate_calendar_event_address(
+ target: &RadrootsSocialTarget,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ let mut tags = Vec::new();
+ push_calendar_event_address(&mut tags, target, field)
+}
+
+fn is_calendar_event_kind(kind: u32) -> bool {
+ matches!(kind, KIND_CALENDAR_DATE_EVENT | KIND_CALENDAR_TIME_EVENT)
+}
+
+fn rsvp_status_as_str(status: &RadrootsCalendarEventRsvpStatus) -> &'static str {
+ match status {
+ RadrootsCalendarEventRsvpStatus::Accepted => "accepted",
+ RadrootsCalendarEventRsvpStatus::Declined => "declined",
+ RadrootsCalendarEventRsvpStatus::Tentative => "tentative",
+ }
+}
+
+fn free_busy_as_str(free_busy: &RadrootsCalendarEventFreeBusy) -> &'static str {
+ match free_busy {
+ RadrootsCalendarEventFreeBusy::Free => "free",
+ RadrootsCalendarEventFreeBusy::Busy => "busy",
+ }
+}
diff --git a/crates/events_codec/src/lib.rs b/crates/events_codec/src/lib.rs
@@ -10,6 +10,8 @@ mod field_helpers;
pub mod job;
pub mod parsed;
pub mod profile;
+pub mod report;
+pub mod repost;
mod social_helpers;
pub mod tag_builders;
pub mod wire;
diff --git a/crates/events_codec/src/list/decode.rs b/crates/events_codec/src/list/decode.rs
@@ -3,8 +3,9 @@ use alloc::{string::String, vec::Vec};
use radroots_events::{
RadrootsNostrEvent,
- kinds::is_nip51_standard_list_kind,
+ kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_standard_list_kind},
list::{RadrootsList, RadrootsListEntry},
+ tags::TAG_R,
};
use crate::error::EventParseError;
@@ -46,10 +47,44 @@ pub fn list_from_tags(
got: kind,
});
}
+ if kind == KIND_LIST_READ_WRITE_RELAYS {
+ validate_relay_tags(tags)?;
+ }
let entries = list_entries_from_tags(tags)?;
Ok(RadrootsList { content, entries })
}
+fn validate_relay_tags(tags: &[Vec<String>]) -> Result<(), EventParseError> {
+ if tags.is_empty() {
+ return Err(EventParseError::MissingTag(TAG_R));
+ }
+ for tag in tags {
+ if tag.first().map(|value| value.as_str()) != Some(TAG_R) {
+ return Err(EventParseError::InvalidTag(TAG_R));
+ }
+ let Some(url) = tag.get(1) else {
+ return Err(EventParseError::InvalidTag(TAG_R));
+ };
+ if !is_ws_relay_url(url) {
+ return Err(EventParseError::InvalidTag(TAG_R));
+ }
+ if tag.len() > 3 {
+ return Err(EventParseError::InvalidTag(TAG_R));
+ }
+ if let Some(marker) = tag.get(2) {
+ if marker != "read" && marker != "write" {
+ return Err(EventParseError::InvalidTag(TAG_R));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn is_ws_relay_url(value: &str) -> bool {
+ (value.starts_with("wss://") && value.len() > "wss://".len())
+ || (value.starts_with("ws://") && value.len() > "ws://".len())
+}
+
pub fn data_from_event(
id: String,
author: String,
diff --git a/crates/events_codec/src/list/encode.rs b/crates/events_codec/src/list/encode.rs
@@ -2,8 +2,9 @@
use alloc::{string::String, vec::Vec};
use radroots_events::{
- kinds::is_nip51_standard_list_kind,
+ kinds::{KIND_LIST_READ_WRITE_RELAYS, is_nip51_standard_list_kind},
list::{RadrootsList, RadrootsListEntry},
+ tags::TAG_R,
};
use crate::error::EventEncodeError;
@@ -47,6 +48,9 @@ pub fn to_wire_parts_with_kind(
if !is_nip51_standard_list_kind(kind) {
return Err(EventEncodeError::InvalidKind(kind));
}
+ if kind == KIND_LIST_READ_WRITE_RELAYS {
+ validate_relay_entries(&list.entries)?;
+ }
let tags = list_build_tags(list)?;
Ok(WireEventParts {
kind,
@@ -55,6 +59,37 @@ pub fn to_wire_parts_with_kind(
})
}
+fn validate_relay_entries(entries: &[RadrootsListEntry]) -> Result<(), EventEncodeError> {
+ if entries.is_empty() {
+ return Err(EventEncodeError::EmptyRequiredField("relay.entries"));
+ }
+ for entry in entries {
+ if entry.tag != TAG_R {
+ return Err(EventEncodeError::InvalidField("relay.tag"));
+ }
+ let Some(url) = entry.values.first() else {
+ return Err(EventEncodeError::EmptyRequiredField("relay.url"));
+ };
+ if !is_ws_relay_url(url) {
+ return Err(EventEncodeError::InvalidField("relay.url"));
+ }
+ if entry.values.len() > 2 {
+ return Err(EventEncodeError::InvalidField("relay.marker"));
+ }
+ if let Some(marker) = entry.values.get(1) {
+ if marker != "read" && marker != "write" {
+ return Err(EventEncodeError::InvalidField("relay.marker"));
+ }
+ }
+ }
+ Ok(())
+}
+
+fn is_ws_relay_url(value: &str) -> bool {
+ (value.starts_with("wss://") && value.len() > "wss://".len())
+ || (value.starts_with("ws://") && value.len() > "ws://".len())
+}
+
#[cfg(feature = "serde_json")]
pub fn list_private_entries_json(
entries: &[RadrootsListEntry],
diff --git a/crates/events_codec/src/listing/decode.rs b/crates/events_codec/src/listing/decode.rs
@@ -21,7 +21,7 @@ use radroots_events::{
},
plot::RadrootsPlotRef,
resource_area::RadrootsResourceAreaRef,
- tags::TAG_D,
+ tags::{TAG_D, TAG_PUBLISHED_AT},
};
use crate::d_tag::validate_d_tag_tag;
@@ -259,6 +259,7 @@ pub fn listing_from_event_parts(
let mut delivery_method: Option<RadrootsListingDeliveryMethod> = None;
let mut images: Vec<RadrootsListingImage> = Vec::new();
let mut geohash: Option<String> = None;
+ let mut published_at: Option<u64> = None;
let has_structured_location = tags
.iter()
@@ -273,6 +274,9 @@ pub fn listing_from_event_parts(
"title" => set_if_empty(&mut product.title, tag.get(1)),
"category" => set_if_empty(&mut product.category, tag.get(1)),
"summary" => set_optional(&mut product.summary, tag.get(1)),
+ TAG_PUBLISHED_AT => {
+ published_at = Some(parse_u64_tag_value(tag.get(1), TAG_PUBLISHED_AT)?);
+ }
"process" => set_optional(&mut product.process, tag.get(1)),
"lot" => set_optional(&mut product.lot, tag.get(1)),
"location" => {
@@ -490,7 +494,7 @@ pub fn listing_from_event_parts(
Ok(RadrootsListing {
d_tag,
- published_at: None,
+ published_at,
farm: farm_ref,
product,
primary_bin_id,
diff --git a/crates/events_codec/src/listing/tags.rs b/crates/events_codec/src/listing/tags.rs
@@ -22,7 +22,7 @@ use radroots_events::listing::{
};
use radroots_events::plot::RadrootsPlotRef;
use radroots_events::resource_area::RadrootsResourceAreaRef;
-use radroots_events::tags::TAG_D;
+use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT};
use crate::d_tag::validate_d_tag;
use crate::error::EventEncodeError;
@@ -133,6 +133,9 @@ pub fn listing_tags_with_options(
if let Some(summary) = product.summary.as_deref() {
push_tag_value(&mut tags, "summary", summary);
}
+ if let Some(published_at) = listing.published_at {
+ tags.push(vec![TAG_PUBLISHED_AT.to_string(), published_at.to_string()]);
+ }
if let Some(process) = product.process.as_deref() {
push_tag_value(&mut tags, "process", process);
}
diff --git a/crates/events_codec/src/report/decode.rs b/crates/events_codec/src/report/decode.rs
@@ -0,0 +1,213 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ kinds::KIND_REPORT,
+ report::RadrootsReport,
+ social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget},
+ tags::{TAG_A, TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256},
+};
+
+use crate::error::EventParseError;
+use crate::field_helpers::{parse_address_tag, required_tag_value, validate_lowercase_hex_64_tag};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+use crate::social_helpers::first_tag_value;
+
+pub fn report_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsReport, EventParseError> {
+ if kind != KIND_REPORT {
+ return Err(EventParseError::InvalidKind {
+ expected: "1984",
+ got: kind,
+ });
+ }
+ let p_tag = find_tag(tags, TAG_P).ok_or(EventParseError::MissingTag(TAG_P))?;
+ let reported_pubkey = required_tag_value(tags, TAG_P)?;
+ let report_type = parse_report_type(
+ p_tag
+ .get(2)
+ .map(|value| value.as_str())
+ .ok_or(EventParseError::InvalidTag(TAG_P))?,
+ TAG_P,
+ )?;
+ let event = parse_event_target(tags, &report_type)?;
+ let file = parse_file_target(tags, &report_type)?;
+ Ok(RadrootsReport {
+ reported_pubkey,
+ report_type,
+ event,
+ file,
+ content: if content.is_empty() {
+ None
+ } else {
+ Some(content.to_string())
+ },
+ })
+}
+
+pub fn data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsReport>, EventParseError> {
+ let report = report_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ report,
+ ))
+}
+
+pub fn parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsReport>, EventParseError> {
+ let data = data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+fn parse_event_target(
+ tags: &[Vec<String>],
+ report_type: &RadrootsReportType,
+) -> Result<Option<RadrootsSocialTarget>, EventParseError> {
+ if let Some(tag) = find_tag(tags, TAG_A) {
+ validate_optional_target_report_type(tag, 2, report_type, TAG_A)?;
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_A))?;
+ let address = parse_address_tag(&value, TAG_A)?;
+ return Ok(Some(RadrootsSocialTarget::Address {
+ address: value,
+ author: Some(address.pubkey),
+ event_kind: Some(address.kind),
+ relays: relays_from_tag(tag, 3),
+ }));
+ }
+ if let Some(tag) = find_tag(tags, TAG_E) {
+ validate_optional_target_report_type(tag, 2, report_type, TAG_E)?;
+ let id = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_E))?;
+ validate_lowercase_hex_64_tag(&id, TAG_E)?;
+ return Ok(Some(RadrootsSocialTarget::Event {
+ id,
+ author: first_tag_value(tags, TAG_P),
+ event_kind: None,
+ relays: relays_from_tag(tag, 3),
+ }));
+ }
+ Ok(None)
+}
+
+fn parse_file_target(
+ tags: &[Vec<String>],
+ report_type: &RadrootsReportType,
+) -> Result<Option<RadrootsReportFileTarget>, EventParseError> {
+ let sha256 = if let Some(tag) = find_tag(tags, TAG_SHA256) {
+ validate_optional_target_report_type(tag, 2, report_type, TAG_SHA256)?;
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_SHA256))?;
+ validate_lowercase_hex_64_tag(&value, TAG_SHA256)?;
+ Some(value)
+ } else {
+ None
+ };
+ let url = first_tag_value(tags, TAG_SERVER);
+ let magnet = first_tag_value(tags, TAG_MAGNET);
+ if sha256.is_none() && url.is_none() && magnet.is_none() {
+ Ok(None)
+ } else {
+ Ok(Some(RadrootsReportFileTarget {
+ sha256,
+ url,
+ magnet,
+ }))
+ }
+}
+
+fn validate_optional_target_report_type(
+ tag: &[String],
+ index: usize,
+ expected: &RadrootsReportType,
+ tag_name: &'static str,
+) -> Result<(), EventParseError> {
+ let Some(value) = tag.get(index) else {
+ return Ok(());
+ };
+ if parse_report_type(value, tag_name)? == *expected {
+ Ok(())
+ } else {
+ Err(EventParseError::InvalidTag(tag_name))
+ }
+}
+
+fn parse_report_type(
+ value: &str,
+ tag_name: &'static str,
+) -> Result<RadrootsReportType, EventParseError> {
+ match value {
+ "nudity" => Ok(RadrootsReportType::Nudity),
+ "malware" => Ok(RadrootsReportType::Malware),
+ "profanity" => Ok(RadrootsReportType::Profanity),
+ "illegal" => Ok(RadrootsReportType::Illegal),
+ "spam" => Ok(RadrootsReportType::Spam),
+ "impersonation" => Ok(RadrootsReportType::Impersonation),
+ "other" => Ok(RadrootsReportType::Other),
+ _ => Err(EventParseError::InvalidTag(tag_name)),
+ }
+}
+
+fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> {
+ tags.iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+}
+
+fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> {
+ let relays = tag
+ .iter()
+ .skip(start)
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+ .collect::<Vec<_>>();
+ if relays.is_empty() {
+ None
+ } else {
+ Some(relays)
+ }
+}
diff --git a/crates/events_codec/src/report/encode.rs b/crates/events_codec/src/report/encode.rs
@@ -0,0 +1,154 @@
+#[cfg(not(feature = "std"))]
+use alloc::{format, string::ToString, vec, vec::Vec};
+
+use radroots_events::{
+ kinds::KIND_REPORT,
+ report::RadrootsReport,
+ social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget},
+ tags::{TAG_A, TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256},
+};
+
+use crate::error::EventEncodeError;
+use crate::field_helpers::{
+ parse_address_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field,
+};
+use crate::social_helpers::validate_http_url;
+use crate::wire::WireEventParts;
+
+pub fn report_build_tags(report: &RadrootsReport) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_report(report)?;
+ let report_type = report_type_as_str(&report.report_type);
+ let mut tags = Vec::new();
+ tags.push(vec![
+ TAG_P.to_string(),
+ report.reported_pubkey.clone(),
+ report_type.to_string(),
+ ]);
+ if let Some(event) = report.event.as_ref() {
+ push_report_event_target(&mut tags, event, report_type)?;
+ }
+ if let Some(file) = report.file.as_ref() {
+ push_report_file_target(&mut tags, file, report_type)?;
+ }
+ Ok(tags)
+}
+
+pub fn to_wire_parts(report: &RadrootsReport) -> Result<WireEventParts, EventEncodeError> {
+ to_wire_parts_with_kind(report, KIND_REPORT)
+}
+
+pub fn to_wire_parts_with_kind(
+ report: &RadrootsReport,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_REPORT {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: report.content.clone().unwrap_or_default(),
+ tags: report_build_tags(report)?,
+ })
+}
+
+fn validate_report(report: &RadrootsReport) -> Result<(), EventEncodeError> {
+ validate_non_empty_field(&report.reported_pubkey, "reported_pubkey")?;
+ if let Some(file) = report.file.as_ref() {
+ validate_file_target(file)?;
+ }
+ Ok(())
+}
+
+fn push_report_event_target(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+ report_type: &'static str,
+) -> Result<(), EventEncodeError> {
+ match target {
+ RadrootsSocialTarget::Event { id, relays, .. } => {
+ validate_lowercase_hex_64(id, "event.id")?;
+ let mut tag = vec![TAG_E.to_string(), id.clone(), report_type.to_string()];
+ if let Some(relays) = relays.as_ref() {
+ tag.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ }
+ tags.push(tag);
+ Ok(())
+ }
+ RadrootsSocialTarget::Address {
+ address, relays, ..
+ } => {
+ let address = parse_address_tag(address, "event.address")
+ .map_err(|_| EventEncodeError::InvalidField("event.address"))?;
+ let mut tag = vec![
+ TAG_A.to_string(),
+ format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag),
+ report_type.to_string(),
+ ];
+ if let Some(relays) = relays.as_ref() {
+ tag.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ }
+ tags.push(tag);
+ Ok(())
+ }
+ RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("event")),
+ }
+}
+
+fn push_report_file_target(
+ tags: &mut Vec<Vec<String>>,
+ file: &RadrootsReportFileTarget,
+ report_type: &'static str,
+) -> Result<(), EventEncodeError> {
+ if let Some(hash) = file.sha256.as_deref() {
+ tags.push(vec![
+ TAG_SHA256.to_string(),
+ hash.to_string(),
+ report_type.to_string(),
+ ]);
+ }
+ if let Some(url) = file.url.as_deref() {
+ push_tag(tags, TAG_SERVER, url);
+ }
+ if let Some(magnet) = file.magnet.as_deref() {
+ push_tag(tags, TAG_MAGNET, magnet);
+ }
+ Ok(())
+}
+
+fn validate_file_target(file: &RadrootsReportFileTarget) -> Result<(), EventEncodeError> {
+ if file.sha256.is_none() && file.url.is_none() && file.magnet.is_none() {
+ return Err(EventEncodeError::EmptyRequiredField("file"));
+ }
+ if let Some(hash) = file.sha256.as_deref() {
+ validate_lowercase_hex_64(hash, "file.sha256")?;
+ }
+ if let Some(url) = file.url.as_deref() {
+ validate_http_url(url, "file.url")?;
+ }
+ if let Some(magnet) = file.magnet.as_deref() {
+ validate_non_empty_field(magnet, "file.magnet")?;
+ }
+ Ok(())
+}
+
+fn report_type_as_str(report_type: &RadrootsReportType) -> &'static str {
+ match report_type {
+ RadrootsReportType::Nudity => "nudity",
+ RadrootsReportType::Malware => "malware",
+ RadrootsReportType::Profanity => "profanity",
+ RadrootsReportType::Illegal => "illegal",
+ RadrootsReportType::Spam => "spam",
+ RadrootsReportType::Impersonation => "impersonation",
+ RadrootsReportType::Other => "other",
+ }
+}
diff --git a/crates/events_codec/src/report/mod.rs b/crates/events_codec/src/report/mod.rs
@@ -0,0 +1,2 @@
+pub mod decode;
+pub mod encode;
diff --git a/crates/events_codec/src/repost/decode.rs b/crates/events_codec/src/repost/decode.rs
@@ -0,0 +1,222 @@
+#[cfg(not(feature = "std"))]
+use alloc::{string::ToString, vec::Vec};
+
+use radroots_events::{
+ RadrootsNostrEvent,
+ kinds::{KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST},
+ repost::{RadrootsGenericRepost, RadrootsRepost},
+ social::RadrootsSocialTarget,
+ tags::{TAG_A, TAG_E, TAG_K, TAG_P},
+};
+
+use crate::error::EventParseError;
+use crate::field_helpers::{parse_address_tag, required_tag_value, validate_lowercase_hex_64_tag};
+use crate::parsed::{RadrootsParsedData, RadrootsParsedEvent};
+use crate::social_helpers::first_tag_value;
+
+pub fn repost_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsRepost, EventParseError> {
+ if kind != KIND_REPOST {
+ return Err(EventParseError::InvalidKind {
+ expected: "6",
+ got: kind,
+ });
+ }
+ let event_tag = find_tag(tags, TAG_E).ok_or(EventParseError::MissingTag(TAG_E))?;
+ let id = event_tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_E))?;
+ validate_lowercase_hex_64_tag(&id, TAG_E)?;
+ Ok(RadrootsRepost {
+ target: RadrootsSocialTarget::Event {
+ id,
+ author: first_tag_value(tags, TAG_P),
+ event_kind: Some(KIND_POST),
+ relays: relays_from_tag(event_tag, 2),
+ },
+ content: optional_content(content),
+ })
+}
+
+pub fn generic_repost_from_event(
+ kind: u32,
+ tags: &[Vec<String>],
+ content: &str,
+) -> Result<RadrootsGenericRepost, EventParseError> {
+ if kind != KIND_GENERIC_REPOST {
+ return Err(EventParseError::InvalidKind {
+ expected: "16",
+ got: kind,
+ });
+ }
+ let target_kind = required_tag_value(tags, TAG_K)?
+ .parse::<u32>()
+ .map_err(|err| EventParseError::InvalidNumber(TAG_K, err))?;
+ if target_kind == KIND_POST {
+ return Err(EventParseError::InvalidTag(TAG_K));
+ }
+ let target = if let Some(tag) = find_tag(tags, TAG_A) {
+ let value = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_A))?;
+ let address = parse_address_tag(&value, TAG_A)?;
+ if address.kind != target_kind {
+ return Err(EventParseError::InvalidTag(TAG_A));
+ }
+ RadrootsSocialTarget::Address {
+ address: value,
+ author: Some(address.pubkey),
+ event_kind: Some(target_kind),
+ relays: relays_from_tag(tag, 2),
+ }
+ } else if let Some(tag) = find_tag(tags, TAG_E) {
+ let id = tag
+ .get(1)
+ .cloned()
+ .ok_or(EventParseError::InvalidTag(TAG_E))?;
+ validate_lowercase_hex_64_tag(&id, TAG_E)?;
+ RadrootsSocialTarget::Event {
+ id,
+ author: first_tag_value(tags, TAG_P),
+ event_kind: Some(target_kind),
+ relays: relays_from_tag(tag, 2),
+ }
+ } else {
+ return Err(EventParseError::MissingTag(TAG_E));
+ };
+ Ok(RadrootsGenericRepost {
+ target,
+ target_kind,
+ content: optional_content(content),
+ })
+}
+
+pub fn repost_data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsRepost>, EventParseError> {
+ let repost = repost_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ repost,
+ ))
+}
+
+pub fn generic_repost_data_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+) -> Result<RadrootsParsedData<RadrootsGenericRepost>, EventParseError> {
+ let repost = generic_repost_from_event(kind, &tags, &content)?;
+ Ok(RadrootsParsedData::new(
+ id,
+ author,
+ published_at,
+ kind,
+ repost,
+ ))
+}
+
+pub fn repost_parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsRepost>, EventParseError> {
+ let data = repost_data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+pub fn generic_repost_parsed_from_event(
+ id: String,
+ author: String,
+ published_at: u32,
+ kind: u32,
+ content: String,
+ tags: Vec<Vec<String>>,
+ sig: String,
+) -> Result<RadrootsParsedEvent<RadrootsGenericRepost>, EventParseError> {
+ let data = generic_repost_data_from_event(
+ id.clone(),
+ author.clone(),
+ published_at,
+ kind,
+ content.clone(),
+ tags.clone(),
+ )?;
+ Ok(RadrootsParsedEvent {
+ event: RadrootsNostrEvent {
+ id,
+ author,
+ created_at: published_at,
+ kind,
+ content,
+ tags,
+ sig,
+ },
+ data,
+ })
+}
+
+fn find_tag<'a>(tags: &'a [Vec<String>], key: &'static str) -> Option<&'a Vec<String>> {
+ tags.iter()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(key))
+}
+
+fn relays_from_tag(tag: &[String], start: usize) -> Option<Vec<String>> {
+ let relays = tag
+ .iter()
+ .skip(start)
+ .filter(|value| !value.trim().is_empty())
+ .cloned()
+ .collect::<Vec<_>>();
+ if relays.is_empty() {
+ None
+ } else {
+ Some(relays)
+ }
+}
+
+fn optional_content(content: &str) -> Option<String> {
+ if content.is_empty() {
+ None
+ } else {
+ Some(content.to_string())
+ }
+}
diff --git a/crates/events_codec/src/repost/encode.rs b/crates/events_codec/src/repost/encode.rs
@@ -0,0 +1,186 @@
+#[cfg(not(feature = "std"))]
+use alloc::{format, string::ToString, vec, vec::Vec};
+
+use radroots_events::{
+ kinds::{KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST},
+ repost::{RadrootsGenericRepost, RadrootsRepost},
+ social::RadrootsSocialTarget,
+ tags::{TAG_A, TAG_E, TAG_K, TAG_P},
+};
+
+use crate::error::EventEncodeError;
+use crate::field_helpers::{
+ parse_address_tag, push_tag, validate_lowercase_hex_64, validate_non_empty_field,
+};
+use crate::wire::WireEventParts;
+
+pub fn repost_build_tags(repost: &RadrootsRepost) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ let mut tags = Vec::new();
+ push_event_target(&mut tags, &repost.target, KIND_POST, "target")?;
+ Ok(tags)
+}
+
+pub fn generic_repost_build_tags(
+ repost: &RadrootsGenericRepost,
+) -> Result<Vec<Vec<String>>, EventEncodeError> {
+ validate_generic_target_kind(repost.target_kind)?;
+ let mut tags = Vec::new();
+ push_generic_target(&mut tags, &repost.target, repost.target_kind)?;
+ push_tag(&mut tags, TAG_K, repost.target_kind.to_string());
+ Ok(tags)
+}
+
+pub fn repost_to_wire_parts(repost: &RadrootsRepost) -> Result<WireEventParts, EventEncodeError> {
+ repost_to_wire_parts_with_kind(repost, KIND_REPOST)
+}
+
+pub fn generic_repost_to_wire_parts(
+ repost: &RadrootsGenericRepost,
+) -> Result<WireEventParts, EventEncodeError> {
+ generic_repost_to_wire_parts_with_kind(repost, KIND_GENERIC_REPOST)
+}
+
+pub fn repost_to_wire_parts_with_kind(
+ repost: &RadrootsRepost,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_REPOST {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: repost.content.clone().unwrap_or_default(),
+ tags: repost_build_tags(repost)?,
+ })
+}
+
+pub fn generic_repost_to_wire_parts_with_kind(
+ repost: &RadrootsGenericRepost,
+ kind: u32,
+) -> Result<WireEventParts, EventEncodeError> {
+ if kind != KIND_GENERIC_REPOST {
+ return Err(EventEncodeError::InvalidKind(kind));
+ }
+ Ok(WireEventParts {
+ kind,
+ content: repost.content.clone().unwrap_or_default(),
+ tags: generic_repost_build_tags(repost)?,
+ })
+}
+
+fn push_event_target(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+ expected_kind: u32,
+ field: &'static str,
+) -> Result<(), EventEncodeError> {
+ let RadrootsSocialTarget::Event {
+ id,
+ author,
+ event_kind,
+ relays,
+ } = target
+ else {
+ return Err(EventEncodeError::InvalidField(field));
+ };
+ validate_lowercase_hex_64(id, "target.id")?;
+ validate_event_kind(*event_kind, expected_kind)?;
+ let mut event_tag = vec![TAG_E.to_string(), id.clone()];
+ if let Some(relays) = relays.as_ref() {
+ event_tag.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ }
+ tags.push(event_tag);
+ if let Some(author) = author.as_deref() {
+ validate_non_empty_field(author, "target.author")?;
+ push_tag(tags, TAG_P, author);
+ }
+ Ok(())
+}
+
+fn push_generic_target(
+ tags: &mut Vec<Vec<String>>,
+ target: &RadrootsSocialTarget,
+ expected_kind: u32,
+) -> Result<(), EventEncodeError> {
+ match target {
+ RadrootsSocialTarget::Event {
+ id,
+ author,
+ event_kind,
+ relays,
+ } => {
+ validate_lowercase_hex_64(id, "target.id")?;
+ validate_event_kind(*event_kind, expected_kind)?;
+ let mut event_tag = vec![TAG_E.to_string(), id.clone()];
+ if let Some(relays) = relays.as_ref() {
+ event_tag.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ }
+ tags.push(event_tag);
+ if let Some(author) = author.as_deref() {
+ validate_non_empty_field(author, "target.author")?;
+ push_tag(tags, TAG_P, author);
+ }
+ Ok(())
+ }
+ RadrootsSocialTarget::Address {
+ address,
+ author,
+ event_kind,
+ relays,
+ } => {
+ let address = parse_address_tag(address, "target.address")
+ .map_err(|_| EventEncodeError::InvalidField("target.address"))?;
+ if address.kind != expected_kind {
+ return Err(EventEncodeError::InvalidField("target_kind"));
+ }
+ validate_event_kind(*event_kind, expected_kind)?;
+ let mut tag = vec![
+ TAG_A.to_string(),
+ format!("{}:{}:{}", address.kind, address.pubkey, address.d_tag),
+ ];
+ if let Some(relays) = relays.as_ref() {
+ tag.extend(
+ relays
+ .iter()
+ .filter(|relay| !relay.trim().is_empty())
+ .cloned(),
+ );
+ }
+ tags.push(tag);
+ if let Some(author) = author.as_deref() {
+ validate_non_empty_field(author, "target.author")?;
+ }
+ Ok(())
+ }
+ RadrootsSocialTarget::External { .. } => Err(EventEncodeError::InvalidField("target")),
+ }
+}
+
+fn validate_event_kind(
+ event_kind: Option<u32>,
+ expected_kind: u32,
+) -> Result<(), EventEncodeError> {
+ if event_kind == Some(expected_kind) {
+ Ok(())
+ } else {
+ Err(EventEncodeError::InvalidField("target_kind"))
+ }
+}
+
+fn validate_generic_target_kind(target_kind: u32) -> Result<(), EventEncodeError> {
+ if target_kind == KIND_POST {
+ Err(EventEncodeError::InvalidField("target_kind"))
+ } else {
+ Ok(())
+ }
+}
diff --git a/crates/events_codec/src/repost/mod.rs b/crates/events_codec/src/repost/mod.rs
@@ -0,0 +1,2 @@
+pub mod decode;
+pub mod encode;
diff --git a/crates/events_codec/tests/calendar.rs b/crates/events_codec/tests/calendar.rs
@@ -1,29 +1,46 @@
#![cfg(feature = "serde_json")]
use radroots_events::{
- calendar::{RadrootsCalendarDateEvent, RadrootsCalendarTimeEvent},
- kinds::{KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_TIME_EVENT, KIND_POST},
- social::{RadrootsCalendarDateValue, RadrootsCalendarParticipant, RadrootsSocialLocation},
+ calendar::{
+ RadrootsCalendar, RadrootsCalendarDateEvent, RadrootsCalendarEventRsvp,
+ RadrootsCalendarTimeEvent,
+ },
+ kinds::{
+ KIND_ARTICLE, KIND_CALENDAR, KIND_CALENDAR_DATE_EVENT, KIND_CALENDAR_EVENT_RSVP,
+ KIND_CALENDAR_TIME_EVENT, KIND_POST,
+ },
+ social::{
+ RadrootsCalendarDateValue, RadrootsCalendarEventFreeBusy, RadrootsCalendarEventRsvpStatus,
+ RadrootsCalendarParticipant, RadrootsSocialLocation, RadrootsSocialTarget,
+ },
tags::{
- TAG_D, TAG_D_DAY, TAG_END, TAG_END_TZID, TAG_G, TAG_IMAGE, TAG_LOCATION, TAG_P, TAG_START,
- TAG_START_TZID, TAG_SUMMARY, TAG_TITLE,
+ TAG_A, TAG_D, TAG_D_DAY, TAG_E, TAG_END, TAG_END_TZID, TAG_FREE_BUSY, TAG_G, TAG_IMAGE,
+ TAG_LOCATION, TAG_P, TAG_START, TAG_START_TZID, TAG_STATUS, TAG_SUMMARY, TAG_TITLE,
},
};
use radroots_events_codec::{
calendar::{
decode::{
- calendar_date_event_from_event, calendar_time_event_from_event, date_data_from_event,
- date_parsed_from_event, time_data_from_event, time_parsed_from_event,
+ calendar_data_from_event, calendar_date_event_from_event, calendar_from_event,
+ calendar_parsed_from_event, calendar_time_event_from_event, date_data_from_event,
+ date_parsed_from_event, rsvp_data_from_event, rsvp_from_event, rsvp_parsed_from_event,
+ time_data_from_event, time_parsed_from_event,
},
encode::{
- calendar_date_event_build_tags, calendar_time_event_build_tags, date_to_wire_parts,
- date_to_wire_parts_with_kind, time_to_wire_parts, time_to_wire_parts_with_kind,
+ calendar_collection_build_tags, calendar_date_event_build_tags,
+ calendar_time_event_build_tags, calendar_to_wire_parts,
+ calendar_to_wire_parts_with_kind, date_to_wire_parts, date_to_wire_parts_with_kind,
+ rsvp_build_tags, rsvp_to_wire_parts, rsvp_to_wire_parts_with_kind, time_to_wire_parts,
+ time_to_wire_parts_with_kind,
},
},
error::{EventEncodeError, EventParseError},
};
const VALID_D_TAG: &str = "CCCCCCCCCCCCCCCCCCCCCA";
+const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const EVENT_AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const EVENT_D_TAG: &str = "EEEEEEEEEEEEEEEEEEEEEA";
fn sample_date_event() -> RadrootsCalendarDateEvent {
RadrootsCalendarDateEvent {
@@ -70,6 +87,42 @@ fn sample_time_event() -> RadrootsCalendarTimeEvent {
}
}
+fn sample_calendar_collection() -> RadrootsCalendar {
+ RadrootsCalendar {
+ d_tag: VALID_D_TAG.to_string(),
+ title: "Farm calendar".to_string(),
+ events: vec![RadrootsSocialTarget::Address {
+ address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
+ author: Some(EVENT_AUTHOR.to_string()),
+ event_kind: Some(KIND_CALENDAR_TIME_EVENT),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ }],
+ summary: Some("CSA and harvest schedule".to_string()),
+ image: Some("https://media.example.test/calendar.jpg".to_string()),
+ }
+}
+
+fn sample_rsvp() -> RadrootsCalendarEventRsvp {
+ RadrootsCalendarEventRsvp {
+ d_tag: VALID_D_TAG.to_string(),
+ event: RadrootsSocialTarget::Address {
+ address: format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}"),
+ author: Some(EVENT_AUTHOR.to_string()),
+ event_kind: Some(KIND_CALENDAR_TIME_EVENT),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ },
+ event_id: Some(EVENT_ID.to_string()),
+ status: RadrootsCalendarEventRsvpStatus::Accepted,
+ free_busy: Some(RadrootsCalendarEventFreeBusy::Busy),
+ note: Some("I can attend after harvest".to_string()),
+ participants: Some(vec![RadrootsCalendarParticipant {
+ pubkey: "crew_pubkey".to_string(),
+ relay: None,
+ role: Some("participant".to_string()),
+ }]),
+ }
+}
+
fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
tags.iter().any(|tag| {
tag.first().map(|entry| entry.as_str()) == Some(key)
@@ -141,6 +194,60 @@ fn calendar_time_event_to_wire_parts_roundtrips_tags() {
}
#[test]
+fn calendar_collection_to_wire_parts_roundtrips_event_addresses() {
+ let calendar = sample_calendar_collection();
+ let parts = calendar_to_wire_parts(&calendar).unwrap();
+
+ assert_eq!(parts.kind, KIND_CALENDAR);
+ assert!(parts.content.is_empty());
+ assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
+ assert!(has_tag(&parts.tags, TAG_TITLE, "Farm calendar"));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_A,
+ format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str()
+ ));
+
+ let decoded = calendar_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.d_tag, VALID_D_TAG);
+ assert_eq!(decoded.title, "Farm calendar");
+ assert_eq!(decoded.events.len(), 1);
+ assert!(matches!(
+ decoded.events[0],
+ RadrootsSocialTarget::Address {
+ event_kind: Some(KIND_CALENDAR_TIME_EVENT),
+ ..
+ }
+ ));
+}
+
+#[test]
+fn calendar_rsvp_to_wire_parts_roundtrips_status_event_id_and_participants() {
+ let rsvp = sample_rsvp();
+ let parts = rsvp_to_wire_parts(&rsvp).unwrap();
+
+ assert_eq!(parts.kind, KIND_CALENDAR_EVENT_RSVP);
+ assert_eq!(parts.content, "I can attend after harvest");
+ assert!(has_tag(&parts.tags, TAG_D, VALID_D_TAG));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_A,
+ format!("{KIND_CALENDAR_TIME_EVENT}:{EVENT_AUTHOR}:{EVENT_D_TAG}").as_str()
+ ));
+ assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
+ assert!(has_tag(&parts.tags, TAG_STATUS, "accepted"));
+ assert!(has_tag(&parts.tags, TAG_FREE_BUSY, "busy"));
+ assert!(has_tag(&parts.tags, TAG_P, "crew_pubkey"));
+
+ let decoded = rsvp_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.event_id.as_deref(), Some(EVENT_ID));
+ assert_eq!(decoded.status, RadrootsCalendarEventRsvpStatus::Accepted);
+ assert_eq!(decoded.free_busy, Some(RadrootsCalendarEventFreeBusy::Busy));
+ assert_eq!(decoded.note.as_deref(), Some("I can attend after harvest"));
+ assert_eq!(decoded.participants.as_ref().map(Vec::len), Some(1));
+}
+
+#[test]
fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() {
assert!(matches!(
date_to_wire_parts_with_kind(&sample_date_event(), KIND_POST),
@@ -193,6 +300,63 @@ fn calendar_codecs_reject_wrong_kind_invalid_dates_and_nonempty_content() {
}
#[test]
+fn calendar_collection_and_rsvp_reject_missing_or_invalid_required_tags() {
+ assert!(matches!(
+ calendar_to_wire_parts_with_kind(&sample_calendar_collection(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+ assert!(matches!(
+ rsvp_to_wire_parts_with_kind(&sample_rsvp(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+
+ let mut calendar = sample_calendar_collection();
+ calendar.events.clear();
+ assert!(matches!(
+ calendar_collection_build_tags(&calendar),
+ Err(EventEncodeError::EmptyRequiredField("events"))
+ ));
+
+ let mut rsvp = sample_rsvp();
+ if let RadrootsSocialTarget::Address { event_kind, .. } = &mut rsvp.event {
+ *event_kind = Some(KIND_ARTICLE);
+ }
+ assert!(matches!(
+ rsvp_build_tags(&rsvp),
+ Err(EventEncodeError::InvalidField("event"))
+ ));
+
+ let mut tags = calendar_collection_build_tags(&sample_calendar_collection()).unwrap();
+ tags.retain(|tag| tag.first().map(|value| value.as_str()) != Some(TAG_A));
+ assert!(matches!(
+ calendar_from_event(KIND_CALENDAR, &tags, ""),
+ Err(EventParseError::MissingTag(TAG_A))
+ ));
+
+ let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
+ let status = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_STATUS))
+ .expect("status tag");
+ status[1] = "maybe".to_string();
+ assert!(matches!(
+ rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
+ Err(EventParseError::InvalidTag(TAG_STATUS))
+ ));
+
+ let mut tags = rsvp_build_tags(&sample_rsvp()).unwrap();
+ let free_busy = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_FREE_BUSY))
+ .expect("fb tag");
+ free_busy[1] = "unknown".to_string();
+ assert!(matches!(
+ rsvp_from_event(KIND_CALENDAR_EVENT_RSVP, &tags, ""),
+ Err(EventParseError::InvalidTag(TAG_FREE_BUSY))
+ ));
+}
+
+#[test]
fn calendar_wrappers_preserve_event_metadata() {
let date = sample_date_event();
let date_parts = date_to_wire_parts(&date).unwrap();
@@ -245,4 +409,56 @@ fn calendar_wrappers_preserve_event_metadata() {
)
.unwrap();
assert_eq!(time_parsed.event.created_at, 8);
+
+ let calendar = sample_calendar_collection();
+ let calendar_parts = calendar_to_wire_parts(&calendar).unwrap();
+ let calendar_data = calendar_data_from_event(
+ "calendar_id".to_string(),
+ "author".to_string(),
+ 9,
+ calendar_parts.kind,
+ calendar_parts.content.clone(),
+ calendar_parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(calendar_data.kind, KIND_CALENDAR);
+ assert_eq!(calendar_data.data.title, "Farm calendar");
+
+ let calendar_parsed = calendar_parsed_from_event(
+ "calendar_id".to_string(),
+ "author".to_string(),
+ 9,
+ calendar_parts.kind,
+ calendar_parts.content,
+ calendar_parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(calendar_parsed.event.sig, "sig");
+
+ let rsvp = sample_rsvp();
+ let rsvp_parts = rsvp_to_wire_parts(&rsvp).unwrap();
+ let rsvp_data = rsvp_data_from_event(
+ "rsvp_id".to_string(),
+ "author".to_string(),
+ 10,
+ rsvp_parts.kind,
+ rsvp_parts.content.clone(),
+ rsvp_parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(rsvp_data.kind, KIND_CALENDAR_EVENT_RSVP);
+ assert_eq!(rsvp_data.data.event_id.as_deref(), Some(EVENT_ID));
+
+ let rsvp_parsed = rsvp_parsed_from_event(
+ "rsvp_id".to_string(),
+ "author".to_string(),
+ 10,
+ rsvp_parts.kind,
+ rsvp_parts.content,
+ rsvp_parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(rsvp_parsed.event.created_at, 10);
}
diff --git a/crates/events_codec/tests/list.rs b/crates/events_codec/tests/list.rs
@@ -1,6 +1,7 @@
use radroots_events::{
- kinds::{KIND_LIST_MUTE, KIND_POST},
+ kinds::{KIND_LIST_MUTE, KIND_LIST_READ_WRITE_RELAYS, KIND_POST},
list::{RadrootsList, RadrootsListEntry},
+ tags::TAG_R,
};
use radroots_events_codec::error::{EventEncodeError, EventParseError};
use radroots_events_codec::list::decode::{
@@ -172,3 +173,98 @@ fn list_index_from_event_propagates_parse_errors() {
}
));
}
+
+#[test]
+fn relay_list_kind_roundtrips_nip65_r_tags() {
+ let list = RadrootsList {
+ content: String::new(),
+ entries: vec![
+ RadrootsListEntry {
+ tag: TAG_R.to_string(),
+ values: vec!["wss://read.example.test".to_string(), "read".to_string()],
+ },
+ RadrootsListEntry {
+ tag: TAG_R.to_string(),
+ values: vec!["wss://write.example.test".to_string(), "write".to_string()],
+ },
+ RadrootsListEntry {
+ tag: TAG_R.to_string(),
+ values: vec!["wss://both.example.test".to_string()],
+ },
+ ],
+ };
+
+ let parts = to_wire_parts_with_kind(&list, KIND_LIST_READ_WRITE_RELAYS).unwrap();
+ assert_eq!(parts.kind, KIND_LIST_READ_WRITE_RELAYS);
+ assert!(parts.content.is_empty());
+ assert_eq!(parts.tags.len(), 3);
+
+ let decoded = list_from_tags(parts.kind, parts.content, &parts.tags).unwrap();
+ assert_eq!(decoded.entries.len(), 3);
+ assert_eq!(decoded.entries[0].values[1], "read");
+ assert_eq!(decoded.entries[1].values[1], "write");
+ assert_eq!(decoded.entries[2].values.len(), 1);
+}
+
+#[test]
+fn relay_list_kind_validates_url_shape_and_markers() {
+ let invalid_url = RadrootsList {
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: TAG_R.to_string(),
+ values: vec!["https://relay.example.test".to_string()],
+ }],
+ };
+ assert!(matches!(
+ to_wire_parts_with_kind(&invalid_url, KIND_LIST_READ_WRITE_RELAYS),
+ Err(EventEncodeError::InvalidField("relay.url"))
+ ));
+ assert!(matches!(
+ list_from_tags(
+ KIND_LIST_READ_WRITE_RELAYS,
+ String::new(),
+ &[vec![
+ TAG_R.to_string(),
+ "https://relay.example.test".to_string()
+ ]]
+ ),
+ Err(EventParseError::InvalidTag(TAG_R))
+ ));
+
+ let invalid_marker = RadrootsList {
+ content: String::new(),
+ entries: vec![RadrootsListEntry {
+ tag: TAG_R.to_string(),
+ values: vec!["wss://relay.example.test".to_string(), "both".to_string()],
+ }],
+ };
+ assert!(matches!(
+ to_wire_parts_with_kind(&invalid_marker, KIND_LIST_READ_WRITE_RELAYS),
+ Err(EventEncodeError::InvalidField("relay.marker"))
+ ));
+ assert!(matches!(
+ list_from_tags(
+ KIND_LIST_READ_WRITE_RELAYS,
+ String::new(),
+ &[vec![
+ TAG_R.to_string(),
+ "wss://relay.example.test".to_string(),
+ "both".to_string()
+ ]]
+ ),
+ Err(EventParseError::InvalidTag(TAG_R))
+ ));
+
+ let empty = RadrootsList {
+ content: String::new(),
+ entries: Vec::new(),
+ };
+ assert!(matches!(
+ to_wire_parts_with_kind(&empty, KIND_LIST_READ_WRITE_RELAYS),
+ Err(EventEncodeError::EmptyRequiredField("relay.entries"))
+ ));
+ assert!(matches!(
+ list_from_tags(KIND_LIST_READ_WRITE_RELAYS, String::new(), &[]),
+ Err(EventParseError::MissingTag(TAG_R))
+ ));
+}
diff --git a/crates/events_codec/tests/listing.rs b/crates/events_codec/tests/listing.rs
@@ -5,7 +5,7 @@ use radroots_core::{
RadrootsCoreDiscountThreshold, RadrootsCoreDiscountValue, RadrootsCoreMoney,
RadrootsCoreQuantity, RadrootsCoreQuantityPrice, RadrootsCoreUnit,
};
-use radroots_events::tags::TAG_D;
+use radroots_events::tags::{TAG_D, TAG_PUBLISHED_AT};
use radroots_events::{
farm::RadrootsFarmRef,
kinds::{KIND_LISTING, KIND_LISTING_DRAFT, KIND_POST},
@@ -222,13 +222,38 @@ fn listing_from_event_rejects_wrong_kind() {
#[test]
fn draft_listing_roundtrip_from_event() {
- let listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAQ");
+ listing.published_at = Some(1_781_895_600);
let parts = to_wire_parts_with_kind(&listing, KIND_LISTING_DRAFT).unwrap();
let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
assert_eq!(parts.kind, KIND_LISTING_DRAFT);
assert_eq!(parts.content, "# Widget");
assert_eq!(decoded.d_tag, listing.d_tag);
+ assert_eq!(decoded.published_at, Some(1_781_895_600));
+}
+
+#[test]
+fn listing_roundtrips_published_at_for_active_and_rejects_bad_value() {
+ let mut listing = sample_listing("AAAAAAAAAAAAAAAAAAAAAg");
+ listing.published_at = Some(1_781_895_600);
+ let parts = to_wire_parts_with_kind(&listing, KIND_LISTING).unwrap();
+ assert!(parts.tags.iter().any(|tag| {
+ tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT)
+ && tag.get(1).map(|value| value.as_str()) == Some("1781895600")
+ }));
+
+ let decoded = listing_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.published_at, Some(1_781_895_600));
+
+ let mut tags = parts.tags;
+ let published_at = tags
+ .iter_mut()
+ .find(|tag| tag.first().map(|value| value.as_str()) == Some(TAG_PUBLISHED_AT))
+ .expect("published_at tag");
+ published_at[1] = "bad".to_string();
+ let err = listing_from_event(KIND_LISTING, &tags, "# Widget").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(TAG_PUBLISHED_AT)));
}
#[test]
diff --git a/crates/events_codec/tests/report.rs b/crates/events_codec/tests/report.rs
@@ -0,0 +1,194 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::{
+ kinds::{KIND_POST, KIND_REPORT},
+ report::RadrootsReport,
+ social::{RadrootsReportFileTarget, RadrootsReportType, RadrootsSocialTarget},
+ tags::{TAG_E, TAG_MAGNET, TAG_P, TAG_SERVER, TAG_SHA256},
+};
+use radroots_events_codec::{
+ error::{EventEncodeError, EventParseError},
+ report::{
+ decode::{data_from_event, parsed_from_event, report_from_event},
+ encode::{report_build_tags, to_wire_parts, to_wire_parts_with_kind},
+ },
+};
+
+const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const REPORTED: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const FILE_HASH: &str = "cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc";
+
+fn profile_report() -> RadrootsReport {
+ RadrootsReport {
+ reported_pubkey: REPORTED.to_string(),
+ report_type: RadrootsReportType::Spam,
+ event: None,
+ file: None,
+ content: None,
+ }
+}
+
+fn event_report() -> RadrootsReport {
+ RadrootsReport {
+ reported_pubkey: REPORTED.to_string(),
+ report_type: RadrootsReportType::Illegal,
+ event: Some(RadrootsSocialTarget::Event {
+ id: EVENT_ID.to_string(),
+ author: Some(REPORTED.to_string()),
+ event_kind: Some(KIND_POST),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ }),
+ file: None,
+ content: Some("Contains prohibited listing text".to_string()),
+ }
+}
+
+fn file_report() -> RadrootsReport {
+ RadrootsReport {
+ reported_pubkey: REPORTED.to_string(),
+ report_type: RadrootsReportType::Malware,
+ event: None,
+ file: Some(RadrootsReportFileTarget {
+ sha256: Some(FILE_HASH.to_string()),
+ url: Some("https://media.example.test/blob".to_string()),
+ magnet: Some("magnet:?xt=urn:btih:example".to_string()),
+ }),
+ content: None,
+ }
+}
+
+fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
+ tags.iter().any(|tag| {
+ tag.first().map(|entry| entry.as_str()) == Some(key)
+ && tag.get(1).map(|entry| entry.as_str()) == Some(value)
+ })
+}
+
+#[test]
+fn report_to_wire_parts_roundtrips_pubkey_event_and_file_reports() {
+ let profile = to_wire_parts(&profile_report()).unwrap();
+ assert_eq!(profile.kind, KIND_REPORT);
+ assert!(profile.content.is_empty());
+ assert!(has_tag(&profile.tags, TAG_P, REPORTED));
+ assert_eq!(
+ profile.tags[0].get(2).map(|value| value.as_str()),
+ Some("spam")
+ );
+ let decoded = report_from_event(profile.kind, &profile.tags, &profile.content).unwrap();
+ assert_eq!(decoded.reported_pubkey, REPORTED);
+ assert_eq!(decoded.report_type, RadrootsReportType::Spam);
+
+ let event = to_wire_parts(&event_report()).unwrap();
+ assert_eq!(event.content, "Contains prohibited listing text");
+ assert!(has_tag(&event.tags, TAG_E, EVENT_ID));
+ let decoded = report_from_event(event.kind, &event.tags, &event.content).unwrap();
+ assert!(matches!(
+ decoded.event,
+ Some(RadrootsSocialTarget::Event { .. })
+ ));
+ assert_eq!(decoded.report_type, RadrootsReportType::Illegal);
+
+ let file = to_wire_parts(&file_report()).unwrap();
+ assert!(has_tag(&file.tags, TAG_SHA256, FILE_HASH));
+ assert!(has_tag(
+ &file.tags,
+ TAG_SERVER,
+ "https://media.example.test/blob"
+ ));
+ assert!(has_tag(
+ &file.tags,
+ TAG_MAGNET,
+ "magnet:?xt=urn:btih:example"
+ ));
+ let decoded = report_from_event(file.kind, &file.tags, &file.content).unwrap();
+ assert_eq!(
+ decoded.file.and_then(|target| target.sha256).as_deref(),
+ Some(FILE_HASH)
+ );
+}
+
+#[test]
+fn report_codec_rejects_missing_pubkey_unknown_type_bad_hash_and_wrong_kind() {
+ let mut report = profile_report();
+ report.reported_pubkey = " ".to_string();
+ assert!(matches!(
+ report_build_tags(&report),
+ Err(EventEncodeError::EmptyRequiredField("reported_pubkey"))
+ ));
+
+ assert!(matches!(
+ to_wire_parts_with_kind(&profile_report(), KIND_POST),
+ Err(EventEncodeError::InvalidKind(KIND_POST))
+ ));
+
+ let mut report = file_report();
+ report.file.as_mut().unwrap().sha256 = Some("bad".to_string());
+ assert!(matches!(
+ to_wire_parts(&report),
+ Err(EventEncodeError::InvalidField("file.sha256"))
+ ));
+
+ let err = report_from_event(KIND_REPORT, &[], "").unwrap_err();
+ assert!(matches!(err, EventParseError::MissingTag(TAG_P)));
+
+ let tags = vec![vec![
+ TAG_P.to_string(),
+ REPORTED.to_string(),
+ "unknown".to_string(),
+ ]];
+ let err = report_from_event(KIND_REPORT, &tags, "").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(TAG_P)));
+
+ let tags = vec![
+ vec![
+ TAG_P.to_string(),
+ REPORTED.to_string(),
+ "malware".to_string(),
+ ],
+ vec![
+ TAG_SHA256.to_string(),
+ "bad".to_string(),
+ "malware".to_string(),
+ ],
+ ];
+ let err = report_from_event(KIND_REPORT, &tags, "").unwrap_err();
+ assert!(matches!(err, EventParseError::InvalidTag(TAG_SHA256)));
+
+ let err = report_from_event(KIND_POST, &tags, "").unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "1984",
+ got: KIND_POST
+ }
+ ));
+}
+
+#[test]
+fn report_wrappers_preserve_event_metadata() {
+ let parts = to_wire_parts(&event_report()).unwrap();
+ let data = data_from_event(
+ "report_id".to_string(),
+ "author".to_string(),
+ 12,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(data.kind, KIND_REPORT);
+ assert_eq!(data.data.report_type, RadrootsReportType::Illegal);
+
+ let parsed = parsed_from_event(
+ "report_id".to_string(),
+ "author".to_string(),
+ 12,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(parsed.event.sig, "sig");
+ assert_eq!(parsed.data.data.reported_pubkey, REPORTED);
+}
diff --git a/crates/events_codec/tests/repost.rs b/crates/events_codec/tests/repost.rs
@@ -0,0 +1,202 @@
+#![cfg(feature = "serde_json")]
+
+use radroots_events::{
+ kinds::{KIND_ARTICLE, KIND_GENERIC_REPOST, KIND_POST, KIND_REPOST},
+ repost::{RadrootsGenericRepost, RadrootsRepost},
+ social::RadrootsSocialTarget,
+ tags::{TAG_A, TAG_E, TAG_K, TAG_P},
+};
+use radroots_events_codec::{
+ error::{EventEncodeError, EventParseError},
+ repost::{
+ decode::{
+ generic_repost_data_from_event, generic_repost_from_event,
+ generic_repost_parsed_from_event, repost_data_from_event, repost_from_event,
+ repost_parsed_from_event,
+ },
+ encode::{
+ generic_repost_build_tags, generic_repost_to_wire_parts,
+ generic_repost_to_wire_parts_with_kind, repost_build_tags, repost_to_wire_parts,
+ repost_to_wire_parts_with_kind,
+ },
+ },
+};
+
+const EVENT_ID: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa";
+const AUTHOR: &str = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb";
+const ARTICLE_D_TAG: &str = "DDDDDDDDDDDDDDDDDDDDDA";
+
+fn note_repost() -> RadrootsRepost {
+ RadrootsRepost {
+ target: RadrootsSocialTarget::Event {
+ id: EVENT_ID.to_string(),
+ author: Some(AUTHOR.to_string()),
+ event_kind: Some(KIND_POST),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ },
+ content: None,
+ }
+}
+
+fn generic_article_repost() -> RadrootsGenericRepost {
+ RadrootsGenericRepost {
+ target: RadrootsSocialTarget::Address {
+ address: format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}"),
+ author: Some(AUTHOR.to_string()),
+ event_kind: Some(KIND_ARTICLE),
+ relays: Some(vec!["wss://relay.example.test".to_string()]),
+ },
+ target_kind: KIND_ARTICLE,
+ content: Some("{\"kind\":30023}".to_string()),
+ }
+}
+
+fn has_tag(tags: &[Vec<String>], key: &str, value: &str) -> bool {
+ tags.iter().any(|tag| {
+ tag.first().map(|entry| entry.as_str()) == Some(key)
+ && tag.get(1).map(|entry| entry.as_str()) == Some(value)
+ })
+}
+
+#[test]
+fn repost_to_wire_parts_roundtrips_kind_one_target() {
+ let repost = note_repost();
+ let parts = repost_to_wire_parts(&repost).unwrap();
+
+ assert_eq!(parts.kind, KIND_REPOST);
+ assert!(parts.content.is_empty());
+ assert!(has_tag(&parts.tags, TAG_E, EVENT_ID));
+ assert!(has_tag(&parts.tags, TAG_P, AUTHOR));
+
+ let decoded = repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert!(matches!(
+ decoded.target,
+ RadrootsSocialTarget::Event {
+ event_kind: Some(KIND_POST),
+ ..
+ }
+ ));
+ assert!(decoded.content.is_none());
+}
+
+#[test]
+fn generic_repost_to_wire_parts_roundtrips_address_target() {
+ let repost = generic_article_repost();
+ let parts = generic_repost_to_wire_parts(&repost).unwrap();
+
+ assert_eq!(parts.kind, KIND_GENERIC_REPOST);
+ assert_eq!(parts.content, "{\"kind\":30023}");
+ assert!(has_tag(
+ &parts.tags,
+ TAG_A,
+ format!("{KIND_ARTICLE}:{AUTHOR}:{ARTICLE_D_TAG}").as_str()
+ ));
+ assert!(has_tag(
+ &parts.tags,
+ TAG_K,
+ KIND_ARTICLE.to_string().as_str()
+ ));
+
+ let decoded = generic_repost_from_event(parts.kind, &parts.tags, &parts.content).unwrap();
+ assert_eq!(decoded.target_kind, KIND_ARTICLE);
+ assert!(matches!(
+ decoded.target,
+ RadrootsSocialTarget::Address {
+ event_kind: Some(KIND_ARTICLE),
+ ..
+ }
+ ));
+ assert_eq!(decoded.content.as_deref(), Some("{\"kind\":30023}"));
+}
+
+#[test]
+fn repost_codecs_reject_wrong_kind_and_wrong_target_kind() {
+ assert!(matches!(
+ repost_to_wire_parts_with_kind(¬e_repost(), KIND_GENERIC_REPOST),
+ Err(EventEncodeError::InvalidKind(KIND_GENERIC_REPOST))
+ ));
+ assert!(matches!(
+ generic_repost_to_wire_parts_with_kind(&generic_article_repost(), KIND_REPOST),
+ Err(EventEncodeError::InvalidKind(KIND_REPOST))
+ ));
+
+ let mut repost = note_repost();
+ if let RadrootsSocialTarget::Event { event_kind, .. } = &mut repost.target {
+ *event_kind = Some(KIND_ARTICLE);
+ }
+ assert!(matches!(
+ repost_build_tags(&repost),
+ Err(EventEncodeError::InvalidField("target_kind"))
+ ));
+
+ let mut generic = generic_article_repost();
+ generic.target_kind = KIND_POST;
+ assert!(matches!(
+ generic_repost_build_tags(&generic),
+ Err(EventEncodeError::InvalidField("target_kind"))
+ ));
+
+ let err = repost_from_event(KIND_GENERIC_REPOST, &[], "").unwrap_err();
+ assert!(matches!(
+ err,
+ EventParseError::InvalidKind {
+ expected: "6",
+ got: KIND_GENERIC_REPOST
+ }
+ ));
+
+ let err = generic_repost_from_event(KIND_GENERIC_REPOST, &[], "").unwrap_err();
+ assert!(matches!(err, EventParseError::MissingTag(TAG_K)));
+}
+
+#[test]
+fn repost_wrappers_preserve_event_metadata() {
+ let parts = repost_to_wire_parts(¬e_repost()).unwrap();
+ let data = repost_data_from_event(
+ "repost_id".to_string(),
+ "author".to_string(),
+ 10,
+ parts.kind,
+ parts.content.clone(),
+ parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(data.kind, KIND_REPOST);
+ assert_eq!(data.published_at, 10);
+
+ let parsed = repost_parsed_from_event(
+ "repost_id".to_string(),
+ "author".to_string(),
+ 10,
+ parts.kind,
+ parts.content,
+ parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(parsed.event.sig, "sig");
+
+ let generic_parts = generic_repost_to_wire_parts(&generic_article_repost()).unwrap();
+ let generic_data = generic_repost_data_from_event(
+ "generic_id".to_string(),
+ "author".to_string(),
+ 11,
+ generic_parts.kind,
+ generic_parts.content.clone(),
+ generic_parts.tags.clone(),
+ )
+ .unwrap();
+ assert_eq!(generic_data.data.target_kind, KIND_ARTICLE);
+
+ let generic_parsed = generic_repost_parsed_from_event(
+ "generic_id".to_string(),
+ "author".to_string(),
+ 11,
+ generic_parts.kind,
+ generic_parts.content,
+ generic_parts.tags,
+ "sig".to_string(),
+ )
+ .unwrap();
+ assert_eq!(generic_parsed.event.created_at, 11);
+}