mod.rs (11002B)
1 pub mod message; 2 3 use enostr::{FullKeypair, NormRelayUrl, Pubkey, SecretKey}; 4 use hashbrown::HashSet; 5 pub use message::send_conversation_message; 6 pub use nostr::secp256k1::rand::rngs::OsRng; 7 use nostr::secp256k1::rand::Rng; 8 use nostr::{ 9 event::{EventBuilder, Kind, Tag}, 10 key::PublicKey, 11 nips::nip44, 12 util::JsonUtil, 13 }; 14 use nostrdb::{Filter, FilterBuilder, Ndb, Note, NoteBuilder, Transaction}; 15 use notedeck::get_p_tags; 16 17 fn build_rumor_json( 18 message: &str, 19 participants: &[Pubkey], 20 sender_pubkey: &Pubkey, 21 ) -> Option<String> { 22 let sender = nostrcrate_pk(sender_pubkey)?; 23 let mut tags = Vec::new(); 24 for participant in participants { 25 if let Some(pk) = nostrcrate_pk(participant) { 26 tags.push(Tag::public_key(pk)); 27 } else { 28 tracing::warn!("invalid participant {}", participant); 29 } 30 } 31 32 let builder = EventBuilder::new(Kind::PrivateDirectMessage, message).tags(tags); 33 Some(builder.build(sender).as_json()) 34 } 35 36 pub fn giftwrap_message( 37 rng: &mut OsRng, 38 sender_secret: &SecretKey, 39 recipient: &Pubkey, 40 rumor_json: &str, 41 ) -> Option<Note<'static>> { 42 let Some(recipient_pk) = nostrcrate_pk(recipient) else { 43 tracing::warn!("failed to convert recipient pubkey {}", recipient); 44 return None; 45 }; 46 47 let encrypted_rumor = match nip44::encrypt_with_rng( 48 rng, 49 sender_secret, 50 &recipient_pk, 51 rumor_json, 52 nip44::Version::V2, 53 ) { 54 Ok(payload) => payload, 55 Err(err) => { 56 tracing::error!("failed to encrypt rumor for {recipient}: {err}"); 57 return None; 58 } 59 }; 60 61 let seal_created = randomized_timestamp(rng); 62 let Some(seal_json) = build_seal_json(&encrypted_rumor, sender_secret, seal_created) else { 63 tracing::error!("failed to build seal for recipient {}", recipient); 64 return None; 65 }; 66 67 let wrap_keys = FullKeypair::generate(); 68 let encrypted_seal = match nip44::encrypt_with_rng( 69 rng, 70 &wrap_keys.secret_key, 71 &recipient_pk, 72 &seal_json, 73 nip44::Version::V2, 74 ) { 75 Ok(payload) => payload, 76 Err(err) => { 77 tracing::error!("failed to encrypt seal for wrap: {err}"); 78 return None; 79 } 80 }; 81 82 let wrap_created = randomized_timestamp(rng); 83 build_giftwrap_note(&encrypted_seal, &wrap_keys, recipient, wrap_created) 84 } 85 86 fn build_seal_json( 87 content_ciphertext: &str, 88 sender_secret: &SecretKey, 89 created_at: u64, 90 ) -> Option<String> { 91 let builder = NoteBuilder::new() 92 .kind(13) 93 .content(content_ciphertext) 94 .created_at(created_at); 95 96 builder 97 .sign(&sender_secret.secret_bytes()) 98 .build()? 99 .json() 100 .ok() 101 } 102 103 fn build_giftwrap_note( 104 content: &str, 105 wrap_keys: &FullKeypair, 106 recipient: &Pubkey, 107 created_at: u64, 108 ) -> Option<Note<'static>> { 109 let builder = NoteBuilder::new() 110 .kind(1059) 111 .content(content) 112 .created_at(created_at) 113 .start_tag() 114 .tag_str("p") 115 .tag_str(&recipient.hex()); 116 117 builder.sign(&wrap_keys.secret_key.secret_bytes()).build() 118 } 119 120 fn nostrcrate_pk(pk: &Pubkey) -> Option<PublicKey> { 121 PublicKey::from_slice(pk.bytes()).ok() 122 } 123 124 fn current_timestamp() -> u64 { 125 use std::time::{SystemTime, UNIX_EPOCH}; 126 127 SystemTime::now() 128 .duration_since(UNIX_EPOCH) 129 .unwrap_or_default() 130 .as_secs() 131 } 132 133 fn randomized_timestamp(rng: &mut OsRng) -> u64 { 134 const MAX_SKEW_SECS: u64 = 2 * 24 * 60 * 60; 135 let now = current_timestamp(); 136 let tweak = rng.gen_range(0..=MAX_SKEW_SECS); 137 now.saturating_sub(tweak) 138 } 139 140 #[profiling::function] 141 pub fn get_participants<'a>(note: &Note<'a>) -> Vec<&'a [u8; 32]> { 142 let mut participants = get_p_tags(note); 143 let chat_message_sender = note.pubkey(); 144 if !participants.contains(&chat_message_sender) { 145 // the chat message sender must be in the participants set 146 participants.push(chat_message_sender); 147 } 148 participants 149 } 150 151 pub fn conversation_filter(cur_acc: &Pubkey) -> Vec<Filter> { 152 vec![ 153 FilterBuilder::new() 154 .kinds([14]) 155 .pubkey([cur_acc.bytes()]) 156 .build(), 157 FilterBuilder::new() 158 .kinds([14]) 159 .authors([cur_acc.bytes()]) 160 .build(), 161 ] 162 } 163 164 /// Unfortunately this gives an OR across participants 165 pub fn chatroom_filter(participants: Vec<&[u8; 32]>, me: &[u8; 32]) -> Vec<Filter> { 166 vec![FilterBuilder::new() 167 .kinds([14]) 168 .authors(participants.clone()) 169 .pubkey([me]) 170 .build()] 171 } 172 173 /// Builds a filter for one participant's kind `10050` DM relay list. 174 pub fn participant_dm_relay_list_filter(participant: &Pubkey) -> Filter { 175 FilterBuilder::new() 176 .kinds([10050]) 177 .authors([participant.bytes()]) 178 .limit(1) 179 .build() 180 } 181 182 /// Returns `true` when `note` is a kind `10050` DM relay-list authored by `participant`. 183 pub fn is_participant_dm_relay_list(note: &Note<'_>, participant: &Pubkey) -> bool { 184 note.kind() == 10050 && note.pubkey() == participant.bytes() 185 } 186 187 /// Queries NDB for presence of one participant's kind `10050` DM relay list. 188 pub fn has_participant_dm_relay_list(ndb: &Ndb, txn: &Transaction, participant: &Pubkey) -> bool { 189 let filter = participant_dm_relay_list_filter(participant); 190 let Ok(results) = ndb.query(txn, std::slice::from_ref(&filter), 1) else { 191 return false; 192 }; 193 194 !results.is_empty() 195 } 196 197 /// Default relay URLs used when creating a new kind `10050` DM relay-list note. 198 pub fn default_dm_relay_urls() -> &'static [&'static str] { 199 &["wss://relay.damus.io", "wss://nos.lol"] 200 } 201 202 /// Builds a signed kind `10050` DM relay-list note using default relay URLs. 203 pub fn build_default_dm_relay_list_note(sender_secret: &SecretKey) -> Option<Note<'static>> { 204 let mut builder = NoteBuilder::new().kind(10050).content(""); 205 206 for relay in default_dm_relay_urls() { 207 builder = builder.start_tag().tag_str("relay").tag_str(relay); 208 } 209 210 builder.sign(&sender_secret.secret_bytes()).build() 211 } 212 213 /// Parses a kind `10050` note into unique websocket relay URLs. 214 pub fn parse_dm_relay_list_relays(note: &Note<'_>) -> Vec<NormRelayUrl> { 215 if note.kind() != 10050 { 216 return Vec::new(); 217 } 218 219 let mut seen = HashSet::new(); 220 let mut relays = Vec::new(); 221 222 for tag in note.tags() { 223 if tag.count() < 2 { 224 continue; 225 } 226 227 let Some("relay") = tag.get_str(0) else { 228 continue; 229 }; 230 231 let Some(url) = tag.get_str(1) else { 232 continue; 233 }; 234 235 let Ok(norm_url) = NormRelayUrl::new(url) else { 236 continue; 237 }; 238 239 if !seen.insert(norm_url.clone()) { 240 continue; 241 } 242 243 relays.push(norm_url); 244 } 245 246 relays 247 } 248 249 /// Queries NDB for one participant's latest kind `10050` relay list. 250 /// 251 /// Returns explicit websocket relay URLs when available, else an empty vec. 252 pub fn query_participant_dm_relays( 253 ndb: &Ndb, 254 txn: &Transaction, 255 participant: &Pubkey, 256 ) -> Vec<NormRelayUrl> { 257 let filter = participant_dm_relay_list_filter(participant); 258 let Ok(results) = ndb.query(txn, std::slice::from_ref(&filter), 1) else { 259 return Vec::new(); 260 }; 261 262 let Some(result) = results.first() else { 263 return Vec::new(); 264 }; 265 266 parse_dm_relay_list_relays(&result.note) 267 } 268 269 // easily retrievable from Note<'a> 270 pub struct Nip17ChatMessage<'a> { 271 pub sender: &'a [u8; 32], 272 pub p_tags: Vec<&'a [u8; 32]>, 273 pub subject: Option<&'a str>, 274 pub reply_to: Option<&'a [u8; 32]>, // NoteId 275 pub message: &'a str, 276 pub created_at: u64, 277 } 278 279 pub fn parse_chat_message<'a>(note: &Note<'a>) -> Option<Nip17ChatMessage<'a>> { 280 if note.kind() != 14 { 281 return None; 282 } 283 284 let mut p_tags = Vec::new(); 285 let mut subject = None; 286 let mut reply_to = None; 287 288 for tag in note.tags() { 289 if tag.count() < 2 { 290 continue; 291 } 292 let Some(first) = tag.get_str(0) else { 293 continue; 294 }; 295 296 if first == "p" { 297 if let Some(id) = tag.get_id(1) { 298 p_tags.push(id); 299 } 300 } else if first == "subject" { 301 subject = tag.get_str(1); 302 } else if first == "e" { 303 reply_to = tag.get_id(1); 304 } 305 } 306 307 Some(Nip17ChatMessage { 308 sender: note.pubkey(), 309 p_tags, 310 subject, 311 reply_to, 312 message: note.content(), 313 created_at: note.created_at(), 314 }) 315 } 316 317 #[cfg(test)] 318 mod tests { 319 use super::*; 320 use nostrdb::NoteBuilder; 321 322 fn relay_note(relays: &[&str]) -> Note<'static> { 323 let signer = FullKeypair::generate(); 324 let mut builder = NoteBuilder::new().kind(10050).content(""); 325 for relay in relays { 326 builder = builder.start_tag().tag_str("relay").tag_str(relay); 327 } 328 329 builder 330 .sign(&signer.secret_key.secret_bytes()) 331 .build() 332 .expect("relay note") 333 } 334 335 /// Verifies the relay-list filter targets kind `10050`, the participant author, and limit `1`. 336 #[test] 337 fn participant_dm_relay_list_filter_is_stable() { 338 let participant = Pubkey::new([0x22; 32]); 339 let actual = participant_dm_relay_list_filter(&participant); 340 let expected = FilterBuilder::new() 341 .kinds([10050]) 342 .authors([participant.bytes()]) 343 .limit(1) 344 .build(); 345 346 assert_eq!( 347 actual.json().expect("actual filter json"), 348 expected.json().expect("expected filter json") 349 ); 350 } 351 352 /// Verifies relay parsing ignores invalid URLs and deduplicates repeated relay tags. 353 #[test] 354 fn parse_dm_relay_list_relays_dedupes_and_skips_invalid_urls() { 355 let note = relay_note(&[ 356 "wss://relay-a.example.com", 357 "notaurl", 358 "wss://relay-a.example.com", 359 "wss://relay-b.example.com", 360 ]); 361 362 let parsed = parse_dm_relay_list_relays(¬e); 363 assert_eq!(parsed.len(), 2); 364 365 let actual: HashSet<NormRelayUrl> = HashSet::from_iter(parsed); 366 let expected = HashSet::from_iter( 367 ["wss://relay-a.example.com", "wss://relay-b.example.com"] 368 .into_iter() 369 .map(|relay| NormRelayUrl::new(relay).expect("norm relay")), 370 ); 371 372 assert_eq!(actual, expected); 373 } 374 375 /// Verifies default DM relay-list note construction emits kind `10050` and relay tags. 376 #[test] 377 fn build_default_dm_relay_list_note_contains_default_relays() { 378 let signer = FullKeypair::generate(); 379 let note = build_default_dm_relay_list_note(&signer.secret_key).expect("relay list note"); 380 381 assert_eq!(note.kind(), 10050); 382 let urls = parse_dm_relay_list_relays(¬e); 383 assert!(!urls.is_empty()); 384 } 385 }