nostr_events.rs (8628B)
1 //! Nostr event creation and parsing for nostrverse spaces. 2 //! 3 //! Space events (kind 37555) are NIP-33 parameterized replaceable events 4 //! where the content is a protoverse `.space` s-expression. 5 6 use enostr::FilledKeypair; 7 use nostrdb::{Ndb, Note, NoteBuilder}; 8 use protoverse::Space; 9 10 use crate::kinds; 11 12 /// Build a space event (kind 37555) from a protoverse Space. 13 /// 14 /// Tags: ["d", space_id], ["name", space_name], ["summary", text_description] 15 /// Content: serialized .space s-expression 16 pub fn build_space_event<'a>(space: &Space, space_id: &str) -> NoteBuilder<'a> { 17 let content = protoverse::serialize(space); 18 let summary = protoverse::describe(space); 19 let name = space.name(space.root).unwrap_or("Untitled Space"); 20 21 NoteBuilder::new() 22 .kind(kinds::ROOM as u32) 23 .content(&content) 24 .start_tag() 25 .tag_str("d") 26 .tag_str(space_id) 27 .start_tag() 28 .tag_str("name") 29 .tag_str(name) 30 .start_tag() 31 .tag_str("summary") 32 .tag_str(&summary) 33 } 34 35 /// Parse a space event's content into a protoverse Space. 36 pub fn parse_space_event(note: &Note<'_>) -> Option<Space> { 37 let content = note.content(); 38 if content.is_empty() { 39 return None; 40 } 41 protoverse::parse(content).ok() 42 } 43 44 /// Extract the "d" tag (space identifier) from a note. 45 pub fn get_space_id<'a>(note: &'a Note<'a>) -> Option<&'a str> { 46 get_tag_value(note, "d") 47 } 48 49 /// Extract a tag value by name from a note. 50 fn get_tag_value<'a>(note: &'a Note<'a>, tag_name: &str) -> Option<&'a str> { 51 for tag in note.tags() { 52 if tag.count() < 2 { 53 continue; 54 } 55 let Some(name) = tag.get_str(0) else { 56 continue; 57 }; 58 if name != tag_name { 59 continue; 60 } 61 return tag.get_str(1); 62 } 63 None 64 } 65 66 /// Build a coarse presence heartbeat event (kind 10555). 67 /// 68 /// Published on meaningful position change, plus periodic keep-alive. 69 /// Tags: ["a", room_naddr], ["position", "x y z"], ["expiration", unix_ts] 70 /// Content: empty 71 /// 72 /// The expiration tag (NIP-40) tells relays/nostrdb to discard the event 73 /// after 90 seconds, matching the client-side stale timeout. 74 pub fn build_presence_event<'a>( 75 room_naddr: &str, 76 position: glam::Vec3, 77 velocity: glam::Vec3, 78 ) -> NoteBuilder<'a> { 79 let pos_str = format!("{} {} {}", position.x, position.y, position.z); 80 let vel_str = format!("{} {} {}", velocity.x, velocity.y, velocity.z); 81 82 let expiration = std::time::SystemTime::now() 83 .duration_since(std::time::UNIX_EPOCH) 84 .unwrap_or_default() 85 .as_secs() 86 + 90; 87 let exp_str = expiration.to_string(); 88 89 NoteBuilder::new() 90 .kind(kinds::PRESENCE as u32) 91 .content("") 92 .start_tag() 93 .tag_str("a") 94 .tag_str(room_naddr) 95 .start_tag() 96 .tag_str("position") 97 .tag_str(&pos_str) 98 .start_tag() 99 .tag_str("velocity") 100 .tag_str(&vel_str) 101 .start_tag() 102 .tag_str("expiration") 103 .tag_str(&exp_str) 104 } 105 106 /// Parse a whitespace-separated "x y z" string into a Vec3. 107 fn parse_vec3(s: &str) -> Option<glam::Vec3> { 108 let mut parts = s.split_whitespace(); 109 let x: f32 = parts.next()?.parse().ok()?; 110 let y: f32 = parts.next()?.parse().ok()?; 111 let z: f32 = parts.next()?.parse().ok()?; 112 Some(glam::Vec3::new(x, y, z)) 113 } 114 115 /// Parse a presence event's position tag into a Vec3. 116 pub fn parse_presence_position(note: &Note<'_>) -> Option<glam::Vec3> { 117 parse_vec3(get_tag_value(note, "position")?) 118 } 119 120 /// Parse a presence event's velocity tag into a Vec3. 121 /// Returns Vec3::ZERO if no velocity tag (backward compatible with old events). 122 pub fn parse_presence_velocity(note: &Note<'_>) -> glam::Vec3 { 123 get_tag_value(note, "velocity") 124 .and_then(parse_vec3) 125 .unwrap_or(glam::Vec3::ZERO) 126 } 127 128 /// Extract the "a" tag (space naddr) from a presence note. 129 pub fn get_presence_space<'a>(note: &'a Note<'a>) -> Option<&'a str> { 130 get_tag_value(note, "a") 131 } 132 133 /// Sign and ingest a nostr event into the local nostrdb. 134 /// 135 /// Returns the built note on success so callers can publish it directly. 136 pub fn ingest_event( 137 builder: NoteBuilder<'_>, 138 ndb: &Ndb, 139 kp: FilledKeypair, 140 ) -> Option<Note<'static>> { 141 let note = builder 142 .sign(&kp.secret_key.secret_bytes()) 143 .build() 144 .expect("build note"); 145 146 let Ok(event) = enostr::ClientMessage::event(¬e) else { 147 tracing::error!("ingest_event: failed to build client message"); 148 return None; 149 }; 150 151 let Ok(json) = event.to_json() else { 152 tracing::error!("ingest_event: failed to serialize json"); 153 return None; 154 }; 155 156 let _ = ndb.process_event_with(&json, nostrdb::IngestMetadata::new().client(true)); 157 158 Some(note) 159 } 160 161 #[cfg(test)] 162 mod tests { 163 use super::*; 164 165 #[test] 166 fn test_build_space_event() { 167 let space = protoverse::parse( 168 r#"(space (name "Test Space") 169 (group (table (id desk) (name "My Desk"))))"#, 170 ) 171 .unwrap(); 172 173 let mut builder = build_space_event(&space, "my-space"); 174 let note = builder.build().expect("build note"); 175 176 // Content should be the serialized space 177 let content = note.content(); 178 assert!(content.contains("space")); 179 assert!(content.contains("Test Space")); 180 181 // Should have d, name, summary tags 182 let mut has_d = false; 183 let mut has_name = false; 184 let mut has_summary = false; 185 186 for tag in note.tags() { 187 if tag.count() < 2 { 188 continue; 189 } 190 match tag.get_str(0) { 191 Some("d") => { 192 assert_eq!(tag.get_str(1), Some("my-space")); 193 has_d = true; 194 } 195 Some("name") => { 196 assert_eq!(tag.get_str(1), Some("Test Space")); 197 has_name = true; 198 } 199 Some("summary") => { 200 has_summary = true; 201 } 202 _ => {} 203 } 204 } 205 206 assert!(has_d, "missing d tag"); 207 assert!(has_name, "missing name tag"); 208 assert!(has_summary, "missing summary tag"); 209 } 210 211 #[test] 212 fn test_parse_space_event_roundtrip() { 213 let original = r#"(space (name "Test Space") 214 (group (table (id desk) (name "My Desk"))))"#; 215 216 let space = protoverse::parse(original).unwrap(); 217 let mut builder = build_space_event(&space, "test-space"); 218 let note = builder.build().expect("build note"); 219 220 // Parse the event content back into a Space 221 let parsed = parse_space_event(¬e).expect("parse space event"); 222 assert_eq!(parsed.name(parsed.root), Some("Test Space")); 223 224 // Should have same structure 225 assert_eq!(space.cells.len(), parsed.cells.len()); 226 } 227 228 #[test] 229 fn test_get_space_id() { 230 let space = protoverse::parse("(space (name \"X\"))").unwrap(); 231 let mut builder = build_space_event(&space, "my-id"); 232 let note = builder.build().expect("build note"); 233 234 assert_eq!(get_space_id(¬e), Some("my-id")); 235 } 236 237 #[test] 238 fn test_build_presence_event() { 239 let pos = glam::Vec3::new(1.5, 0.0, -3.2); 240 let vel = glam::Vec3::new(2.0, 0.0, -1.0); 241 let mut builder = build_presence_event("37555:abc123:my-room", pos, vel); 242 let note = builder.build().expect("build note"); 243 244 assert_eq!(note.content(), ""); 245 assert_eq!(get_presence_space(¬e), Some("37555:abc123:my-room")); 246 247 let parsed_pos = parse_presence_position(¬e).expect("parse position"); 248 assert!((parsed_pos.x - 1.5).abs() < 0.01); 249 assert!((parsed_pos.y - 0.0).abs() < 0.01); 250 assert!((parsed_pos.z - (-3.2)).abs() < 0.01); 251 252 let parsed_vel = parse_presence_velocity(¬e); 253 assert!((parsed_vel.x - 2.0).abs() < 0.01); 254 assert!((parsed_vel.y - 0.0).abs() < 0.01); 255 assert!((parsed_vel.z - (-1.0)).abs() < 0.01); 256 257 // Should have an expiration tag (NIP-40) 258 let exp = get_tag_value(¬e, "expiration").expect("missing expiration tag"); 259 let exp_ts: u64 = exp.parse().expect("expiration should be a number"); 260 let now = std::time::SystemTime::now() 261 .duration_since(std::time::UNIX_EPOCH) 262 .unwrap() 263 .as_secs(); 264 assert!(exp_ts > now, "expiration should be in the future"); 265 assert!(exp_ts <= now + 91, "expiration should be ~90s from now"); 266 } 267 }