notedeck

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

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(&note);
    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(&note);
    383         assert!(!urls.is_empty());
    384     }
    385 }