1#![allow(
4 unreachable_pub,
5 reason = "UniFFI adapter validation stays inside this private core module"
6)]
7
8use crate::event::RumorEvent;
9use crate::{
10 LocalIdentity, NostrEventDraft, NostrEventId, NostrEventKind, NostrPublicKey, NostrRumor,
11 NostrTag, SoftchatError,
12};
13
14#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Nip17TextMessage {
21 rumor: NostrRumor,
22 recipient: NostrPublicKey,
23}
24
25impl Nip17TextMessage {
26 pub fn from_rumor(rumor: NostrRumor) -> Result<Self, SoftchatError> {
36 validate_rumor(&rumor)
37 .map(|recipient| Self { rumor, recipient })
38 .map_err(|_| SoftchatError::InvalidNip17Message)
39 }
40
41 #[must_use]
43 pub const fn id(&self) -> NostrEventId {
44 self.rumor.id()
45 }
46
47 #[must_use]
52 pub fn sender(&self) -> NostrPublicKey {
53 self.rumor.public_key()
54 }
55
56 #[must_use]
58 pub fn recipient(&self) -> &NostrPublicKey {
59 &self.recipient
60 }
61
62 #[must_use]
64 pub const fn created_at(&self) -> u64 {
65 self.rumor.created_at()
66 }
67
68 #[must_use]
70 pub fn content(&self) -> &str {
71 self.rumor.content()
72 }
73
74 #[must_use]
76 pub const fn rumor(&self) -> &NostrRumor {
77 &self.rumor
78 }
79
80 #[must_use]
82 pub fn into_rumor(self) -> NostrRumor {
83 self.rumor
84 }
85}
86
87impl LocalIdentity {
88 pub fn create_nip17_text_message(
101 &self,
102 recipient: &NostrPublicKey,
103 created_at: u64,
104 content: impl Into<String>,
105 ) -> Result<Nip17TextMessage, SoftchatError> {
106 let content = content.into();
107 if recipient == &self.public_key() || content.is_empty() {
108 return Err(SoftchatError::Nip17MessageCreationFailed);
109 }
110
111 let recipient_tag = NostrTag::new(vec!["p".to_owned(), recipient.to_hex()])
112 .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
113 let draft = NostrEventDraft::new(
114 created_at,
115 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
116 vec![recipient_tag],
117 content,
118 )
119 .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
120 let rumor = self
121 .create_rumor(draft)
122 .map_err(|_| SoftchatError::Nip17MessageCreationFailed)?;
123 Nip17TextMessage::from_rumor(rumor).map_err(|_| SoftchatError::Nip17MessageCreationFailed)
124 }
125}
126
127#[derive(Clone, Debug, Eq, PartialEq)]
129#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
130pub struct Nip17Message {
131 pub rumor: RumorEvent,
133 pub recipient_public_key: String,
135}
136
137impl From<&Nip17TextMessage> for Nip17Message {
138 fn from(message: &Nip17TextMessage) -> Self {
139 Self {
140 rumor: RumorEvent::from(message.rumor()),
141 recipient_public_key: message.recipient().to_hex(),
142 }
143 }
144}
145
146impl TryFrom<Nip17Message> for Nip17TextMessage {
147 type Error = SoftchatError;
148
149 fn try_from(message: Nip17Message) -> Result<Self, Self::Error> {
150 let declared_recipient = NostrPublicKey::from_hex(&message.recipient_public_key)
151 .map_err(|_| SoftchatError::InvalidNip17Message)?;
152 let message = Self::from_rumor(
153 NostrRumor::try_from(message.rumor).map_err(|_| SoftchatError::InvalidNip17Message)?,
154 )?;
155 if message.recipient != declared_recipient {
156 return Err(SoftchatError::InvalidNip17Message);
157 }
158 Ok(message)
159 }
160}
161
162#[cfg(any(
164 feature = "native-bindings",
165 all(feature = "javascript-bindings", target_arch = "wasm32"),
166 test
167))]
168#[cfg_attr(feature = "native-bindings", uniffi::export)]
169pub fn validate_nip17_text_message(rumor: RumorEvent) -> Result<Nip17Message, SoftchatError> {
170 Nip17TextMessage::from_rumor(
171 NostrRumor::try_from(rumor).map_err(|_| SoftchatError::InvalidNip17Message)?,
172 )
173 .map(|message| Nip17Message::from(&message))
174}
175
176fn validate_rumor(rumor: &NostrRumor) -> Result<NostrPublicKey, SoftchatError> {
177 if rumor.kind() != NostrEventKind::PRIVATE_DIRECT_MESSAGE || rumor.content().is_empty() {
178 return Err(SoftchatError::InvalidNip17Message);
179 }
180
181 let mut recipient = None;
182 for tag in rumor.tags() {
183 if tag.first().map(String::as_str) != Some("p") {
184 continue;
185 }
186 if recipient.is_some() {
187 return Err(SoftchatError::InvalidNip17Message);
188 }
189 let raw_public_key = tag.get(1).ok_or(SoftchatError::InvalidNip17Message)?;
190 let public_key = NostrPublicKey::from_hex(raw_public_key)
191 .map_err(|_| SoftchatError::InvalidNip17Message)?;
192 if public_key.to_hex() != *raw_public_key || public_key == rumor.public_key() {
193 return Err(SoftchatError::InvalidNip17Message);
194 }
195 recipient = Some(public_key);
196 }
197 recipient.ok_or(SoftchatError::InvalidNip17Message)
198}
199
200#[cfg(test)]
201mod tests {
202 use nostr::SecretKey;
203 use serde::Deserialize;
204
205 use super::*;
206 use crate::{Nip59EnvelopeKind, SignedNostrEvent, UnwrappedNip59Envelope};
207
208 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
209 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
210 const OFFICIAL_EXAMPLE: &str =
211 include_str!("../../../fixtures/nostr/nip17/nip17-official-example.json");
212
213 #[derive(Deserialize)]
214 #[serde(rename_all = "camelCase")]
215 struct OfficialExample {
216 sender_nsec: String,
217 recipient_nsec: String,
218 recipient_gift_wrap: serde_json::Value,
219 sender_gift_wrap: serde_json::Value,
220 }
221
222 fn classify(envelope: UnwrappedNip59Envelope) -> Result<Nip17TextMessage, SoftchatError> {
223 Nip17TextMessage::from_rumor(envelope.into_rumor())
224 }
225
226 #[test]
227 fn creates_wraps_receives_and_classifies_one_to_one_text() -> Result<(), SoftchatError> {
228 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
229 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
230 let message =
231 alice.create_nip17_text_message(&bob.public_key(), 1_700_000_000, "hello Bob")?;
232
233 let recipient_copy = alice.gift_wrap(
234 &bob.public_key(),
235 message.rumor(),
236 Nip59EnvelopeKind::Durable,
237 1_700_172_800,
238 )?;
239 let sender_copy = alice.gift_wrap(
240 &alice.public_key(),
241 message.rumor(),
242 Nip59EnvelopeKind::Durable,
243 1_700_172_800,
244 )?;
245
246 let received = classify(bob.unwrap_gift_wrap(&recipient_copy)?)?;
247 let retained = classify(alice.unwrap_gift_wrap(&sender_copy)?)?;
248 assert_eq!(received, message);
249 assert_eq!(retained, message);
250 assert_eq!(received.sender(), alice.public_key());
251 assert_eq!(received.recipient(), &bob.public_key());
252 assert_eq!(received.content(), "hello Bob");
253 Ok(())
254 }
255
256 #[test]
257 fn receives_both_official_nip17_copies() -> Result<(), Box<dyn std::error::Error>> {
258 let fixture: OfficialExample = serde_json::from_str(OFFICIAL_EXAMPLE)?;
259 let sender_secret = SecretKey::parse(&fixture.sender_nsec)?;
260 let recipient_secret = SecretKey::parse(&fixture.recipient_nsec)?;
261 let sender = LocalIdentity::from_secret_bytes(&sender_secret.to_secret_bytes())?;
262 let recipient = LocalIdentity::from_secret_bytes(&recipient_secret.to_secret_bytes())?;
263 let recipient_outer =
264 SignedNostrEvent::from_json(&fixture.recipient_gift_wrap.to_string())?;
265 let sender_outer = SignedNostrEvent::from_json(&fixture.sender_gift_wrap.to_string())?;
266
267 let received = classify(recipient.unwrap_gift_wrap(&recipient_outer)?)?;
268 let retained = classify(sender.unwrap_gift_wrap(&sender_outer)?)?;
269
270 assert_eq!(received, retained);
271 assert_eq!(received.sender(), sender.public_key());
272 assert_eq!(received.recipient(), &recipient.public_key());
273 assert_eq!(received.content(), "Hola, que tal?");
274 Ok(())
275 }
276
277 #[test]
278 fn preserves_relay_hints_and_unknown_tags_in_typed_view() -> Result<(), SoftchatError> {
279 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
280 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
281 let rumor = alice.create_rumor(NostrEventDraft::new(
282 1_700_000_000,
283 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
284 vec![
285 NostrTag::new(vec![
286 "p".to_owned(),
287 bob.public_key().to_hex(),
288 "wss://relay.example".to_owned(),
289 ])?,
290 NostrTag::new(vec!["future".to_owned(), "exact".to_owned()])?,
291 ],
292 "hello",
293 )?)?;
294 let message = Nip17TextMessage::from_rumor(rumor.clone())?;
295
296 assert_eq!(message.rumor(), &rumor);
297 assert_eq!(
298 message
299 .rumor()
300 .tags()
301 .map(<[String]>::to_vec)
302 .collect::<Vec<_>>(),
303 vec![
304 vec![
305 "p".to_owned(),
306 bob.public_key().to_hex(),
307 "wss://relay.example".to_owned(),
308 ],
309 vec!["future".to_owned(), "exact".to_owned()],
310 ]
311 );
312 Ok(())
313 }
314
315 #[test]
316 fn rejects_empty_self_group_duplicate_and_wrong_kind_messages() -> Result<(), SoftchatError> {
317 let alice = LocalIdentity::from_secret_hex(ALICE_SECRET)?;
318 let bob = LocalIdentity::from_secret_hex(BOB_SECRET)?;
319 assert!(matches!(
320 alice.create_nip17_text_message(&alice.public_key(), 1, "self"),
321 Err(SoftchatError::Nip17MessageCreationFailed)
322 ));
323 assert!(matches!(
324 alice.create_nip17_text_message(&bob.public_key(), 1, ""),
325 Err(SoftchatError::Nip17MessageCreationFailed)
326 ));
327
328 for (kind, tags, content) in [
329 (
330 NostrEventKind::SHORT_TEXT_NOTE,
331 vec![NostrTag::new(vec![
332 "p".to_owned(),
333 bob.public_key().to_hex(),
334 ])?],
335 "wrong kind",
336 ),
337 (
338 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
339 Vec::new(),
340 "missing",
341 ),
342 (
343 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
344 vec![
345 NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
346 NostrTag::new(vec!["p".to_owned(), bob.public_key().to_hex()])?,
347 ],
348 "duplicate",
349 ),
350 (
351 NostrEventKind::PRIVATE_DIRECT_MESSAGE,
352 vec![NostrTag::new(vec![
353 "p".to_owned(),
354 bob.public_key().to_hex(),
355 ])?],
356 "",
357 ),
358 ] {
359 let rumor = alice.create_rumor(NostrEventDraft::new(1, kind, tags, content)?)?;
360 assert!(matches!(
361 Nip17TextMessage::from_rumor(rumor),
362 Err(SoftchatError::InvalidNip17Message)
363 ));
364 }
365 Ok(())
366 }
367}