Skip to main content

nula_core/nips/
nip10.rs

1//! [NIP-10] Replies and Mentions in Text Notes.
2//!
3//! NIP-10 specifies how `kind: 1` notes reference one another to build
4//! threads. The recommended ("preferred") form attaches a marker to each
5//! `e` tag:
6//!
7//! ```text
8//! ["e", "<event-id>", "<relay-hint>", "<marker>", "<author-pubkey>?"]
9//! ```
10//!
11//! - `root` — the top of the thread.
12//! - `reply` — the parent note this one is replying to.
13//! - `mention` — a quoted reference, not a reply.
14//!
15//! `p` tags carry the pubkeys mentioned in the thread (typically the
16//! authors of all referenced events). NIP-10 also describes a legacy
17//! positional form; this module emits the marker form on the way out and
18//! tolerates both on the way in.
19//!
20//! [NIP-10]: https://github.com/nostr-protocol/nips/blob/master/10.md
21
22use std::fmt;
23use std::str::FromStr;
24
25use thiserror::Error;
26
27use crate::event::{
28    Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
29};
30use crate::key::{PublicKey, PublicKeyError};
31use crate::types::{RelayUrl, RelayUrlError};
32
33/// NIP-10 marker for an `e` tag.
34#[derive(Debug, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)]
35#[non_exhaustive]
36pub enum NoteMarker {
37    /// Top of the thread.
38    Root,
39    /// The parent note this one replies to.
40    Reply,
41    /// Quoted (not replied to).
42    Mention,
43}
44
45impl NoteMarker {
46    /// Static wire string used in the third column of an `e` tag.
47    #[must_use]
48    pub const fn as_str(self) -> &'static str {
49        match self {
50            Self::Root => "root",
51            Self::Reply => "reply",
52            Self::Mention => "mention",
53        }
54    }
55}
56
57impl fmt::Display for NoteMarker {
58    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
59        f.write_str(self.as_str())
60    }
61}
62
63/// Errors raised when parsing a [`NoteMarker`].
64#[derive(Debug, Clone, Error)]
65#[non_exhaustive]
66pub enum NoteMarkerError {
67    /// The marker string was not one of `root`, `reply`, `mention`.
68    #[error("unknown NIP-10 marker `{0}`")]
69    Unknown(String),
70}
71
72impl FromStr for NoteMarker {
73    type Err = NoteMarkerError;
74
75    fn from_str(s: &str) -> Result<Self, Self::Err> {
76        match s {
77            "root" => Ok(Self::Root),
78            "reply" => Ok(Self::Reply),
79            "mention" => Ok(Self::Mention),
80            other => Err(NoteMarkerError::Unknown(other.to_owned())),
81        }
82    }
83}
84
85/// Reference to another event from inside a thread.
86#[derive(Debug, Clone, PartialEq, Eq, Hash)]
87pub struct EventReference {
88    /// The id of the referenced event.
89    pub event_id: EventId,
90    /// Optional relay hint where the event can be fetched.
91    pub relay_hint: Option<RelayUrl>,
92    /// Optional NIP-10 marker.
93    pub marker: Option<NoteMarker>,
94    /// Optional hint of the referenced event's author.
95    pub author_hint: Option<PublicKey>,
96}
97
98impl EventReference {
99    /// Construct a reference with no hints or marker.
100    #[must_use]
101    pub const fn new(event_id: EventId) -> Self {
102        Self {
103            event_id,
104            relay_hint: None,
105            marker: None,
106            author_hint: None,
107        }
108    }
109
110    /// Set the relay hint.
111    #[must_use]
112    pub fn with_relay_hint(mut self, relay: RelayUrl) -> Self {
113        self.relay_hint = Some(relay);
114        self
115    }
116
117    /// Set the NIP-10 marker.
118    #[must_use]
119    pub const fn with_marker(mut self, marker: NoteMarker) -> Self {
120        self.marker = Some(marker);
121        self
122    }
123
124    /// Set the author hint.
125    #[must_use]
126    pub const fn with_author_hint(mut self, author: PublicKey) -> Self {
127        self.author_hint = Some(author);
128        self
129    }
130}
131
132/// NIP-10 thread metadata for a `kind: 1` note.
133#[derive(Debug, Default, Clone, PartialEq, Eq)]
134pub struct ThreadContext {
135    /// Every `e` tag, in the order they appear on the wire.
136    pub events: Vec<EventReference>,
137    /// Pubkeys collected from the `p` tags.
138    pub mentioned_pubkeys: Vec<PublicKey>,
139}
140
141impl ThreadContext {
142    /// Construct an empty context.
143    #[must_use]
144    pub fn new() -> Self {
145        Self::default()
146    }
147
148    /// Append an event reference and return `self`.
149    #[must_use]
150    pub fn reference(mut self, reference: EventReference) -> Self {
151        self.events.push(reference);
152        self
153    }
154
155    /// Append a mentioned pubkey.
156    #[must_use]
157    pub fn mention(mut self, pubkey: PublicKey) -> Self {
158        self.mentioned_pubkeys.push(pubkey);
159        self
160    }
161
162    /// First reference whose marker is [`NoteMarker::Root`], if any.
163    #[must_use]
164    pub fn root(&self) -> Option<&EventReference> {
165        self.events
166            .iter()
167            .find(|r| r.marker == Some(NoteMarker::Root))
168    }
169
170    /// First reference whose marker is [`NoteMarker::Reply`], if any.
171    #[must_use]
172    pub fn reply(&self) -> Option<&EventReference> {
173        self.events
174            .iter()
175            .find(|r| r.marker == Some(NoteMarker::Reply))
176    }
177
178    /// Every reference whose marker is [`NoteMarker::Mention`].
179    pub fn mentions(&self) -> impl Iterator<Item = &EventReference> {
180        self.events
181            .iter()
182            .filter(|r| r.marker == Some(NoteMarker::Mention))
183    }
184
185    /// Fill in markers on `e` references that came from the *deprecated
186    /// positional form* of NIP-10.
187    ///
188    /// Per NIP-10 §"deprecated positional form": when an event carries
189    /// `e` tags without explicit markers, the position determines the
190    /// role:
191    ///
192    /// - 0 unmarked references: nothing to do
193    /// - 1 unmarked reference: it is the [`NoteMarker::Root`]
194    /// - 2+ unmarked references: first is [`NoteMarker::Root`], last is
195    ///   [`NoteMarker::Reply`], every entry in between is
196    ///   [`NoteMarker::Mention`]
197    ///
198    /// Existing markers are never overwritten — references that already
199    /// have a marker keep it. This makes the operation safe to call on
200    /// any [`ThreadContext`], including ones produced by
201    /// [`ThreadContext::from_event`] on a legacy thread mixed with
202    /// modern markers.
203    #[must_use]
204    pub fn infer_legacy_markers(mut self) -> Self {
205        let unmarked: Vec<usize> = self
206            .events
207            .iter()
208            .enumerate()
209            .filter_map(|(i, r)| if r.marker.is_none() { Some(i) } else { None })
210            .collect();
211        let assign = |slot: &mut Self, idx: usize, marker: NoteMarker| {
212            if let Some(r) = slot.events.get_mut(idx) {
213                r.marker = Some(marker);
214            }
215        };
216        match unmarked.as_slice() {
217            [] => {}
218            [only] => assign(&mut self, *only, NoteMarker::Root),
219            [first, middle @ .., last] => {
220                assign(&mut self, *first, NoteMarker::Root);
221                assign(&mut self, *last, NoteMarker::Reply);
222                for &idx in middle {
223                    assign(&mut self, idx, NoteMarker::Mention);
224                }
225            }
226        }
227        self
228    }
229
230    /// Render the context as the [`Tag`]s that go into a `kind: 1` note.
231    #[must_use]
232    pub fn to_tags(&self) -> Vec<Tag> {
233        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
234        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
235
236        let mut tags = Vec::with_capacity(self.events.len() + self.mentioned_pubkeys.len());
237        for r in &self.events {
238            tags.push(build_e_tag(&e_kind, r));
239        }
240        for pk in &self.mentioned_pubkeys {
241            tags.push(Tag::with(&p_kind, [pk.to_hex()]));
242        }
243        tags
244    }
245
246    /// Reconstruct a [`ThreadContext`] from `event`'s tags.
247    ///
248    /// The parser is tolerant: malformed `e`/`p` tags are skipped instead
249    /// of failing the whole event, since real-world clients have produced
250    /// many variations over the years. Use [`EventReference::from_tag`]
251    /// directly for the strict, fail-fast version.
252    #[must_use]
253    pub fn from_event(event: &Event) -> Self {
254        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
255        let p_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
256
257        let mut context = Self::new();
258        for tag in &event.tags {
259            let head = tag.kind();
260            if head == e_kind
261                && let Ok(reference) = EventReference::from_tag(tag)
262            {
263                context.events.push(reference);
264            } else if head == p_kind
265                && let Some(pk) = tag
266                    .values()
267                    .get(1)
268                    .and_then(|s| s.parse::<PublicKey>().ok())
269            {
270                context.mentioned_pubkeys.push(pk);
271            }
272        }
273        context
274    }
275}
276
277impl EventBuilder {
278    /// Build a `kind: 1` text note carrying the supplied [`ThreadContext`]
279    /// (i.e. a NIP-10 reply or mention).
280    #[must_use]
281    pub fn note_with_context<S: Into<String>>(content: S, context: &ThreadContext) -> Self {
282        Self::new(Kind::TEXT_NOTE, content).tags(context.to_tags())
283    }
284}
285
286fn build_e_tag(e_kind: &TagKind, reference: &EventReference) -> Tag {
287    let event_id = reference.event_id.to_hex();
288    let relay = reference
289        .relay_hint
290        .as_ref()
291        .map(|r| r.as_str().to_owned())
292        .unwrap_or_default();
293    let marker = reference
294        .marker
295        .map(|m| m.as_str().to_owned())
296        .unwrap_or_default();
297    let author = reference
298        .author_hint
299        .map(PublicKey::to_hex)
300        .unwrap_or_default();
301
302    if !author.is_empty() {
303        Tag::with(e_kind, [event_id, relay, marker, author])
304    } else if !marker.is_empty() {
305        Tag::with(e_kind, [event_id, relay, marker])
306    } else if !relay.is_empty() {
307        Tag::with(e_kind, [event_id, relay])
308    } else {
309        Tag::with(e_kind, [event_id])
310    }
311}
312
313/// Errors that decoding strict-mode (i.e. fail-fast) NIP-10 references can
314/// produce. Currently only used by [`EventReference::from_tag`].
315#[derive(Debug, Clone, Error)]
316#[non_exhaustive]
317pub enum ThreadError {
318    /// The tag head was not `e`.
319    #[error("expected `e` tag, got `{0}`")]
320    NotEventTag(String),
321    /// The tag had no event id.
322    #[error("`e` tag is missing the event id")]
323    MissingEventId,
324    /// The event id did not parse.
325    #[error(transparent)]
326    InvalidEventId(#[from] EventIdError),
327    /// The relay hint did not parse.
328    #[error(transparent)]
329    InvalidRelay(#[from] RelayUrlError),
330    /// The marker did not parse.
331    #[error(transparent)]
332    InvalidMarker(#[from] NoteMarkerError),
333    /// The author hint did not parse.
334    #[error(transparent)]
335    InvalidAuthor(#[from] PublicKeyError),
336}
337
338impl EventReference {
339    /// Strict, fail-fast version of the per-tag parser used by
340    /// [`ThreadContext::from_event`]. Use this when you want to surface
341    /// malformed `e` tags instead of silently dropping them.
342    ///
343    /// # Errors
344    ///
345    /// Returns the matching [`ThreadError`] for any malformed component.
346    pub fn from_tag(tag: &Tag) -> Result<Self, ThreadError> {
347        let e_kind = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::E));
348        if tag.kind() != e_kind {
349            return Err(ThreadError::NotEventTag(tag.kind().as_str().to_owned()));
350        }
351        let mut values = tag.values().iter().skip(1);
352        let id = values
353            .next()
354            .ok_or(ThreadError::MissingEventId)?
355            .parse::<EventId>()?;
356        let relay_hint = match values.next() {
357            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
358            _ => None,
359        };
360        let marker = match values.next() {
361            Some(s) if !s.is_empty() => Some(s.parse::<NoteMarker>()?),
362            _ => None,
363        };
364        let author_hint = match values.next() {
365            Some(s) if !s.is_empty() => Some(s.parse::<PublicKey>()?),
366            _ => None,
367        };
368        Ok(Self {
369            event_id: id,
370            relay_hint,
371            marker,
372            author_hint,
373        })
374    }
375}
376
377#[cfg(test)]
378mod tests {
379    use super::*;
380    use crate::Keys;
381    use crate::types::Timestamp;
382
383    fn keys() -> Keys {
384        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
385    }
386
387    fn event_id(seed: u8) -> EventId {
388        EventId::from_byte_array([seed; 32])
389    }
390
391    fn pk(seed: u8) -> PublicKey {
392        let mut bytes = [0u8; 32];
393        bytes[31] = seed;
394        let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
395        *Keys::from_secret_key(sk).public_key()
396    }
397
398    #[test]
399    fn marker_round_trip() {
400        for marker in [NoteMarker::Root, NoteMarker::Reply, NoteMarker::Mention] {
401            let s = marker.as_str();
402            assert_eq!(s.parse::<NoteMarker>().unwrap(), marker);
403        }
404    }
405
406    #[test]
407    fn marker_rejects_unknown() {
408        let err = "thread".parse::<NoteMarker>().unwrap_err();
409        assert!(matches!(err, NoteMarkerError::Unknown(_)));
410    }
411
412    #[test]
413    fn round_trip_through_event() {
414        let context = ThreadContext::new()
415            .reference(
416                EventReference::new(event_id(0xaa))
417                    .with_relay_hint(RelayUrl::parse("wss://relay.example/").unwrap())
418                    .with_marker(NoteMarker::Root)
419                    .with_author_hint(pk(1)),
420            )
421            .reference(
422                EventReference::new(event_id(0xbb))
423                    .with_marker(NoteMarker::Reply)
424                    .with_author_hint(pk(2)),
425            )
426            .reference(EventReference::new(event_id(0xcc)).with_marker(NoteMarker::Mention))
427            .mention(pk(3));
428
429        let event = EventBuilder::note_with_context("hi thread", &context)
430            .created_at(Timestamp::from_secs(1))
431            .sign_with_keys(&keys())
432            .unwrap();
433        event.verify().unwrap();
434        let parsed = ThreadContext::from_event(&event);
435        assert_eq!(parsed, context);
436
437        assert_eq!(parsed.root().unwrap().event_id, event_id(0xaa));
438        assert_eq!(parsed.reply().unwrap().event_id, event_id(0xbb));
439        let mentions: Vec<_> = parsed.mentions().collect();
440        assert_eq!(mentions.len(), 1);
441        assert_eq!(mentions[0].event_id, event_id(0xcc));
442    }
443
444    #[test]
445    fn legacy_positional_tags_decode_without_marker() {
446        // No marker columns; only the event id.
447        let event = EventBuilder::text_note("legacy thread")
448            .created_at(Timestamp::from_secs(2))
449            .tag(Tag::new(["e", &event_id(0xaa).to_hex()]).unwrap())
450            .sign_with_keys(&keys())
451            .unwrap();
452        let parsed = ThreadContext::from_event(&event);
453        assert_eq!(parsed.events.len(), 1);
454        assert!(parsed.events[0].marker.is_none());
455        assert!(parsed.root().is_none());
456    }
457
458    #[test]
459    fn malformed_e_tag_is_skipped_in_lenient_parse() {
460        let event = EventBuilder::text_note("bad ref")
461            .created_at(Timestamp::from_secs(3))
462            .tags([
463                Tag::new(["e", "not-a-hex-id"]).unwrap(),
464                Tag::new(["e", &event_id(0x10).to_hex()]).unwrap(),
465            ])
466            .sign_with_keys(&keys())
467            .unwrap();
468        let parsed = ThreadContext::from_event(&event);
469        // The bad one is silently dropped, the good one survives.
470        assert_eq!(parsed.events.len(), 1);
471    }
472
473    #[test]
474    fn from_tag_strict_returns_errors() {
475        let bad = Tag::new(["e", "not-a-hex-id"]).unwrap();
476        let err = EventReference::from_tag(&bad).unwrap_err();
477        assert!(matches!(err, ThreadError::InvalidEventId(_)));
478    }
479
480    #[test]
481    fn from_tag_rejects_non_e_tag() {
482        let tag = Tag::new(["p", &pk(1).to_hex()]).unwrap();
483        let err = EventReference::from_tag(&tag).unwrap_err();
484        assert!(matches!(err, ThreadError::NotEventTag(_)));
485    }
486
487    #[test]
488    fn legacy_positional_single_e_tag_becomes_root() {
489        let context = ThreadContext::new()
490            .reference(EventReference::new(event_id(0xaa)))
491            .infer_legacy_markers();
492        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
493        assert!(context.reply().is_none());
494    }
495
496    #[test]
497    fn legacy_positional_multi_e_tag_assigns_root_reply_mention() {
498        let context = ThreadContext::new()
499            .reference(EventReference::new(event_id(0xaa)))
500            .reference(EventReference::new(event_id(0xbb)))
501            .reference(EventReference::new(event_id(0xcc)))
502            .reference(EventReference::new(event_id(0xdd)))
503            .infer_legacy_markers();
504        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
505        assert_eq!(context.events[1].marker, Some(NoteMarker::Mention));
506        assert_eq!(context.events[2].marker, Some(NoteMarker::Mention));
507        assert_eq!(context.events[3].marker, Some(NoteMarker::Reply));
508    }
509
510    #[test]
511    fn legacy_positional_two_e_tags_become_root_and_reply() {
512        let context = ThreadContext::new()
513            .reference(EventReference::new(event_id(0xaa)))
514            .reference(EventReference::new(event_id(0xbb)))
515            .infer_legacy_markers();
516        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
517        assert_eq!(context.events[1].marker, Some(NoteMarker::Reply));
518    }
519
520    #[test]
521    fn legacy_positional_inference_preserves_existing_markers() {
522        // Mixed thread: an explicit Root plus an unmarked tail. Inference
523        // must not overwrite the explicit marker; it labels only the
524        // unmarked entries (here: only one, which becomes Root by the
525        // single-unmarked rule).
526        let context = ThreadContext::new()
527            .reference(EventReference::new(event_id(0xaa)).with_marker(NoteMarker::Root))
528            .reference(EventReference::new(event_id(0xbb)))
529            .infer_legacy_markers();
530        assert_eq!(context.events[0].marker, Some(NoteMarker::Root));
531        assert_eq!(context.events[1].marker, Some(NoteMarker::Root));
532    }
533
534    #[test]
535    fn legacy_positional_no_e_tags_is_a_noop() {
536        let context = ThreadContext::new().infer_legacy_markers();
537        assert!(context.events.is_empty());
538    }
539}