Skip to main content

nula_core/nips/
nip22.rs

1//! [NIP-22] Comments.
2//!
3//! NIP-22 introduces `kind: 1111` events that comment on **any** other
4//! Nostr or external identifier (URL, podcast item, …). The tag scheme
5//! distinguishes *root* scope (uppercase) from *parent* scope (lowercase):
6//!
7//! | tag | scope  | content                                            |
8//! |-----|--------|----------------------------------------------------|
9//! | `E` | root   | regular event id                                   |
10//! | `A` | root   | replaceable coordinate (`<kind>:<pubkey>:<id>`)    |
11//! | `I` | root   | external identifier (NIP-73)                       |
12//! | `K` | root   | event kind                                         |
13//! | `P` | root   | author pubkey                                      |
14//! | `e` | parent | regular event id                                   |
15//! | `a` | parent | replaceable coordinate                             |
16//! | `i` | parent | external identifier                                |
17//! | `k` | parent | event kind                                         |
18//! | `p` | parent | author pubkey                                      |
19//!
20//! The crate models the three scope flavours through [`CommentScope`] and
21//! exposes [`Comment`] as the authoring/parsing struct.
22//!
23//! [NIP-22]: https://github.com/nostr-protocol/nips/blob/master/22.md
24
25use thiserror::Error;
26
27use crate::event::{
28    Coordinate, CoordinateError, Event, EventBuilder, EventId, EventIdError, Kind, Tag, TagKind,
29};
30use crate::key::{PublicKey, PublicKeyError};
31use crate::types::{RelayUrl, RelayUrlError};
32
33/// What a comment is rooted at or replying to.
34#[derive(Debug, Clone, PartialEq, Eq, Hash)]
35#[non_exhaustive]
36pub enum CommentScope {
37    /// A regular event id.
38    Event {
39        /// Target event id.
40        id: EventId,
41        /// Optional relay hint.
42        relay_hint: Option<RelayUrl>,
43    },
44    /// A parameterized replaceable event coordinate.
45    Address {
46        /// `(kind, author, identifier)` of the addressable event.
47        coordinate: Coordinate,
48        /// Optional relay hint.
49        relay_hint: Option<RelayUrl>,
50    },
51    /// An external identifier (NIP-73), e.g. a URL.
52    External {
53        /// Free-form identifier value.
54        value: String,
55        /// Optional relay or context hint.
56        context: Option<String>,
57    },
58}
59
60impl CommentScope {
61    /// Construct an [`CommentScope::Event`] without a relay hint.
62    #[must_use]
63    pub const fn event(id: EventId) -> Self {
64        Self::Event {
65            id,
66            relay_hint: None,
67        }
68    }
69
70    /// Construct an [`CommentScope::Address`] without a relay hint.
71    #[must_use]
72    pub const fn address(coordinate: Coordinate) -> Self {
73        Self::Address {
74            coordinate,
75            relay_hint: None,
76        }
77    }
78
79    /// Construct an [`CommentScope::External`] from an identifier.
80    #[must_use]
81    pub fn external(value: impl Into<String>) -> Self {
82        Self::External {
83            value: value.into(),
84            context: None,
85        }
86    }
87}
88
89/// A NIP-22 `kind: 1111` comment ready to be turned into an [`Event`].
90///
91/// Both `root` and `parent` are mandatory; if the comment replies directly
92/// to the root, set both fields to the same scope.
93///
94/// # Spec note on `K` / `P` tags
95///
96/// NIP-22 declares that the `K` (root kind) and `P` (root pubkey) tags
97/// "MUST be" present alongside an `E`/`A` root scope. The crate stores
98/// them as `Option`s for two practical reasons:
99///
100/// - the [`CommentScope::External`] flavour has no inherent kind or
101///   author (the comment targets a URL, podcast, or NIP-73 identifier),
102/// - real-world events from Coracle, Damus and others sometimes ship a
103///   comment without the K/P columns, and the lenient parser would
104///   otherwise reject them.
105///
106/// Authors writing new NIP-22 events targeting [`CommentScope::Event`] /
107/// [`CommentScope::Address`] roots SHOULD set [`Self::root_kind`] (and
108/// the matching parent kind / author hints) so downstream relays can
109/// index the comment correctly.
110#[derive(Debug, Clone, PartialEq, Eq)]
111pub struct Comment {
112    /// Top of the conversation.
113    pub root: CommentScope,
114    /// Optional kind hint for the root.
115    pub root_kind: Option<Kind>,
116    /// Optional author hint for the root.
117    pub root_author: Option<PublicKey>,
118    /// Direct parent (the message being replied to).
119    pub parent: CommentScope,
120    /// Optional kind hint for the parent.
121    pub parent_kind: Option<Kind>,
122    /// Optional author hint for the parent.
123    pub parent_author: Option<PublicKey>,
124    /// Free-form comment text.
125    pub content: String,
126}
127
128impl Comment {
129    /// Construct a comment whose `parent` equals its `root`.
130    #[must_use]
131    pub fn top_level(root: CommentScope, content: impl Into<String>) -> Self {
132        Self {
133            parent: root.clone(),
134            parent_kind: None,
135            parent_author: None,
136            root,
137            root_kind: None,
138            root_author: None,
139            content: content.into(),
140        }
141    }
142
143    /// Set the root kind hint.
144    #[must_use]
145    pub const fn with_root_kind(mut self, kind: Kind) -> Self {
146        self.root_kind = Some(kind);
147        self
148    }
149
150    /// Set the root author hint.
151    #[must_use]
152    pub const fn with_root_author(mut self, author: PublicKey) -> Self {
153        self.root_author = Some(author);
154        self
155    }
156
157    /// Set the parent kind hint.
158    #[must_use]
159    pub const fn with_parent_kind(mut self, kind: Kind) -> Self {
160        self.parent_kind = Some(kind);
161        self
162    }
163
164    /// Set the parent author hint.
165    #[must_use]
166    pub const fn with_parent_author(mut self, author: PublicKey) -> Self {
167        self.parent_author = Some(author);
168        self
169    }
170
171    /// Override the parent scope (use when the parent differs from the
172    /// root).
173    #[must_use]
174    pub fn with_parent(mut self, parent: CommentScope) -> Self {
175        self.parent = parent;
176        self
177    }
178
179    /// Render the comment as the [`Tag`]s that go into its `kind: 1111`
180    /// event.
181    #[must_use]
182    pub fn to_tags(&self) -> Vec<Tag> {
183        let mut tags = Vec::new();
184        push_scope_tags(&mut tags, &self.root, /*root=*/ true);
185        if let Some(k) = self.root_kind {
186            tags.push(Tag::with(
187                &TagKind::from_wire("K"),
188                [k.as_u16().to_string()],
189            ));
190        }
191        if let Some(p) = self.root_author {
192            tags.push(Tag::with(&TagKind::from_wire("P"), [p.to_hex()]));
193        }
194        push_scope_tags(&mut tags, &self.parent, /*root=*/ false);
195        if let Some(k) = self.parent_kind {
196            tags.push(Tag::with(
197                &TagKind::from_wire("k"),
198                [k.as_u16().to_string()],
199            ));
200        }
201        if let Some(p) = self.parent_author {
202            tags.push(Tag::with(&TagKind::from_wire("p"), [p.to_hex()]));
203        }
204        tags
205    }
206
207    /// Reconstruct a [`Comment`] from a `kind: 1111` [`Event`].
208    ///
209    /// # Errors
210    ///
211    /// Returns the matching [`CommentError`] when the event is the wrong
212    /// kind, missing one of the required scope tags, or carries malformed
213    /// values.
214    pub fn from_event(event: &Event) -> Result<Self, CommentError> {
215        if event.kind != Kind::from(1111_u16) {
216            return Err(CommentError::UnexpectedKind(event.kind.as_u16()));
217        }
218
219        let mut root: Option<CommentScope> = None;
220        let mut parent: Option<CommentScope> = None;
221        let mut root_kind: Option<Kind> = None;
222        let mut parent_kind: Option<Kind> = None;
223        let mut root_author: Option<PublicKey> = None;
224        let mut parent_author: Option<PublicKey> = None;
225
226        for tag in &event.tags {
227            let head = tag.kind().as_str().to_owned();
228            match head.as_str() {
229                "E" => root = Some(parse_event_scope(tag)?),
230                "A" => root = Some(parse_address_scope(tag)?),
231                "I" => root = Some(parse_external_scope(tag)?),
232                "K" => root_kind = Some(parse_kind(tag, "K")?),
233                "P" => root_author = Some(parse_pubkey(tag, "P")?),
234                "e" => parent = Some(parse_event_scope(tag)?),
235                "a" => parent = Some(parse_address_scope(tag)?),
236                "i" => parent = Some(parse_external_scope(tag)?),
237                "k" => parent_kind = Some(parse_kind(tag, "k")?),
238                "p" => parent_author = Some(parse_pubkey(tag, "p")?),
239                _ => {} // forward-compat
240            }
241        }
242
243        Ok(Self {
244            root: root.ok_or(CommentError::MissingRoot)?,
245            root_kind,
246            root_author,
247            parent: parent.ok_or(CommentError::MissingParent)?,
248            parent_kind,
249            parent_author,
250            content: event.content.clone(),
251        })
252    }
253}
254
255impl EventBuilder {
256    /// Build a `kind: 1111` event from `comment`.
257    #[must_use]
258    pub fn comment(comment: &Comment) -> Self {
259        Self::new(Kind::from(1111_u16), comment.content.clone()).tags(comment.to_tags())
260    }
261}
262
263fn push_scope_tags(out: &mut Vec<Tag>, scope: &CommentScope, root: bool) {
264    let event_head = if root { "E" } else { "e" };
265    let addr_head = if root { "A" } else { "a" };
266    let ext_head = if root { "I" } else { "i" };
267
268    match scope {
269        CommentScope::Event { id, relay_hint } => {
270            let mut values = vec![id.to_hex()];
271            if let Some(r) = relay_hint {
272                values.push(r.as_str().to_owned());
273            }
274            out.push(Tag::with(&TagKind::from_wire(event_head), values));
275        }
276        CommentScope::Address {
277            coordinate,
278            relay_hint,
279        } => {
280            let mut values = vec![coordinate.to_wire()];
281            if let Some(r) = relay_hint {
282                values.push(r.as_str().to_owned());
283            }
284            out.push(Tag::with(&TagKind::from_wire(addr_head), values));
285        }
286        CommentScope::External { value, context } => {
287            let mut values = vec![value.clone()];
288            if let Some(c) = context {
289                values.push(c.clone());
290            }
291            out.push(Tag::with(&TagKind::from_wire(ext_head), values));
292        }
293    }
294}
295
296fn parse_event_scope(tag: &Tag) -> Result<CommentScope, CommentError> {
297    let mut args = tag.values().iter().skip(1);
298    let id = args
299        .next()
300        .ok_or(CommentError::MissingValue { tag: "E/e" })?
301        .parse::<EventId>()?;
302    let relay_hint = match args.next() {
303        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
304        _ => None,
305    };
306    Ok(CommentScope::Event { id, relay_hint })
307}
308
309fn parse_address_scope(tag: &Tag) -> Result<CommentScope, CommentError> {
310    let mut args = tag.values().iter().skip(1);
311    let coordinate = args
312        .next()
313        .ok_or(CommentError::MissingValue { tag: "A/a" })?
314        .parse::<Coordinate>()?;
315    let relay_hint = match args.next() {
316        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
317        _ => None,
318    };
319    Ok(CommentScope::Address {
320        coordinate,
321        relay_hint,
322    })
323}
324
325fn parse_external_scope(tag: &Tag) -> Result<CommentScope, CommentError> {
326    let mut args = tag.values().iter().skip(1);
327    let value = args
328        .next()
329        .ok_or(CommentError::MissingValue { tag: "I/i" })?
330        .clone();
331    let context = match args.next() {
332        Some(s) if !s.is_empty() => Some(s.clone()),
333        _ => None,
334    };
335    Ok(CommentScope::External { value, context })
336}
337
338fn parse_kind(tag: &Tag, name: &'static str) -> Result<Kind, CommentError> {
339    let value = tag
340        .values()
341        .get(1)
342        .ok_or(CommentError::MissingValue { tag: name })?;
343    let raw: u16 = value
344        .parse()
345        .map_err(|_| CommentError::InvalidKind(value.clone()))?;
346    Ok(Kind::from(raw))
347}
348
349fn parse_pubkey(tag: &Tag, name: &'static str) -> Result<PublicKey, CommentError> {
350    let value = tag
351        .values()
352        .get(1)
353        .ok_or(CommentError::MissingValue { tag: name })?;
354    Ok(value.parse::<PublicKey>()?)
355}
356
357/// Errors raised when parsing a NIP-22 comment event.
358#[derive(Debug, Clone, Error)]
359#[non_exhaustive]
360pub enum CommentError {
361    /// The event's kind was not `1111`.
362    #[error("expected kind 1111, got {0}")]
363    UnexpectedKind(u16),
364    /// The event was missing a root scope tag (`E`/`A`/`I`).
365    #[error("comment is missing the root scope tag (E/A/I)")]
366    MissingRoot,
367    /// The event was missing a parent scope tag (`e`/`a`/`i`).
368    #[error("comment is missing the parent scope tag (e/a/i)")]
369    MissingParent,
370    /// A recognised tag was missing its value.
371    #[error("`{tag}` tag is missing its value")]
372    MissingValue {
373        /// Wire name of the offending tag head.
374        tag: &'static str,
375    },
376    /// A `K`/`k` value did not parse as `u16`.
377    #[error("invalid kind value `{0}`")]
378    InvalidKind(String),
379    /// An `E`/`e` tag's id did not parse.
380    #[error(transparent)]
381    InvalidEventId(#[from] EventIdError),
382    /// An `A`/`a` tag's coordinate did not parse.
383    #[error(transparent)]
384    InvalidCoordinate(#[from] CoordinateError),
385    /// A relay hint did not parse.
386    #[error(transparent)]
387    InvalidRelay(#[from] RelayUrlError),
388    /// A `P`/`p` tag's pubkey did not parse.
389    #[error(transparent)]
390    InvalidPubkey(#[from] PublicKeyError),
391}
392
393#[cfg(test)]
394mod tests {
395    use super::*;
396    use crate::Keys;
397    use crate::types::Timestamp;
398
399    fn keys() -> Keys {
400        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
401    }
402
403    fn pk(seed: u8) -> PublicKey {
404        let mut bytes = [0u8; 32];
405        bytes[31] = seed;
406        let sk = crate::SecretKey::from_byte_array(bytes).unwrap();
407        *Keys::from_secret_key(sk).public_key()
408    }
409
410    #[test]
411    fn top_level_event_round_trip() {
412        let id = EventId::from_byte_array([0xaa; 32]);
413        let comment = Comment::top_level(CommentScope::event(id), "hello!")
414            .with_root_kind(Kind::TEXT_NOTE)
415            .with_root_author(pk(1))
416            .with_parent_kind(Kind::TEXT_NOTE)
417            .with_parent_author(pk(1));
418        let event = EventBuilder::comment(&comment)
419            .created_at(Timestamp::from_secs(1))
420            .sign_with_keys(&keys())
421            .unwrap();
422        event.verify().unwrap();
423        assert_eq!(event.kind, Kind::from(1111_u16));
424        let parsed = Comment::from_event(&event).unwrap();
425        assert_eq!(parsed, comment);
426    }
427
428    #[test]
429    fn nested_reply_round_trip() {
430        let root_id = EventId::from_byte_array([0x10; 32]);
431        let parent_id = EventId::from_byte_array([0x20; 32]);
432        let comment = Comment::top_level(CommentScope::event(root_id), "ack")
433            .with_parent(CommentScope::event(parent_id))
434            .with_root_kind(Kind::TEXT_NOTE)
435            .with_root_author(pk(2))
436            .with_parent_kind(Kind::TEXT_NOTE)
437            .with_parent_author(pk(3));
438        let event = EventBuilder::comment(&comment)
439            .created_at(Timestamp::from_secs(2))
440            .sign_with_keys(&keys())
441            .unwrap();
442        let parsed = Comment::from_event(&event).unwrap();
443        assert_eq!(parsed, comment);
444    }
445
446    #[test]
447    fn address_scope_round_trip() {
448        let coord = Coordinate::new(Kind::from(30_023_u16), pk(4), "long-form-1");
449        let comment =
450            Comment::top_level(CommentScope::address(coord), "first comment on the article");
451        let event = EventBuilder::comment(&comment)
452            .created_at(Timestamp::from_secs(3))
453            .sign_with_keys(&keys())
454            .unwrap();
455        let parsed = Comment::from_event(&event).unwrap();
456        assert_eq!(parsed, comment);
457    }
458
459    #[test]
460    fn external_scope_round_trip() {
461        let comment = Comment::top_level(
462            CommentScope::external("https://example.com/article"),
463            "external pointer",
464        );
465        let event = EventBuilder::comment(&comment)
466            .created_at(Timestamp::from_secs(4))
467            .sign_with_keys(&keys())
468            .unwrap();
469        let parsed = Comment::from_event(&event).unwrap();
470        assert_eq!(parsed, comment);
471    }
472
473    #[test]
474    fn rejects_wrong_kind() {
475        let event = EventBuilder::text_note("not a comment")
476            .created_at(Timestamp::from_secs(5))
477            .sign_with_keys(&keys())
478            .unwrap();
479        let err = Comment::from_event(&event).unwrap_err();
480        assert!(matches!(err, CommentError::UnexpectedKind(1)));
481    }
482
483    #[test]
484    fn rejects_missing_root() {
485        let event = EventBuilder::new(Kind::from(1111_u16), "")
486            .created_at(Timestamp::from_secs(6))
487            .tag(Tag::new(["e", &EventId::from_byte_array([0u8; 32]).to_hex()]).unwrap())
488            .sign_with_keys(&keys())
489            .unwrap();
490        let err = Comment::from_event(&event).unwrap_err();
491        assert!(matches!(err, CommentError::MissingRoot));
492    }
493
494    #[test]
495    fn rejects_missing_parent() {
496        let event = EventBuilder::new(Kind::from(1111_u16), "")
497            .created_at(Timestamp::from_secs(7))
498            .tag(Tag::new(["E", &EventId::from_byte_array([0u8; 32]).to_hex()]).unwrap())
499            .sign_with_keys(&keys())
500            .unwrap();
501        let err = Comment::from_event(&event).unwrap_err();
502        assert!(matches!(err, CommentError::MissingParent));
503    }
504}