Skip to main content

softchat/
nip17.rs

1//! Minimum typed NIP-17 one-to-one text-message profile.
2
3use crate::{
4    LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor,
5    NostrTag, RumorEvent, SoftchatError,
6};
7
8/// A validated one-to-one kind-14 text-message rumor.
9///
10/// This first profile intentionally does not assign typed semantics to reply,
11/// subject, forwarding, media, or group tags. Exact unknown tags remain on the
12/// underlying rumor and therefore remain part of its event ID.
13#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct Nip17TextMessage {
15    rumor: NostrRumor,
16    recipient: NostrPublicKey,
17}
18
19impl Nip17TextMessage {
20    /// Validate a kind-14 rumor as one non-empty text message to one peer.
21    ///
22    /// One canonical `p` tag is required. Its optional relay-hint and extension
23    /// fields, plus all non-`p` tags, remain exact on the underlying rumor.
24    ///
25    /// # Errors
26    ///
27    /// Returns [`SoftchatError::InvalidNip17Message`] for an unsupported kind,
28    /// empty text, malformed or duplicate recipient, or self-recipient.
29    pub fn from_rumor(rumor: NostrRumor) -> Result<Self, SoftchatError> {
30        validate_rumor(&rumor)
31            .map(|recipient| Self { rumor, recipient })
32            .map_err(|_| SoftchatError::InvalidNip17Message)
33    }
34
35    /// Return the canonical rumor ID used for conversation references.
36    #[must_use]
37    pub const fn id(&self) -> NostrEventId {
38        self.rumor.id()
39    }
40
41    /// Return the rumor's declared author.
42    ///
43    /// The author is authenticated only when the rumor came from a validated
44    /// NIP-59 envelope.
45    #[must_use]
46    pub fn sender(&self) -> NostrPublicKey {
47        self.rumor.public_key()
48    }
49
50    /// Return the one peer named by the canonical recipient tag.
51    #[must_use]
52    pub fn recipient(&self) -> &NostrPublicKey {
53        &self.recipient
54    }
55
56    /// Return the canonical application timestamp.
57    #[must_use]
58    pub const fn created_at(&self) -> u64 {
59        self.rumor.created_at()
60    }
61
62    /// Borrow the non-empty plain-text content.
63    #[must_use]
64    pub fn content(&self) -> &str {
65        self.rumor.content()
66    }
67
68    /// Borrow the complete exact rumor, including unknown tags.
69    #[must_use]
70    pub const fn rumor(&self) -> &NostrRumor {
71        &self.rumor
72    }
73
74    /// Consume the typed view without changing the underlying rumor.
75    #[must_use]
76    pub fn into_rumor(self) -> NostrRumor {
77        self.rumor
78    }
79}
80
81impl LocalIdentity {
82    /// Create one minimal one-to-one NIP-17 text-message rumor.
83    ///
84    /// The writer emits exactly one canonical `p` tag and no extension tags.
85    /// Callers explicitly create separate NIP-59 copies for the recipient and
86    /// sender when recoverable local history is required.
87    ///
88    /// # Errors
89    ///
90    /// Returns [`SoftchatError::Nip17MessageCreationFailed`] for self-addressed
91    /// messages, empty text, invalid timestamps, or oversized rumors.
92    pub fn create_nip17_text_message(
93        &self,
94        recipient: &NostrPublicKey,
95        created_at: u64,
96        content: impl Into<String>,
97    ) -> Result<Nip17TextMessage, SoftchatError> {
98        let content = content.into();
99        if recipient == &self.public_key() || content.is_empty() {
100            return Err(SoftchatError::Nip17MessageCreationFailed);
101        }
102
103        let recipient_tag = NostrTag::new(vec!["p".to_owned(), recipient.to_hex()])
104            .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
105        let draft = NostrEventDraft::new(
106            created_at,
107            NostrEventKind::PRIVATE_DIRECT_MESSAGE,
108            vec![recipient_tag],
109            content,
110        )
111        .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
112        let rumor = self
113            .create_rumor(draft)
114            .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
115        Nip17TextMessage::from_rumor(rumor).map_err(|_| SoftchatError::Nip17MessageCreationFailed)
116    }
117}
118
119/// Flat one-to-one text-message view for generated native bindings.
120#[derive(Clone, Debug, Eq, PartialEq)]
121#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
122pub struct Nip17Message {
123    /// Complete exact unsigned rumor.
124    pub rumor: RumorEvent,
125    /// Canonical recipient public key derived from its sole `p` tag.
126    pub recipient_public_key: String,
127}
128
129impl From<&Nip17TextMessage> for Nip17Message {
130    fn from(message: &Nip17TextMessage) -> Self {
131        Self {
132            rumor: RumorEvent::from(message.rumor()),
133            recipient_public_key: message.recipient().to_hex(),
134        }
135    }
136}
137
138impl TryFrom<Nip17Message> for Nip17TextMessage {
139    type Error = SoftchatError;
140
141    fn try_from(message: Nip17Message) -> Result<Self, Self::Error> {
142        let declared_recipient = NostrPublicKey::from_hex(&message.recipient_public_key)
143            .map_err(|_| SoftchatError::InvalidNip17Message)?;
144        let message = Self::from_rumor(
145            NostrRumor::try_from(message.rumor).map_err(|_| SoftchatError::InvalidNip17Message)?,
146        )?;
147        if message.recipient != declared_recipient {
148            return Err(SoftchatError::InvalidNip17Message);
149        }
150        Ok(message)
151    }
152}
153
154/// Validate a binding-owned rumor as a one-to-one NIP-17 text message.
155#[cfg_attr(feature = "native-bindings", uniffi::export)]
156pub fn validate_nip17_text_message(rumor: RumorEvent) -> Result<Nip17Message, SoftchatError> {
157    Nip17TextMessage::from_rumor(
158        NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidNip17Message)?,
159    )
160    .map(|message| Nip17Message::from(&message))
161}
162
163fn validate_rumor(rumor: &NostrRumor) -> Result<NostrPublicKey, SoftchatError> {
164    if rumor.kind() != NostrEventKind::PRIVATE_DIRECT_MESSAGE || rumor.content().is_empty() {
165        return Err(SoftchatError::InvalidNip17Message);
166    }
167
168    let mut recipient = None;
169    for tag in rumor.tags() {
170        if tag.first().map(String::as_str) != Some("p") {
171            continue;
172        }
173        if recipient.is_some() {
174            return Err(SoftchatError::InvalidNip17Message);
175        }
176        let raw_public_key = tag.get(1).ok_or(SoftchatError::InvalidNip17Message)?;
177        let public_key = NostrPublicKey::from_hex(raw_public_key)
178            .map_err(|_| SoftchatError::InvalidNip17Message)?;
179        if public_key.to_hex() != *raw_public_key || public_key == rumor.public_key() {
180            return Err(SoftchatError::InvalidNip17Message);
181        }
182        recipient = Some(public_key);
183    }
184    recipient.ok_or(SoftchatError::InvalidNip17Message)
185}
186
187#[cfg(test)]
188mod tests {
189    use nostr::SecretKey;
190    use serde::Deserialize;
191
192    use super::*;
193    use crate::{Nip59EnvelopeKind, SignedNostrEvent, UnwrappedNip59Envelope};
194
195    const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
196    const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
197    const OFFICIAL_EXAMPLE: &str =
198        include_str!("../../../fixtures/nostr/nip17/nip17-official-example.json");
199
200    #[derive(Deserialize)]
201    #[serde(rename_all = "camelCase")]
202    struct OfficialExample {
203        sender_nsec: String,
204        recipient_nsec: String,
205        recipient_gift_wrap: serde_json::Value,
206        sender_gift_wrap: serde_json::Value,
207    }
208
209    fn classify(envelope: UnwrappedNip59Envelope) -> Result<Nip17TextMessage, SoftchatError> {
210        Nip17TextMessage::from_rumor(envelope.into_rumor())
211    }
212
213    #[test]
214    fn creates_wraps_receives_and_classifies_one_to_one_text() -> Result<(), SoftchatError> {
215        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
216        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
217        let message =
218            alice.create_nip17_text_message(&bob.public_key(), 1_700_000_000, "hello Bob")?;
219
220        let recipient_copy = alice.gift_wrap(
221            &bob.public_key(),
222            message.rumor(),
223            Nip59EnvelopeKind::Durable,
224            1_700_172_800,
225        )?;
226        let sender_copy = alice.gift_wrap(
227            &alice.public_key(),
228            message.rumor(),
229            Nip59EnvelopeKind::Durable,
230            1_700_172_800,
231        )?;
232
233        let received = classify(bob.unwrap_gift_wrap(&recipient_copy)?)?;
234        let retained = classify(alice.unwrap_gift_wrap(&sender_copy)?)?;
235        assert_eq!(received, message);
236        assert_eq!(retained, message);
237        assert_eq!(received.sender(), alice.public_key());
238        assert_eq!(received.recipient(), &bob.public_key());
239        assert_eq!(received.content(), "hello Bob");
240        Ok(())
241    }
242
243    #[test]
244    fn receives_both_official_nip17_copies() -> Result<(), Box<dyn std::error::Error>> {
245        let fixture: OfficialExample = serde_json::from_str(OFFICIAL_EXAMPLE)?;
246        let sender_secret = SecretKey::parse(&fixture.sender_nsec)?;
247        let recipient_secret = SecretKey::parse(&fixture.recipient_nsec)?;
248        let sender = LocalIdentity::from_secret_bytes(&sender_secret.to_secret_bytes())?;
249        let recipient = LocalIdentity::from_secret_bytes(&recipient_secret.to_secret_bytes())?;
250        let recipient_outer =
251            SignedNostrEvent::from_json(&fixture.recipient_gift_wrap.to_string())?;
252        let sender_outer = SignedNostrEvent::from_json(&fixture.sender_gift_wrap.to_string())?;
253
254        let received = classify(recipient.unwrap_gift_wrap(&recipient_outer)?)?;
255        let retained = classify(sender.unwrap_gift_wrap(&sender_outer)?)?;
256
257        assert_eq!(received, retained);
258        assert_eq!(received.sender(), sender.public_key());
259        assert_eq!(received.recipient(), &recipient.public_key());
260        assert_eq!(received.content(), "Hola, que tal?");
261        Ok(())
262    }
263
264    #[test]
265    fn preserves_relay_hints_and_unknown_tags_in_typed_view() -> Result<(), SoftchatError> {
266        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
267        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
268        let rumor = alice.create_rumor(NostrEventDraft::new(
269            1_700_000_000,
270            NostrEventKind::PRIVATE_DIRECT_MESSAGE,
271            vec![
272                NostrTag::new(vec![
273                    "p".to_owned(),
274                    bob.public_key().to_hex(),
275                    "wss://relay.example".to_owned(),
276                ])?,
277                NostrTag::new(vec!["future".to_owned(), "exact".to_owned()])?,
278            ],
279            "hello",
280        )?)?;
281        let message = Nip17TextMessage::from_rumor(rumor.clone())?;
282
283        assert_eq!(message.rumor(), &rumor);
284        assert_eq!(
285            message
286                .rumor()
287                .tags()
288                .map(<[String]>::to_vec)
289                .collect::<Vec<_>>(),
290            vec![
291                vec![
292                    "p".to_owned(),
293                    bob.public_key().to_hex(),
294                    "wss://relay.example".to_owned(),
295                ],
296                vec!["future".to_owned(), "exact".to_owned()],
297            ]
298        );
299        Ok(())
300    }
301
302    #[test]
303    fn rejects_empty_self_group_duplicate_and_wrong_kind_messages() -> Result<(), SoftchatError> {
304        let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
305        let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
306        assert!(matches!(
307            alice.create_nip17_text_message(&alice.public_key(), 1, "self"),
308            Err(SoftchatError::Nip17MessageCreationFailed)
309        ));
310        assert!(matches!(
311            alice.create_nip17_text_message(&bob.public_key(), 1, ""),
312            Err(SoftchatError::Nip17MessageCreationFailed)
313        ));
314
315        for (kind, tags, content) in [
316            (
317                NostrEventKind::SHORT_TEXT_NOTE,
318                vec![NostrTag::new(vec![
319                    "p".to_owned(),
320                    bob.public_key().to_hex(),
321                ])?],
322                "wrong kind",
323            ),
324            (
325                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
326                Vec::new(),
327                "missing",
328            ),
329            (
330                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
331                vec![
332                    NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
333                    NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
334                ],
335                "duplicate",
336            ),
337            (
338                NostrEventKind::PRIVATE_DIRECT_MESSAGE,
339                vec![NostrTag::new(vec![
340                    "p".to_owned(),
341                    bob.public_key().to_hex(),
342                ])?],
343                "",
344            ),
345        ] {
346            let rumor = alice.create_rumor(NostrEventDraft::new(1, kind, tags, content)?)?;
347            assert!(matches!(
348                Nip17TextMessage::from_rumor(rumor),
349                Err(SoftchatError::InvalidNip17Message)
350            ));
351        }
352        Ok(())
353    }
354}