1use serde::{Deserialize, Serialize};
12use serde_json::Map as JsonMap;
13use serde_json::Value as JsonValue;
14
15use crate::event::{EventBuilder, Kind};
16use crate::types::Url;
17
18#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
23pub struct Metadata {
24 #[serde(default, skip_serializing_if = "Option::is_none")]
26 pub name: Option<String>,
27 #[serde(default, skip_serializing_if = "Option::is_none")]
29 pub display_name: Option<String>,
30 #[serde(default, skip_serializing_if = "Option::is_none")]
32 pub about: Option<String>,
33 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub website: Option<Url>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
38 pub picture: Option<Url>,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub banner: Option<Url>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
44 pub nip05: Option<String>,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub lud06: Option<String>,
48 #[serde(default, skip_serializing_if = "Option::is_none")]
50 pub lud16: Option<String>,
51 #[serde(default, skip_serializing_if = "Option::is_none")]
54 pub bot: Option<bool>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub birthday: Option<Birthday>,
60 #[serde(flatten)]
73 pub custom: JsonMap<String, JsonValue>,
74}
75
76#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
86pub struct Birthday {
87 #[serde(default, skip_serializing_if = "Option::is_none")]
91 pub year: Option<u16>,
92 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub month: Option<u8>,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub day: Option<u8>,
101}
102
103impl Birthday {
104 #[must_use]
106 pub const fn new(year: u16, month: u8, day: u8) -> Self {
107 Self {
108 year: Some(year),
109 month: Some(month),
110 day: Some(day),
111 }
112 }
113
114 #[must_use]
117 pub const fn month_day(month: u8, day: u8) -> Self {
118 Self {
119 year: None,
120 month: Some(month),
121 day: Some(day),
122 }
123 }
124}
125
126impl Metadata {
127 #[must_use]
129 pub fn new() -> Self {
130 Self::default()
131 }
132
133 #[must_use]
135 pub fn with_name<S: Into<String>>(mut self, name: S) -> Self {
136 self.name = Some(name.into());
137 self
138 }
139
140 #[must_use]
142 pub fn with_display_name<S: Into<String>>(mut self, name: S) -> Self {
143 self.display_name = Some(name.into());
144 self
145 }
146
147 #[must_use]
149 pub fn with_about<S: Into<String>>(mut self, about: S) -> Self {
150 self.about = Some(about.into());
151 self
152 }
153
154 #[must_use]
156 pub fn with_website(mut self, website: Url) -> Self {
157 self.website = Some(website);
158 self
159 }
160
161 #[must_use]
163 pub fn with_picture(mut self, picture: Url) -> Self {
164 self.picture = Some(picture);
165 self
166 }
167
168 #[must_use]
170 pub fn with_banner(mut self, banner: Url) -> Self {
171 self.banner = Some(banner);
172 self
173 }
174
175 #[must_use]
177 pub fn with_nip05<S: Into<String>>(mut self, nip05: S) -> Self {
178 self.nip05 = Some(nip05.into());
179 self
180 }
181
182 #[must_use]
184 pub fn with_lud06<S: Into<String>>(mut self, lud06: S) -> Self {
185 self.lud06 = Some(lud06.into());
186 self
187 }
188
189 #[must_use]
191 pub fn with_lud16<S: Into<String>>(mut self, lud16: S) -> Self {
192 self.lud16 = Some(lud16.into());
193 self
194 }
195
196 #[must_use]
198 pub const fn with_bot(mut self, bot: bool) -> Self {
199 self.bot = Some(bot);
200 self
201 }
202
203 #[must_use]
205 pub const fn with_birthday(mut self, birthday: Birthday) -> Self {
206 self.birthday = Some(birthday);
207 self
208 }
209
210 #[must_use]
214 pub fn legacy_display_name(&self) -> Option<&str> {
215 self.custom.get("displayName").and_then(JsonValue::as_str)
216 }
217
218 #[must_use]
221 pub fn legacy_username(&self) -> Option<&str> {
222 self.custom.get("username").and_then(JsonValue::as_str)
223 }
224
225 #[must_use]
230 pub fn with_custom<S, V>(mut self, key: S, value: V) -> Self
231 where
232 S: Into<String>,
233 V: Into<JsonValue>,
234 {
235 self.custom.insert(key.into(), value.into());
236 self
237 }
238
239 pub fn to_event_content(&self) -> Result<String, serde_json::Error> {
248 serde_json::to_string(self)
249 }
250
251 pub fn from_event_content(content: &str) -> Result<Self, serde_json::Error> {
257 serde_json::from_str(content)
258 }
259}
260
261impl EventBuilder {
262 pub fn metadata(metadata: &Metadata) -> Result<Self, serde_json::Error> {
269 let content = metadata.to_event_content()?;
270 Ok(Self::new(Kind::METADATA, content))
271 }
272}
273
274#[cfg(test)]
275mod tests {
276 use super::*;
277 use crate::Keys;
278
279 fn keys() -> Keys {
280 Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
281 }
282
283 #[test]
284 fn empty_round_trip() {
285 let meta = Metadata::default();
286 let json = meta.to_event_content().unwrap();
287 assert_eq!(json, "{}");
288 let parsed = Metadata::from_event_content(&json).unwrap();
289 assert_eq!(parsed, meta);
290 }
291
292 #[test]
293 fn populated_round_trip() {
294 let meta = Metadata::new()
295 .with_name("alice")
296 .with_display_name("Alice")
297 .with_about("Cypherpunk.")
298 .with_website(Url::parse("https://alice.example").unwrap())
299 .with_picture(Url::parse("https://alice.example/pfp.png").unwrap())
300 .with_banner(Url::parse("https://alice.example/banner.png").unwrap())
301 .with_nip05("alice@alice.example")
302 .with_lud06("LNURL1...")
303 .with_lud16("alice@getalby.com");
304 let json = meta.to_event_content().unwrap();
305 let parsed = Metadata::from_event_content(&json).unwrap();
306 assert_eq!(parsed, meta);
307 }
308
309 #[test]
310 fn custom_fields_survive_round_trip() {
311 let meta = Metadata::new()
312 .with_name("alice")
313 .with_custom("custom_handle", "@alice")
314 .with_custom("x_internal_id", 7);
315 let json = meta.to_event_content().unwrap();
316 let parsed = Metadata::from_event_content(&json).unwrap();
317 assert_eq!(parsed, meta);
318 assert_eq!(
319 parsed.custom.get("custom_handle"),
320 Some(&JsonValue::String("@alice".into()))
321 );
322 assert_eq!(
323 parsed.custom.get("x_internal_id"),
324 Some(&JsonValue::Number(7.into()))
325 );
326 assert!(parsed.custom.get("bot").is_none());
328 }
329
330 #[test]
331 fn unknown_fields_remain_in_custom() {
332 let json = r#"{"name":"alice","future_field":42}"#;
333 let meta = Metadata::from_event_content(json).unwrap();
334 assert_eq!(meta.name.as_deref(), Some("alice"));
335 assert_eq!(
336 meta.custom.get("future_field"),
337 Some(&JsonValue::Number(42.into()))
338 );
339 }
340
341 #[test]
342 fn event_builder_metadata_helper_signs_kind_zero() {
343 let meta = Metadata::new()
344 .with_name("alice")
345 .with_about("Hello, Nostr.");
346 let event = EventBuilder::metadata(&meta)
347 .unwrap()
348 .sign_with_keys(&keys())
349 .unwrap();
350 assert_eq!(event.kind, Kind::METADATA);
351 let parsed = Metadata::from_event_content(&event.content).unwrap();
352 assert_eq!(parsed, meta);
353 event.verify().unwrap();
354 }
355
356 mod nip24_fixtures {
362 use super::*;
363
364 #[test]
367 fn nip01_minimal_round_trip() {
368 let json = r#"{"name":"alice","about":"cypherpunk","picture":"https://alice.example/pfp.png"}"#;
369 let parsed = Metadata::from_event_content(json).unwrap();
370 assert_eq!(parsed.name.as_deref(), Some("alice"));
371 assert_eq!(parsed.about.as_deref(), Some("cypherpunk"));
372 assert_eq!(
373 parsed.picture.as_ref().map(Url::as_str),
374 Some("https://alice.example/pfp.png"),
375 );
376 let again = Metadata::from_event_content(&parsed.to_event_content().unwrap()).unwrap();
378 assert_eq!(again, parsed);
379 }
380
381 #[test]
383 fn nip24_full_payload_round_trip() {
384 let meta = Metadata::new()
385 .with_name("alice")
386 .with_display_name("Alice the Cypherpunk")
387 .with_about("Building on Nostr.")
388 .with_website(Url::parse("https://alice.example").unwrap())
389 .with_picture(Url::parse("https://alice.example/pfp.png").unwrap())
390 .with_banner(Url::parse("https://alice.example/banner.png").unwrap())
391 .with_nip05("alice@alice.example")
392 .with_lud06("LNURL1DP68GURN8GHJ7AMPD3KX2AR0VEEKZAR0WD5XJTNRDAKJ7TNHV4KXCTTTDEHHWM30D3H82UNVWQHKZURF9AKXUATJD3CZ7CT9XGEK2ATWXSHHQH4UQAQE")
393 .with_lud16("alice@getalby.com")
394 .with_bot(false)
395 .with_birthday(Birthday::new(1990, 6, 15));
396
397 let json = meta.to_event_content().unwrap();
398 for needle in [
403 r#""name":"alice""#,
404 r#""display_name":"Alice the Cypherpunk""#,
405 r#""website":"https://alice.example/""#,
406 r#""banner":"https://alice.example/banner.png""#,
407 r#""nip05":"alice@alice.example""#,
408 r#""lud16":"alice@getalby.com""#,
409 r#""bot":false"#,
410 r#""day":15"#,
411 r#""month":6"#,
412 r#""year":1990"#,
413 ] {
414 assert!(
415 json.contains(needle),
416 "missing `{needle}` in serialized metadata: {json}",
417 );
418 }
419 assert_eq!(Metadata::from_event_content(&json).unwrap(), meta);
420 }
421
422 #[test]
426 fn forward_compat_unknown_fields_round_trip() {
427 let json = r#"{"name":"bob","damus_donation_v2":21,"website":"https://b.example/"}"#;
428 let parsed = Metadata::from_event_content(json).unwrap();
429 assert_eq!(parsed.name.as_deref(), Some("bob"));
430 assert_eq!(
431 parsed.custom.get("damus_donation_v2"),
432 Some(&serde_json::Value::Number(21.into()))
433 );
434 let again = parsed.to_event_content().unwrap();
436 assert!(again.contains(r#""damus_donation_v2":21"#));
437 }
438
439 #[test]
442 fn partial_birthday_round_trip() {
443 let meta = Metadata::new()
444 .with_name("mallory")
445 .with_birthday(Birthday::month_day(4, 1));
446 let json = meta.to_event_content().unwrap();
447 assert!(json.contains(r#""birthday":{"month":4,"day":1}"#));
448 assert!(
449 !json.contains("\"year\""),
450 "omitted year must not appear in the payload: {json}"
451 );
452 assert_eq!(Metadata::from_event_content(&json).unwrap(), meta);
453 }
454
455 #[test]
460 fn deprecated_fields_are_accessible_via_legacy_getters() {
461 let json = r#"{"displayName":"Dave","username":"davey","name":"dave","display_name":"Dave (new)"}"#;
462 let meta = Metadata::from_event_content(json).unwrap();
463
464 assert_eq!(meta.name.as_deref(), Some("dave"));
465 assert_eq!(meta.display_name.as_deref(), Some("Dave (new)"));
466 assert_eq!(meta.legacy_username(), Some("davey"));
467 assert_eq!(meta.legacy_display_name(), Some("Dave"));
468
469 let again = meta.to_event_content().unwrap();
471 assert!(again.contains(r#""displayName":"Dave""#));
472 assert!(again.contains(r#""username":"davey""#));
473 }
474 }
475}