1use std::collections::BTreeMap;
17
18use serde::Deserialize;
19use thiserror::Error;
20
21use super::nip65::RelayMarker;
22use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind};
23use crate::key::{PublicKey, PublicKeyError};
24use crate::types::{RelayUrl, RelayUrlError};
25
26#[derive(Debug, Clone, PartialEq, Eq, Hash)]
28pub struct Contact {
29 pub pubkey: PublicKey,
31 pub relay_hint: Option<RelayUrl>,
33 pub petname: Option<String>,
35}
36
37impl Contact {
38 #[must_use]
40 pub const fn new(pubkey: PublicKey) -> Self {
41 Self {
42 pubkey,
43 relay_hint: None,
44 petname: None,
45 }
46 }
47
48 #[must_use]
50 pub fn with_relay_hint(mut self, hint: RelayUrl) -> Self {
51 self.relay_hint = Some(hint);
52 self
53 }
54
55 #[must_use]
57 pub fn with_petname(mut self, petname: impl Into<String>) -> Self {
58 self.petname = Some(petname.into());
59 self
60 }
61}
62
63#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct ContactList {
70 pub contacts: Vec<Contact>,
72}
73
74impl ContactList {
75 #[must_use]
77 pub fn new() -> Self {
78 Self::default()
79 }
80
81 #[must_use]
83 pub fn follow(mut self, contact: Contact) -> Self {
84 self.contacts.push(contact);
85 self
86 }
87
88 #[must_use]
90 pub const fn len(&self) -> usize {
91 self.contacts.len()
92 }
93
94 #[must_use]
96 pub const fn is_empty(&self) -> bool {
97 self.contacts.is_empty()
98 }
99
100 #[must_use]
102 pub fn to_tags(&self) -> Vec<Tag> {
103 let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
104 self.contacts
105 .iter()
106 .map(|c| build_p_tag(&p_kind, c))
107 .collect()
108 }
109
110 pub fn from_event(event: &Event) -> Result<Self, ContactListError> {
120 if event.kind != Kind::CONTACTS {
121 return Err(ContactListError::UnexpectedKind(event.kind.as_u16()));
122 }
123 let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
124 let mut contacts = Vec::with_capacity(event.tags.as_slice().len());
125 for tag in &event.tags {
126 if tag.kind() != p_kind {
127 continue;
128 }
129 let mut values = tag.values().iter().skip(1);
130 let pubkey = values
131 .next()
132 .ok_or(ContactListError::MissingPubkey)?
133 .parse::<PublicKey>()?;
134 let relay_hint = match values.next() {
135 Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
136 _ => None,
137 };
138 let petname = match values.next() {
139 Some(s) if !s.is_empty() => Some(s.clone()),
140 _ => None,
141 };
142 contacts.push(Contact {
143 pubkey,
144 relay_hint,
145 petname,
146 });
147 }
148 Ok(Self { contacts })
149 }
150}
151
152impl EventBuilder {
153 #[must_use]
158 pub fn contact_list(list: &ContactList) -> Self {
159 Self::new(Kind::CONTACTS, "").tags(list.to_tags())
160 }
161}
162
163fn build_p_tag(p_kind: &TagKind, contact: &Contact) -> Tag {
164 let pubkey = contact.pubkey.to_hex();
165 let relay = contact
166 .relay_hint
167 .as_ref()
168 .map(|r| r.as_str().to_owned())
169 .unwrap_or_default();
170 let petname = contact.petname.clone().unwrap_or_default();
171
172 if !petname.is_empty() {
173 Tag::with(p_kind, [pubkey, relay, petname])
174 } else if !relay.is_empty() {
175 Tag::with(p_kind, [pubkey, relay])
176 } else {
177 Tag::with(p_kind, [pubkey])
178 }
179}
180
181#[derive(Debug, Error)]
183#[non_exhaustive]
184pub enum ContactListError {
185 #[error("expected kind 3, got {0}")]
187 UnexpectedKind(u16),
188 #[error("`p` tag is missing the pubkey value")]
190 MissingPubkey,
191 #[error(transparent)]
193 InvalidPubkey(#[from] PublicKeyError),
194 #[error(transparent)]
196 InvalidRelay(#[from] RelayUrlError),
197 #[error("invalid legacy relay JSON: {0}")]
199 InvalidLegacyJson(#[from] serde_json::Error),
200}
201
202#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Deserialize)]
207struct LegacyRelayEntry {
208 #[serde(default)]
209 read: bool,
210 #[serde(default)]
211 write: bool,
212}
213
214impl ContactList {
215 pub fn legacy_relays(
250 content: &str,
251 ) -> Result<BTreeMap<RelayUrl, RelayMarker>, ContactListError> {
252 let trimmed = content.trim();
253 if trimmed.is_empty() {
254 return Ok(BTreeMap::new());
255 }
256 let raw: BTreeMap<String, LegacyRelayEntry> = serde_json::from_str(trimmed)?;
257 let mut out = BTreeMap::new();
258 for (url, entry) in raw {
259 let marker = match (entry.read, entry.write) {
260 (true, true) => RelayMarker::ReadWrite,
261 (true, false) => RelayMarker::Read,
262 (false, true) => RelayMarker::Write,
263 (false, false) => continue,
264 };
265 let parsed = RelayUrl::parse(&url)?;
266 out.insert(parsed, marker);
267 }
268 Ok(out)
269 }
270}
271
272#[cfg(test)]
273mod tests {
274 use super::*;
275 use crate::Keys;
276 use crate::types::Timestamp;
277
278 fn keys() -> Keys {
279 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
280 }
281
282 fn pk(seed: u8) -> PublicKey {
283 let mut bytes = [0u8; 32];
284 bytes[31] = seed;
285 let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
286 *Keys::from_secret_key(sk).public_key()
287 }
288
289 #[test]
290 fn empty_round_trip() {
291 let list = ContactList::new();
292 let event = EventBuilder::contact_list(&list)
293 .created_at(Timestamp::from_secs(1))
294 .sign_with_keys(&keys())
295 .unwrap();
296 event.verify().unwrap();
297 assert_eq!(event.kind, Kind::CONTACTS);
298 let parsed = ContactList::from_event(&event).unwrap();
299 assert_eq!(parsed, list);
300 }
301
302 #[test]
303 fn round_trip_with_full_metadata() {
304 let list = ContactList::new()
305 .follow(
306 Contact::new(pk(1))
307 .with_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
308 .with_petname("alice"),
309 )
310 .follow(Contact::new(pk(2)))
311 .follow(
312 Contact::new(pk(3)).with_relay_hint(RelayUrl::parse("wss://r.x.com/").unwrap()),
313 );
314 let event = EventBuilder::contact_list(&list)
315 .created_at(Timestamp::from_secs(2))
316 .sign_with_keys(&keys())
317 .unwrap();
318 let parsed = ContactList::from_event(&event).unwrap();
319 assert_eq!(parsed, list);
320 }
321
322 #[test]
323 fn order_is_preserved() {
324 let list = ContactList::new()
325 .follow(Contact::new(pk(3)))
326 .follow(Contact::new(pk(1)))
327 .follow(Contact::new(pk(2)));
328 let event = EventBuilder::contact_list(&list)
329 .created_at(Timestamp::from_secs(3))
330 .sign_with_keys(&keys())
331 .unwrap();
332 let parsed = ContactList::from_event(&event).unwrap();
333 assert_eq!(
334 parsed.contacts.iter().map(|c| c.pubkey).collect::<Vec<_>>(),
335 list.contacts.iter().map(|c| c.pubkey).collect::<Vec<_>>(),
336 );
337 }
338
339 #[test]
340 fn unknown_tags_are_ignored() {
341 let event = EventBuilder::new(Kind::CONTACTS, "")
342 .created_at(Timestamp::from_secs(4))
343 .tags([
344 Tag::new(["p", &pk(1).to_hex()]).unwrap(),
345 Tag::new(["alt", "ignored"]).unwrap(),
346 ])
347 .sign_with_keys(&keys())
348 .unwrap();
349 let parsed = ContactList::from_event(&event).unwrap();
350 assert_eq!(parsed.len(), 1);
351 }
352
353 #[test]
354 fn rejects_wrong_kind() {
355 let event = EventBuilder::text_note("not contacts")
356 .created_at(Timestamp::from_secs(5))
357 .sign_with_keys(&keys())
358 .unwrap();
359 let err = ContactList::from_event(&event).unwrap_err();
360 assert!(matches!(err, ContactListError::UnexpectedKind(1)));
361 }
362
363 #[test]
364 fn rejects_missing_pubkey() {
365 let event = EventBuilder::new(Kind::CONTACTS, "")
366 .created_at(Timestamp::from_secs(6))
367 .tag(Tag::new(["p"]).unwrap())
368 .sign_with_keys(&keys())
369 .unwrap();
370 let err = ContactList::from_event(&event).unwrap_err();
371 assert!(matches!(err, ContactListError::MissingPubkey));
372 }
373
374 #[test]
375 fn legacy_relays_empty_content_returns_empty_map() {
376 let map = ContactList::legacy_relays("").unwrap();
377 assert!(map.is_empty());
378 }
379
380 #[test]
381 fn legacy_relays_round_trip_full_matrix() {
382 let json = r#"{
383 "wss://both.example/": {"read": true, "write": true},
384 "wss://read.example/": {"read": true, "write": false},
385 "wss://write.example/": {"read": false, "write": true},
386 "wss://muted.example/": {"read": false, "write": false}
387 }"#;
388 let map = ContactList::legacy_relays(json).unwrap();
389 assert_eq!(map.len(), 3, "skipped: muted entry has no marker");
390 assert_eq!(
391 map.get(&RelayUrl::parse("wss://both.example/").unwrap()),
392 Some(&RelayMarker::ReadWrite),
393 );
394 assert_eq!(
395 map.get(&RelayUrl::parse("wss://read.example/").unwrap()),
396 Some(&RelayMarker::Read),
397 );
398 assert_eq!(
399 map.get(&RelayUrl::parse("wss://write.example/").unwrap()),
400 Some(&RelayMarker::Write),
401 );
402 }
403
404 #[test]
405 fn legacy_relays_rejects_invalid_json() {
406 let err = ContactList::legacy_relays("not json").unwrap_err();
407 assert!(matches!(err, ContactListError::InvalidLegacyJson(_)));
408 }
409
410 #[test]
411 fn legacy_relays_rejects_invalid_relay_url() {
412 let json = r#"{"https://not-a-relay.example": {"read": true, "write": true}}"#;
413 let err = ContactList::legacy_relays(json).unwrap_err();
414 assert!(matches!(err, ContactListError::InvalidRelay(_)));
415 }
416}