notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

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(&note) 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(&note).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(&note), 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(&note), Some("37555:abc123:my-room"));
    246 
    247         let parsed_pos = parse_presence_position(&note).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(&note);
    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(&note, "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 }