Skip to main content

softchat/
facade.rs

1//! Binding-friendly authored operations over the retained identity capability.
2
3use serde::Deserialize;
4
5use crate::{
6    AppDataContext, AppDataSyncView, ApplicationDataView, AttachmentMetadata, ChatMessageDraft,
7    ChatMessageView, ChatRelationInput, Contact, DeletionView, EditView, FollowListView,
8    HttpAuthorizationPlan, LocalIdentityHandle, NostrEventId, NostrEventKind, NostrPublicKey,
9    NostrTag, ReactionDraft, ReactionView, SignedEvent, SignedNostrEvent, SoftchatError,
10    SubjectDraft, SubjectView, TypingView, UserMetadataView, create_nip98_authorization,
11    plan_nip42_authentication,
12};
13
14/// Binding-owned canonical kind-14 writer input.
15#[derive(Clone, Debug, Deserialize)]
16#[serde(rename_all = "camelCase", deny_unknown_fields)]
17#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
18pub struct ChatMessageInput {
19    /// Explicit portable timestamp.
20    pub created_at: i64,
21    /// Every participant except the author, as canonical public keys.
22    pub participants: Vec<String>,
23    /// Text content; may be empty only with an attachment.
24    pub content: String,
25    /// Optional reply or forward relation.
26    pub relation: ChatRelationInput,
27    /// Canonical attachment metadata.
28    pub attachments: Vec<AttachmentMetadata>,
29    /// Exact `emoji` tag arrays.
30    pub emoji_tags: Vec<Vec<String>>,
31    /// Caller-owned future tags.
32    pub extension_tags: Vec<Vec<String>>,
33}
34
35/// Binding-owned canonical group-subject writer input.
36#[derive(Clone, Debug, Deserialize)]
37#[serde(rename_all = "camelCase", deny_unknown_fields)]
38#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
39pub struct SubjectInput {
40    /// Explicit portable timestamp.
41    pub created_at: i64,
42    /// Every participant except the author.
43    pub participants: Vec<String>,
44    /// Subject text, including an explicit empty clear.
45    pub subject: String,
46    /// Exact `emoji` tag arrays.
47    pub emoji_tags: Vec<Vec<String>>,
48    /// Optional icon metadata mirrored to both compatible tag shapes.
49    pub icon: Option<AttachmentMetadata>,
50    /// Caller-owned future tags.
51    pub extension_tags: Vec<Vec<String>>,
52}
53
54/// Binding-owned canonical private-reaction writer input.
55#[derive(Clone, Debug, Deserialize)]
56#[serde(rename_all = "camelCase", deny_unknown_fields)]
57#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
58pub struct ReactionInput {
59    /// Every participant except the author.
60    pub participants: Vec<String>,
61    /// Referenced event ID.
62    pub parent_id: String,
63    /// Referenced event author.
64    pub parent_author: String,
65    /// Referenced event kind.
66    pub parent_kind: i32,
67    /// Unicode reaction or `:shortcode:`.
68    pub reaction: String,
69    /// HTTPS custom-emoji URL; empty means absent.
70    pub custom_emoji_url: String,
71    /// Explicit portable timestamp.
72    pub created_at: i64,
73}
74
75/// Binding-owned private user-metadata writer input.
76#[derive(Clone, Debug, Deserialize)]
77#[serde(rename_all = "camelCase", deny_unknown_fields)]
78#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
79pub struct UserMetadataInput {
80    /// Explicit portable timestamp.
81    pub created_at: i64,
82    /// Optional short name.
83    pub name: Option<String>,
84    /// Optional display name.
85    pub display_name: Option<String>,
86    /// Optional profile description.
87    pub about: Option<String>,
88    /// Optional HTTPS picture URL.
89    pub picture: Option<String>,
90    /// Optional HTTPS website URL.
91    pub website: Option<String>,
92    /// Optional HTTPS banner URL.
93    pub banner: Option<String>,
94    /// Optional bot marker.
95    pub bot: Option<bool>,
96    /// Compact object containing caller-owned unknown fields.
97    pub unknown_json: String,
98}
99
100#[cfg_attr(feature = "native-bindings", uniffi::export)]
101impl LocalIdentityHandle {
102    /// Create a canonical complete kind-14 message rumor.
103    ///
104    /// # Errors
105    ///
106    /// Returns stable validation, lifecycle, or creation failures.
107    pub fn create_chat_message(
108        &self,
109        input: ChatMessageInput,
110    ) -> Result<ChatMessageView, SoftchatError> {
111        let draft = ChatMessageDraft {
112            created_at: timestamp(input.created_at, SoftchatError::SoftchatEventCreationFailed)?,
113            participants: public_keys(
114                input.participants,
115                SoftchatError::SoftchatEventCreationFailed,
116            )?,
117            content: input.content,
118            relation: input.relation.into_relation()?,
119            attachments: input.attachments,
120            emoji_tags: tags(input.emoji_tags, SoftchatError::SoftchatEventCreationFailed)?,
121            extension_tags: tags(
122                input.extension_tags,
123                SoftchatError::SoftchatEventCreationFailed,
124            )?,
125        };
126        self.with_identity(|identity| identity.create_chat_message(draft))
127    }
128
129    /// Create a canonical group-subject update rumor.
130    ///
131    /// # Errors
132    ///
133    /// Returns stable validation, lifecycle, or creation failures.
134    pub fn create_subject(&self, input: SubjectInput) -> Result<SubjectView, SoftchatError> {
135        let draft = SubjectDraft {
136            created_at: timestamp(input.created_at, SoftchatError::SoftchatEventCreationFailed)?,
137            participants: public_keys(
138                input.participants,
139                SoftchatError::SoftchatEventCreationFailed,
140            )?,
141            subject: input.subject,
142            emoji_tags: tags(input.emoji_tags, SoftchatError::SoftchatEventCreationFailed)?,
143            icon: input.icon,
144            extension_tags: tags(
145                input.extension_tags,
146                SoftchatError::SoftchatEventCreationFailed,
147            )?,
148        };
149        self.with_identity(|identity| identity.create_subject(draft))
150    }
151
152    /// Create a canonical private reaction rumor.
153    ///
154    /// # Errors
155    ///
156    /// Returns stable validation, lifecycle, or creation failures.
157    pub fn create_reaction(&self, input: ReactionInput) -> Result<ReactionView, SoftchatError> {
158        let error = SoftchatError::SoftchatEventCreationFailed;
159        let draft = ReactionDraft {
160            participants: public_keys(input.participants, error)?,
161            parent_id: NostrEventId::from_hex(&input.parent_id)
162                .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?,
163            parent_author: NostrPublicKey::from_hex(&input.parent_author)
164                .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?,
165            parent_kind: u32::try_from(input.parent_kind)
166                .map_err(|_| SoftchatError::SoftchatEventCreationFailed)
167                .and_then(NostrEventKind::try_from_u32)
168                .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?,
169            reaction: input.reaction,
170            custom_emoji_url: (!input.custom_emoji_url.is_empty())
171                .then_some(input.custom_emoji_url),
172            created_at: timestamp(input.created_at, SoftchatError::SoftchatEventCreationFailed)?,
173        };
174        self.with_identity(|identity| identity.create_reaction(draft))
175    }
176
177    /// Create a canonical private deletion request.
178    ///
179    /// # Errors
180    ///
181    /// Returns stable validation, lifecycle, or creation failures.
182    pub fn create_deletion(
183        &self,
184        participants: Vec<String>,
185        event_ids: Vec<String>,
186        reason: String,
187        created_at: i64,
188    ) -> Result<DeletionView, SoftchatError> {
189        let participants = public_keys(participants, SoftchatError::SoftchatEventCreationFailed)?;
190        let event_ids = event_ids
191            .iter()
192            .map(|value| {
193                NostrEventId::from_hex(value)
194                    .map_err(|_| SoftchatError::SoftchatEventCreationFailed)
195            })
196            .collect::<Result<Vec<_>, _>>()?;
197        let created_at = timestamp(created_at, SoftchatError::SoftchatEventCreationFailed)?;
198        self.with_identity(|identity| {
199            identity.create_deletion(participants, event_ids, reason, created_at)
200        })
201    }
202
203    /// Create a canonical text-and-emoji edit rumor.
204    ///
205    /// # Errors
206    ///
207    /// Returns stable validation, lifecycle, or creation failures.
208    pub fn create_edit(
209        &self,
210        participants: Vec<String>,
211        original_event_id: String,
212        content: String,
213        emoji_tags: Vec<Vec<String>>,
214        created_at: i64,
215    ) -> Result<EditView, SoftchatError> {
216        let participants = public_keys(participants, SoftchatError::SoftchatEventCreationFailed)?;
217        let original_event_id = NostrEventId::from_hex(&original_event_id)
218            .map_err(|_| SoftchatError::SoftchatEventCreationFailed)?;
219        let emoji_tags = tags(emoji_tags, SoftchatError::SoftchatEventCreationFailed)?;
220        let created_at = timestamp(created_at, SoftchatError::SoftchatEventCreationFailed)?;
221        self.with_identity(|identity| {
222            identity.create_edit(
223                participants,
224                original_event_id,
225                content,
226                emoji_tags,
227                created_at,
228            )
229        })
230    }
231
232    /// Create one ephemeral typing rumor.
233    ///
234    /// # Errors
235    ///
236    /// Returns stable validation, lifecycle, or creation failures.
237    pub fn create_typing(
238        &self,
239        participants: Vec<String>,
240        created_at: i64,
241    ) -> Result<TypingView, SoftchatError> {
242        let participants = public_keys(participants, SoftchatError::SoftchatEventCreationFailed)?;
243        let created_at = timestamp(created_at, SoftchatError::SoftchatEventCreationFailed)?;
244        self.with_identity(|identity| identity.create_typing(participants, created_at))
245    }
246
247    /// Create canonical private kind-0 user metadata.
248    ///
249    /// # Errors
250    ///
251    /// Returns stable metadata or lifecycle failures.
252    pub fn create_user_metadata(
253        &self,
254        input: UserMetadataInput,
255    ) -> Result<UserMetadataView, SoftchatError> {
256        let created_at = timestamp(input.created_at, SoftchatError::InvalidUserMetadata)?;
257        self.with_identity(|identity| {
258            identity.create_user_metadata(
259                created_at,
260                input.name,
261                input.display_name,
262                input.about,
263                input.picture,
264                input.website,
265                input.banner,
266                input.bot,
267                &input.unknown_json,
268            )
269        })
270    }
271
272    /// Create a canonical private author-copy contact backup.
273    ///
274    /// # Errors
275    ///
276    /// Returns stable contact-list or lifecycle failures.
277    pub fn create_follow_list(
278        &self,
279        created_at: i64,
280        contacts: Vec<Contact>,
281    ) -> Result<FollowListView, SoftchatError> {
282        let created_at = timestamp(created_at, SoftchatError::InvalidContactList)?;
283        self.with_identity(|identity| identity.create_follow_list(created_at, contacts))
284    }
285
286    /// Create direct generic NIP-78 application data.
287    ///
288    /// # Errors
289    ///
290    /// Returns stable application-data or lifecycle failures.
291    pub fn create_application_data(
292        &self,
293        created_at: i64,
294        identifier: String,
295        content: String,
296    ) -> Result<ApplicationDataView, SoftchatError> {
297        let created_at = timestamp(created_at, SoftchatError::InvalidApplicationData)?;
298        self.with_identity(|identity| {
299            identity.create_application_data(created_at, identifier, content)
300        })
301    }
302
303    /// Create directly signed, self-encrypted kind-30079 data.
304    ///
305    /// # Errors
306    ///
307    /// Returns stable application-data or lifecycle failures.
308    pub fn create_app_data_sync(
309        &self,
310        created_at: i64,
311        context: AppDataContext,
312        json: String,
313    ) -> Result<AppDataSyncView, SoftchatError> {
314        let created_at = timestamp(created_at, SoftchatError::InvalidApplicationData)?;
315        self.with_identity(|identity| identity.create_app_data_sync(created_at, context, &json))
316    }
317
318    /// Authenticate and decrypt one self-authored kind-30079 event.
319    ///
320    /// # Errors
321    ///
322    /// Returns stable application-data or lifecycle failures.
323    pub fn decrypt_app_data_sync(
324        &self,
325        event: SignedEvent,
326    ) -> Result<AppDataSyncView, SoftchatError> {
327        let event =
328            SignedNostrEvent::try_from(event).map_err(|_| SoftchatError::InvalidApplicationData)?;
329        self.with_identity(|identity| identity.decrypt_app_data_sync(&event))
330    }
331
332    /// Create one signed challenge-bound NIP-42 AUTH event.
333    ///
334    /// # Errors
335    ///
336    /// Returns stable authentication, timestamp, or lifecycle failures.
337    pub fn create_nip42_authentication(
338        &self,
339        challenge: String,
340        relay_url: String,
341        created_at: i64,
342    ) -> Result<SignedEvent, SoftchatError> {
343        let created_at = timestamp(created_at, SoftchatError::InvalidRelayAuthentication)?;
344        self.with_identity(|identity| {
345            plan_nip42_authentication(identity, &challenge, &relay_url, created_at)
346                .map(|event| SignedEvent::from(&event))
347        })
348    }
349
350    /// Create one immutable signed NIP-98 HTTP authorization plan.
351    ///
352    /// # Errors
353    ///
354    /// Returns stable HTTP-plan, timestamp, or lifecycle failures.
355    pub fn create_nip98_authorization(
356        &self,
357        method: String,
358        url: String,
359        payload: Option<Vec<u8>>,
360        created_at: i64,
361    ) -> Result<HttpAuthorizationPlan, SoftchatError> {
362        let created_at = timestamp(created_at, SoftchatError::InvalidHttpAuthorization)?;
363        self.with_identity(|identity| {
364            create_nip98_authorization(identity, &method, &url, payload.as_deref(), created_at)
365        })
366    }
367}
368
369fn timestamp(value: i64, error: SoftchatError) -> Result<u64, SoftchatError> {
370    u64::try_from(value).map_err(|_| error)
371}
372
373fn public_keys(
374    values: Vec<String>,
375    error: SoftchatError,
376) -> Result<Vec<NostrPublicKey>, SoftchatError> {
377    values
378        .iter()
379        .map(|value| NostrPublicKey::from_hex(value).map_err(|_| error))
380        .collect()
381}
382
383fn tags(values: Vec<Vec<String>>, error: SoftchatError) -> Result<Vec<NostrTag>, SoftchatError> {
384    values
385        .into_iter()
386        .map(|value| NostrTag::new(value).map_err(|_| error))
387        .collect()
388}
389
390#[cfg(test)]
391mod tests {
392    use super::*;
393
394    const ALICE: [u8; 32] = [
395        0x5c, 0x0c, 0x52, 0x3f, 0x52, 0xa5, 0xb6, 0xfa, 0xd3, 0x9e, 0xd2, 0x40, 0x30, 0x92, 0xdf,
396        0x8c, 0xeb, 0xc3, 0x63, 0x18, 0xb3, 0x93, 0x83, 0xbc, 0xa6, 0xc0, 0x08, 0x08, 0x62, 0x6f,
397        0xab, 0x3a,
398    ];
399    const BOB_PUBLIC: &str = "4ddeb9109a8cd29ba279a637f5ec344f2479ee07df1f4043f3fe26d8948cfef9";
400
401    #[test]
402    fn retained_identity_exposes_canonical_chat_and_request_plans() -> Result<(), SoftchatError> {
403        let identity = LocalIdentityHandle::new(ALICE.to_vec())?;
404        let message = identity.create_chat_message(ChatMessageInput {
405            created_at: 1,
406            participants: vec![BOB_PUBLIC.to_owned()],
407            content: "hello".to_owned(),
408            relation: ChatRelationInput {
409                kind: crate::ChatRelationKind::None,
410                event_id: String::new(),
411                relay_hint: String::new(),
412                original_author: String::new(),
413            },
414            attachments: Vec::new(),
415            emoji_tags: Vec::new(),
416            extension_tags: Vec::new(),
417        })?;
418        assert_eq!(message.rumor.kind, 14);
419        let auth = identity.create_nip42_authentication(
420            "challenge".to_owned(),
421            "wss://relay.example".to_owned(),
422            2,
423        )?;
424        assert_eq!(auth.kind, 22_242);
425        let http = identity.create_nip98_authorization(
426            "post".to_owned(),
427            "https://relay.example/upload".to_owned(),
428            Some(b"body".to_vec()),
429            3,
430        )?;
431        assert_eq!(http.method, "POST");
432        Ok(())
433    }
434}