Skip to main content

nula_core/nips/
nipa4.rs

1//! [NIP-A4] Public Messages.
2//!
3//! `kind: 24` is a plaintext public message addressed to one or more
4//! recipients via `p` tags. The spec deliberately forbids `e` tags so
5//! these events form a flat notification surface (no chains, no
6//! threads). Replies, reactions, and zaps still cross-reference the
7//! event via NIP-22 / NIP-25 / NIP-57 with the `k` tag pointing at
8//! `24`.
9//!
10//! [NIP-A4]: https://github.com/nostr-protocol/nips/blob/master/A4.md
11
12use thiserror::Error;
13
14use crate::event::{Alphabet, Event, EventBuilder, Kind, SingleLetterTag, Tag, TagKind};
15use crate::key::{PublicKey, PublicKeyError};
16use crate::types::{RelayUrl, RelayUrlError};
17
18/// `kind: 24` — public message.
19pub const KIND_PUBLIC_MESSAGE: Kind = Kind::PUBLIC_MESSAGE;
20
21/// A `p` tag column on a public message.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct PublicMessageRecipient {
24    /// Recipient pubkey.
25    pub pubkey: PublicKey,
26    /// Optional relay hint per NIP-65 inbox routing.
27    pub relay_hint: Option<RelayUrl>,
28}
29
30/// Typed bundle for a `kind: 24` public-message event.
31#[derive(Debug, Clone, PartialEq, Eq)]
32pub struct PublicMessage {
33    /// Plaintext body.
34    pub content: String,
35    /// At least one recipient (per spec).
36    pub recipients: Vec<PublicMessageRecipient>,
37    /// Forward-compatible passthrough for unknown tags.
38    pub extra_tags: Vec<Tag>,
39}
40
41/// Errors raised while parsing a NIP-A4 event.
42#[derive(Debug, Error)]
43#[non_exhaustive]
44pub enum PublicMessageError {
45    /// Event kind is not `24`.
46    #[error("unexpected kind for NIP-A4 public message: {}", .0.as_u16())]
47    WrongKind(Kind),
48    /// `p` tag is missing the pubkey column.
49    #[error("`p` tag missing recipient pubkey")]
50    MalformedRecipient,
51    /// Spec forbids `e` tags on public messages.
52    #[error("NIP-A4 public message MUST NOT include `e` tags")]
53    ForbiddenEventTag,
54    /// Event has no `p` recipient.
55    #[error("NIP-A4 public message has no recipients")]
56    MissingRecipient,
57    /// Wrapped pubkey parser error.
58    #[error(transparent)]
59    InvalidPublicKey(#[from] PublicKeyError),
60    /// Wrapped relay-URL parser error.
61    #[error(transparent)]
62    InvalidRelayUrl(#[from] RelayUrlError),
63}
64
65impl PublicMessageRecipient {
66    /// Construct a recipient with no relay hint.
67    #[must_use]
68    pub const fn new(pubkey: PublicKey) -> Self {
69        Self {
70            pubkey,
71            relay_hint: None,
72        }
73    }
74
75    /// Attach a relay hint.
76    #[must_use]
77    pub fn relay_hint(mut self, relay: RelayUrl) -> Self {
78        self.relay_hint = Some(relay);
79        self
80    }
81
82    fn to_tag(&self) -> Tag {
83        let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::P));
84        self.relay_hint.as_ref().map_or_else(
85            || Tag::with(&head, [self.pubkey.to_hex()]),
86            |relay| Tag::with(&head, [self.pubkey.to_hex(), relay.as_str().to_owned()]),
87        )
88    }
89
90    fn from_tag(tag: &Tag) -> Result<Self, PublicMessageError> {
91        let pk_hex = tag.get(1).ok_or(PublicMessageError::MalformedRecipient)?;
92        let pubkey = PublicKey::parse(pk_hex)?;
93        let relay_hint = match tag.get(2) {
94            Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
95            _ => None,
96        };
97        Ok(Self { pubkey, relay_hint })
98    }
99}
100
101impl PublicMessage {
102    /// Construct a public message addressed to the given recipients.
103    #[must_use]
104    pub fn new(content: impl Into<String>, recipients: Vec<PublicMessageRecipient>) -> Self {
105        Self {
106            content: content.into(),
107            recipients,
108            extra_tags: Vec::new(),
109        }
110    }
111
112    /// Parse a `kind: 24` public-message event.
113    ///
114    /// # Errors
115    ///
116    /// See [`PublicMessageError`] for the failure modes. Notably an
117    /// `e` tag triggers [`PublicMessageError::ForbiddenEventTag`] per
118    /// spec §"Warnings".
119    pub fn from_event(event: &Event) -> Result<Self, PublicMessageError> {
120        if event.kind != KIND_PUBLIC_MESSAGE {
121            return Err(PublicMessageError::WrongKind(event.kind));
122        }
123        let mut recipients: Vec<PublicMessageRecipient> = Vec::new();
124        let mut extra_tags: Vec<Tag> = Vec::new();
125        for tag in &event.tags {
126            match tag.kind() {
127                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::P => {
128                    recipients.push(PublicMessageRecipient::from_tag(tag)?);
129                }
130                TagKind::SingleLetter(s) if !s.uppercase && s.character == Alphabet::E => {
131                    return Err(PublicMessageError::ForbiddenEventTag);
132                }
133                _ => extra_tags.push(tag.clone()),
134            }
135        }
136        if recipients.is_empty() {
137            return Err(PublicMessageError::MissingRecipient);
138        }
139        Ok(Self {
140            content: event.content.clone(),
141            recipients,
142            extra_tags,
143        })
144    }
145}
146
147impl EventBuilder {
148    /// Author a NIP-A4 `kind: 24` public message.
149    ///
150    /// # Errors
151    ///
152    /// Returns [`PublicMessageError::MissingRecipient`] when
153    /// [`PublicMessage::recipients`] is empty.
154    pub fn public_message(msg: &PublicMessage) -> Result<Self, PublicMessageError> {
155        if msg.recipients.is_empty() {
156            return Err(PublicMessageError::MissingRecipient);
157        }
158        let mut builder = Self::new(KIND_PUBLIC_MESSAGE, msg.content.clone());
159        for recipient in &msg.recipients {
160            builder = builder.tag(recipient.to_tag());
161        }
162        for tag in &msg.extra_tags {
163            builder = builder.tag(tag.clone());
164        }
165        Ok(builder)
166    }
167}
168
169#[cfg(test)]
170mod tests {
171    use super::*;
172    use crate::Keys;
173
174    fn keys() -> Keys {
175        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
176    }
177
178    #[test]
179    fn public_message_round_trip() {
180        let recipient = PublicMessageRecipient::new(*keys().public_key())
181            .relay_hint(RelayUrl::parse("wss://relay.example/").unwrap());
182        let msg = PublicMessage::new("hello", vec![recipient]);
183        let event = EventBuilder::public_message(&msg)
184            .unwrap()
185            .sign_with_keys(&keys())
186            .unwrap();
187        let parsed = PublicMessage::from_event(&event).unwrap();
188        assert_eq!(parsed, msg);
189    }
190
191    #[test]
192    fn missing_recipient_is_rejected() {
193        let msg = PublicMessage::new("hello", Vec::new());
194        assert!(matches!(
195            EventBuilder::public_message(&msg),
196            Err(PublicMessageError::MissingRecipient)
197        ));
198    }
199
200    #[test]
201    fn event_tag_is_rejected() {
202        use crate::event::EventId;
203        let event = EventBuilder::new(KIND_PUBLIC_MESSAGE, "rough")
204            .tag(Tag::e(EventId::from_byte_array([0x99; 32])))
205            .tag(Tag::p(*keys().public_key()))
206            .sign_with_keys(&keys())
207            .unwrap();
208        assert!(matches!(
209            PublicMessage::from_event(&event),
210            Err(PublicMessageError::ForbiddenEventTag)
211        ));
212    }
213
214    #[test]
215    fn wrong_kind_is_rejected() {
216        // Non-`kind:24` events must not parse as public messages.
217        let event = EventBuilder::text_note("nope")
218            .sign_with_keys(&keys())
219            .unwrap();
220        assert!(matches!(
221            PublicMessage::from_event(&event),
222            Err(PublicMessageError::WrongKind(_))
223        ));
224    }
225
226    #[test]
227    fn extra_tags_round_trip_intact() {
228        // Unknown tags MUST pass through unchanged so the typed bundle
229        // is forward-compatible with future spec extensions.
230        let recipient = PublicMessageRecipient::new(*keys().public_key());
231        let mut msg = PublicMessage::new("hi", vec![recipient]);
232        msg.extra_tags.push(Tag::with(
233            &TagKind::from_wire("vendor-foo"),
234            ["bar".to_owned()],
235        ));
236        let event = EventBuilder::public_message(&msg)
237            .unwrap()
238            .sign_with_keys(&keys())
239            .unwrap();
240        let parsed = PublicMessage::from_event(&event).unwrap();
241        assert_eq!(parsed.extra_tags.len(), 1);
242        assert_eq!(parsed.extra_tags[0].name(), "vendor-foo");
243    }
244}