Skip to main content

nula_core/nips/
nip38.rs

1//! [NIP-38] User Statuses.
2//!
3//! `kind: 30315` ("User Status") is an **addressable**, optionally
4//! **expiring** event. The `d` tag — the addressable identifier —
5//! also doubles as the *status type*: NIP-38 standardises `general`
6//! and `music` but leaves every other value open. The event body is
7//! the human-readable status text; an empty body is a spec-level
8//! signal to clear the status.
9//!
10//! Optional content:
11//!
12//! - a single link via an `r` / `p` / `e` / `a` tag (NIP-38 accepts
13//!   any of the four — this module exposes all four through
14//!   [`StatusLink`]);
15//! - a NIP-40 `expiration` tag (handled by the existing builder
16//!   helper and re-used here).
17//!
18//! # Authoring and reading
19//!
20//! Use [`EventBuilder::user_status`] to author, and [`UserStatus::from_event`]
21//! to parse an existing event back into the typed bundle. The
22//! builder guarantees:
23//!
24//! - `kind = 30315`;
25//! - exactly one `d` tag with the status-type identifier;
26//! - at most one link tag (the last `with_link` call wins);
27//! - the NIP-40 `expiration` tag when [`UserStatus::expires_at`] is
28//!   set.
29//!
30//! [NIP-38]: https://github.com/nostr-protocol/nips/blob/master/38.md
31
32use thiserror::Error;
33
34use crate::event::{
35    Alphabet, Coordinate, Event, EventBuilder, EventId, Kind, SingleLetterTag, Tag, TagKind,
36};
37use crate::key::PublicKey;
38use crate::types::Timestamp;
39
40/// `kind: 30315` — user status addressable event.
41pub const KIND_USER_STATUS: Kind = Kind::new(30_315);
42
43/// `d`-tag identifier for the "general" status type.
44pub const STATUS_TYPE_GENERAL: &str = "general";
45
46/// `d`-tag identifier for the "music" status type.
47pub const STATUS_TYPE_MUSIC: &str = "music";
48
49/// Status *type* (the `d`-tag identifier, doubling as the
50/// addressable coordinate).
51///
52/// NIP-38 standardises `general` and `music` but explicitly leaves
53/// room for other values through [`Self::Custom`].
54#[derive(Debug, Clone, PartialEq, Eq, Hash)]
55#[non_exhaustive]
56pub enum StatusType {
57    /// `general` — freeform status such as "Working", "Hiking".
58    General,
59    /// `music` — live-listening status; usually paired with an
60    /// `expiration` tag set to the track's end time.
61    Music,
62    /// Any other status type.
63    Custom(String),
64}
65
66impl StatusType {
67    /// Parse a `d`-tag identifier.
68    #[must_use]
69    pub fn parse(identifier: &str) -> Self {
70        match identifier {
71            STATUS_TYPE_GENERAL => Self::General,
72            STATUS_TYPE_MUSIC => Self::Music,
73            other => Self::Custom(other.to_owned()),
74        }
75    }
76
77    /// Render back to the wire identifier.
78    #[must_use]
79    pub const fn as_str(&self) -> &str {
80        match self {
81            Self::General => STATUS_TYPE_GENERAL,
82            Self::Music => STATUS_TYPE_MUSIC,
83            Self::Custom(s) => s.as_str(),
84        }
85    }
86}
87
88impl std::fmt::Display for StatusType {
89    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
90        f.write_str(self.as_str())
91    }
92}
93
94/// Optional link attached to a user status.
95///
96/// NIP-38 mentions an `r`, `p`, `e`, or `a` tag; we surface all four
97/// via this enum so the builder and reader keep the semantics
98/// round-trippable.
99#[derive(Debug, Clone, PartialEq, Eq)]
100#[non_exhaustive]
101pub enum StatusLink {
102    /// `r` tag — external URI. NIP-38 explicitly shows non-HTTP
103    /// schemes such as `spotify:search:…` in its examples, so the
104    /// variant carries a plain [`String`] rather than a
105    /// [`Url`](crate::types::Url) (which is strict about absolute
106    /// HTTP-family URLs).
107    Web(String),
108    /// `p` tag — referenced profile.
109    Profile(PublicKey),
110    /// `e` tag — referenced regular event id.
111    Event(EventId),
112    /// `a` tag — referenced addressable coordinate.
113    Addressable(Coordinate),
114}
115
116impl StatusLink {
117    /// Convert this link into the corresponding [`Tag`].
118    #[must_use]
119    pub fn to_tag(&self) -> Tag {
120        match self {
121            Self::Web(uri) => {
122                let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::R));
123                Tag::with(&head, [uri.clone()])
124            }
125            Self::Profile(pk) => Tag::p(*pk),
126            Self::Event(id) => Tag::e(*id),
127            Self::Addressable(coord) => {
128                let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::A));
129                Tag::with(&head, [coord.to_wire()])
130            }
131        }
132    }
133}
134
135/// A parsed or freshly constructed user status.
136#[derive(Debug, Clone, PartialEq, Eq)]
137pub struct UserStatus {
138    /// Status type (maps to the addressable `d` tag).
139    pub status_type: StatusType,
140    /// Status text. An empty string is a spec-level clear signal.
141    pub content: String,
142    /// Optional link (at most one; see [`StatusLink`]).
143    pub link: Option<StatusLink>,
144    /// Optional NIP-40 expiration.
145    pub expires_at: Option<Timestamp>,
146}
147
148impl UserStatus {
149    /// Construct a new status with only the two required pieces.
150    #[must_use]
151    pub fn new(status_type: StatusType, content: impl Into<String>) -> Self {
152        Self {
153            status_type,
154            content: content.into(),
155            link: None,
156            expires_at: None,
157        }
158    }
159
160    /// Attach a link. At most one link is carried; subsequent calls
161    /// replace the previous value (matches the builder's behaviour).
162    #[must_use]
163    pub fn with_link(mut self, link: StatusLink) -> Self {
164        self.link = Some(link);
165        self
166    }
167
168    /// Attach an NIP-40 expiration.
169    #[must_use]
170    pub const fn with_expiration(mut self, ts: Timestamp) -> Self {
171        self.expires_at = Some(ts);
172        self
173    }
174
175    /// `true` when [`Self::content`] is empty — the spec's clear
176    /// signal.
177    #[must_use]
178    pub const fn is_clear(&self) -> bool {
179        self.content.is_empty()
180    }
181
182    /// Parse a NIP-38 event back into the typed bundle.
183    ///
184    /// # Errors
185    ///
186    /// - [`UserStatusError::WrongKind`] for any kind other than
187    ///   [`KIND_USER_STATUS`].
188    /// - [`UserStatusError::MissingDTag`] when the addressable
189    ///   `d`-identifier is absent.
190    pub fn from_event(event: &Event) -> Result<Self, UserStatusError> {
191        if event.kind != KIND_USER_STATUS {
192            return Err(UserStatusError::WrongKind(event.kind));
193        }
194        let d = find_d_tag(event).ok_or(UserStatusError::MissingDTag)?;
195        let status_type = StatusType::parse(d);
196        let link = parse_link(event);
197        let expires_at = event.expiration().ok().flatten();
198        Ok(Self {
199            status_type,
200            content: event.content.clone(),
201            link,
202            expires_at,
203        })
204    }
205}
206
207/// Errors raised when reading a [`UserStatus`] off an [`Event`].
208#[derive(Debug, Error)]
209#[non_exhaustive]
210pub enum UserStatusError {
211    /// The event was not `kind: 30315`.
212    #[error("expected kind 30315 (user status), got kind {}", .0.as_u16())]
213    WrongKind(Kind),
214    /// The required `d` tag was absent.
215    #[error("NIP-38 event must carry exactly one `d` tag")]
216    MissingDTag,
217}
218
219fn find_d_tag(event: &Event) -> Option<&str> {
220    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::D));
221    event.tags.find_first(&head).and_then(|tag| tag.get(1))
222}
223
224fn parse_link(event: &Event) -> Option<StatusLink> {
225    for tag in &event.tags {
226        let TagKind::SingleLetter(letter) = tag.kind() else {
227            continue;
228        };
229        if letter.uppercase {
230            continue;
231        }
232        let Some(value) = tag.get(1) else {
233            continue;
234        };
235        let link = match letter.character {
236            Alphabet::R => Some(StatusLink::Web(value.to_owned())),
237            Alphabet::P => PublicKey::parse(value).ok().map(StatusLink::Profile),
238            Alphabet::E => EventId::parse(value).ok().map(StatusLink::Event),
239            Alphabet::A => Coordinate::parse(value).ok().map(StatusLink::Addressable),
240            _ => None,
241        };
242        if let Some(link) = link {
243            return Some(link);
244        }
245    }
246    None
247}
248
249impl EventBuilder {
250    /// Author a NIP-38 user-status event.
251    ///
252    /// The builder pins `kind = 30315` and always emits the `d` tag
253    /// carrying the status-type identifier. The NIP-40 expiration
254    /// tag is attached through the existing
255    /// [`EventBuilder::expiration`] path when
256    /// [`UserStatus::expires_at`] is set, so callers that also
257    /// chain `.expiration(ts)` manually will get a single
258    /// consolidated tag.
259    #[must_use]
260    pub fn user_status(status: UserStatus) -> Self {
261        let mut builder =
262            Self::new(KIND_USER_STATUS, status.content).tag(Tag::d(status.status_type.as_str()));
263        if let Some(link) = status.link {
264            builder = builder.tag(link.to_tag());
265        }
266        if let Some(ts) = status.expires_at {
267            builder = builder.expiration(ts);
268        }
269        builder
270    }
271}
272
273#[cfg(test)]
274mod tests {
275    use super::*;
276    use crate::Keys;
277
278    fn keys() -> Keys {
279        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
280    }
281
282    #[test]
283    fn status_type_round_trips_through_parse_and_as_str() {
284        for (s, v) in [
285            ("general", StatusType::General),
286            ("music", StatusType::Music),
287            ("lunar-phase", StatusType::Custom("lunar-phase".into())),
288        ] {
289            let parsed = StatusType::parse(s);
290            assert_eq!(parsed, v);
291            assert_eq!(parsed.as_str(), s);
292        }
293    }
294
295    #[test]
296    fn builder_emits_kind_and_d_tag() {
297        let status = UserStatus::new(StatusType::General, "Working");
298        let event = EventBuilder::user_status(status)
299            .sign_with_keys(&keys())
300            .unwrap();
301        assert_eq!(event.kind, KIND_USER_STATUS);
302        assert_eq!(event.content, "Working");
303        let d = find_d_tag(&event).unwrap();
304        assert_eq!(d, STATUS_TYPE_GENERAL);
305    }
306
307    #[test]
308    fn builder_attaches_web_link_and_expiration() {
309        let uri = "spotify:search:Intergalatic".to_owned();
310        let status = UserStatus::new(StatusType::Music, "Intergalatic - Beastie Boys")
311            .with_link(StatusLink::Web(uri.clone()))
312            .with_expiration(Timestamp::from_secs(1_692_845_589));
313        let event = EventBuilder::user_status(status)
314            .sign_with_keys(&keys())
315            .unwrap();
316
317        let parsed = UserStatus::from_event(&event).unwrap();
318        assert_eq!(parsed.status_type, StatusType::Music);
319        assert_eq!(parsed.content, "Intergalatic - Beastie Boys");
320        assert_eq!(parsed.link, Some(StatusLink::Web(uri)));
321        assert_eq!(parsed.expires_at, Some(Timestamp::from_secs(1_692_845_589)));
322    }
323
324    #[test]
325    fn from_event_round_trips_profile_link() {
326        let pk = *keys().public_key();
327        let status =
328            UserStatus::new(StatusType::General, "mentoring").with_link(StatusLink::Profile(pk));
329        let event = EventBuilder::user_status(status)
330            .sign_with_keys(&keys())
331            .unwrap();
332        let parsed = UserStatus::from_event(&event).unwrap();
333        assert_eq!(parsed.link, Some(StatusLink::Profile(pk)));
334    }
335
336    #[test]
337    fn from_event_rejects_wrong_kind() {
338        let event = EventBuilder::text_note("not a status")
339            .sign_with_keys(&keys())
340            .unwrap();
341        assert!(matches!(
342            UserStatus::from_event(&event),
343            Err(UserStatusError::WrongKind(_))
344        ));
345    }
346
347    #[test]
348    fn empty_content_signals_a_clear() {
349        let status = UserStatus::new(StatusType::General, "");
350        assert!(status.is_clear());
351    }
352
353    #[test]
354    fn custom_status_type_round_trips_on_d_tag() {
355        let status = UserStatus::new(StatusType::Custom("focus".into()), "heads-down");
356        let event = EventBuilder::user_status(status)
357            .sign_with_keys(&keys())
358            .unwrap();
359        let parsed = UserStatus::from_event(&event).unwrap();
360        assert_eq!(parsed.status_type, StatusType::Custom("focus".into()));
361    }
362}