Skip to main content

nula_core/nips/
nipc7.rs

1//! [NIP-C7] Chats.
2//!
3//! `kind: 9` carries the body of a chat message in `.content`. Replies
4//! reuse the same `kind: 9` and quote the parent through a `q` tag,
5//! intentionally avoiding the threaded `e` model NIP-10 uses for
6//! `kind: 1` notes.
7//!
8//! [NIP-C7]: https://github.com/nostr-protocol/nips/blob/master/C7.md
9
10use thiserror::Error;
11
12use crate::event::{
13    Alphabet, Event, EventBuilder, EventId, EventIdError, Kind, SingleLetterTag, Tag, TagKind,
14};
15use crate::key::{PublicKey, PublicKeyError};
16use crate::types::{RelayUrl, RelayUrlError};
17
18/// `kind: 9` — chat message.
19pub const KIND_CHAT_MESSAGE: Kind = Kind::CHAT_MESSAGE;
20
21/// Typed bundle for a `kind: 9` chat message.
22#[derive(Debug, Clone, PartialEq, Eq)]
23pub struct ChatMessage {
24    /// Free-form body of the message.
25    pub content: String,
26    /// Optional `q`-quoted parent (only set for replies).
27    pub quote: Option<ChatQuote>,
28    /// Forward-compatible passthrough for unknown tags.
29    pub extra_tags: Vec<Tag>,
30}
31
32/// Reply target for a chat message (`q` tag).
33#[derive(Debug, Clone, PartialEq, Eq)]
34pub struct ChatQuote {
35    /// Parent event id.
36    pub id: EventId,
37    /// Optional relay hint.
38    pub relay_hint: Option<RelayUrl>,
39    /// Optional author pubkey of the parent (4th column of `q`).
40    pub author: Option<PublicKey>,
41}
42
43/// Errors raised while parsing a NIP-C7 event.
44#[derive(Debug, Error)]
45#[non_exhaustive]
46pub enum ChatError {
47    /// Event kind is not `9`.
48    #[error("unexpected kind for NIP-C7 chat message: {}", .0.as_u16())]
49    WrongKind(Kind),
50    /// `q` tag is missing the parent id column.
51    #[error("`q` tag missing parent event id")]
52    MalformedQuote,
53    /// Wrapped event-id parser error.
54    #[error(transparent)]
55    InvalidEventId(#[from] EventIdError),
56    /// Wrapped relay-URL parser error.
57    #[error(transparent)]
58    InvalidRelayUrl(#[from] RelayUrlError),
59    /// Wrapped pubkey parser error.
60    #[error(transparent)]
61    InvalidPublicKey(#[from] PublicKeyError),
62}
63
64impl ChatMessage {
65    /// Construct a top-level chat message.
66    #[must_use]
67    pub fn new(content: impl Into<String>) -> Self {
68        Self {
69            content: content.into(),
70            quote: None,
71            extra_tags: Vec::new(),
72        }
73    }
74
75    /// Attach a quoted parent.
76    #[must_use]
77    pub fn quote(mut self, quote: ChatQuote) -> Self {
78        self.quote = Some(quote);
79        self
80    }
81
82    /// Parse a `kind: 9` chat-message event.
83    ///
84    /// # Errors
85    ///
86    /// See [`ChatError`] for the failure modes.
87    pub fn from_event(event: &Event) -> Result<Self, ChatError> {
88        if event.kind != KIND_CHAT_MESSAGE {
89            return Err(ChatError::WrongKind(event.kind));
90        }
91        let mut quote: Option<ChatQuote> = None;
92        let mut extra_tags: Vec<Tag> = Vec::new();
93        for tag in &event.tags {
94            match tag.kind() {
95                TagKind::SingleLetter(s)
96                    if !s.uppercase && s.character == Alphabet::Q && quote.is_none() =>
97                {
98                    quote = Some(parse_quote(tag)?);
99                }
100                _ => extra_tags.push(tag.clone()),
101            }
102        }
103        Ok(Self {
104            content: event.content.clone(),
105            quote,
106            extra_tags,
107        })
108    }
109}
110
111fn parse_quote(tag: &Tag) -> Result<ChatQuote, ChatError> {
112    let id = tag
113        .get(1)
114        .ok_or(ChatError::MalformedQuote)
115        .and_then(|s| EventId::parse(s).map_err(Into::into))?;
116    let relay_hint = match tag.get(2) {
117        Some(s) if !s.is_empty() => Some(RelayUrl::parse(s)?),
118        _ => None,
119    };
120    let author = match tag.get(3) {
121        Some(s) if !s.is_empty() => Some(PublicKey::parse(s)?),
122        _ => None,
123    };
124    Ok(ChatQuote {
125        id,
126        relay_hint,
127        author,
128    })
129}
130
131fn quote_tag(quote: &ChatQuote) -> Tag {
132    let head = TagKind::single_letter(SingleLetterTag::lowercase(Alphabet::Q));
133    let relay = quote
134        .relay_hint
135        .as_ref()
136        .map_or_else(String::new, |r| r.as_str().to_owned());
137    match (quote.author, quote.relay_hint.is_some()) {
138        (Some(pk), _) => Tag::with(&head, [quote.id.to_hex(), relay, pk.to_hex()]),
139        (None, true) => Tag::with(&head, [quote.id.to_hex(), relay]),
140        (None, false) => Tag::with(&head, [quote.id.to_hex()]),
141    }
142}
143
144impl EventBuilder {
145    /// Author a NIP-C7 `kind: 9` chat message.
146    #[must_use]
147    pub fn chat_message(msg: &ChatMessage) -> Self {
148        let mut builder = Self::new(KIND_CHAT_MESSAGE, msg.content.clone());
149        if let Some(quote) = &msg.quote {
150            builder = builder.tag(quote_tag(quote));
151        }
152        for tag in &msg.extra_tags {
153            builder = builder.tag(tag.clone());
154        }
155        builder
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162    use crate::Keys;
163
164    fn keys() -> Keys {
165        Keys::parse("0000000000000000000000000000000000000000000000000000000000000003").unwrap()
166    }
167
168    #[test]
169    fn chat_message_round_trip() {
170        let quote = ChatQuote {
171            id: EventId::from_byte_array([0x55; 32]),
172            relay_hint: Some(RelayUrl::parse("wss://relay.example/").unwrap()),
173            author: Some(*keys().public_key()),
174        };
175        let msg = ChatMessage::new("yes").quote(quote.clone());
176        let event = EventBuilder::chat_message(&msg)
177            .sign_with_keys(&keys())
178            .unwrap();
179        let parsed = ChatMessage::from_event(&event).unwrap();
180        assert_eq!(parsed, msg);
181        assert_eq!(parsed.quote, Some(quote));
182    }
183
184    #[test]
185    fn top_level_chat_round_trip() {
186        let msg = ChatMessage::new("GM");
187        let event = EventBuilder::chat_message(&msg)
188            .sign_with_keys(&keys())
189            .unwrap();
190        let parsed = ChatMessage::from_event(&event).unwrap();
191        assert_eq!(parsed, msg);
192        assert!(parsed.quote.is_none());
193    }
194
195    #[test]
196    fn wrong_kind_is_rejected() {
197        let event = EventBuilder::text_note("nope")
198            .sign_with_keys(&keys())
199            .unwrap();
200        assert!(matches!(
201            ChatMessage::from_event(&event),
202            Err(ChatError::WrongKind(_))
203        ));
204    }
205}