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:
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;