1use crate::{
4 LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor,
5 NostrTag, RumorEvent, SoftchatError,
6};
7
8#[derive(Clone, Debug, Eq, PartialEq)]
14pub struct Nip17TextMessage {
15 rumor: NostrRumor,
16 recipient: NostrPublicKey,
17}
18
19impl Nip17TextMessage {
20 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 #[must_use]
37 pub const fn id(&self) -> NostrEventId {
38 self.rumor.id()
39 }
40
41 #[must_use]
46 pub fn sender(&self) -> NostrPublicKey {
47 self.rumor.public_key()
48 }
49
50 #[must_use]
52 pub fn recipient(&self) -> &NostrPublicKey {
53 &self.recipient
54 }
55
56 #[must_use]
58 pub const fn created_at(&self) -> u64 {
59 self.rumor.created_at()
60 }
61
62 #[must_use]
64 pub fn content(&self) -> &str {
65 self.rumor.content()
66 }
67
68 #[must_use]
70 pub const fn rumor(&self) -> &NostrRumor {
71 &self.rumor
72 }
73
74 #[must_use]
76 pub fn into_rumor(self) -> NostrRumor {
77 self.rumor
78 }
79}
80
81impl LocalIdentity {
82 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#[derive(Clone, Debug, Eq, PartialEq)]
121#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
122pub struct Nip17Message {
123 pub rumor: RumorEvent,
125 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#[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}