lib

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

commit 0c2fe0fc1da81aeee83621510d9e1d4c9e6da4a1
parent 5d0fe65a0984a7c901994b5c8a32ed0de097be10
Author: triesap <tyson@radroots.org>
Date:   Wed,  7 Jan 2026 16:48:13 +0000

events: add geohash chat


- add RadrootsGeoChat event model and kind constant
- implement geochat encode/decode and tag builder wiring
- add geochat codec tests for tags and validation
- fix no_std imports and gate d_tag decode helper

Diffstat:
Mevents-codec/src/d_tag.rs | 5++++-
Aevents-codec/src/geochat/decode.rs | 135+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/geochat/encode.rs | 60++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aevents-codec/src/geochat/mod.rs | 4++++
Mevents-codec/src/lib.rs | 1+
Mevents-codec/src/tag_builders.rs | 10++++++++++
Aevents-codec/tests/geochat.rs | 112+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mevents/bindings/ts/src/kinds.ts | 1+
Mevents/bindings/ts/src/types.ts | 6++++++
Aevents/src/geochat.rs | 42++++++++++++++++++++++++++++++++++++++++++
Mevents/src/kinds.rs | 2++
Mevents/src/lib.rs | 1+
12 files changed, 378 insertions(+), 1 deletion(-)

diff --git a/events-codec/src/d_tag.rs b/events-codec/src/d_tag.rs @@ -1,6 +1,8 @@ #![forbid(unsafe_code)] -use crate::error::{EventEncodeError, EventParseError}; +use crate::error::EventEncodeError; +#[cfg(feature = "serde_json")] +use crate::error::EventParseError; pub fn is_d_tag_base64url(value: &str) -> bool { const D_TAG_LEN: usize = 22; @@ -28,6 +30,7 @@ pub(crate) fn validate_d_tag(value: &str, field: &'static str) -> Result<(), Eve } } +#[cfg(feature = "serde_json")] pub(crate) fn validate_d_tag_tag(value: &str, tag: &'static str) -> Result<(), EventParseError> { if is_d_tag_base64url(value) { Ok(()) diff --git a/events-codec/src/geochat/decode.rs b/events-codec/src/geochat/decode.rs @@ -0,0 +1,135 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; + +use radroots_events::{ + RadrootsNostrEvent, + geochat::{RadrootsGeoChat, RadrootsGeoChatEventIndex, RadrootsGeoChatEventMetadata}, + kinds::KIND_GEOCHAT, +}; + +use crate::error::EventParseError; + +const DEFAULT_KIND: u32 = KIND_GEOCHAT; +const TAG_G: &str = "g"; +const TAG_N: &str = "n"; +const TAG_T: &str = "t"; +const TAG_T_TELEPORT: &str = "teleport"; + +fn parse_geohash_tag(tags: &[Vec<String>]) -> Result<String, EventParseError> { + let tag = tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_G)) + .ok_or(EventParseError::MissingTag("g"))?; + let geohash = tag.get(1).ok_or(EventParseError::InvalidTag("g"))?; + if geohash.trim().is_empty() { + return Err(EventParseError::InvalidTag("g")); + } + Ok(geohash.to_string()) +} + +fn parse_nickname_tag(tags: &[Vec<String>]) -> Result<Option<String>, EventParseError> { + let tag = match tags + .iter() + .find(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_N)) + { + Some(tag) => tag, + None => return Ok(None), + }; + let nickname = tag.get(1).ok_or(EventParseError::InvalidTag("n"))?; + if nickname.trim().is_empty() { + return Err(EventParseError::InvalidTag("n")); + } + Ok(Some(nickname.to_string())) +} + +fn parse_teleport_tag(tags: &[Vec<String>]) -> Result<bool, EventParseError> { + for tag in tags + .iter() + .filter(|t| t.get(0).map(|s| s.as_str()) == Some(TAG_T)) + { + let value = tag.get(1).ok_or(EventParseError::InvalidTag("t"))?; + if value.trim().is_empty() { + return Err(EventParseError::InvalidTag("t")); + } + if value.eq_ignore_ascii_case(TAG_T_TELEPORT) { + return Ok(true); + } + } + Ok(false) +} + +pub fn geochat_from_tags( + kind: u32, + tags: &[Vec<String>], + content: &str, +) -> Result<RadrootsGeoChat, EventParseError> { + if kind != DEFAULT_KIND { + return Err(EventParseError::InvalidKind { + expected: "20000", + got: kind, + }); + } + if content.trim().is_empty() { + return Err(EventParseError::InvalidTag("content")); + } + + let geohash = parse_geohash_tag(tags)?; + let nickname = parse_nickname_tag(tags)?; + let teleported = parse_teleport_tag(tags)?; + + Ok(RadrootsGeoChat { + geohash, + content: content.to_string(), + nickname, + teleported, + }) +} + +pub fn metadata_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, +) -> Result<RadrootsGeoChatEventMetadata, EventParseError> { + let geochat = geochat_from_tags(kind, &tags, &content)?; + Ok(RadrootsGeoChatEventMetadata { + id, + author, + published_at, + kind, + geochat, + }) +} + +pub fn index_from_event( + id: String, + author: String, + published_at: u32, + kind: u32, + content: String, + tags: Vec<Vec<String>>, + sig: String, +) -> Result<RadrootsGeoChatEventIndex, EventParseError> { + let metadata = metadata_from_event( + id.clone(), + author.clone(), + published_at, + kind, + content.clone(), + tags.clone(), + )?; + Ok(RadrootsGeoChatEventIndex { + event: RadrootsNostrEvent { + id, + author, + created_at: published_at, + kind, + content, + tags, + sig, + }, + metadata, + }) +} diff --git a/events-codec/src/geochat/encode.rs b/events-codec/src/geochat/encode.rs @@ -0,0 +1,60 @@ +#[cfg(not(feature = "std"))] +use alloc::{string::{String, ToString}, vec::Vec}; +#[cfg(not(feature = "std"))] +use alloc::vec; + +use radroots_events::geochat::RadrootsGeoChat; +use radroots_events::kinds::KIND_GEOCHAT; + +use crate::error::EventEncodeError; +use crate::wire::WireEventParts; + +const DEFAULT_KIND: u32 = KIND_GEOCHAT; +const TAG_G: &str = "g"; +const TAG_N: &str = "n"; +const TAG_T: &str = "t"; +const TAG_T_TELEPORT: &str = "teleport"; + +fn push_tag(tags: &mut Vec<Vec<String>>, key: &str, value: &str) { + tags.push(vec![key.to_string(), value.to_string()]); +} + +pub fn geochat_build_tags( + geochat: &RadrootsGeoChat, +) -> Result<Vec<Vec<String>>, EventEncodeError> { + let geohash = geochat.geohash.trim(); + if geohash.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("geohash")); + } + + let mut tags = Vec::with_capacity( + 1 + usize::from(geochat.nickname.is_some()) + usize::from(geochat.teleported), + ); + push_tag(&mut tags, TAG_G, geohash); + + if let Some(nickname) = geochat.nickname.as_ref() { + let nickname = nickname.trim(); + if nickname.is_empty() { + return Err(EventEncodeError::EmptyRequiredField("nickname")); + } + push_tag(&mut tags, TAG_N, nickname); + } + + if geochat.teleported { + push_tag(&mut tags, TAG_T, TAG_T_TELEPORT); + } + + Ok(tags) +} + +pub fn to_wire_parts(geochat: &RadrootsGeoChat) -> Result<WireEventParts, EventEncodeError> { + if geochat.content.trim().is_empty() { + return Err(EventEncodeError::EmptyRequiredField("content")); + } + let tags = geochat_build_tags(geochat)?; + Ok(WireEventParts { + kind: DEFAULT_KIND, + content: geochat.content.clone(), + tags, + }) +} diff --git a/events-codec/src/geochat/mod.rs b/events-codec/src/geochat/mod.rs @@ -0,0 +1,4 @@ +#![forbid(unsafe_code)] + +pub mod decode; +pub mod encode; diff --git a/events-codec/src/lib.rs b/events-codec/src/lib.rs @@ -20,6 +20,7 @@ pub mod farm; pub mod resource_area; pub mod resource_cap; pub mod gift_wrap; +pub mod geochat; pub mod message; pub mod message_file; pub mod post; diff --git a/events-codec/src/tag_builders.rs b/events-codec/src/tag_builders.rs @@ -12,6 +12,7 @@ use radroots_events::{ coop::RadrootsCoop, follow::RadrootsFollow, farm::RadrootsFarm, + geochat::RadrootsGeoChat, resource_area::RadrootsResourceArea, resource_cap::RadrootsResourceHarvestCap, gift_wrap::RadrootsGiftWrap, @@ -37,6 +38,7 @@ use crate::document::encode::document_build_tags; use crate::coop::encode::coop_build_tags; use crate::follow::encode::follow_build_tags; use crate::farm::encode::farm_build_tags; +use crate::geochat::encode::geochat_build_tags; use crate::resource_area::encode::resource_area_build_tags; use crate::resource_cap::encode::resource_harvest_cap_build_tags; use crate::job::encode::JobEncodeError; @@ -106,6 +108,14 @@ impl RadrootsEventTagBuilder for RadrootsMessageFile { } } +impl RadrootsEventTagBuilder for RadrootsGeoChat { + type Error = EventEncodeError; + + fn build_tags(&self) -> Result<Vec<Vec<String>>, Self::Error> { + geochat_build_tags(self) + } +} + impl RadrootsEventTagBuilder for RadrootsFollow { type Error = EventEncodeError; diff --git a/events-codec/tests/geochat.rs b/events-codec/tests/geochat.rs @@ -0,0 +1,112 @@ +use radroots_events::{ + geochat::RadrootsGeoChat, + kinds::{KIND_GEOCHAT, KIND_POST}, +}; +use radroots_events_codec::error::{EventEncodeError, EventParseError}; +use radroots_events_codec::geochat::decode::geochat_from_tags; +use radroots_events_codec::geochat::encode::{geochat_build_tags, to_wire_parts}; + +#[test] +fn geochat_build_tags_requires_geohash() { + let geochat = RadrootsGeoChat { + geohash: " ".to_string(), + content: "hello".to_string(), + nickname: None, + teleported: false, + }; + + let err = geochat_build_tags(&geochat).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("geohash") + )); +} + +#[test] +fn geochat_build_tags_requires_nickname_if_present() { + let geochat = RadrootsGeoChat { + geohash: "dr5rsj7".to_string(), + content: "hello".to_string(), + nickname: Some(" ".to_string()), + teleported: false, + }; + + let err = geochat_build_tags(&geochat).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("nickname") + )); +} + +#[test] +fn geochat_to_wire_parts_requires_content() { + let geochat = RadrootsGeoChat { + geohash: "dr5rsj7".to_string(), + content: " ".to_string(), + nickname: None, + teleported: false, + }; + + let err = to_wire_parts(&geochat).unwrap_err(); + assert!(matches!( + err, + EventEncodeError::EmptyRequiredField("content") + )); +} + +#[test] +fn geochat_to_wire_parts_sets_tags() { + let geochat = RadrootsGeoChat { + geohash: "dr5rsj7".to_string(), + content: "hello".to_string(), + nickname: Some("alex".to_string()), + teleported: true, + }; + + let parts = to_wire_parts(&geochat).unwrap(); + assert_eq!(parts.kind, KIND_GEOCHAT); + assert_eq!(parts.content, "hello"); + assert_eq!( + parts.tags, + vec![ + vec!["g".to_string(), "dr5rsj7".to_string()], + vec!["n".to_string(), "alex".to_string()], + vec!["t".to_string(), "teleport".to_string()], + ] + ); +} + +#[test] +fn geochat_from_tags_requires_kind_geohash_and_content() { + let tags = vec![vec!["g".to_string(), "dr5rsj7".to_string()]]; + let err = geochat_from_tags(KIND_POST, &tags, "hello").unwrap_err(); + assert!(matches!( + err, + EventParseError::InvalidKind { + expected: "20000", + got: KIND_POST + } + )); + + let err = geochat_from_tags(KIND_GEOCHAT, &tags, " ").unwrap_err(); + assert!(matches!(err, EventParseError::InvalidTag("content"))); + + let err = geochat_from_tags(KIND_GEOCHAT, &[], "hello").unwrap_err(); + assert!(matches!(err, EventParseError::MissingTag("g"))); +} + +#[test] +fn geochat_roundtrip_from_tags() { + let tags = vec![ + vec!["g".to_string(), "dr5rsj7".to_string()], + vec!["n".to_string(), "alex".to_string()], + vec!["t".to_string(), "teleport".to_string()], + ]; + + let geochat = geochat_from_tags(KIND_GEOCHAT, &tags, "hello").unwrap(); + + assert_eq!(geochat.geohash, "dr5rsj7"); + assert_eq!(geochat.content, "hello"); + assert_eq!(geochat.nickname.as_deref(), Some("alex")); + assert!(geochat.teleported); +} diff --git a/events/bindings/ts/src/kinds.ts b/events/bindings/ts/src/kinds.ts @@ -7,6 +7,7 @@ export const KIND_MESSAGE = 14; export const KIND_MESSAGE_FILE = 15; export const KIND_GIFT_WRAP = 1059; export const KIND_COMMENT = 1111; +export const KIND_GEOCHAT = 20000; export const KIND_LIST_MUTE = 10000; export const KIND_LIST_PINNED_NOTES = 10001; export const KIND_LIST_READ_WRITE_RELAYS = 10002; diff --git a/events/bindings/ts/src/types.ts b/events/bindings/ts/src/types.ts @@ -64,6 +64,12 @@ export type RadrootsFollowProfile = { published_at: number, public_key: string, export type RadrootsGcsLocation = { lat: number, lng: number, geohash: string, point: RadrootsGeoJsonPoint, polygon: RadrootsGeoJsonPolygon, accuracy?: number | null, altitude?: number | null, tag_0?: string | null, label?: string | null, area?: number | null, elevation?: number | null, soil?: string | null, climate?: string | null, gc_id?: string | null, gc_name?: string | null, gc_admin1_id?: string | null, gc_admin1_name?: string | null, gc_country_id?: string | null, gc_country_name?: string | null, }; +export type RadrootsGeoChat = { geohash: string, content: string, nickname?: string | null, teleported: boolean, }; + +export type RadrootsGeoChatEventIndex = { event: RadrootsNostrEvent, metadata: RadrootsGeoChatEventMetadata, }; + +export type RadrootsGeoChatEventMetadata = { id: string, author: string, published_at: number, kind: number, geochat: RadrootsGeoChat, }; + export type RadrootsGeoJsonPoint = { type: string, coordinates: [number, number], }; export type RadrootsGeoJsonPolygon = { type: string, coordinates: Array<Array<[number, number]>>, }; diff --git a/events/src/geochat.rs b/events/src/geochat.rs @@ -0,0 +1,42 @@ +#![forbid(unsafe_code)] + +#[cfg(feature = "ts-rs")] +use ts_rs::TS; + +use crate::RadrootsNostrEvent; + +#[cfg(not(feature = "std"))] +use alloc::string::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 RadrootsGeoChatEventIndex { + pub event: RadrootsNostrEvent, + pub metadata: RadrootsGeoChatEventMetadata, +} + +#[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 RadrootsGeoChatEventMetadata { + pub id: String, + pub author: String, + pub published_at: u32, + pub kind: u32, + pub geochat: RadrootsGeoChat, +} + +#[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 RadrootsGeoChat { + pub geohash: String, + pub content: String, + #[cfg_attr(feature = "ts-rs", ts(optional, type = "string | null"))] + pub nickname: Option<String>, + pub teleported: bool, +} diff --git a/events/src/kinds.rs b/events/src/kinds.rs @@ -7,6 +7,7 @@ pub const KIND_MESSAGE: u32 = 14; pub const KIND_MESSAGE_FILE: u32 = 15; pub const KIND_GIFT_WRAP: u32 = 1059; pub const KIND_COMMENT: u32 = 1111; +pub const KIND_GEOCHAT: u32 = 20000; pub const KIND_LIST_MUTE: u32 = 10000; pub const KIND_LIST_PINNED_NOTES: u32 = 10001; pub const KIND_LIST_READ_WRITE_RELAYS: u32 = 10002; @@ -139,6 +140,7 @@ mod kinds_constants_tests { ("KIND_MESSAGE_FILE", KIND_MESSAGE_FILE), ("KIND_GIFT_WRAP", KIND_GIFT_WRAP), ("KIND_COMMENT", KIND_COMMENT), + ("KIND_GEOCHAT", KIND_GEOCHAT), ("KIND_LIST_MUTE", KIND_LIST_MUTE), ("KIND_LIST_PINNED_NOTES", KIND_LIST_PINNED_NOTES), ("KIND_LIST_READ_WRITE_RELAYS", KIND_LIST_READ_WRITE_RELAYS), diff --git a/events/src/lib.rs b/events/src/lib.rs @@ -13,6 +13,7 @@ pub mod comment; pub mod account; pub mod follow; pub mod gift_wrap; +pub mod geochat; pub mod job; pub mod job_feedback; pub mod job_request;