1#![allow(
4 unreachable_pub,
5 reason = "UniFFI exports public handles from this private adapter module"
6)]
7
8use std::collections::{BTreeMap, BTreeSet};
9use std::fmt;
10use std::path::{Path, PathBuf};
11use std::sync::atomic::{AtomicBool, Ordering};
12use std::sync::{Arc, Mutex, MutexGuard, OnceLock};
13
14use bech32::{Bech32, Hrp};
15use sha2::{Digest, Sha256};
16use url::Url;
17
18use crate::account_diagnostics::{
19 AccountLogBatch, AccountLogBuffer, AccountLogCategory, AccountLogLevel, AccountLogSource,
20 AccountLogSubject, Diagnostic,
21};
22#[cfg(test)]
23use crate::account_diagnostics::{AccountLogMetadata, MAX_ACCOUNT_LOG_BATCH};
24use crate::account_product::{
25 command_hash, product_draft_match, profile_update_from_patch, validate_command_id,
26 validate_conversation_id,
27};
28use crate::account_transport::{AccountTransportRun, StoredAccountTransportOutcome, result_hash};
29use crate::event::{RumorEvent, SignedEvent, SignedNostrEvent};
30use crate::media::FfiHttpAuthorizationPlan;
31use crate::message::LocalIdentityHandle;
32use crate::storage::ProductAccountOperationResult;
33use crate::{
34 AccountDatabase, AccountDatabaseInfo, AccountDiagnostics, AccountDraft, AccountEngine,
35 AccountEventNode, AccountMediaItem, AccountMediaLease, AccountMediaOperation,
36 AccountMediaOperationPage, AccountMessage, AccountMessageView, AccountMutationResult,
37 AccountOperationResult, AccountProfile, AccountProjection, AccountSettings,
38 AccountSettingsMutation, AccountTransportBatch, AccountTransportResult, AppDataContext,
39 AssetReplacementTarget, AttachmentMetadata, ChatMessageDraft, ChatRelation, ClaimedDelivery,
40 Contact, ConversationCursor, ConversationPage, ConversationRecord, ConversationSummary,
41 ConversationView, DeliveryClaim, DeliveryIntentSnapshot, DeliveryReducer,
42 DeliveryStateMutation, DnsRelayRecord, IngestionReceipt, LinkPreviewRecord, LocalContactRecord,
43 MessageContentInput, MessageCursor, MessagePage, MessageReactionInput, NegentropyItem,
44 Nip59EnvelopeKind, NostrEventId, NostrEventKind, NostrPublicKey, NostrTag, OperationSnapshot,
45 PendingAssetReplacement, PreparedIncomingBatch, PreparedOutgoingOperation,
46 ProductOperationResult, PushPlatform, ReactionDraft, RelayCatalogEntry, RelayCatalogMutation,
47 RelayCatalogPlan, RelayCatalogReducer, RelayConnectionState, RelayDeliveryResult,
48 RelayEndpointPlan, RelayFailoverMutation, RelayFailureKind, RelayFilter, RelayRetryPlan,
49 SoftchatError, StickerRecord, StoredEventJson, StoredIngestion, StoredRelayCatalogPlan,
50 SubjectDraft, SyncAction, SyncActionKind, SyncEngine, SyncEngineSnapshot, conversation_id,
51 parse_relay_endpoint, plan_relay_retry, route_nip59_envelope,
52};
53
54#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
56pub struct AccountRuntimeHandle {
57 account_id: String,
58 identity: Arc<LocalIdentityHandle>,
59 database: Mutex<AccountDatabase>,
60 database_path: PathBuf,
61 closed: AtomicBool,
62 sync_engines: Mutex<BTreeMap<String, SyncEngine>>,
63 transport_run: Mutex<Option<AccountTransportRun>>,
64 typing_authored: Mutex<BTreeMap<String, (i64, String)>>,
65 pub(crate) logs: Arc<AccountLogBuffer>,
66 logging_configured: AtomicBool,
67}
68
69#[derive(Clone, Debug)]
71#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
72pub struct AccountDescriptor {
73 pub public_key: String,
75 pub database_filename: String,
77}
78
79#[derive(Clone, Debug)]
81#[cfg_attr(feature = "native-bindings", derive(uniffi::Record))]
82pub struct IncomingAccountRoute {
83 pub event_id: String,
85 pub recipient_public_key: String,
87 pub envelope_kind: Nip59EnvelopeKind,
89}
90
91#[cfg_attr(feature = "native-bindings", uniffi::export)]
101pub fn route_incoming_account_event(
102 event_json: String,
103) -> Result<IncomingAccountRoute, SoftchatError> {
104 let route = route_nip59_envelope(&event_json)?;
105 Ok(IncomingAccountRoute {
106 event_id: route.outer_event_id().to_hex(),
107 recipient_public_key: route.recipient().to_hex(),
108 envelope_kind: route.kind(),
109 })
110}
111
112#[cfg_attr(feature = "native-bindings", uniffi::export)]
118#[must_use]
119pub fn generate_account_secret_key() -> Vec<u8> {
120 nostr::SecretKey::generate().to_secret_bytes().to_vec()
121}
122
123#[cfg_attr(feature = "native-bindings", uniffi::export)]
129pub fn parse_account_secret_key(encoded: String) -> Result<Vec<u8>, SoftchatError> {
130 nostr::SecretKey::parse(&encoded)
131 .map(|secret| secret.to_secret_bytes().to_vec())
132 .map_err(|_| SoftchatError::InvalidSecretKey)
133}
134
135#[cfg_attr(feature = "native-bindings", uniffi::export)]
145pub fn encode_account_secret_key(mut secret_key_bytes: Vec<u8>) -> Result<String, SoftchatError> {
146 let result = (|| {
147 let secret = nostr::SecretKey::from_slice(&secret_key_bytes)
148 .map_err(|_| SoftchatError::InvalidSecretKey)?;
149 bech32::encode::<Bech32>(
150 Hrp::parse("nsec").map_err(|_| SoftchatError::InternalFailure)?,
151 &secret.to_secret_bytes(),
152 )
153 .map_err(|_| SoftchatError::InternalFailure)
154 })();
155 secret_key_bytes.fill(0);
156 result
157}
158
159#[cfg_attr(feature = "native-bindings", uniffi::export)]
165pub fn parse_account_conversation_id(value: String) -> Result<String, SoftchatError> {
166 if value.len() != 64 || !value.bytes().all(|byte| byte.is_ascii_hexdigit()) {
167 return Err(SoftchatError::InvalidAccountOperation);
168 }
169 Ok(value.to_ascii_lowercase())
170}
171
172#[cfg_attr(feature = "native-bindings", uniffi::export)]
178pub fn parse_account_message_id(value: String) -> Result<String, SoftchatError> {
179 Ok(NostrEventId::from_hex(&value)?.to_hex())
180}
181
182#[cfg_attr(feature = "native-bindings", uniffi::export)]
190pub fn describe_account_secret(
191 secret_key_bytes: Vec<u8>,
192) -> Result<AccountDescriptor, SoftchatError> {
193 let identity = LocalIdentityHandle::new(secret_key_bytes)?;
194 let public_key = identity.public_key()?;
195 identity.erase();
196 Ok(AccountDescriptor {
197 database_filename: account_database_filename(&public_key)?,
198 public_key,
199 })
200}
201
202impl fmt::Debug for AccountRuntimeHandle {
203 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
204 formatter
205 .debug_struct("AccountRuntimeHandle")
206 .field("account_id", &self.account_id)
207 .field("database", &"<account-private sqlite>")
208 .field("closed", &self.closed.load(Ordering::Acquire))
209 .field("active_syncs", &lock(&self.sync_engines).len())
210 .finish_non_exhaustive()
211 }
212}
213
214#[cfg_attr(feature = "native-bindings", uniffi::export)]
215impl AccountRuntimeHandle {
216 #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
226 pub fn open(
227 secret_key_bytes: Vec<u8>,
228 database_directory: String,
229 ) -> Result<Self, SoftchatError> {
230 let identity = Arc::new(LocalIdentityHandle::new(secret_key_bytes)?);
231 let account_id = identity.public_key()?;
232 let database_path = derived_account_path(&database_directory, &account_id)?;
233 {
234 let mut registry = lock(open_account_paths());
235 if !registry.insert(database_path.clone()) {
236 identity.erase();
237 return Err(SoftchatError::AccountAlreadyOpen);
238 }
239 }
240 let mut database = match AccountDatabase::open(&database_path, account_id.clone()) {
241 Ok(database) => database,
242 Err(error) => {
243 lock(open_account_paths()).remove(&database_path);
244 identity.erase();
245 return Err(error);
246 }
247 };
248 let logs = Arc::new(AccountLogBuffer::new(AccountLogLevel::Off));
249 database.diagnostics.buffer = Some(logs.clone());
250 Ok(Self {
251 account_id,
252 identity,
253 database: Mutex::new(database),
254 database_path,
255 closed: AtomicBool::new(false),
256 sync_engines: Mutex::new(BTreeMap::new()),
257 transport_run: Mutex::new(None),
258 typing_authored: Mutex::new(BTreeMap::new()),
259 logs,
260 logging_configured: AtomicBool::new(false),
261 })
262 }
263
264 #[must_use]
266 pub fn account_id(&self) -> String {
267 self.account_id.clone()
268 }
269
270 pub fn public_key(&self) -> Result<String, SoftchatError> {
276 self.ensure_open()?;
277 Ok(self.account_id.clone())
278 }
279
280 #[must_use]
282 pub fn database_filename(&self) -> String {
283 self.database_path
284 .file_name()
285 .and_then(|value| value.to_str())
286 .unwrap_or_default()
287 .to_owned()
288 }
289
290 pub fn descriptor(&self) -> Result<AccountDescriptor, SoftchatError> {
299 self.ensure_open()?;
300 Ok(AccountDescriptor {
301 public_key: self.account_id.clone(),
302 database_filename: self.database_filename(),
303 })
304 }
305
306 pub fn close_account(&self) {
312 if !self.closed.swap(true, Ordering::AcqRel) {
313 lock(&self.sync_engines).clear();
314 lock(&self.transport_run).take();
315 lock(&self.typing_authored).clear();
316 self.identity.erase();
317 self.logs.flush();
318 self.logs.emit(Diagnostic::new(
319 AccountLogCategory::Lifecycle,
320 "account.close",
321 "Account closed",
322 ));
323 }
324 }
325
326 pub fn set_log_level(&self, level: AccountLogLevel) -> Result<(), SoftchatError> {
336 self.ensure_open()?;
337 let previous = self.logs.level();
338 self.logs.set_level(level);
339 if level != AccountLogLevel::Off && previous != level {
340 if self.logging_configured.swap(true, Ordering::Relaxed) {
341 self.logs.emit(
342 Diagnostic::new(
343 AccountLogCategory::Lifecycle,
344 "account.logging.configure",
345 "Logging configured",
346 )
347 .debug(),
348 );
349 } else {
350 self.logs.emit(Diagnostic::new(
351 AccountLogCategory::Lifecycle,
352 "account.opened",
353 "Account opened",
354 ));
355 }
356 }
357 Ok(())
358 }
359
360 pub fn drain_log_events(&self, limit: u32) -> Result<AccountLogBatch, SoftchatError> {
371 self.logs.drain(limit)
372 }
373
374 pub fn get_or_create_conversation(
382 &self,
383 member_public_keys: Vec<String>,
384 now: i64,
385 ) -> Result<ConversationRecord, SoftchatError> {
386 let own_public_key = self.public_key()?;
387 self.database()?.get_or_create_product_conversation(
388 &own_public_key,
389 member_public_keys,
390 now,
391 )
392 }
393
394 pub fn conversation(
400 &self,
401 conversation_id: String,
402 ) -> Result<Option<ConversationRecord>, SoftchatError> {
403 let own_public_key = self.public_key()?;
404 self.database()?
405 .product_conversation(&conversation_id, &own_public_key)
406 }
407
408 pub fn conversation_page(
414 &self,
415 archived: bool,
416 cursor: Option<ConversationCursor>,
417 limit: u32,
418 ) -> Result<ConversationPage, SoftchatError> {
419 let own_public_key = self.public_key()?;
420 self.database()?
421 .product_conversations(&own_public_key, archived, cursor, limit)
422 }
423
424 pub fn message(&self, message_id: String) -> Result<Option<AccountMessageView>, SoftchatError> {
430 self.database()?.product_message(&message_id)
431 }
432
433 pub fn messages_by_ids(
442 &self,
443 message_ids: Vec<String>,
444 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
445 self.database()?.product_messages_by_ids(&message_ids)
446 }
447
448 pub fn message_page(
454 &self,
455 conversation_id: String,
456 cursor: Option<MessageCursor>,
457 limit: u32,
458 ) -> Result<MessagePage, SoftchatError> {
459 self.database()?
460 .product_messages(&conversation_id, cursor, limit)
461 }
462
463 pub fn message_replies(
472 &self,
473 message_id: String,
474 cursor: Option<MessageCursor>,
475 limit: u32,
476 ) -> Result<MessagePage, SoftchatError> {
477 self.database()?
478 .product_message_replies(&message_id, cursor, limit)
479 }
480
481 pub fn message_context(
487 &self,
488 message_id: String,
489 before: u32,
490 after: u32,
491 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
492 self.database()?
493 .product_message_context(&message_id, before, after)
494 }
495
496 pub fn search_messages(
504 &self,
505 conversation_id: String,
506 query: String,
507 limit: u32,
508 ) -> Result<Vec<AccountMessageView>, SoftchatError> {
509 self.database()?.search_product_messages(
510 (!conversation_id.is_empty()).then_some(conversation_id),
511 &query,
512 limit,
513 )
514 }
515
516 pub fn message_local_extras(
522 &self,
523 message_id: String,
524 ) -> Result<Option<crate::MessageLocalExtras>, SoftchatError> {
525 self.database()?.product_message_local_extras(&message_id)
526 }
527
528 pub fn set_message_local_extras(
534 &self,
535 message_id: String,
536 transcript: Option<String>,
537 waveform: Vec<u8>,
538 now: i64,
539 ) -> Result<AccountMutationResult, SoftchatError> {
540 self.database()?
541 .set_product_message_local_extras(&message_id, transcript, waveform, now)
542 }
543
544 pub fn conversation_count(&self, archived: bool) -> Result<u32, SoftchatError> {
550 self.database()?.product_conversation_count(archived)
551 }
552
553 pub fn has_unread(&self, include_archived: bool) -> Result<bool, SoftchatError> {
559 let own_public_key = self.public_key()?;
560 self.database()?
561 .product_has_unread(&own_public_key, include_archived)
562 }
563
564 pub fn search_conversations(
573 &self,
574 query: String,
575 archived: Option<bool>,
576 limit: u32,
577 ) -> Result<Vec<ConversationRecord>, SoftchatError> {
578 let own_public_key = self.public_key()?;
579 self.database()?
580 .search_product_conversations(&own_public_key, &query, archived, limit)
581 }
582
583 pub fn send_message(
598 &self,
599 command_id: String,
600 conversation_id: String,
601 content: MessageContentInput,
602 reply_to_message_id: String,
603 draft_to_consume: Option<AccountDraft>,
604 now: i64,
605 ) -> Result<ProductOperationResult, SoftchatError> {
606 let result = (|| {
607 validate_command_id(&command_id)?;
608 let draft_match = draft_to_consume.map(product_draft_match).transpose()?;
609 let payload_hash = send_message_command_hash(
610 &conversation_id,
611 &content,
612 &reply_to_message_id,
613 draft_match.as_ref(),
614 )?;
615 let (rumor, recipients) = author_product_chat_message(
616 self,
617 &conversation_id,
618 content,
619 &reply_to_message_id,
620 now,
621 )?;
622 let message_id = rumor.id.clone();
623 let operation = {
624 let mut database = self.database()?;
625 self.identity.with_identity(|identity| {
626 database.enqueue_product_rumor(
627 identity,
628 command_id.clone(),
629 payload_hash,
630 message_id.clone(),
631 rumor,
632 recipients.clone(),
633 recipients,
634 Nip59EnvelopeKind::Durable,
635 now,
636 draft_match
637 .as_ref()
638 .map(|value| (conversation_id.as_str(), value)),
639 )
640 })?
641 };
642 stored_product_result(self, operation)
643 })();
644 let mut diagnostic = Diagnostic::new(
645 AccountLogCategory::Messaging,
646 "message.command.failed",
647 "Account command failed",
648 );
649 diagnostic.subject = Some(AccountLogSubject::ChatMessage);
650 diagnostic.source = Some(AccountLogSource::LocalCommand);
651 self.logs.failed(diagnostic, &result);
652 result
653 }
654
655 pub fn forward_message(
664 &self,
665 command_id: String,
666 message_id: String,
667 destination_conversation_id: String,
668 now: i64,
669 ) -> Result<ProductOperationResult, SoftchatError> {
670 let result = (|| {
671 validate_command_id(&command_id)?;
672 NostrEventId::from_hex(&message_id)?;
673 let payload_hash =
674 command_hash(&("forward", &message_id, &destination_conversation_id))?;
675 let existing = self
676 .database()?
677 .product_operation_if_committed(&command_id, &payload_hash)?;
678 if let Some(operation) = existing {
679 return stored_product_result(self, operation);
680 }
681 let original = self
682 .database()?
683 .product_message(&message_id)?
684 .filter(|message| !message.deleted)
685 .ok_or(SoftchatError::InvalidAccountOperation)?;
686 let own_public_key = self.public_key()?;
687 let destination = self
688 .database()?
689 .product_conversation(&destination_conversation_id, &own_public_key)?
690 .ok_or(SoftchatError::InvalidAccountOperation)?;
691 let recipients = remote_members(&destination, &own_public_key);
692 let created_at =
693 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
694 let relay_hint = self.database()?.active_relay_url()?;
695 let rumor = self.identity.with_identity(|identity| {
696 identity
697 .create_chat_message(ChatMessageDraft {
698 created_at,
699 participants: public_keys(&recipients)?,
700 content: original.text,
701 relation: ChatRelation::Forward {
702 event_id: NostrEventId::from_hex(&original.id)?,
703 relay_hint: Some(relay_hint),
704 original_author: NostrPublicKey::from_hex(&original.author_public_key)?,
705 },
706 attachments: original.attachments,
707 emoji_tags: nostr_tags(original.emoji_tags)?,
708 extension_tags: Vec::new(),
709 })
710 .map(|view| view.rumor)
711 })?;
712 let forwarded_message_id = rumor.id.clone();
713 let operation = {
714 let mut database = self.database()?;
715 self.identity.with_identity(|identity| {
716 database.enqueue_product_rumor(
717 identity,
718 command_id,
719 payload_hash,
720 forwarded_message_id.clone(),
721 rumor,
722 recipients.clone(),
723 recipients,
724 Nip59EnvelopeKind::Durable,
725 now,
726 None,
727 )
728 })?
729 };
730 stored_product_result(self, operation)
731 })();
732 let mut diagnostic = Diagnostic::new(
733 AccountLogCategory::Messaging,
734 "message.command.failed",
735 "Account command failed",
736 );
737 diagnostic.subject = Some(AccountLogSubject::ChatMessage);
738 diagnostic.source = Some(AccountLogSource::LocalCommand);
739 self.logs.failed(diagnostic, &result);
740 result
741 }
742
743 pub fn set_conversation_subject(
749 &self,
750 command_id: String,
751 conversation_id: String,
752 subject: String,
753 icon: Option<AttachmentMetadata>,
754 emoji_tags: Vec<Vec<String>>,
755 now: i64,
756 ) -> Result<ProductOperationResult, SoftchatError> {
757 let result = (|| {
758 validate_command_id(&command_id)?;
759 if subject.len() > crate::MAX_PRODUCT_MESSAGE_BYTES {
760 return Err(SoftchatError::InvalidAccountOperation);
761 }
762 let own_public_key = self.public_key()?;
763 let conversation = self
764 .database()?
765 .product_conversation(&conversation_id, &own_public_key)?
766 .ok_or(SoftchatError::InvalidAccountOperation)?;
767 let recipients = remote_members(&conversation, &own_public_key);
768 let payload_hash =
769 command_hash(&("subject", &conversation_id, &subject, &icon, &emoji_tags))?;
770 let created_at =
771 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
772 let rumor = self.identity.with_identity(|identity| {
773 identity
774 .create_subject(SubjectDraft {
775 created_at,
776 participants: public_keys(&recipients)?,
777 subject,
778 emoji_tags: nostr_tags(emoji_tags)?,
779 icon,
780 extension_tags: Vec::new(),
781 })
782 .map(|view| view.rumor)
783 })?;
784 let operation = {
785 let mut database = self.database()?;
786 self.identity.with_identity(|identity| {
787 database.enqueue_product_rumor(
788 identity,
789 command_id,
790 payload_hash,
791 String::new(),
792 rumor,
793 recipients.clone(),
794 recipients,
795 Nip59EnvelopeKind::Durable,
796 now,
797 None,
798 )
799 })?
800 };
801 stored_product_result(self, operation)
802 })();
803 let mut diagnostic = Diagnostic::new(
804 AccountLogCategory::Messaging,
805 "message.command.failed",
806 "Account command failed",
807 );
808 diagnostic.subject = Some(AccountLogSubject::Subject);
809 diagnostic.source = Some(AccountLogSource::LocalCommand);
810 self.logs.failed(diagnostic, &result);
811 result
812 }
813
814 pub fn toggle_message_reaction(
826 &self,
827 command_id: String,
828 message_id: String,
829 reaction: MessageReactionInput,
830 now: i64,
831 ) -> Result<ProductOperationResult, SoftchatError> {
832 let result = (|| {
833 validate_command_id(&command_id)?;
834 NostrEventId::from_hex(&message_id)?;
835 let adding_hash = command_hash(&("reaction", &message_id, &reaction, false))?;
838 let removing_hash = command_hash(&("reaction", &message_id, &reaction, true))?;
839 let mut database = self.database()?;
840 let committed = match database.product_operation_if_committed(&command_id, &adding_hash)
841 {
842 Err(SoftchatError::CommandConflict) => {
843 database.product_operation_if_committed(&command_id, &removing_hash)?
844 }
845 result => result?,
846 };
847 if let Some(operation) = committed {
848 drop(database);
849 return stored_product_result(self, operation);
850 }
851 let own_public_key = self.public_key()?;
852 let parent = database
853 .product_message(&message_id)?
854 .filter(|message| !message.deleted)
855 .ok_or(SoftchatError::InvalidAccountOperation)?;
856 let conversation = database
857 .product_conversation(&parent.conversation_id, &own_public_key)?
858 .ok_or(SoftchatError::InvalidAccountOperation)?;
859 let recipients = remote_members(&conversation, &own_public_key);
860 let existing_ids = database.active_product_reaction_ids(
861 &message_id,
862 &own_public_key,
863 &reaction.value,
864 )?;
865 let payload_hash = if !existing_ids.is_empty() {
866 removing_hash
867 } else {
868 adding_hash
869 };
870 let created_at =
871 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
872 let rumor = if !existing_ids.is_empty() {
873 self.identity.with_identity(|identity| {
874 identity
875 .create_deletion(
876 public_keys(&recipients)?,
877 existing_ids
878 .iter()
879 .map(|id| NostrEventId::from_hex(id))
880 .collect::<Result<Vec<_>, _>>()?,
881 String::new(),
882 created_at,
883 )
884 .map(|view| view.rumor)
885 })?
886 } else {
887 self.identity.with_identity(|identity| {
888 identity
889 .create_reaction(ReactionDraft {
890 participants: public_keys(&recipients)?,
891 parent_id: NostrEventId::from_hex(&parent.id)?,
892 parent_author: NostrPublicKey::from_hex(&parent.author_public_key)?,
893 parent_kind: NostrEventKind::PRIVATE_DIRECT_MESSAGE,
894 reaction: reaction.value,
895 custom_emoji_url: reaction.custom_emoji_url,
896 created_at,
897 })
898 .map(|view| view.rumor)
899 })?
900 };
901 let operation = self.identity.with_identity(|identity| {
902 database.enqueue_product_rumor(
903 identity,
904 command_id,
905 payload_hash,
906 message_id.clone(),
907 rumor,
908 recipients.clone(),
909 Vec::new(),
910 Nip59EnvelopeKind::Durable,
911 now,
912 None,
913 )
914 })?;
915 drop(database);
916 stored_product_result(self, operation)
917 })();
918 let mut diagnostic = Diagnostic::new(
919 AccountLogCategory::Messaging,
920 "message.command.failed",
921 "Account command failed",
922 );
923 diagnostic.subject = Some(AccountLogSubject::Reaction);
924 diagnostic.source = Some(AccountLogSource::LocalCommand);
925 self.logs.failed(diagnostic, &result);
926 result
927 }
928
929 pub fn edit_message(
939 &self,
940 command_id: String,
941 message_id: String,
942 replacement: MessageContentInput,
943 now: i64,
944 ) -> Result<ProductOperationResult, SoftchatError> {
945 let result = (|| {
946 validate_command_id(&command_id)?;
947 replacement.validate_edit()?;
948 let target = NostrEventId::from_hex(&message_id)?;
949 let payload_hash = command_hash(&("edit", &message_id, &replacement))?;
950 let existing = {
951 let database = self.database()?;
952 database.product_operation_if_committed(&command_id, &payload_hash)?
953 };
954 if let Some(operation) = existing {
955 return stored_product_result(self, operation);
956 }
957 let own_public_key = self.public_key()?;
958 let original = self
959 .database()?
960 .product_message(&message_id)?
961 .filter(|message| message.author_public_key == own_public_key && !message.deleted)
962 .ok_or(SoftchatError::InvalidAccountOperation)?;
963 if replacement.text.is_empty() && original.attachments.is_empty() {
964 return Err(SoftchatError::InvalidAccountOperation);
965 }
966 let conversation = self
967 .database()?
968 .product_conversation(&original.conversation_id, &own_public_key)?
969 .ok_or(SoftchatError::InvalidAccountOperation)?;
970 let recipients = remote_members(&conversation, &own_public_key);
971 let created_at =
972 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
973 let rumor = self.identity.with_identity(|identity| {
974 identity
975 .create_edit(
976 public_keys(&recipients)?,
977 target,
978 replacement.text,
979 nostr_tags(replacement.emoji_tags)?,
980 created_at,
981 )
982 .map(|view| view.rumor)
983 })?;
984 let operation = {
985 let mut database = self.database()?;
986 self.identity.with_identity(|identity| {
987 database.enqueue_product_rumor(
988 identity,
989 command_id,
990 payload_hash,
991 message_id.clone(),
992 rumor,
993 recipients,
994 Vec::new(),
995 Nip59EnvelopeKind::Durable,
996 now,
997 None,
998 )
999 })?
1000 };
1001 stored_product_result(self, operation)
1002 })();
1003 let mut diagnostic = Diagnostic::new(
1004 AccountLogCategory::Messaging,
1005 "message.command.failed",
1006 "Account command failed",
1007 );
1008 diagnostic.subject = Some(AccountLogSubject::Edit);
1009 diagnostic.source = Some(AccountLogSource::LocalCommand);
1010 self.logs.failed(diagnostic, &result);
1011 result
1012 }
1013
1014 pub fn delete_message(
1020 &self,
1021 command_id: String,
1022 message_id: String,
1023 now: i64,
1024 ) -> Result<ProductOperationResult, SoftchatError> {
1025 let result = (|| {
1026 validate_command_id(&command_id)?;
1027 if message_id.len() != 64 {
1028 return Err(SoftchatError::InvalidAccountOperation);
1029 }
1030 let target = NostrEventId::from_hex(&message_id)?;
1031 let payload_hash = command_hash(&("delete", &message_id))?;
1032 let existing = {
1033 let database = self.database()?;
1034 database.product_operation_if_committed(&command_id, &payload_hash)?
1035 };
1036 if let Some(operation) = existing {
1037 return stored_product_result(self, operation);
1038 }
1039 let own_public_key = self.public_key()?;
1040 let original = self
1041 .database()?
1042 .product_message(&message_id)?
1043 .filter(|message| message.author_public_key == own_public_key && !message.deleted)
1044 .ok_or(SoftchatError::InvalidAccountOperation)?;
1045 let conversation = self
1046 .database()?
1047 .product_conversation(&original.conversation_id, &own_public_key)?
1048 .ok_or(SoftchatError::InvalidAccountOperation)?;
1049 let recipients = remote_members(&conversation, &own_public_key);
1050 let created_at =
1051 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1052 let rumor = self.identity.with_identity(|identity| {
1053 identity
1054 .create_deletion(
1055 public_keys(&recipients)?,
1056 vec![target],
1057 String::new(),
1058 created_at,
1059 )
1060 .map(|view| view.rumor)
1061 })?;
1062 let operation = {
1063 let mut database = self.database()?;
1064 self.identity.with_identity(|identity| {
1065 database.enqueue_product_rumor(
1066 identity,
1067 command_id,
1068 payload_hash,
1069 message_id.clone(),
1070 rumor,
1071 recipients,
1072 Vec::new(),
1073 Nip59EnvelopeKind::Durable,
1074 now,
1075 None,
1076 )
1077 })?
1078 };
1079 stored_product_result(self, operation)
1080 })();
1081 let mut diagnostic = Diagnostic::new(
1082 AccountLogCategory::Messaging,
1083 "message.command.failed",
1084 "Account command failed",
1085 );
1086 diagnostic.subject = Some(AccountLogSubject::Deletion);
1087 diagnostic.source = Some(AccountLogSource::LocalCommand);
1088 self.logs.failed(diagnostic, &result);
1089 result
1090 }
1091
1092 pub fn delete_messages(
1102 &self,
1103 command_id: String,
1104 message_ids: Vec<String>,
1105 now: i64,
1106 ) -> Result<ProductOperationResult, SoftchatError> {
1107 let result = (|| {
1108 validate_command_id(&command_id)?;
1109 if message_ids.is_empty() || message_ids.len() > crate::MAX_PRODUCT_QUERY_PAGE as usize
1110 {
1111 return Err(SoftchatError::InvalidAccountOperation);
1112 }
1113 if message_ids.iter().any(|message_id| message_id.len() != 64) {
1114 return Err(SoftchatError::InvalidAccountOperation);
1115 }
1116 let targets = message_ids
1117 .iter()
1118 .map(|message_id| NostrEventId::from_hex(message_id))
1119 .collect::<Result<Vec<_>, _>>()?;
1120 let mut distinct_ids = BTreeSet::new();
1121 if message_ids
1122 .iter()
1123 .any(|message_id| !distinct_ids.insert(message_id.as_str()))
1124 {
1125 return Err(SoftchatError::InvalidAccountOperation);
1126 }
1127 let payload_hash = command_hash(&("delete-many", &message_ids))?;
1128 let existing = {
1129 let database = self.database()?;
1130 database.product_operation_if_committed(&command_id, &payload_hash)?
1131 };
1132 if let Some(operation) = existing {
1133 return stored_product_result(self, operation);
1134 }
1135
1136 let own_public_key = self.public_key()?;
1137 let messages = message_ids
1138 .iter()
1139 .map(|message_id| {
1140 self.database()?
1141 .product_message(message_id)?
1142 .filter(|message| {
1143 message.author_public_key == own_public_key && !message.deleted
1144 })
1145 .ok_or(SoftchatError::InvalidAccountOperation)
1146 })
1147 .collect::<Result<Vec<_>, _>>()?;
1148 let conversation_id = messages
1149 .first()
1150 .map(|message| message.conversation_id.clone())
1151 .ok_or(SoftchatError::InvalidAccountOperation)?;
1152 if messages
1153 .iter()
1154 .any(|message| message.conversation_id != conversation_id)
1155 {
1156 return Err(SoftchatError::InvalidAccountOperation);
1157 }
1158 let conversation = self
1159 .database()?
1160 .product_conversation(&conversation_id, &own_public_key)?
1161 .ok_or(SoftchatError::InvalidAccountOperation)?;
1162 let recipients = remote_members(&conversation, &own_public_key);
1163 let created_at =
1164 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1165 let rumor = self.identity.with_identity(|identity| {
1166 identity
1167 .create_deletion(
1168 public_keys(&recipients)?,
1169 targets,
1170 String::new(),
1171 created_at,
1172 )
1173 .map(|view| view.rumor)
1174 })?;
1175 let result_message_id = message_ids
1176 .first()
1177 .cloned()
1178 .ok_or(SoftchatError::InvalidAccountOperation)?;
1179 let operation = {
1180 let mut database = self.database()?;
1181 self.identity.with_identity(|identity| {
1182 database.enqueue_product_rumor(
1183 identity,
1184 command_id,
1185 payload_hash,
1186 result_message_id,
1187 rumor,
1188 recipients,
1189 Vec::new(),
1190 Nip59EnvelopeKind::Durable,
1191 now,
1192 None,
1193 )
1194 })?
1195 };
1196 stored_product_result(self, operation)
1197 })();
1198 let mut diagnostic = Diagnostic::new(
1199 AccountLogCategory::Messaging,
1200 "message.command.failed",
1201 "Account command failed",
1202 );
1203 diagnostic.subject = Some(AccountLogSubject::Deletion);
1204 diagnostic.source = Some(AccountLogSource::LocalCommand);
1205 self.logs.failed(diagnostic, &result);
1206 result
1207 }
1208
1209 pub fn typing_changed(
1217 &self,
1218 command_id: String,
1219 conversation_id: String,
1220 is_typing: bool,
1221 now: i64,
1222 ) -> Result<Option<ProductOperationResult>, SoftchatError> {
1223 let result = (|| {
1224 validate_command_id(&command_id)?;
1225 if !is_typing {
1226 lock(&self.typing_authored).remove(&conversation_id);
1227 return Ok(None);
1228 }
1229 if !self
1230 .database()?
1231 .product_settings()?
1232 .typing_indicators_enabled
1233 {
1234 return Ok(None);
1235 }
1236 {
1237 let authored = lock(&self.typing_authored);
1238 if authored
1239 .get(&conversation_id)
1240 .is_some_and(|(last, prior_id)| {
1241 prior_id != &command_id && now >= *last && now.saturating_sub(*last) < 3
1242 })
1243 {
1244 return Ok(None);
1245 }
1246 }
1247 let own_public_key = self.public_key()?;
1248 let conversation = self
1249 .database()?
1250 .product_conversation(&conversation_id, &own_public_key)?
1251 .ok_or(SoftchatError::InvalidAccountOperation)?;
1252 let recipients = remote_members(&conversation, &own_public_key);
1253 let payload_hash = command_hash(&("typing", &conversation_id, is_typing))?;
1254 let created_at =
1255 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1256 let rumor = self.identity.with_identity(|identity| {
1257 identity
1258 .create_typing(public_keys(&recipients)?, created_at)
1259 .map(|view| view.rumor)
1260 })?;
1261 let operation = {
1262 let mut database = self.database()?;
1263 self.identity.with_identity(|identity| {
1264 database.enqueue_product_rumor(
1265 identity,
1266 command_id.clone(),
1267 payload_hash,
1268 String::new(),
1269 rumor,
1270 recipients,
1271 Vec::new(),
1272 Nip59EnvelopeKind::Ephemeral,
1273 now,
1274 None,
1275 )
1276 })?
1277 };
1278 let result = stored_product_result(self, operation)?;
1279 lock(&self.typing_authored).insert(conversation_id, (now, command_id));
1280 Ok(Some(result))
1281 })();
1282 let mut diagnostic = Diagnostic::new(
1283 AccountLogCategory::Messaging,
1284 "message.command.failed",
1285 "Account command failed",
1286 );
1287 diagnostic.subject = Some(AccountLogSubject::Typing);
1288 diagnostic.source = Some(AccountLogSource::LocalCommand);
1289 self.logs.failed(diagnostic, &result);
1290 result
1291 }
1292
1293 pub fn set_conversation_archived(
1299 &self,
1300 conversation_id: String,
1301 archived: bool,
1302 now: i64,
1303 ) -> Result<AccountMutationResult, SoftchatError> {
1304 self.database()?
1305 .set_product_conversation_flag(&conversation_id, "archived", archived, now)
1306 }
1307
1308 pub fn set_conversation_pinned(
1314 &self,
1315 conversation_id: String,
1316 pinned: bool,
1317 now: i64,
1318 ) -> Result<AccountMutationResult, SoftchatError> {
1319 self.database()?
1320 .set_product_conversation_flag(&conversation_id, "pinned", pinned, now)
1321 }
1322
1323 pub fn mark_conversation_read(
1335 &self,
1336 command_id: String,
1337 conversation_id: String,
1338 boundary: MessageCursor,
1339 now: i64,
1340 ) -> Result<ProductOperationResult, SoftchatError> {
1341 let result = (|| {
1342 validate_command_id(&command_id)?;
1343 validate_conversation_id(&conversation_id)?;
1344 NostrEventId::from_hex(&boundary.message_id)?;
1345 let boundary_created_at = u64::try_from(boundary.created_at)
1346 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
1347 if boundary_created_at > crate::MAX_PORTABLE_TIMESTAMP_SECONDS {
1348 return Err(SoftchatError::InvalidAccountOperation);
1349 }
1350 let payload_hash = command_hash(&("mark-read", &conversation_id, &boundary))?;
1351 let mut database = self.database()?;
1352 if let Some(operation) =
1353 database.product_operation_if_committed(&command_id, &payload_hash)?
1354 {
1355 drop(database);
1356 return stored_product_result(self, operation);
1357 }
1358 let preview = database.preview_product_read_state_update(
1359 &conversation_id,
1360 Some(boundary.clone()),
1361 false,
1362 now,
1363 &command_id,
1364 )?;
1365 let authored_at = database.next_app_data_event_timestamp("read-state", now)?;
1366 let created_at =
1367 u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1368 let event = self.identity.with_identity(|identity| {
1369 identity
1370 .create_app_data_sync(
1371 created_at,
1372 AppDataContext::ReadState,
1373 &preview.canonical_json,
1374 )
1375 .map(|view| view.event)
1376 })?;
1377 let operation = database.enqueue_product_signed_with_read_state(
1378 command_id,
1379 payload_hash,
1380 vec![event],
1381 preview.state,
1382 preview.canonical_json,
1383 now,
1384 )?;
1385 drop(database);
1386 stored_product_result(self, operation)
1387 })();
1388 let mut diagnostic = Diagnostic::new(
1389 AccountLogCategory::Synchronization,
1390 "message.command.failed",
1391 "Account command failed",
1392 );
1393 diagnostic.subject = Some(AccountLogSubject::ReadState);
1394 diagnostic.source = Some(AccountLogSource::LocalCommand);
1395 self.logs.failed(diagnostic, &result);
1396 result
1397 }
1398
1399 pub fn mark_conversation_unread(
1408 &self,
1409 command_id: String,
1410 conversation_id: String,
1411 now: i64,
1412 ) -> Result<ProductOperationResult, SoftchatError> {
1413 let result = (|| {
1414 validate_command_id(&command_id)?;
1415 validate_conversation_id(&conversation_id)?;
1416 let payload_hash = command_hash(&("mark-unread", &conversation_id))?;
1417 let mut database = self.database()?;
1418 if let Some(operation) =
1419 database.product_operation_if_committed(&command_id, &payload_hash)?
1420 {
1421 drop(database);
1422 return stored_product_result(self, operation);
1423 }
1424 let preview = database.preview_product_read_state_update(
1425 &conversation_id,
1426 None,
1427 true,
1428 now,
1429 &command_id,
1430 )?;
1431 let authored_at = database.next_app_data_event_timestamp("read-state", now)?;
1432 let created_at =
1433 u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1434 let event = self.identity.with_identity(|identity| {
1435 identity
1436 .create_app_data_sync(
1437 created_at,
1438 AppDataContext::ReadState,
1439 &preview.canonical_json,
1440 )
1441 .map(|view| view.event)
1442 })?;
1443 let operation = database.enqueue_product_signed_with_read_state(
1444 command_id,
1445 payload_hash,
1446 vec![event],
1447 preview.state,
1448 preview.canonical_json,
1449 now,
1450 )?;
1451 drop(database);
1452 stored_product_result(self, operation)
1453 })();
1454 let mut diagnostic = Diagnostic::new(
1455 AccountLogCategory::Synchronization,
1456 "message.command.failed",
1457 "Account command failed",
1458 );
1459 diagnostic.subject = Some(AccountLogSubject::ReadState);
1460 diagnostic.source = Some(AccountLogSource::LocalCommand);
1461 self.logs.failed(diagnostic, &result);
1462 result
1463 }
1464
1465 pub fn account_read_state(&self) -> Result<Vec<crate::AccountReadState>, SoftchatError> {
1471 self.database()?.product_read_states()
1472 }
1473
1474 pub fn save_conversation_draft(
1480 &self,
1481 conversation_id: String,
1482 draft: Option<AccountDraft>,
1483 now: i64,
1484 ) -> Result<AccountMutationResult, SoftchatError> {
1485 self.database()?
1486 .save_product_draft(&conversation_id, draft, now)
1487 }
1488
1489 pub fn account_settings(&self) -> Result<AccountSettings, SoftchatError> {
1495 self.database()?.product_settings()
1496 }
1497
1498 pub fn apply_account_settings_patch(
1513 &self,
1514 command_id: String,
1515 patch_json: String,
1516 now: i64,
1517 ) -> Result<AccountSettingsMutation, SoftchatError> {
1518 let result = (|| {
1519 validate_command_id(&command_id)?;
1520 if patch_json.len() > crate::MAX_ACCOUNT_SETTINGS_BYTES {
1521 return Err(SoftchatError::InvalidAccountSettings);
1522 }
1523 let canonical_patch: serde_json::Value = serde_json::from_str(&patch_json)
1524 .map_err(|_| SoftchatError::InvalidAccountSettings)?;
1525 if !canonical_patch.is_object() {
1526 return Err(SoftchatError::InvalidAccountSettings);
1527 }
1528 let payload_hash = command_hash(&("settings", canonical_patch))?;
1529 let mut database = self.database()?;
1530 let operation = if let Some(committed) =
1531 database.product_operation_if_committed(&command_id, &payload_hash)?
1532 {
1533 committed
1534 } else {
1535 let mutation = database.preview_product_settings_patch(&patch_json)?;
1536 let authored_at = database.next_app_data_event_timestamp("app-settings", now)?;
1537 let wire_json =
1538 crate::account_product::settings_wire_json(&mutation.canonical_json)?;
1539 let created_at = u64::try_from(authored_at)
1540 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
1541 let event = self.identity.with_identity(|identity| {
1542 identity
1543 .create_app_data_sync(created_at, AppDataContext::AppSettings, &wire_json)
1544 .map(|view| view.event)
1545 })?;
1546 database.enqueue_product_signed_with_settings(
1547 command_id,
1548 payload_hash,
1549 vec![event],
1550 mutation.settings.schema_version,
1551 mutation.canonical_json.clone(),
1552 wire_json,
1553 now,
1554 )?
1555 };
1556 let settings = database.product_settings()?;
1557 let canonical_json = database.product_settings_canonical_json()?;
1558 Ok(AccountSettingsMutation {
1559 settings,
1560 canonical_json,
1561 revision: operation.operation.revision,
1562 })
1563 })();
1564 let mut diagnostic = Diagnostic::new(
1565 AccountLogCategory::Synchronization,
1566 "message.command.failed",
1567 "Account command failed",
1568 );
1569 diagnostic.subject = Some(AccountLogSubject::AppSettings);
1570 diagnostic.source = Some(AccountLogSource::LocalCommand);
1571 self.logs.failed(diagnostic, &result);
1572 result
1573 }
1574
1575 pub fn profile(&self, public_key: String) -> Result<AccountProfile, SoftchatError> {
1581 self.database()?.product_profile(&public_key)
1582 }
1583
1584 pub fn profiles(&self, public_keys: Vec<String>) -> Result<Vec<AccountProfile>, SoftchatError> {
1594 self.database()?.product_profiles_for_keys(&public_keys)
1595 }
1596
1597 pub fn profile_page(
1603 &self,
1604 limit: u32,
1605 offset: u32,
1606 ) -> Result<Vec<AccountProfile>, SoftchatError> {
1607 self.database()?.product_profiles(limit, offset)
1608 }
1609
1610 pub fn follows(&self) -> Result<Vec<Contact>, SoftchatError> {
1616 let own_public_key = self.public_key()?;
1617 self.database()?.product_follows(&own_public_key)
1618 }
1619
1620 pub fn search_profiles(
1627 &self,
1628 query: String,
1629 limit: u32,
1630 ) -> Result<Vec<AccountProfile>, SoftchatError> {
1631 self.database()?.search_product_profiles(&query, limit)
1632 }
1633
1634 pub fn update_profile(
1644 &self,
1645 command_id: String,
1646 patch_json: String,
1647 now: i64,
1648 ) -> Result<ProductOperationResult, SoftchatError> {
1649 let result = (|| {
1650 validate_command_id(&command_id)?;
1651 let own_public_key = self.public_key()?;
1652 let mut database = self.database()?;
1653 let current = database.authenticated_product_profile(&own_public_key)?;
1654 let authored_at = if current.updated_at > 0 && current.updated_at >= now {
1655 current
1656 .updated_at
1657 .checked_add(1)
1658 .ok_or(SoftchatError::InvalidAccountOperation)?
1659 } else {
1660 now
1661 };
1662 let created_at =
1663 u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1664 let (profile, canonical_patch) = profile_update_from_patch(¤t, &patch_json)?;
1665 let payload_hash = command_hash(&("profile_patch", canonical_patch))?;
1666 let rumor = self.identity.with_identity(|identity| {
1667 identity
1668 .create_user_metadata(
1669 created_at,
1670 profile.name,
1671 profile.display_name,
1672 profile.about,
1673 profile.picture,
1674 profile.website,
1675 profile.banner,
1676 profile.bot,
1677 &profile.unknown_json,
1678 )
1679 .map(|view| view.rumor)
1680 })?;
1681 let recipients = all_known_public_keys(&database)?
1682 .into_iter()
1683 .filter(|key| key != &own_public_key)
1684 .collect::<Vec<_>>();
1685 let operation = self.identity.with_identity(|identity| {
1686 database.enqueue_product_rumor(
1687 identity,
1688 command_id,
1689 payload_hash,
1690 String::new(),
1691 rumor,
1692 recipients,
1693 Vec::new(),
1694 Nip59EnvelopeKind::Durable,
1695 now,
1696 None,
1697 )
1698 })?;
1699 stored_product_result(self, operation)
1700 })();
1701 let mut diagnostic = Diagnostic::new(
1702 AccountLogCategory::Profile,
1703 "message.command.failed",
1704 "Account command failed",
1705 );
1706 diagnostic.subject = Some(AccountLogSubject::UserMetadata);
1707 diagnostic.source = Some(AccountLogSource::LocalCommand);
1708 self.logs.failed(diagnostic, &result);
1709 result
1710 }
1711
1712 pub fn replace_follows(
1718 &self,
1719 command_id: String,
1720 contacts: Vec<Contact>,
1721 now: i64,
1722 ) -> Result<ProductOperationResult, SoftchatError> {
1723 let result = (|| {
1724 let mut database = self.database()?;
1725 let operation =
1726 self.enqueue_follow_replacement(&mut database, command_id, contacts, now)?;
1727 drop(database);
1728 stored_product_result(self, operation)
1729 })();
1730 self.logs.failed(
1731 Diagnostic::new(
1732 AccountLogCategory::Profile,
1733 "profile.follows.replace",
1734 "Follow list command failed",
1735 ),
1736 &result,
1737 );
1738 result
1739 }
1740
1741 pub fn follow(
1751 &self,
1752 command_id: String,
1753 contact: Contact,
1754 now: i64,
1755 ) -> Result<ProductOperationResult, SoftchatError> {
1756 let result = (|| {
1757 let own_public_key = self.public_key()?;
1758 let mut database = self.database()?;
1759 let mut contacts = database.product_follows(&own_public_key)?;
1760 contacts.retain(|value| value.public_key != contact.public_key);
1761 contacts.push(contact);
1762 contacts.sort_by(|left, right| left.public_key.cmp(&right.public_key));
1763 let operation =
1764 self.enqueue_follow_replacement(&mut database, command_id, contacts, now)?;
1765 drop(database);
1766 stored_product_result(self, operation)
1767 })();
1768 self.logs.failed(
1769 Diagnostic::new(
1770 AccountLogCategory::Profile,
1771 "profile.follow",
1772 "Follow list command failed",
1773 ),
1774 &result,
1775 );
1776 result
1777 }
1778
1779 pub fn unfollow(
1788 &self,
1789 command_id: String,
1790 public_key: String,
1791 now: i64,
1792 ) -> Result<ProductOperationResult, SoftchatError> {
1793 let result = (|| {
1794 NostrPublicKey::from_hex(&public_key)?;
1795 let own_public_key = self.public_key()?;
1796 let mut database = self.database()?;
1797 let mut contacts = database.product_follows(&own_public_key)?;
1798 contacts.retain(|value| value.public_key != public_key);
1799 let operation =
1800 self.enqueue_follow_replacement(&mut database, command_id, contacts, now)?;
1801 drop(database);
1802 stored_product_result(self, operation)
1803 })();
1804 self.logs.failed(
1805 Diagnostic::new(
1806 AccountLogCategory::Profile,
1807 "profile.unfollow",
1808 "Follow list command failed",
1809 ),
1810 &result,
1811 );
1812 result
1813 }
1814
1815 pub fn register_push_token(
1821 &self,
1822 command_id: String,
1823 platform: PushPlatform,
1824 token: String,
1825 installation_id: String,
1826 now: i64,
1827 ) -> Result<ProductOperationResult, SoftchatError> {
1828 let result = (|| {
1829 validate_command_id(&command_id)?;
1830 if token.is_empty()
1831 || token.len() > 16 * 1024
1832 || installation_id.is_empty()
1833 || installation_id.len() > 512
1834 {
1835 return Err(SoftchatError::InvalidAccountOperation);
1836 }
1837 let (identifier, token_key) = match platform {
1838 PushPlatform::Fcm => ("fcmToken-v2", "fcmToken"),
1839 PushPlatform::Apns => ("apnsToken-v2", "apnsToken"),
1840 };
1841 let content = serde_json::to_string(&serde_json::json!({
1842 (token_key): token,
1843 "deviceIdentifier": installation_id,
1844 }))
1845 .map_err(|_| SoftchatError::InternalFailure)?;
1846 let payload_hash = command_hash(&("push", platform, &content))?;
1847 let created_at =
1848 u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
1849 let event = self.identity.with_identity(|identity| {
1850 identity
1851 .create_application_data(created_at, identifier.to_owned(), content)
1852 .map(|view| view.event)
1853 })?;
1854 let operation = self.database()?.enqueue_product_signed(
1855 command_id,
1856 payload_hash,
1857 String::new(),
1858 vec![event],
1859 now,
1860 AccountLogSubject::PushRegistration,
1861 )?;
1862 stored_product_result(self, operation)
1863 })();
1864 let mut diagnostic = Diagnostic::new(
1865 AccountLogCategory::Lifecycle,
1866 "message.command.failed",
1867 "Account command failed",
1868 );
1869 diagnostic.subject = Some(AccountLogSubject::PushRegistration);
1870 diagnostic.source = Some(AccountLogSource::LocalCommand);
1871 self.logs.failed(diagnostic, &result);
1872 result
1873 }
1874
1875 pub fn media_gallery(
1886 &self,
1887 conversation_id: String,
1888 before: Option<MessageCursor>,
1889 limit: u32,
1890 ) -> Result<Vec<AccountMediaItem>, SoftchatError> {
1891 self.database()?.product_media_gallery(
1892 (!conversation_id.is_empty()).then_some(conversation_id),
1893 before,
1894 limit,
1895 )
1896 }
1897
1898 #[allow(clippy::too_many_arguments)]
1910 pub fn prepare_media_message(
1911 &self,
1912 command_id: String,
1913 conversation_id: String,
1914 content: MessageContentInput,
1915 reply_to_message_id: String,
1916 sources: Vec<crate::MediaPreparationInput>,
1917 draft_to_consume: Option<AccountDraft>,
1918 now: i64,
1919 ) -> Result<crate::PendingMediaMessage, SoftchatError> {
1920 let result = (|| {
1921 validate_command_id(&command_id)?;
1922 if sources.is_empty() || sources.len() > crate::MAX_PRODUCT_DRAFT_ATTACHMENTS {
1923 return Err(SoftchatError::InvalidMediaOperation);
1924 }
1925 content
1926 .validate_bounds()
1927 .map_err(|_| SoftchatError::InvalidMediaOperation)?;
1928 if !content.attachments.is_empty() {
1929 return Err(SoftchatError::InvalidMediaOperation);
1930 }
1931 let probe_content = MessageContentInput {
1932 text: content.text.clone(),
1933 attachments: crate::account_product::reserved_product_attachments(sources.len())?,
1934 emoji_tags: content.emoji_tags.clone(),
1935 };
1936 author_product_chat_message(
1937 self,
1938 &conversation_id,
1939 probe_content,
1940 &reply_to_message_id,
1941 now,
1942 )
1943 .map_err(|_| SoftchatError::InvalidMediaOperation)?;
1944 let draft_match = draft_to_consume
1945 .map(product_draft_match)
1946 .transpose()
1947 .map_err(|_| SoftchatError::InvalidMediaOperation)?;
1948 let payload_hash = media_message_command_hash(
1949 &conversation_id,
1950 &content,
1951 &reply_to_message_id,
1952 &sources,
1953 draft_match.as_ref(),
1954 )?;
1955 self.database()?.prepare_product_media_message(
1956 crate::account_product::ProductMediaMessagePreparation {
1957 command_id: &command_id,
1958 conversation_id: &conversation_id,
1959 content: &content,
1960 reply_to_message_id: &reply_to_message_id,
1961 sources,
1962 command_hash: &payload_hash,
1963 draft_match,
1964 now,
1965 },
1966 )
1967 })();
1968 let mut diagnostic = Diagnostic::new(
1969 AccountLogCategory::Media,
1970 "media.command.failed",
1971 "Media command failed",
1972 );
1973 diagnostic.source = Some(AccountLogSource::LocalCommand);
1974 self.logs.failed(diagnostic, &result);
1975 result
1976 }
1977
1978 pub fn pending_media_message(
1984 &self,
1985 command_id: String,
1986 ) -> Result<Option<crate::PendingMediaMessage>, SoftchatError> {
1987 self.database()?.product_pending_media_message(&command_id)
1988 }
1989
1990 pub fn cancel_pending_media_message(
2000 &self,
2001 command_id: String,
2002 now: i64,
2003 ) -> Result<crate::PendingMediaMessage, SoftchatError> {
2004 self.database()?
2005 .cancel_product_pending_media_message(&command_id, now)
2006 }
2007
2008 pub fn pending_media_messages(
2014 &self,
2015 limit: u32,
2016 ) -> Result<Vec<crate::PendingMediaMessage>, SoftchatError> {
2017 self.database()?.product_pending_media_messages(limit)
2018 }
2019
2020 pub fn prepare_asset_replacement(
2032 &self,
2033 command_id: String,
2034 target: AssetReplacementTarget,
2035 conversation_id: String,
2036 source_fingerprint: String,
2037 now: i64,
2038 ) -> Result<PendingAssetReplacement, SoftchatError> {
2039 let result = (|| {
2040 validate_command_id(&command_id)?;
2041 let payload_hash = command_hash(&(
2042 "asset-replacement",
2043 target,
2044 &conversation_id,
2045 &source_fingerprint,
2046 ))?;
2047 self.database()?.prepare_product_asset_replacement(
2048 crate::account_product::ProductAssetReplacementPreparation {
2049 command_id: &command_id,
2050 target,
2051 conversation_id: &conversation_id,
2052 source_fingerprint: &source_fingerprint,
2053 command_hash: &payload_hash,
2054 now,
2055 },
2056 )
2057 })();
2058 let mut diagnostic = Diagnostic::new(
2059 AccountLogCategory::Media,
2060 "media.command.failed",
2061 "Media command failed",
2062 );
2063 diagnostic.source = Some(AccountLogSource::LocalCommand);
2064 self.logs.failed(diagnostic, &result);
2065 result
2066 }
2067
2068 pub fn pending_asset_replacement(
2076 &self,
2077 command_id: String,
2078 ) -> Result<Option<PendingAssetReplacement>, SoftchatError> {
2079 self.database()?
2080 .product_pending_asset_replacement(&command_id)
2081 }
2082
2083 pub fn pending_asset_replacements(
2091 &self,
2092 limit: u32,
2093 ) -> Result<Vec<PendingAssetReplacement>, SoftchatError> {
2094 self.database()?.product_pending_asset_replacements(limit)
2095 }
2096
2097 pub fn prepare_media_operation(
2103 &self,
2104 command_id: String,
2105 conversation_id: String,
2106 direction: String,
2107 source_fingerprint: String,
2108 now: i64,
2109 ) -> Result<AccountMediaOperation, SoftchatError> {
2110 let result = (|| {
2111 self.database()?.prepare_product_media(
2112 &command_id,
2113 (!conversation_id.is_empty()).then_some(conversation_id),
2114 &direction,
2115 &source_fingerprint,
2116 now,
2117 )
2118 })();
2119 let mut diagnostic = Diagnostic::new(
2120 AccountLogCategory::Media,
2121 "media.command.failed",
2122 "Media command failed",
2123 );
2124 diagnostic.source = Some(AccountLogSource::LocalCommand);
2125 self.logs.failed(diagnostic, &result);
2126 result
2127 }
2128
2129 pub fn prepare_media_download(
2139 &self,
2140 message_id: String,
2141 attachment_url: String,
2142 now: i64,
2143 ) -> Result<AccountMediaOperation, SoftchatError> {
2144 let result = (|| {
2145 self.database()?
2146 .prepare_product_media_download(&message_id, &attachment_url, now)
2147 })();
2148 let mut diagnostic = Diagnostic::new(
2149 AccountLogCategory::Media,
2150 "media.command.failed",
2151 "Media command failed",
2152 );
2153 diagnostic.source = Some(AccountLogSource::LocalCommand);
2154 self.logs.failed(diagnostic, &result);
2155 result
2156 }
2157
2158 pub fn prepare_conversation_icon_download(
2170 &self,
2171 conversation_id: String,
2172 now: i64,
2173 ) -> Result<AccountMediaOperation, SoftchatError> {
2174 let result = (|| {
2175 self.database()?
2176 .prepare_product_conversation_icon_download(&conversation_id, now)
2177 })();
2178 let mut diagnostic = Diagnostic::new(
2179 AccountLogCategory::Media,
2180 "media.command.failed",
2181 "Media command failed",
2182 );
2183 diagnostic.source = Some(AccountLogSource::LocalCommand);
2184 self.logs.failed(diagnostic, &result);
2185 result
2186 }
2187
2188 pub fn restart_media_download(
2199 &self,
2200 operation_id: String,
2201 now: i64,
2202 ) -> Result<AccountMediaOperation, SoftchatError> {
2203 let result = (|| {
2204 self.database()?
2205 .restart_product_media_download(&operation_id, now)
2206 })();
2207 let mut diagnostic = Diagnostic::new(
2208 AccountLogCategory::Media,
2209 "media.command.failed",
2210 "Media command failed",
2211 );
2212 diagnostic.source = Some(AccountLogSource::LocalCommand);
2213 self.logs.failed(diagnostic, &result);
2214 result
2215 }
2216
2217 pub fn complete_media_operation(
2230 &self,
2231 operation_id: String,
2232 lease_id: String,
2233 attachment: AttachmentMetadata,
2234 byte_count: u64,
2235 now: i64,
2236 ) -> Result<crate::MediaCompletionResult, SoftchatError> {
2237 let completion = self.database()?.complete_product_media(
2238 &operation_id,
2239 &lease_id,
2240 attachment,
2241 byte_count,
2242 now,
2243 );
2244 self.logs.failed(
2245 Diagnostic::new(
2246 AccountLogCategory::Media,
2247 "media.completion.failed",
2248 "Media completion rejected",
2249 ),
2250 &completion,
2251 );
2252 let operation = completion?;
2253 let publication = (|| {
2254 let pending_message_command = self
2255 .database()?
2256 .pending_media_command_for_operation(&operation_id)?;
2257 let pending_asset_command = self
2258 .database()?
2259 .pending_asset_command_for_operation(&operation_id)?;
2260 let completed_operation = pending_message_command
2261 .map(|command_id| self.finalize_pending_media_message(&command_id, now))
2262 .transpose()?
2263 .flatten()
2264 .or(pending_asset_command
2265 .map(|command_id| self.finalize_pending_asset_replacement(&command_id, now))
2266 .transpose()?
2267 .flatten());
2268 Ok(completed_operation)
2269 })();
2270 self.logs.failed(
2271 Diagnostic::new(
2272 AccountLogCategory::Media,
2273 "media.publication.deferred",
2274 "Media committed; dependent publication deferred",
2275 ),
2276 &publication,
2277 );
2278 let completed_operation = publication?;
2279 Ok(crate::MediaCompletionResult {
2280 operation,
2281 completed_operation,
2282 })
2283 }
2284
2285 pub fn recover_pending_media_messages(
2291 &self,
2292 limit: u32,
2293 now: i64,
2294 ) -> Result<Vec<ProductOperationResult>, SoftchatError> {
2295 let result = (|| {
2296 let commands = self.database()?.ready_pending_media_commands(limit)?;
2297 if !commands.is_empty() {
2298 let mut event = Diagnostic::new(
2299 AccountLogCategory::Recovery,
2300 "recovery.started",
2301 "Pending publication recovery started",
2302 );
2303 event.source = Some(AccountLogSource::Recovery);
2304 event.summarize = true;
2305 self.logs.emit(event);
2306 }
2307 let mut published = Vec::new();
2308 for command_id in commands {
2309 if let Some(result) = self.finalize_pending_media_message(&command_id, now)? {
2310 let mut diagnostic = Diagnostic::new(
2311 AccountLogCategory::Recovery,
2312 "recovery.completed",
2313 "Pending publication recovered",
2314 )
2315 .count(
2316 crate::account_diagnostics::AccountLogCounterKind::RecoveredItems,
2317 1,
2318 );
2319 diagnostic.source = Some(AccountLogSource::Recovery);
2320 diagnostic.summarize = true;
2321 self.logs.emit(diagnostic);
2322 published.push(result);
2323 }
2324 }
2325 Ok(published)
2326 })();
2327 let mut event = Diagnostic::new(
2328 AccountLogCategory::Recovery,
2329 "recovery.failed",
2330 "Pending publication recovery failed",
2331 );
2332 event.source = Some(AccountLogSource::Recovery);
2333 self.logs.failed(event, &result);
2334 result
2335 }
2336
2337 pub fn recover_pending_asset_replacements(
2345 &self,
2346 limit: u32,
2347 now: i64,
2348 ) -> Result<Vec<ProductOperationResult>, SoftchatError> {
2349 let result = (|| {
2350 let commands = self.database()?.ready_pending_asset_commands(limit)?;
2351 if !commands.is_empty() {
2352 let mut event = Diagnostic::new(
2353 AccountLogCategory::Recovery,
2354 "recovery.started",
2355 "Pending publication recovery started",
2356 );
2357 event.source = Some(AccountLogSource::Recovery);
2358 event.summarize = true;
2359 self.logs.emit(event);
2360 }
2361 let mut published = Vec::new();
2362 for command_id in commands {
2363 if let Some(result) = self.finalize_pending_asset_replacement(&command_id, now)? {
2364 let mut diagnostic = Diagnostic::new(
2365 AccountLogCategory::Recovery,
2366 "recovery.completed",
2367 "Pending publication recovered",
2368 )
2369 .count(
2370 crate::account_diagnostics::AccountLogCounterKind::RecoveredItems,
2371 1,
2372 );
2373 diagnostic.source = Some(AccountLogSource::Recovery);
2374 diagnostic.summarize = true;
2375 self.logs.emit(diagnostic);
2376 published.push(result);
2377 }
2378 }
2379 Ok(published)
2380 })();
2381 let mut event = Diagnostic::new(
2382 AccountLogCategory::Recovery,
2383 "recovery.failed",
2384 "Pending publication recovery failed",
2385 );
2386 event.source = Some(AccountLogSource::Recovery);
2387 self.logs.failed(event, &result);
2388 result
2389 }
2390
2391 pub fn claim_media_operation(
2399 &self,
2400 operation_id: String,
2401 lease_id: String,
2402 now: i64,
2403 lease_duration_seconds: u32,
2404 ) -> Result<Option<AccountMediaLease>, SoftchatError> {
2405 let result = (|| {
2406 self.database()?.claim_product_media(
2407 &operation_id,
2408 &lease_id,
2409 now,
2410 lease_duration_seconds,
2411 )
2412 })();
2413 let mut diagnostic = Diagnostic::new(
2414 AccountLogCategory::Media,
2415 "media.command.failed",
2416 "Media command failed",
2417 );
2418 diagnostic.source = Some(AccountLogSource::LocalCommand);
2419 self.logs.failed(diagnostic, &result);
2420 result
2421 }
2422
2423 pub fn finish_media_operation_lease(
2429 &self,
2430 operation_id: String,
2431 lease_id: String,
2432 retryable: bool,
2433 error_category: String,
2434 now: i64,
2435 ) -> Result<AccountMediaOperation, SoftchatError> {
2436 let result = (|| {
2437 self.database()?.finish_product_media_lease(
2438 &operation_id,
2439 &lease_id,
2440 retryable,
2441 &error_category,
2442 now,
2443 )
2444 })();
2445 let mut diagnostic = Diagnostic::new(
2446 AccountLogCategory::Media,
2447 "media.command.failed",
2448 "Media command failed",
2449 );
2450 diagnostic.source = Some(AccountLogSource::LocalCommand);
2451 self.logs.failed(diagnostic, &result);
2452 result
2453 }
2454
2455 pub fn create_media_upload_authorization(
2464 &self,
2465 method: String,
2466 url: String,
2467 payload_sha256: String,
2468 now: i64,
2469 ) -> Result<FfiHttpAuthorizationPlan, SoftchatError> {
2470 let result = (|| {
2471 let created_at =
2472 u64::try_from(now).map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
2473 self.identity.with_identity(|identity| {
2474 crate::create_nip98_authorization_for_payload_hash(
2475 identity,
2476 &method,
2477 &url,
2478 &payload_sha256,
2479 created_at,
2480 )
2481 .map(FfiHttpAuthorizationPlan::from)
2482 })
2483 })();
2484 let mut diagnostic = Diagnostic::new(
2485 AccountLogCategory::Media,
2486 "media.authorization.failed",
2487 "Upload authorization failed",
2488 );
2489 diagnostic.source = Some(AccountLogSource::LocalCommand);
2490 if result.is_ok() {
2491 self.logs.emit(
2492 Diagnostic::new(
2493 AccountLogCategory::Media,
2494 "media.authorization.prepared",
2495 "Upload authorization prepared",
2496 )
2497 .debug(),
2498 );
2499 }
2500 self.logs.failed(diagnostic, &result);
2501 result
2502 }
2503
2504 pub fn transition_media_operation(
2510 &self,
2511 operation_id: String,
2512 transition: String,
2513 error_category: String,
2514 now: i64,
2515 ) -> Result<AccountMediaOperation, SoftchatError> {
2516 let result = (|| {
2517 self.database()?.transition_product_media(
2518 &operation_id,
2519 &transition,
2520 &error_category,
2521 now,
2522 )
2523 })();
2524 let mut diagnostic = Diagnostic::new(
2525 AccountLogCategory::Media,
2526 "media.command.failed",
2527 "Media command failed",
2528 );
2529 diagnostic.source = Some(AccountLogSource::LocalCommand);
2530 self.logs.failed(diagnostic, &result);
2531 result
2532 }
2533
2534 pub fn message_downloads(
2543 &self,
2544 message_id: String,
2545 ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
2546 self.database()?.product_message_downloads(&message_id)
2547 }
2548
2549 pub fn media_operations(
2555 &self,
2556 limit: u32,
2557 ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
2558 self.database()?.product_media_operations(limit)
2559 }
2560
2561 pub fn media_operations_by_ids(
2570 &self,
2571 operation_ids: Vec<String>,
2572 ) -> Result<Vec<AccountMediaOperation>, SoftchatError> {
2573 self.database()?
2574 .product_media_operations_by_ids(&operation_ids)
2575 }
2576
2577 pub fn recoverable_media_operations(
2586 &self,
2587 cursor: Option<String>,
2588 limit: u32,
2589 ) -> Result<AccountMediaOperationPage, SoftchatError> {
2590 self.database()?
2591 .product_recoverable_media_operations(cursor.as_deref(), limit)
2592 }
2593
2594 pub fn media_operation(
2600 &self,
2601 operation_id: String,
2602 ) -> Result<Option<AccountMediaOperation>, SoftchatError> {
2603 self.database()?.product_media_operation(&operation_id)
2604 }
2605
2606 fn finalize_pending_media_message(
2607 &self,
2608 command_id: &str,
2609 now: i64,
2610 ) -> Result<Option<ProductOperationResult>, SoftchatError> {
2611 if !self
2612 .database()?
2613 .claim_pending_media_publication(command_id)?
2614 {
2615 return Ok(None);
2616 }
2617 let pending = self
2618 .database()?
2619 .product_pending_media_message(command_id)?
2620 .ok_or(SoftchatError::InvalidMediaOperation)?;
2621 match pending.state {
2622 crate::PendingMediaMessageState::Published
2623 | crate::PendingMediaMessageState::WaitingForUploads
2624 | crate::PendingMediaMessageState::Failed
2625 | crate::PendingMediaMessageState::Cancelled => return Ok(None),
2626 crate::PendingMediaMessageState::ReadyToPublish => {}
2627 }
2628 let attachments = pending
2629 .attachments
2630 .iter()
2631 .map(|operation| {
2632 if operation.state != crate::AccountMediaOperationState::Completed {
2633 return Err(SoftchatError::InvalidMediaOperation);
2634 }
2635 operation
2636 .attachment
2637 .clone()
2638 .ok_or(SoftchatError::InvalidMediaOperation)
2639 })
2640 .collect::<Result<Vec<_>, _>>()?;
2641 let result = self.send_message(
2642 pending.command_id.clone(),
2643 pending.conversation_id,
2644 MessageContentInput {
2645 text: pending.text,
2646 attachments,
2647 emoji_tags: pending.emoji_tags,
2648 },
2649 pending.reply_to_message_id,
2650 None,
2651 now,
2652 )?;
2653 let message_id = result
2654 .message
2655 .as_ref()
2656 .map(|message| message.id.clone())
2657 .ok_or(SoftchatError::InvalidPersistenceResult)?;
2658 self.database()?
2659 .mark_pending_media_published(command_id, &message_id, now)?;
2660 Ok(Some(result))
2661 }
2662
2663 fn finalize_pending_asset_replacement(
2664 &self,
2665 command_id: &str,
2666 now: i64,
2667 ) -> Result<Option<ProductOperationResult>, SoftchatError> {
2668 let pending = self
2669 .database()?
2670 .product_pending_asset_replacement(command_id)?
2671 .ok_or(SoftchatError::InvalidMediaOperation)?;
2672 match pending.state {
2673 crate::PendingAssetReplacementState::Published
2674 | crate::PendingAssetReplacementState::WaitingForUpload
2675 | crate::PendingAssetReplacementState::Failed
2676 | crate::PendingAssetReplacementState::Cancelled => return Ok(None),
2677 crate::PendingAssetReplacementState::ReadyToPublish => {}
2678 }
2679 let attachment = pending
2680 .operation
2681 .attachment
2682 .clone()
2683 .filter(|_| {
2684 pending.operation.state == crate::AccountMediaOperationState::Completed
2685 && pending.operation.protection == crate::AccountMediaProtection::Public
2686 })
2687 .ok_or(SoftchatError::InvalidMediaOperation)?;
2688 if attachment.encryption.is_some() {
2689 return Err(SoftchatError::InvalidMediaOperation);
2690 }
2691 let result = match pending.target {
2692 AssetReplacementTarget::ProfilePicture => self.update_profile(
2693 pending.command_id.clone(),
2694 serde_json::json!({ "picture": attachment.url }).to_string(),
2695 now,
2696 )?,
2697 AssetReplacementTarget::ProfileBanner => self.update_profile(
2698 pending.command_id.clone(),
2699 serde_json::json!({ "banner": attachment.url }).to_string(),
2700 now,
2701 )?,
2702 AssetReplacementTarget::ConversationIcon => {
2703 let (subject, emoji_tags) = self
2704 .database()?
2705 .pending_asset_subject(&pending.command_id)?;
2706 self.set_conversation_subject(
2707 pending.command_id.clone(),
2708 pending.conversation_id,
2709 subject,
2710 Some(attachment),
2711 emoji_tags,
2712 now,
2713 )?
2714 }
2715 };
2716 self.database()?.mark_pending_asset_published(
2717 command_id,
2718 &result.operation.operation_id,
2719 now,
2720 )?;
2721 Ok(Some(result))
2722 }
2723
2724 pub fn database_info(&self) -> Result<AccountDatabaseInfo, SoftchatError> {
2730 self.database()?.info()
2731 }
2732
2733 pub fn account_diagnostics(&self) -> Result<AccountDiagnostics, SoftchatError> {
2739 self.database()?.diagnostics()
2740 }
2741
2742 pub fn reset_account_sync(&self, now: i64) -> Result<AccountTransportBatch, SoftchatError> {
2751 let result = (|| {
2752 lock(&self.sync_engines).clear();
2753 let mut database = self.database()?;
2754 let reset = database.reset_sync(now)?;
2755 let mut diagnostic = Diagnostic::new(
2756 AccountLogCategory::Synchronization,
2757 "sync.reset",
2758 "Synchronization checkpoint reset",
2759 );
2760 diagnostic.revision = Some(reset.revision);
2761 self.logs.emit(diagnostic);
2762 let mut transport = lock(&self.transport_run);
2763 if let Some(run) = transport.as_mut() {
2764 run.restart_sync(&mut database, &self.identity, now)
2765 } else {
2766 Ok(AccountTransportBatch {
2767 actions: Vec::new(),
2768 connection_state: RelayConnectionState::Disconnected,
2769 idle: true,
2770 revision: database.info()?.revision,
2771 typing_indicators: Vec::new(),
2772 sync: None,
2773 })
2774 }
2775 })();
2776 self.logs.failed(
2777 Diagnostic::new(
2778 AccountLogCategory::Synchronization,
2779 "sync.reset",
2780 "Synchronization reset failed",
2781 ),
2782 &result,
2783 );
2784 result
2785 }
2786
2787 pub fn start_account_transport(
2796 &self,
2797 run_id: String,
2798 ) -> Result<AccountTransportBatch, SoftchatError> {
2799 let result = (|| {
2800 let recipient = self.public_key()?;
2801 let filters = vec![inbox_filter_json(&recipient, None, None)?];
2802 let database = self.database()?;
2803 let mut slot = lock(&self.transport_run);
2804 if slot.is_some() {
2805 return Err(SoftchatError::InvalidRelaySession);
2806 }
2807 let (run, batch) = AccountTransportRun::start(run_id, filters, &database)?;
2808 *slot = Some(run);
2809 Ok(batch)
2810 })();
2811 self.logs.failed(
2812 Diagnostic::new(
2813 AccountLogCategory::Relay,
2814 "relay.operation.failed",
2815 "Relay operation failed",
2816 ),
2817 &result,
2818 );
2819 result
2820 }
2821
2822 pub fn apply_account_transport_result(
2835 &self,
2836 transport_result: AccountTransportResult,
2837 now: i64,
2838 ) -> Result<AccountTransportBatch, SoftchatError> {
2839 let result = (|| {
2840 let hash = result_hash(&transport_result)?;
2841 let mut database = self.database()?;
2842 if let Some((stored_hash, outcome_json)) = database
2843 .stored_transport_result(&transport_result.run_id, &transport_result.action_id)?
2844 {
2845 if stored_hash != hash || outcome_json.is_empty() {
2846 return Err(SoftchatError::InvalidRelaySession);
2847 }
2848 let outcome: StoredAccountTransportOutcome = serde_json::from_str(&outcome_json)
2849 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
2850 return Ok(outcome.into_batch());
2851 }
2852 let mut slot = lock(&self.transport_run);
2853 slot.as_ref()
2854 .ok_or(SoftchatError::InvalidRelaySession)?
2855 .validate_result_preflight(&transport_result)?;
2856 let mut candidate = slot.take().ok_or(SoftchatError::InvalidRelaySession)?;
2857 let transition = database.apply_transport_result_atomically(|database| {
2858 let mut batch =
2859 candidate.handle_result(&transport_result, database, &self.identity, now)?;
2860 let expected_revision = database
2861 .info()?
2862 .revision
2863 .checked_add(1)
2864 .ok_or(SoftchatError::InvalidPersistenceResult)?;
2865 batch.revision = expected_revision;
2866 let outcome_json =
2867 serde_json::to_string(&StoredAccountTransportOutcome::from(&batch))
2868 .map_err(|_| SoftchatError::InternalFailure)?;
2869 let committed_revision = database.record_transport_result(
2870 &transport_result.run_id,
2871 &transport_result.action_id,
2872 transport_result.generation,
2873 &hash,
2874 &outcome_json,
2875 )?;
2876 if committed_revision != expected_revision {
2877 return Err(SoftchatError::InvalidPersistenceResult);
2878 }
2879 Ok(batch)
2880 });
2881 if transition.is_ok() {
2882 *slot = Some(candidate);
2883 }
2884 transition
2885 })();
2886 self.logs.failed(
2887 Diagnostic::new(
2888 AccountLogCategory::Relay,
2889 "relay.operation.failed",
2890 "Relay operation failed",
2891 ),
2892 &result,
2893 );
2894 result
2895 }
2896
2897 pub fn wake_account_transport(&self, now: i64) -> Result<AccountTransportBatch, SoftchatError> {
2907 let result = (|| {
2908 let mut database = self.database()?;
2909 let mut slot = lock(&self.transport_run);
2910 let run = slot.as_mut().ok_or(SoftchatError::InvalidRelaySession)?;
2911 run.wake(&mut database, now)
2912 })();
2913 self.logs.failed(
2914 Diagnostic::new(
2915 AccountLogCategory::Relay,
2916 "relay.operation.failed",
2917 "Relay operation failed",
2918 ),
2919 &result,
2920 );
2921 result
2922 }
2923
2924 pub fn cancel_account_transport(&self) -> Result<AccountTransportBatch, SoftchatError> {
2930 let result = (|| {
2931 let mut database = self.database()?;
2932 let mut slot = lock(&self.transport_run);
2933 let run = slot.as_mut().ok_or(SoftchatError::InvalidRelaySession)?;
2934 let batch = run.cancel(&mut database)?;
2935 slot.take();
2936 Ok(batch)
2937 })();
2938 self.logs.failed(
2939 Diagnostic::new(
2940 AccountLogCategory::Relay,
2941 "relay.operation.failed",
2942 "Relay operation failed",
2943 ),
2944 &result,
2945 );
2946 result
2947 }
2948
2949 pub fn ingest(
2956 &self,
2957 events: Vec<SignedEvent>,
2958 received_at: i64,
2959 ) -> Result<StoredIngestion, SoftchatError> {
2960 let result = self.ingest_unlogged(events, received_at);
2961 self.logs.failed(
2962 Diagnostic::new(
2963 AccountLogCategory::Storage,
2964 "storage.ingest",
2965 "Incoming batch rejected",
2966 ),
2967 &result,
2968 );
2969 result
2970 }
2971
2972 pub fn ingest_event_jsons(
2982 &self,
2983 event_jsons: Vec<String>,
2984 received_at: i64,
2985 ) -> Result<StoredIngestion, SoftchatError> {
2986 let preflight = preflight_ingestion_jsons(&event_jsons);
2987 let result = (|| {
2988 preflight?;
2989 let events = event_jsons
2990 .into_iter()
2991 .map(|event_json| {
2992 SignedNostrEvent::from_json(&event_json).map(|event| SignedEvent::from(&event))
2993 })
2994 .collect::<Result<Vec<_>, _>>()?;
2995 let mut database = self.database()?;
2996 self.identity.with_identity(|identity| {
2997 database.ingest_with_authenticated_replays(identity, events, received_at)
2998 })
2999 })();
3000 self.logs.failed(
3001 Diagnostic::new(
3002 AccountLogCategory::Storage,
3003 "storage.ingest_json",
3004 "Incoming batch rejected",
3005 ),
3006 &result,
3007 );
3008 result
3009 }
3010
3011 #[allow(clippy::too_many_arguments)]
3018 pub fn enqueue_rumor(
3019 &self,
3020 operation_id: String,
3021 rumor: RumorEvent,
3022 recipients: Vec<String>,
3023 notification_recipients: Vec<String>,
3024 relay_urls: Vec<String>,
3025 kind: Nip59EnvelopeKind,
3026 now: i64,
3027 ) -> Result<AccountOperationResult, SoftchatError> {
3028 let mut database = self.database()?;
3029 self.identity.with_identity(|identity| {
3030 database.enqueue_rumor(
3031 identity,
3032 operation_id,
3033 rumor,
3034 recipients,
3035 notification_recipients,
3036 relay_urls,
3037 kind,
3038 now,
3039 )
3040 })
3041 }
3042
3043 pub fn media_upload_url(&self) -> Result<String, SoftchatError> {
3053 let (configured, relay_url) = {
3054 let database = self.database()?;
3055 (
3056 database.product_settings()?.media_service,
3057 database.active_relay_url()?,
3058 )
3059 };
3060 if let Some(configured) = configured {
3061 return Ok(configured);
3062 }
3063 let endpoint = parse_relay_endpoint(&relay_url)?;
3064 let mut url = Url::parse(&endpoint.network_url)
3065 .map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
3066 url.set_scheme("https")
3067 .map_err(|_| SoftchatError::InvalidHttpAuthorization)?;
3068 url.set_path("/api/v2/files");
3069 url.set_query(None);
3070 url.set_fragment(None);
3071 Ok(url.to_string())
3072 }
3073
3074 pub fn enqueue_signed(
3080 &self,
3081 operation_id: String,
3082 events: Vec<SignedEvent>,
3083 relay_urls: Vec<String>,
3084 now: i64,
3085 ) -> Result<AccountOperationResult, SoftchatError> {
3086 let mut database = self.database()?;
3087 self.identity.with_identity(|identity| {
3088 database.enqueue_signed(identity, operation_id, events, relay_urls, now)
3089 })
3090 }
3091
3092 pub fn recover_signed_outbox_batch(
3105 &self,
3106 operation_id: String,
3107 event_batch_json: String,
3108 now: i64,
3109 ) -> Result<AccountOperationResult, SoftchatError> {
3110 if event_batch_json.len() > crate::MAX_NOSTR_EVENT_JSON_BYTES {
3111 return Err(SoftchatError::InvalidAccountOperation);
3112 }
3113 let serde_json::Value::Array(raw_events) = crate::json::parse_value(&event_batch_json)
3114 .map_err(|_| SoftchatError::InvalidAccountOperation)?
3115 else {
3116 return Err(SoftchatError::InvalidAccountOperation);
3117 };
3118 if raw_events.is_empty() || raw_events.len() > crate::MAX_OPERATION_EVENT_COPIES {
3119 return Err(SoftchatError::InvalidAccountOperation);
3120 }
3121 let events = raw_events
3122 .into_iter()
3123 .map(|event| {
3124 SignedNostrEvent::from_json(&event.to_string())
3125 .map(|verified| SignedEvent::from(&verified))
3126 })
3127 .collect::<Result<Vec<_>, _>>()?;
3128 let payload_hash = command_hash(&("signed_outbox_recovery", &events))?;
3129 Ok(self
3130 .database()?
3131 .enqueue_product_signed(
3132 operation_id,
3133 payload_hash,
3134 String::new(),
3135 events,
3136 now,
3137 AccountLogSubject::Unknown,
3138 )?
3139 .operation)
3140 }
3141
3142 pub fn claim_deliveries(
3148 &self,
3149 lease_id: String,
3150 relay_url: String,
3151 now: i64,
3152 lease_duration_seconds: u32,
3153 ) -> Result<Option<ClaimedDelivery>, SoftchatError> {
3154 self.database()?
3155 .claim_deliveries(lease_id, relay_url, now, lease_duration_seconds)
3156 }
3157
3158 pub fn mark_socket_written(
3164 &self,
3165 claim: DeliveryClaim,
3166 intent_ids: Vec<String>,
3167 ) -> Result<AccountMutationResult, SoftchatError> {
3168 self.database()?.mark_socket_written(&claim, intent_ids)
3169 }
3170
3171 pub fn apply_relay_results(
3177 &self,
3178 claim: DeliveryClaim,
3179 results: Vec<RelayDeliveryResult>,
3180 ) -> Result<AccountMutationResult, SoftchatError> {
3181 self.database()?.apply_relay_results(&claim, results)
3182 }
3183
3184 pub fn release_claim(
3190 &self,
3191 claim: DeliveryClaim,
3192 ) -> Result<AccountMutationResult, SoftchatError> {
3193 self.database()?.release_claim(&claim)
3194 }
3195
3196 pub fn operation(&self, operation_id: String) -> Result<OperationSnapshot, SoftchatError> {
3202 self.database()?.operation(&operation_id)
3203 }
3204
3205 pub fn operation_page(&self, limit: u32) -> Result<Vec<OperationSnapshot>, SoftchatError> {
3211 self.database()?.operations(limit)
3212 }
3213
3214 pub fn retry_operation(
3220 &self,
3221 operation_id: String,
3222 ) -> Result<AccountOperationResult, SoftchatError> {
3223 self.database()?.retry_operation(&operation_id)
3224 }
3225
3226 pub fn cancel_operation(
3232 &self,
3233 operation_id: String,
3234 ) -> Result<AccountOperationResult, SoftchatError> {
3235 self.database()?.cancel_operation(&operation_id)
3236 }
3237
3238 pub fn relay_catalog(&self) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
3244 self.database()?.relay_catalog()
3245 }
3246
3247 pub fn inbox_filters(
3258 &self,
3259 since_timestamp: Option<i64>,
3260 ) -> Result<Vec<String>, SoftchatError> {
3261 self.ensure_open()?;
3262 let recipient_public_key = self
3263 .identity
3264 .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3265 Ok(vec![inbox_filter_json(
3266 &recipient_public_key,
3267 since_timestamp,
3268 None,
3269 )?])
3270 }
3271
3272 pub fn reconcile_account_relays(
3278 &self,
3279 discovered: Vec<DnsRelayRecord>,
3280 refreshed_at: i64,
3281 fallback_url: String,
3282 dns_authenticated: bool,
3283 ) -> Result<StoredRelayCatalogPlan, SoftchatError> {
3284 self.database()?.reconcile_relay_catalog(
3285 discovered,
3286 refreshed_at,
3287 fallback_url,
3288 dns_authenticated,
3289 )
3290 }
3291
3292 pub fn add_account_relay(
3298 &self,
3299 relay_url: String,
3300 created_at: i64,
3301 ) -> Result<RelayCatalogMutation, SoftchatError> {
3302 self.database()?.add_custom_relay(relay_url, created_at)
3303 }
3304
3305 pub fn exclude_account_relay(
3311 &self,
3312 relay_url: String,
3313 now: i64,
3314 ) -> Result<RelayCatalogMutation, SoftchatError> {
3315 self.database()?.exclude_relay(relay_url, now)
3316 }
3317
3318 pub fn restore_account_relays(&self, now: i64) -> Result<RelayCatalogMutation, SoftchatError> {
3324 self.database()?.restore_automatic_relays(now)
3325 }
3326
3327 pub fn set_account_active_relay(
3333 &self,
3334 relay_url: String,
3335 now: i64,
3336 ) -> Result<RelayCatalogMutation, SoftchatError> {
3337 self.database()?.set_active_relay(relay_url, now)
3338 }
3339
3340 pub fn account_relay_failover_candidates(
3346 &self,
3347 failed_url: String,
3348 ) -> Result<Vec<String>, SoftchatError> {
3349 self.database()?.relay_failover_candidates(failed_url)
3350 }
3351
3352 pub fn rotate_failed_account_relay(
3358 &self,
3359 failed_url: String,
3360 now: i64,
3361 ) -> Result<RelayFailoverMutation, SoftchatError> {
3362 self.database()?.rotate_failed_relay(failed_url, now)
3363 }
3364
3365 pub fn list_conversations(
3371 &self,
3372 limit: u32,
3373 offset: u32,
3374 ) -> Result<Vec<ConversationSummary>, SoftchatError> {
3375 self.database()?.list_conversations(limit, offset)
3376 }
3377
3378 pub fn list_conversation_views(
3384 &self,
3385 limit: u32,
3386 offset: u32,
3387 ) -> Result<Vec<ConversationView>, SoftchatError> {
3388 self.database()?.list_conversation_views(limit, offset)
3389 }
3390
3391 pub fn list_messages(
3397 &self,
3398 conversation_id: String,
3399 before_created_at: Option<i64>,
3400 limit: u32,
3401 ) -> Result<Vec<AccountMessage>, SoftchatError> {
3402 self.database()?
3403 .list_messages(&conversation_id, before_created_at, limit)
3404 }
3405
3406 #[allow(clippy::too_many_arguments)]
3414 pub fn list_projections(
3415 &self,
3416 kind: Option<crate::ProjectionKind>,
3417 logical_event_id: String,
3418 conversation_id: String,
3419 target_event_id: String,
3420 author_public_key: String,
3421 before_created_at: Option<i64>,
3422 limit: u32,
3423 offset: u32,
3424 ) -> Result<Vec<AccountProjection>, SoftchatError> {
3425 self.database()?.list_projections(
3426 kind,
3427 logical_event_id,
3428 conversation_id,
3429 target_event_id,
3430 author_public_key,
3431 before_created_at,
3432 limit,
3433 offset,
3434 )
3435 }
3436
3437 #[allow(clippy::too_many_arguments)]
3443 pub fn list_event_nodes(
3444 &self,
3445 kind: Option<crate::ProjectionKind>,
3446 logical_event_id: String,
3447 conversation_id: String,
3448 target_event_id: String,
3449 author_public_key: String,
3450 before_created_at: Option<i64>,
3451 limit: u32,
3452 offset: u32,
3453 ) -> Result<Vec<AccountEventNode>, SoftchatError> {
3454 self.database()?.list_event_nodes(
3455 kind,
3456 logical_event_id,
3457 conversation_id,
3458 target_event_id,
3459 author_public_key,
3460 before_created_at,
3461 limit,
3462 offset,
3463 )
3464 }
3465
3466 pub fn list_known_public_keys(
3472 &self,
3473 limit: u32,
3474 offset: u32,
3475 ) -> Result<Vec<String>, SoftchatError> {
3476 self.database()?.list_known_public_keys(limit, offset)
3477 }
3478
3479 pub fn put_local_contact(
3485 &self,
3486 contact: LocalContactRecord,
3487 ) -> Result<AccountMutationResult, SoftchatError> {
3488 self.database()?.put_local_contact(contact)
3489 }
3490
3491 pub fn remove_local_contact(
3497 &self,
3498 public_key: String,
3499 ) -> Result<AccountMutationResult, SoftchatError> {
3500 self.database()?.remove_local_contact(public_key)
3501 }
3502
3503 pub fn local_contacts(
3509 &self,
3510 limit: u32,
3511 offset: u32,
3512 ) -> Result<Vec<LocalContactRecord>, SoftchatError> {
3513 self.database()?.local_contacts(limit, offset)
3514 }
3515
3516 pub fn replace_stickers(
3522 &self,
3523 stickers: Vec<StickerRecord>,
3524 ) -> Result<AccountMutationResult, SoftchatError> {
3525 self.database()?.replace_stickers(stickers)
3526 }
3527
3528 pub fn stickers(&self) -> Result<Vec<StickerRecord>, SoftchatError> {
3534 self.database()?.stickers()
3535 }
3536
3537 pub fn put_link_preview(
3543 &self,
3544 preview: LinkPreviewRecord,
3545 ) -> Result<AccountMutationResult, SoftchatError> {
3546 self.database()?.put_link_preview(preview)
3547 }
3548
3549 pub fn link_preview(&self, url: String) -> Result<Option<LinkPreviewRecord>, SoftchatError> {
3555 self.database()?.link_preview(url)
3556 }
3557
3558 pub fn clear_account(&self) -> Result<AccountMutationResult, SoftchatError> {
3568 let mut sync_engines = lock(&self.sync_engines);
3569 let mut database = self.database()?;
3570 let mut transport_run = lock(&self.transport_run);
3571 let result = database.clear()?;
3572 let mut diagnostic = Diagnostic::new(
3573 AccountLogCategory::Recovery,
3574 "account.cleared",
3575 "Account data cleared",
3576 );
3577 diagnostic.revision = Some(result.revision);
3578 self.logs.emit(diagnostic);
3579 sync_engines.clear();
3580 transport_run.take();
3581 lock(&self.typing_authored).clear();
3582 Ok(result)
3583 }
3584
3585 pub fn event_json(&self, event_id: String) -> Result<String, SoftchatError> {
3591 self.database()?.event_json(&event_id)
3592 }
3593
3594 pub fn event_jsons(
3600 &self,
3601 event_ids: Vec<String>,
3602 ) -> Result<Vec<StoredEventJson>, SoftchatError> {
3603 self.database()?.event_jsons(event_ids)
3604 }
3605
3606 pub fn sync_fingerprints(
3612 &self,
3613 since_timestamp: Option<i64>,
3614 until_timestamp: i64,
3615 limit: u32,
3616 ) -> Result<Vec<NegentropyItem>, SoftchatError> {
3617 self.database()?
3618 .sync_fingerprints(since_timestamp, until_timestamp, limit)
3619 }
3620
3621 pub fn sync_checkpoint(&self, sync_id: String) -> Result<Option<i64>, SoftchatError> {
3627 self.database()?.sync_checkpoint(&sync_id)
3628 }
3629
3630 pub fn save_sync_checkpoint(
3636 &self,
3637 sync_id: String,
3638 checkpoint_timestamp: i64,
3639 now: i64,
3640 ) -> Result<AccountMutationResult, SoftchatError> {
3641 self.database()?
3642 .save_sync_checkpoint(&sync_id, checkpoint_timestamp, now)
3643 }
3644
3645 #[allow(clippy::too_many_arguments)]
3654 pub fn start_sync(
3655 &self,
3656 sync_id: String,
3657 neg_subscription_id: String,
3658 event_subscription_id: String,
3659 now: i64,
3660 overlap_seconds: u32,
3661 frame_size_limit: u64,
3662 ) -> Result<SyncAction, SoftchatError> {
3663 self.ensure_open()?;
3664 if overlap_seconds > 604_800 {
3665 return Err(SoftchatError::InvalidSyncState);
3666 }
3667 let mut engines = lock(&self.sync_engines);
3668 if engines.contains_key(&sync_id) {
3669 return Err(SoftchatError::InvalidSyncState);
3670 }
3671 let since_timestamp = self
3672 .database()?
3673 .sync_checkpoint(&sync_id)?
3674 .map(|checkpoint| checkpoint.saturating_sub(i64::from(overlap_seconds)));
3675 let mut engine = SyncEngine::new(
3676 sync_id.clone(),
3677 neg_subscription_id,
3678 event_subscription_id,
3679 since_timestamp,
3680 now,
3681 )?;
3682 let action = engine.begin()?;
3683 let action = {
3684 let mut database = self.database()?;
3685 resolve_sync_action(&mut database, &mut engine, action, frame_size_limit)?
3686 };
3687 let recipient_public_key = self
3688 .identity
3689 .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3690 let action = plan_sync_transport(action, &recipient_public_key)?;
3691 engines.insert(sync_id, engine);
3692 Ok(action)
3693 }
3694
3695 pub fn reconcile_sync(
3701 &self,
3702 sync_id: String,
3703 subscription_id: String,
3704 message: Vec<u8>,
3705 frame_size_limit: u64,
3706 ) -> Result<Vec<SyncAction>, SoftchatError> {
3707 self.ensure_open()?;
3708 let mut engines = lock(&self.sync_engines);
3709 let engine = engines
3710 .get_mut(&sync_id)
3711 .ok_or(SoftchatError::InvalidSyncState)?;
3712 let actions = engine.reconcile(subscription_id, message)?;
3713 let actions = {
3714 let mut database = self.database()?;
3715 resolve_sync_actions(&mut database, engine, actions, frame_size_limit)?
3716 };
3717 let recipient_public_key = self
3718 .identity
3719 .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3720 actions
3721 .into_iter()
3722 .map(|action| plan_sync_transport(action, &recipient_public_key))
3723 .collect()
3724 }
3725
3726 pub fn receive_sync_frame(
3736 &self,
3737 sync_id: String,
3738 frame_json: String,
3739 frame_size_limit: u64,
3740 ) -> Result<Option<Vec<SyncAction>>, SoftchatError> {
3741 let Some(frame) = parse_sync_message_frame(&frame_json)? else {
3742 return Ok(None);
3743 };
3744 let SyncTransportFrame::Message {
3745 subscription_id,
3746 message,
3747 } = frame
3748 else {
3749 return Err(SoftchatError::InvalidSyncState);
3750 };
3751 self.reconcile_sync(sync_id, subscription_id, message, frame_size_limit)
3752 .map(Some)
3753 }
3754
3755 pub fn ingest_sync_events(
3762 &self,
3763 sync_id: String,
3764 subscription_id: String,
3765 events: Vec<SignedEvent>,
3766 received_at: i64,
3767 ) -> Result<StoredIngestion, SoftchatError> {
3768 let event_ids = events
3769 .iter()
3770 .map(|event| event.id.clone())
3771 .collect::<Vec<_>>();
3772 let mut engines = lock(&self.sync_engines);
3773 let engine = engines
3774 .get_mut(&sync_id)
3775 .ok_or(SoftchatError::InvalidSyncState)?;
3776 engine.validate_fetched_events(&subscription_id, &event_ids)?;
3777 let mut database = self.database()?;
3778 let stored = self
3779 .identity
3780 .with_identity(|identity| database.ingest(identity, events, received_at))?;
3781 for event_id in &event_ids {
3782 engine.record_fetched_event(subscription_id.clone(), event_id.clone())?;
3783 }
3784 engine.confirm_committed(event_ids)?;
3785 Ok(stored)
3786 }
3787
3788 pub fn finish_sync_fetch(
3794 &self,
3795 sync_id: String,
3796 subscription_id: String,
3797 frame_size_limit: u64,
3798 ) -> Result<Vec<SyncAction>, SoftchatError> {
3799 let mut engines = lock(&self.sync_engines);
3800 let engine = engines
3801 .get_mut(&sync_id)
3802 .ok_or(SoftchatError::InvalidSyncState)?;
3803 let actions = engine.finish_fetch(subscription_id)?;
3804 let actions = {
3805 let mut database = self.database()?;
3806 resolve_sync_actions(&mut database, engine, actions, frame_size_limit)?
3807 };
3808 let recipient_public_key = self
3809 .identity
3810 .with_identity(|identity| Ok(identity.public_key().to_hex()))?;
3811 actions
3812 .into_iter()
3813 .map(|action| plan_sync_transport(action, &recipient_public_key))
3814 .collect()
3815 }
3816
3817 pub fn sync_resend_payloads(
3823 &self,
3824 event_ids: Vec<String>,
3825 ) -> Result<Vec<StoredEventJson>, SoftchatError> {
3826 self.database()?.event_jsons(event_ids)
3827 }
3828
3829 pub fn sync_snapshot(&self, sync_id: String) -> Result<SyncEngineSnapshot, SoftchatError> {
3835 self.ensure_open()?;
3836 lock(&self.sync_engines)
3837 .get(&sync_id)
3838 .map(SyncEngine::snapshot)
3839 .ok_or(SoftchatError::InvalidSyncState)
3840 }
3841
3842 pub fn cancel_sync(&self, sync_id: String) -> Result<SyncEngineSnapshot, SoftchatError> {
3848 self.ensure_open()?;
3849 let mut engine = lock(&self.sync_engines)
3850 .remove(&sync_id)
3851 .ok_or(SoftchatError::InvalidSyncState)?;
3852 Ok(engine.cancel())
3853 }
3854}
3855
3856impl AccountRuntimeHandle {
3857 fn ingest_unlogged(
3858 &self,
3859 events: Vec<SignedEvent>,
3860 received_at: i64,
3861 ) -> Result<StoredIngestion, SoftchatError> {
3862 let mut database = self.database()?;
3863 self.identity
3864 .with_identity(|identity| database.ingest(identity, events, received_at))
3865 }
3866
3867 fn enqueue_follow_replacement(
3868 &self,
3869 database: &mut AccountDatabase,
3870 command_id: String,
3871 contacts: Vec<Contact>,
3872 now: i64,
3873 ) -> Result<ProductAccountOperationResult, SoftchatError> {
3874 validate_command_id(&command_id)?;
3875 let payload_hash = command_hash(&("follows", &contacts))?;
3876 let own_public_key = self.public_key()?;
3877 let latest = database.authenticated_product_follow_updated_at(&own_public_key)?;
3878 let authored_at = if latest > 0 && latest >= now {
3879 latest
3880 .checked_add(1)
3881 .ok_or(SoftchatError::InvalidAccountOperation)?
3882 } else {
3883 now
3884 };
3885 let created_at =
3886 u64::try_from(authored_at).map_err(|_| SoftchatError::InvalidAccountOperation)?;
3887 let rumor = self.identity.with_identity(|identity| {
3888 identity
3889 .create_follow_list(created_at, contacts.clone())
3890 .map(|view| view.rumor)
3891 })?;
3892 let recipients = contacts
3893 .into_iter()
3894 .map(|contact| contact.public_key)
3895 .filter(|key| key != &own_public_key)
3896 .collect::<Vec<_>>();
3897 self.identity.with_identity(|identity| {
3898 database.enqueue_product_rumor(
3899 identity,
3900 command_id,
3901 payload_hash,
3902 String::new(),
3903 rumor,
3904 recipients,
3905 Vec::new(),
3906 Nip59EnvelopeKind::Durable,
3907 now,
3908 None,
3909 )
3910 })
3911 }
3912
3913 fn ensure_open(&self) -> Result<(), SoftchatError> {
3914 if self.closed.load(Ordering::Acquire) {
3915 Err(SoftchatError::AccountClosed)
3916 } else {
3917 Ok(())
3918 }
3919 }
3920
3921 fn database(&self) -> Result<MutexGuard<'_, AccountDatabase>, SoftchatError> {
3922 self.ensure_open()?;
3923 Ok(lock(&self.database))
3924 }
3925}
3926
3927impl Drop for AccountRuntimeHandle {
3928 fn drop(&mut self) {
3929 self.closed.store(true, Ordering::Release);
3930 self.identity.erase();
3931 lock(open_account_paths()).remove(&self.database_path);
3932 }
3933}
3934
3935fn remote_members(conversation: &ConversationRecord, own_public_key: &str) -> Vec<String> {
3936 conversation
3937 .member_public_keys
3938 .iter()
3939 .filter(|key| key.as_str() != own_public_key)
3940 .cloned()
3941 .collect()
3942}
3943
3944fn all_known_public_keys(database: &AccountDatabase) -> Result<Vec<String>, SoftchatError> {
3945 let page_limit = crate::MAX_PRODUCT_QUERY_PAGE;
3946 let mut offset = 0;
3947 let mut result = Vec::new();
3948 while result.len() < crate::MAX_CONTACTS {
3949 let page = database.list_known_public_keys(page_limit, offset)?;
3950 let page_len = page.len();
3951 result.extend(page);
3952 if page_len < page_limit as usize {
3953 break;
3954 }
3955 offset = offset
3956 .checked_add(page_limit)
3957 .ok_or(SoftchatError::InvalidAccountOperation)?;
3958 }
3959 if result.len() > crate::MAX_CONTACTS {
3960 return Err(SoftchatError::InvalidAccountOperation);
3961 }
3962 Ok(result)
3963}
3964
3965fn send_message_command_hash(
3966 conversation_id: &str,
3967 content: &MessageContentInput,
3968 reply_to_message_id: &str,
3969 draft_match: Option<&crate::account_product::ProductDraftMatch>,
3970) -> Result<String, SoftchatError> {
3971 if let Some(draft_match) = draft_match {
3972 command_hash(&(
3973 "send",
3974 conversation_id,
3975 content,
3976 reply_to_message_id,
3977 draft_match,
3978 ))
3979 } else {
3980 command_hash(&("send", conversation_id, content, reply_to_message_id))
3981 }
3982}
3983
3984fn media_message_command_hash(
3985 conversation_id: &str,
3986 content: &MessageContentInput,
3987 reply_to_message_id: &str,
3988 sources: &[crate::MediaPreparationInput],
3989 draft_match: Option<&crate::account_product::ProductDraftMatch>,
3990) -> Result<String, SoftchatError> {
3991 if let Some(draft_match) = draft_match {
3992 command_hash(&(
3993 "media-message",
3994 conversation_id,
3995 content,
3996 reply_to_message_id,
3997 sources,
3998 draft_match,
3999 ))
4000 } else {
4001 command_hash(&(
4002 "media-message",
4003 conversation_id,
4004 content,
4005 reply_to_message_id,
4006 sources,
4007 ))
4008 }
4009}
4010
4011fn public_keys(values: &[String]) -> Result<Vec<NostrPublicKey>, SoftchatError> {
4012 values
4013 .iter()
4014 .map(|value| NostrPublicKey::from_hex(value))
4015 .collect()
4016}
4017
4018fn author_product_chat_message(
4019 account: &AccountRuntimeHandle,
4020 conversation_id: &str,
4021 content: MessageContentInput,
4022 reply_to_message_id: &str,
4023 now: i64,
4024) -> Result<(RumorEvent, Vec<String>), SoftchatError> {
4025 content.validate()?;
4026 let own_public_key = account.public_key()?;
4027 let conversation = account
4028 .database()?
4029 .product_conversation(conversation_id, &own_public_key)?
4030 .ok_or(SoftchatError::InvalidAccountOperation)?;
4031 let recipients = remote_members(&conversation, &own_public_key);
4032 let relation = if reply_to_message_id.is_empty() {
4033 ChatRelation::None
4034 } else {
4035 let parent = account
4036 .database()?
4037 .product_message(reply_to_message_id)?
4038 .filter(|message| message.conversation_id == conversation_id)
4039 .ok_or(SoftchatError::InvalidAccountOperation)?;
4040 ChatRelation::Reply {
4041 event_id: NostrEventId::from_hex(&parent.id)?,
4042 relay_hint: Some(account.database()?.active_relay_url()?),
4043 legacy_unmarked: false,
4044 }
4045 };
4046 let created_at = u64::try_from(now).map_err(|_| SoftchatError::InvalidAccountOperation)?;
4047 let draft = crate::chat::prepare_chat_message_draft(
4048 NostrPublicKey::from_hex(&own_public_key)?,
4049 ChatMessageDraft {
4050 created_at,
4051 participants: public_keys(&recipients)?,
4052 content: content.text,
4053 relation,
4054 attachments: content.attachments,
4055 emoji_tags: nostr_tags(content.emoji_tags)?,
4056 extension_tags: Vec::new(),
4057 },
4058 )?;
4059 crate::envelope::validate_nip59_draft_wire_size(
4060 &draft,
4061 Nip59EnvelopeKind::Durable,
4062 created_at,
4063 !recipients.is_empty(),
4064 )
4065 .map_err(|_| SoftchatError::InvalidAccountOperation)?;
4066 let rumor = account.identity.with_identity(|identity| {
4067 identity
4068 .create_rumor(draft)
4069 .map(|rumor| RumorEvent::from(&rumor))
4070 .map_err(|_| SoftchatError::SoftchatEventCreationFailed)
4071 })?;
4072 Ok((rumor, recipients))
4073}
4074
4075fn preflight_ingestion_jsons(event_jsons: &[String]) -> Result<u64, SoftchatError> {
4076 if event_jsons.is_empty() || event_jsons.len() > crate::MAX_ACCOUNT_INGESTION_EVENTS {
4077 return Err(SoftchatError::InvalidAccountOperation);
4078 }
4079 bounded_ingestion_json_bytes(event_jsons.iter().map(String::len))
4080}
4081
4082fn bounded_ingestion_json_bytes(
4083 lengths: impl IntoIterator<Item = usize>,
4084) -> Result<u64, SoftchatError> {
4085 let total = lengths.into_iter().try_fold(0_usize, |total, length| {
4086 total
4087 .checked_add(length)
4088 .filter(|next| *next <= crate::MAX_ACCOUNT_INGESTION_BYTES)
4089 .ok_or(SoftchatError::InvalidAccountOperation)
4090 })?;
4091 u64::try_from(total).map_err(|_| SoftchatError::InvalidAccountOperation)
4092}
4093
4094fn nostr_tags(values: Vec<Vec<String>>) -> Result<Vec<NostrTag>, SoftchatError> {
4095 values.into_iter().map(NostrTag::new).collect()
4096}
4097
4098fn stored_product_result(
4099 account: &AccountRuntimeHandle,
4100 operation: ProductAccountOperationResult,
4101) -> Result<ProductOperationResult, SoftchatError> {
4102 let message = if operation.result_message_id.is_empty() {
4103 None
4104 } else {
4105 account
4106 .database()?
4107 .product_message(&operation.result_message_id)?
4108 };
4109 Ok(ProductOperationResult {
4110 operation: operation.operation.operation,
4111 revision: operation.operation.revision,
4112 inserted: operation.operation.inserted,
4113 message,
4114 })
4115}
4116
4117#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
4119pub struct AccountEngineHandle {
4120 account_id: String,
4121 identity: Arc<LocalIdentityHandle>,
4122}
4123
4124impl fmt::Debug for AccountEngineHandle {
4125 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
4126 formatter
4127 .debug_struct("AccountEngineHandle")
4128 .field("account_id", &self.account_id)
4129 .finish_non_exhaustive()
4130 }
4131}
4132
4133#[cfg_attr(feature = "native-bindings", uniffi::export)]
4134impl AccountEngineHandle {
4135 #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
4141 pub fn new(
4142 account_id: String,
4143 identity: Arc<LocalIdentityHandle>,
4144 ) -> Result<Self, SoftchatError> {
4145 AccountEngine::validate_account_id(&account_id)?;
4146 identity.with_identity(|_| Ok(()))?;
4147 Ok(Self {
4148 account_id,
4149 identity,
4150 })
4151 }
4152
4153 #[must_use]
4155 pub fn account_id(&self) -> String {
4156 self.account_id.clone()
4157 }
4158
4159 pub fn prepare_incoming(
4165 &self,
4166 events: Vec<SignedEvent>,
4167 ) -> Result<PreparedIncomingBatch, SoftchatError> {
4168 self.identity.with_identity(|identity| {
4169 AccountEngine::prepare_incoming(identity, self.account_id.clone(), events)
4170 })
4171 }
4172
4173 pub fn validate_ingestion_receipt(
4179 &self,
4180 batch: PreparedIncomingBatch,
4181 receipt: IngestionReceipt,
4182 ) -> Result<(), SoftchatError> {
4183 AccountEngine::validate_ingestion_receipt(&batch, &receipt)
4184 }
4185
4186 #[allow(clippy::too_many_arguments)]
4192 pub fn prepare_rumor_operation(
4193 &self,
4194 operation_id: String,
4195 rumor: RumorEvent,
4196 recipients: Vec<String>,
4197 notification_recipients: Vec<String>,
4198 relay_urls: Vec<String>,
4199 kind: Nip59EnvelopeKind,
4200 now: i64,
4201 ) -> Result<PreparedOutgoingOperation, SoftchatError> {
4202 self.identity.with_identity(|identity| {
4203 AccountEngine::prepare_rumor_operation(
4204 identity,
4205 self.account_id.clone(),
4206 operation_id,
4207 rumor,
4208 recipients,
4209 notification_recipients,
4210 relay_urls,
4211 kind,
4212 now,
4213 )
4214 })
4215 }
4216
4217 pub fn prepare_signed_operation(
4223 &self,
4224 operation_id: String,
4225 events: Vec<SignedEvent>,
4226 relay_urls: Vec<String>,
4227 ) -> Result<PreparedOutgoingOperation, SoftchatError> {
4228 self.identity.with_identity(|_| {
4229 AccountEngine::prepare_signed_operation(
4230 self.account_id.clone(),
4231 operation_id,
4232 events,
4233 relay_urls,
4234 )
4235 })
4236 }
4237}
4238
4239#[cfg_attr(feature = "native-bindings", uniffi::export)]
4245pub fn claim_deliveries(
4246 intents: Vec<DeliveryIntentSnapshot>,
4247 lease_id: String,
4248) -> Result<DeliveryClaim, SoftchatError> {
4249 DeliveryReducer::claim(intents, lease_id)
4250}
4251
4252#[cfg_attr(feature = "native-bindings", uniffi::export)]
4258pub fn mark_deliveries_socket_written(
4259 claim: DeliveryClaim,
4260 intent_ids: Vec<String>,
4261) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
4262 DeliveryReducer::mark_socket_written(&claim, intent_ids)
4263}
4264
4265#[cfg_attr(feature = "native-bindings", uniffi::export)]
4271pub fn apply_relay_delivery_results(
4272 claim: DeliveryClaim,
4273 results: Vec<RelayDeliveryResult>,
4274) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
4275 DeliveryReducer::apply_relay_results(&claim, results)
4276}
4277
4278#[cfg_attr(feature = "native-bindings", uniffi::export)]
4284pub fn release_delivery_claim(
4285 claim: DeliveryClaim,
4286) -> Result<Vec<DeliveryStateMutation>, SoftchatError> {
4287 DeliveryReducer::release_claim(&claim)
4288}
4289
4290#[cfg_attr(feature = "native-bindings", uniffi::export)]
4296pub fn derive_operation_snapshot(
4297 intents: Vec<DeliveryIntentSnapshot>,
4298) -> Result<OperationSnapshot, SoftchatError> {
4299 DeliveryReducer::operation_snapshot(intents)
4300}
4301
4302#[cfg_attr(feature = "native-bindings", uniffi::export)]
4308pub fn derive_conversation_id(public_keys: Vec<String>) -> Result<String, SoftchatError> {
4309 conversation_id(public_keys)
4310}
4311
4312#[cfg_attr(feature = "native-bindings", uniffi::export)]
4318pub fn reconcile_relay_catalog(
4319 current: Vec<RelayCatalogEntry>,
4320 discovered: Vec<DnsRelayRecord>,
4321 refreshed_at: i64,
4322 fallback_url: String,
4323 dns_authenticated: bool,
4324) -> Result<RelayCatalogPlan, SoftchatError> {
4325 RelayCatalogReducer::reconcile(
4326 current,
4327 discovered,
4328 refreshed_at,
4329 fallback_url,
4330 dns_authenticated,
4331 )
4332}
4333
4334#[cfg_attr(feature = "native-bindings", uniffi::export)]
4340pub fn add_custom_relay(
4341 current: Vec<RelayCatalogEntry>,
4342 relay_url: String,
4343 created_at: i64,
4344) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4345 RelayCatalogReducer::add_custom(current, relay_url, created_at)
4346}
4347
4348#[cfg_attr(feature = "native-bindings", uniffi::export)]
4354pub fn exclude_relay(
4355 current: Vec<RelayCatalogEntry>,
4356 relay_url: String,
4357) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4358 RelayCatalogReducer::exclude(current, relay_url)
4359}
4360
4361#[cfg_attr(feature = "native-bindings", uniffi::export)]
4367pub fn restore_automatic_relays(
4368 current: Vec<RelayCatalogEntry>,
4369) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4370 RelayCatalogReducer::restore_automatic(current)
4371}
4372
4373#[cfg_attr(feature = "native-bindings", uniffi::export)]
4379pub fn set_active_relay(
4380 current: Vec<RelayCatalogEntry>,
4381 relay_url: String,
4382) -> Result<Vec<RelayCatalogEntry>, SoftchatError> {
4383 RelayCatalogReducer::set_active(current, relay_url)
4384}
4385
4386#[cfg_attr(feature = "native-bindings", uniffi::export)]
4392pub fn relay_failover_candidates(
4393 current: Vec<RelayCatalogEntry>,
4394 failed_url: String,
4395) -> Result<Vec<String>, SoftchatError> {
4396 RelayCatalogReducer::failover_candidates(current, failed_url)
4397}
4398
4399#[cfg_attr(feature = "native-bindings", uniffi::export)]
4401#[must_use]
4402pub fn relay_retry_plan(
4403 attempt: u32,
4404 failure: RelayFailureKind,
4405 bypass_delay_once: bool,
4406) -> RelayRetryPlan {
4407 plan_relay_retry(attempt, failure, bypass_delay_once)
4408}
4409
4410#[cfg_attr(feature = "native-bindings", uniffi::export)]
4416pub fn relay_endpoint_plan(configured_url: String) -> Result<RelayEndpointPlan, SoftchatError> {
4417 parse_relay_endpoint(&configured_url)
4418}
4419
4420#[cfg_attr(feature = "native-bindings", derive(uniffi::Object))]
4422pub struct SyncEngineHandle {
4423 engine: Mutex<SyncEngine>,
4424}
4425
4426impl fmt::Debug for SyncEngineHandle {
4427 fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
4428 formatter
4429 .debug_struct("SyncEngineHandle")
4430 .field("snapshot", &lock(&self.engine).snapshot())
4431 .finish()
4432 }
4433}
4434
4435#[cfg_attr(feature = "native-bindings", uniffi::export)]
4436impl SyncEngineHandle {
4437 #[cfg_attr(feature = "native-bindings", uniffi::constructor)]
4443 pub fn new(
4444 sync_id: String,
4445 neg_subscription_id: String,
4446 event_subscription_id: String,
4447 since_timestamp: Option<i64>,
4448 checkpoint_timestamp: i64,
4449 ) -> Result<Self, SoftchatError> {
4450 Ok(Self {
4451 engine: Mutex::new(SyncEngine::new(
4452 sync_id,
4453 neg_subscription_id,
4454 event_subscription_id,
4455 since_timestamp,
4456 checkpoint_timestamp,
4457 )?),
4458 })
4459 }
4460
4461 pub fn begin(&self) -> Result<SyncAction, SoftchatError> {
4467 lock(&self.engine).begin()
4468 }
4469
4470 pub fn accept_snapshot(
4476 &self,
4477 items: Vec<NegentropyItem>,
4478 frame_size_limit: u64,
4479 ) -> Result<SyncAction, SoftchatError> {
4480 lock(&self.engine).accept_snapshot(items, frame_size_limit)
4481 }
4482
4483 pub fn reconcile(
4489 &self,
4490 subscription_id: String,
4491 message: Vec<u8>,
4492 ) -> Result<Vec<SyncAction>, SoftchatError> {
4493 lock(&self.engine).reconcile(subscription_id, message)
4494 }
4495
4496 pub fn record_fetched_event(
4502 &self,
4503 subscription_id: String,
4504 event_id: String,
4505 ) -> Result<(), SoftchatError> {
4506 lock(&self.engine).record_fetched_event(subscription_id, event_id)
4507 }
4508
4509 pub fn confirm_committed(&self, event_ids: Vec<String>) -> Result<(), SoftchatError> {
4515 lock(&self.engine).confirm_committed(event_ids)
4516 }
4517
4518 pub fn finish_fetch(&self, subscription_id: String) -> Result<Vec<SyncAction>, SoftchatError> {
4524 lock(&self.engine).finish_fetch(subscription_id)
4525 }
4526
4527 pub fn checkpoint_saved(&self, checkpoint_timestamp: i64) -> Result<SyncAction, SoftchatError> {
4533 lock(&self.engine).checkpoint_saved(checkpoint_timestamp)
4534 }
4535
4536 #[must_use]
4538 pub fn cancel(&self) -> SyncEngineSnapshot {
4539 lock(&self.engine).cancel()
4540 }
4541
4542 #[must_use]
4544 pub fn snapshot(&self) -> SyncEngineSnapshot {
4545 lock(&self.engine).snapshot()
4546 }
4547}
4548
4549fn lock<T>(mutex: &Mutex<T>) -> MutexGuard<'_, T> {
4550 match mutex.lock() {
4551 Ok(guard) => guard,
4552 Err(poisoned) => poisoned.into_inner(),
4553 }
4554}
4555
4556fn open_account_paths() -> &'static Mutex<BTreeSet<PathBuf>> {
4557 static OPEN_ACCOUNT_PATHS: OnceLock<Mutex<BTreeSet<PathBuf>>> = OnceLock::new();
4558 OPEN_ACCOUNT_PATHS.get_or_init(|| Mutex::new(BTreeSet::new()))
4559}
4560
4561fn derived_account_path(
4562 database_directory: &str,
4563 public_key: &str,
4564) -> Result<PathBuf, SoftchatError> {
4565 if database_directory.is_empty() || database_directory.len() > 4_096 {
4566 return Err(SoftchatError::InvalidPersistenceResult);
4567 }
4568 let directory = Path::new(database_directory)
4569 .canonicalize()
4570 .map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4571 if !directory.is_dir() {
4572 return Err(SoftchatError::InvalidPersistenceResult);
4573 }
4574 Ok(directory.join(account_database_filename(public_key)?))
4575}
4576
4577fn account_database_filename(public_key: &str) -> Result<String, SoftchatError> {
4578 let public_key_bytes =
4579 hex::decode(public_key).map_err(|_| SoftchatError::InvalidPersistenceResult)?;
4580 let digest = hex::encode(Sha256::digest(public_key_bytes));
4581 Ok(format!("softchat-account-{digest}.sqlite3"))
4582}
4583
4584pub(crate) fn resolve_sync_actions(
4585 database: &mut AccountDatabase,
4586 engine: &mut SyncEngine,
4587 actions: Vec<SyncAction>,
4588 frame_size_limit: u64,
4589) -> Result<Vec<SyncAction>, SoftchatError> {
4590 actions
4591 .into_iter()
4592 .map(|action| resolve_sync_action(database, engine, action, frame_size_limit))
4593 .collect()
4594}
4595
4596pub(crate) fn resolve_sync_action(
4597 database: &mut AccountDatabase,
4598 engine: &mut SyncEngine,
4599 action: SyncAction,
4600 frame_size_limit: u64,
4601) -> Result<SyncAction, SoftchatError> {
4602 match action.kind {
4603 SyncActionKind::LoadSnapshot => {
4604 let since = (action.since_timestamp >= 0).then_some(action.since_timestamp);
4605 let items = database.sync_fingerprints(since, action.until_timestamp, action.limit)?;
4606 engine.accept_snapshot(items, frame_size_limit)
4607 }
4608 SyncActionKind::SaveCheckpoint => {
4609 database.save_sync_checkpoint(
4610 &engine.snapshot().sync_id,
4611 action.checkpoint_timestamp,
4612 action.checkpoint_timestamp,
4613 )?;
4614 engine.checkpoint_saved(action.checkpoint_timestamp)
4615 }
4616 _ => Ok(action),
4617 }
4618}
4619
4620pub(crate) fn plan_sync_transport(
4621 mut action: SyncAction,
4622 recipient_public_key: &str,
4623) -> Result<SyncAction, SoftchatError> {
4624 NostrPublicKey::from_hex(recipient_public_key).map_err(|_| SoftchatError::InvalidSyncState)?;
4625 match action.kind {
4626 SyncActionKind::SendNegOpen => {
4627 if action.subscription_id.is_empty()
4628 || action.until_timestamp < 0
4629 || action.message.is_empty()
4630 {
4631 return Err(SoftchatError::InvalidSyncState);
4632 }
4633 let mut filter = serde_json::Map::new();
4634 filter.insert(
4635 "kinds".to_owned(),
4636 serde_json::json!([
4637 NostrEventKind::GIFT_WRAP.as_u16(),
4638 NostrEventKind::EPHEMERAL_GIFT_WRAP.as_u16()
4639 ]),
4640 );
4641 filter.insert("#p".to_owned(), serde_json::json!([recipient_public_key]));
4642 if action.since_timestamp >= 0 {
4643 filter.insert(
4644 "since".to_owned(),
4645 serde_json::json!(action.since_timestamp),
4646 );
4647 }
4648 filter.insert(
4649 "until".to_owned(),
4650 serde_json::json!(action.until_timestamp),
4651 );
4652 action.frame_json = serde_json::to_string(&serde_json::json!([
4653 "NEG-OPEN",
4654 action.subscription_id,
4655 serde_json::Value::Object(filter),
4656 hex::encode(&action.message)
4657 ]))
4658 .map_err(|_| SoftchatError::InternalFailure)?;
4659 }
4660 SyncActionKind::SendNegMessage => {
4661 if action.subscription_id.is_empty() || action.message.is_empty() {
4662 return Err(SoftchatError::InvalidSyncState);
4663 }
4664 action.frame_json = serde_json::to_string(&serde_json::json!([
4665 "NEG-MSG",
4666 action.subscription_id,
4667 hex::encode(&action.message)
4668 ]))
4669 .map_err(|_| SoftchatError::InternalFailure)?;
4670 }
4671 SyncActionKind::SendNegClose => {
4672 if action.subscription_id.is_empty() {
4673 return Err(SoftchatError::InvalidSyncState);
4674 }
4675 action.frame_json =
4676 serde_json::to_string(&serde_json::json!(["NEG-CLOSE", action.subscription_id]))
4677 .map_err(|_| SoftchatError::InternalFailure)?;
4678 }
4679 SyncActionKind::RequestEvents => {
4680 if action.subscription_id.is_empty() || action.event_ids.is_empty() {
4681 return Err(SoftchatError::InvalidSyncState);
4682 }
4683 action.filters_json.push(
4684 serde_json::to_string(&serde_json::json!({ "ids": action.event_ids }))
4685 .map_err(|_| SoftchatError::InternalFailure)?,
4686 );
4687 }
4688 SyncActionKind::LoadSnapshot
4689 | SyncActionKind::SaveCheckpoint
4690 | SyncActionKind::ResendEvents
4691 | SyncActionKind::Complete => {}
4692 }
4693 Ok(action)
4694}
4695
4696pub(crate) fn inbox_filter_json(
4697 recipient_public_key: &str,
4698 since_timestamp: Option<i64>,
4699 until_timestamp: Option<i64>,
4700) -> Result<String, SoftchatError> {
4701 NostrPublicKey::from_hex(recipient_public_key)
4702 .map_err(|_| SoftchatError::InvalidRelayFilter)?;
4703 let since = since_timestamp
4704 .map(|value| u64::try_from(value).map_err(|_| SoftchatError::InvalidEventTimestamp))
4705 .transpose()?;
4706 let until = until_timestamp
4707 .map(|value| u64::try_from(value).map_err(|_| SoftchatError::InvalidEventTimestamp))
4708 .transpose()?;
4709 if since
4710 .into_iter()
4711 .chain(until)
4712 .any(|value| value > crate::MAX_PORTABLE_TIMESTAMP_SECONDS)
4713 {
4714 return Err(SoftchatError::InvalidEventTimestamp);
4715 }
4716 let mut generic_tags = BTreeMap::new();
4717 generic_tags.insert("#p".to_owned(), vec![recipient_public_key.to_owned()]);
4718 RelayFilter {
4719 kinds: vec![
4720 NostrEventKind::GIFT_WRAP.as_u16(),
4721 NostrEventKind::EPHEMERAL_GIFT_WRAP.as_u16(),
4722 ],
4723 since,
4724 until,
4725 generic_tags,
4726 ..RelayFilter::default()
4727 }
4728 .to_json()
4729}
4730
4731#[derive(Clone, Debug, Eq, PartialEq)]
4732pub(crate) enum SyncTransportFrame {
4733 Message {
4734 subscription_id: String,
4735 message: Vec<u8>,
4736 },
4737 Error {
4738 subscription_id: String,
4739 max_records: Option<u64>,
4740 },
4741 Control {
4742 subscription_id: String,
4743 },
4744}
4745
4746pub(crate) fn parse_sync_message_frame(
4747 frame_json: &str,
4748) -> Result<Option<SyncTransportFrame>, SoftchatError> {
4749 if frame_json.len() > crate::MAX_RELAY_FRAME_BYTES {
4750 return Err(SoftchatError::InvalidSyncState);
4751 }
4752 let Ok(serde_json::Value::Array(values)) =
4753 serde_json::from_str::<serde_json::Value>(frame_json)
4754 else {
4755 return Ok(None);
4756 };
4757 let Some(kind) = values.first().and_then(serde_json::Value::as_str) else {
4758 return Ok(None);
4759 };
4760 match kind {
4761 "NEG-MSG" => {
4762 if values.len() != 3 {
4763 return Err(SoftchatError::InvalidSyncState);
4764 }
4765 let subscription_id = values
4766 .get(1)
4767 .and_then(serde_json::Value::as_str)
4768 .ok_or(SoftchatError::InvalidSyncState)?
4769 .to_owned();
4770 let message_hex = values
4771 .get(2)
4772 .and_then(serde_json::Value::as_str)
4773 .ok_or(SoftchatError::InvalidSyncState)?;
4774 if message_hex.is_empty() || message_hex.len() > crate::MAX_RELAY_FRAME_BYTES {
4775 return Err(SoftchatError::InvalidSyncState);
4776 }
4777 let message = hex::decode(message_hex).map_err(|_| SoftchatError::InvalidSyncState)?;
4778 Ok(Some(SyncTransportFrame::Message {
4779 subscription_id,
4780 message,
4781 }))
4782 }
4783 "NEG-ERR" => {
4784 if !matches!(values.len(), 3 | 4) {
4785 return Err(SoftchatError::InvalidSyncState);
4786 }
4787 let subscription_id = values
4788 .get(1)
4789 .and_then(serde_json::Value::as_str)
4790 .filter(|value| !value.is_empty() && value.len() <= crate::MAX_RELAY_FRAME_BYTES)
4791 .ok_or(SoftchatError::InvalidSyncState)?
4792 .to_owned();
4793 values
4794 .get(2)
4795 .and_then(serde_json::Value::as_str)
4796 .filter(|value| value.len() <= crate::MAX_RELAY_FRAME_BYTES)
4797 .ok_or(SoftchatError::InvalidSyncState)?;
4798 let max_records = values
4799 .get(3)
4800 .map(|value| value.as_u64().ok_or(SoftchatError::InvalidSyncState))
4801 .transpose()?;
4802 Ok(Some(SyncTransportFrame::Error {
4803 subscription_id,
4804 max_records,
4805 }))
4806 }
4807 "NEG-OPEN" | "NEG-CLOSE" => {
4808 let expected_len = if kind == "NEG-OPEN" { 4 } else { 2 };
4809 if values.len() != expected_len {
4810 return Err(SoftchatError::InvalidSyncState);
4811 }
4812 let subscription_id = values
4813 .get(1)
4814 .and_then(serde_json::Value::as_str)
4815 .filter(|value| !value.is_empty() && value.len() <= crate::MAX_RELAY_FRAME_BYTES)
4816 .ok_or(SoftchatError::InvalidSyncState)?
4817 .to_owned();
4818 Ok(Some(SyncTransportFrame::Control { subscription_id }))
4819 }
4820 _ => Ok(None),
4821 }
4822}
4823
4824#[cfg(test)]
4825mod log_tests {
4826 use super::*;
4827
4828 #[test]
4829 fn ingestion_json_preflight_checks_count_bytes_and_overflow_before_parsing() {
4830 assert!(matches!(
4831 preflight_ingestion_jsons(&[]),
4832 Err(SoftchatError::InvalidAccountOperation)
4833 ));
4834 assert!(matches!(
4835 preflight_ingestion_jsons(&vec![
4836 "not-json".to_owned();
4837 crate::MAX_ACCOUNT_INGESTION_EVENTS + 1
4838 ]),
4839 Err(SoftchatError::InvalidAccountOperation)
4840 ));
4841 assert_eq!(
4842 bounded_ingestion_json_bytes([crate::MAX_ACCOUNT_INGESTION_BYTES]),
4843 Ok(crate::MAX_ACCOUNT_INGESTION_BYTES as u64)
4844 );
4845 assert!(matches!(
4846 bounded_ingestion_json_bytes([crate::MAX_ACCOUNT_INGESTION_BYTES, 1]),
4847 Err(SoftchatError::InvalidAccountOperation)
4848 ));
4849 assert!(matches!(
4850 bounded_ingestion_json_bytes([crate::MAX_ACCOUNT_INGESTION_BYTES, usize::MAX]),
4851 Err(SoftchatError::InvalidAccountOperation)
4852 ));
4853 }
4854
4855 #[test]
4856 fn diagnostic_ring_is_bounded_ordered_redacted_and_thresholded() -> Result<(), SoftchatError> {
4857 let logs = AccountLogBuffer::new(AccountLogLevel::Warn);
4858 logs.record(
4859 AccountLogLevel::Info,
4860 AccountLogCategory::Messaging,
4861 "message.send",
4862 None,
4863 None,
4864 AccountLogMetadata::EMPTY,
4865 );
4866 for index in 0..260 {
4867 logs.record(
4868 AccountLogLevel::Error,
4869 AccountLogCategory::Storage,
4870 "storage.ingest",
4871 None,
4872 Some(SoftchatError::InvalidEventJson),
4873 AccountLogMetadata {
4874 revision: Some(i64::from(index)),
4875 item_count: Some(1),
4876 byte_count: Some(128),
4877 },
4878 );
4879 }
4880
4881 let first = logs.drain(MAX_ACCOUNT_LOG_BATCH)?;
4882 assert_eq!(first.dropped_count, 4);
4883 assert_eq!(first.events.len(), MAX_ACCOUNT_LOG_BATCH as usize);
4884 assert_eq!(first.events.first().map(|event| event.sequence), Some(5));
4885 assert_eq!(first.events.last().map(|event| event.sequence), Some(68));
4886 assert!(first.events.iter().all(|event| {
4887 event.operation == "storage.ingest"
4888 && event.error_code == "invalid_event_json"
4889 && event.item_count == Some(1)
4890 && event.byte_count == Some(128)
4891 }));
4892
4893 let second = logs.drain(1)?;
4894 assert_eq!(second.dropped_count, 0);
4895 assert_eq!(second.events[0].sequence, 69);
4896 assert_eq!(logs.drain(0), Err(SoftchatError::InvalidAccountOperation));
4897 assert_eq!(
4898 logs.drain(MAX_ACCOUNT_LOG_BATCH + 1),
4899 Err(SoftchatError::InvalidAccountOperation)
4900 );
4901 Ok(())
4902 }
4903
4904 #[test]
4905 fn consecutive_equivalent_diagnostics_are_counted_once() -> Result<(), SoftchatError> {
4906 let logs = AccountLogBuffer::new(AccountLogLevel::Info);
4907 for _ in 0..3 {
4908 logs.record(
4909 AccountLogLevel::Info,
4910 AccountLogCategory::Profile,
4911 "profile.follow",
4912 None,
4913 None,
4914 AccountLogMetadata {
4915 revision: Some(7),
4916 item_count: Some(1),
4917 byte_count: None,
4918 },
4919 );
4920 }
4921
4922 let batch = logs.drain(1)?;
4923 assert_eq!(batch.events.len(), 1);
4924 assert_eq!(batch.events[0].sequence, 3);
4925 assert_eq!(batch.events[0].occurrence_count, 3);
4926 assert_eq!(batch.events[0].operation, "profile.follow");
4927 Ok(())
4928 }
4929
4930 #[test]
4931 fn disabled_diagnostics_do_not_retain_events() -> Result<(), SoftchatError> {
4932 let logs = AccountLogBuffer::new(AccountLogLevel::Off);
4933 logs.record(
4934 AccountLogLevel::Error,
4935 AccountLogCategory::Lifecycle,
4936 "account.open",
4937 None,
4938 Some(SoftchatError::InternalFailure),
4939 AccountLogMetadata::EMPTY,
4940 );
4941 assert_eq!(
4942 logs.drain(1)?,
4943 AccountLogBatch {
4944 events: Vec::new(),
4945 dropped_count: 0,
4946 }
4947 );
4948 Ok(())
4949 }
4950}
4951
4952#[cfg(test)]
4953mod transport_tests {
4954 use super::*;
4955
4956 const RECIPIENT: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
4957
4958 fn action(kind: SyncActionKind) -> SyncAction {
4959 SyncAction {
4960 kind,
4961 subscription_id: "sync-1".to_owned(),
4962 since_timestamp: 10,
4963 until_timestamp: 20,
4964 limit: 500,
4965 message: vec![1, 2, 3],
4966 frame_json: String::new(),
4967 filters_json: Vec::new(),
4968 event_ids: Vec::new(),
4969 checkpoint_timestamp: -1,
4970 }
4971 }
4972
4973 #[test]
4974 fn sync_transport_plans_complete_frames_and_filters() -> Result<(), Box<dyn std::error::Error>>
4975 {
4976 let open = plan_sync_transport(action(SyncActionKind::SendNegOpen), RECIPIENT)?;
4977 let open_json = serde_json::from_str::<serde_json::Value>(&open.frame_json)?;
4978 assert_eq!(open_json[0], "NEG-OPEN");
4979 assert_eq!(open_json[2]["kinds"], serde_json::json!([1059, 21059]));
4980 assert_eq!(open_json[2]["#p"], serde_json::json!([RECIPIENT]));
4981 assert_eq!(open_json[2]["since"], 10);
4982 assert_eq!(open_json[2]["until"], 20);
4983
4984 let mut request = action(SyncActionKind::RequestEvents);
4985 request.message.clear();
4986 request.event_ids = vec![hex::encode([7_u8; 32])];
4987 let request = plan_sync_transport(request, RECIPIENT)?;
4988 assert_eq!(request.filters_json.len(), 1);
4989 let filter_json = serde_json::from_str::<serde_json::Value>(&request.filters_json[0])?;
4990 assert_eq!(filter_json["ids"][0], hex::encode([7_u8; 32]));
4991 Ok(())
4992 }
4993
4994 #[test]
4995 fn inbox_filter_is_canonical_bounded_and_account_addressed()
4996 -> Result<(), Box<dyn std::error::Error>> {
4997 let filter = inbox_filter_json(RECIPIENT, Some(10), Some(20))?;
4998 assert_eq!(
4999 filter,
5000 format!(r##"{{"kinds":[1059,21059],"since":10,"until":20,"#p":["{RECIPIENT}"]}}"##)
5001 );
5002 assert!(inbox_filter_json(RECIPIENT, Some(-1), None).is_err());
5003 let maximum_timestamp = i64::try_from(crate::MAX_PORTABLE_TIMESTAMP_SECONDS)?;
5004 assert!(inbox_filter_json(RECIPIENT, Some(maximum_timestamp), None).is_ok());
5005 Ok(())
5006 }
5007
5008 #[test]
5009 fn sync_frame_router_accepts_only_bounded_correlated_shapes()
5010 -> Result<(), Box<dyn std::error::Error>> {
5011 assert_eq!(
5012 parse_sync_message_frame(r#"["NEG-MSG","sync-1","010203"]"#)?,
5013 Some(SyncTransportFrame::Message {
5014 subscription_id: "sync-1".to_owned(),
5015 message: vec![1, 2, 3],
5016 })
5017 );
5018 assert!(parse_sync_message_frame(r#"["EVENT","sync-1",{}]"#)?.is_none());
5019 assert!(parse_sync_message_frame(r#"["NEG-MSG","sync-1","zz"]"#).is_err());
5020 assert_eq!(
5021 parse_sync_message_frame(r#"["NEG-ERR","sync-1","failure",500]"#)?,
5022 Some(SyncTransportFrame::Error {
5023 subscription_id: "sync-1".to_owned(),
5024 max_records: Some(500),
5025 })
5026 );
5027 assert_eq!(
5028 parse_sync_message_frame(r#"["NEG-CLOSE","sync-1"]"#)?,
5029 Some(SyncTransportFrame::Control {
5030 subscription_id: "sync-1".to_owned(),
5031 })
5032 );
5033 Ok(())
5034 }
5035}
5036
5037#[cfg(test)]
5038mod product_tests {
5039 use super::*;
5040 use crate::{
5041 AccountTransportAction, AccountTransportActionKind, AccountTransportResultKind,
5042 RelayConnectionState, RelayResponseFrame, SyncPhase,
5043 };
5044
5045 const ALICE_SECRET: &str = "5c0c523f52a5b6fad39ed2403092df8cebc36318b39383bca6c00808626fab3a";
5046 const BOB_SECRET: &str = "4b22aa260e4acb7021e32f38a6cdf4b673c6a277755bfce287e370c924dc936d";
5047
5048 fn secret(value: &str) -> Result<Vec<u8>, SoftchatError> {
5049 hex::decode(value).map_err(|_| SoftchatError::InvalidSecretKey)
5050 }
5051
5052 fn temporary_directory(label: &str) -> Result<PathBuf, Box<dyn std::error::Error>> {
5053 let nonce = std::time::SystemTime::now()
5054 .duration_since(std::time::UNIX_EPOCH)?
5055 .as_nanos();
5056 let path =
5057 std::env::temp_dir().join(format!("softchat-{label}-{}-{nonce}", std::process::id()));
5058 std::fs::create_dir_all(&path)?;
5059 Ok(path)
5060 }
5061
5062 fn transport_result(
5063 action: &AccountTransportAction,
5064 kind: AccountTransportResultKind,
5065 text_frame: String,
5066 ) -> AccountTransportResult {
5067 AccountTransportResult {
5068 run_id: action.run_id.clone(),
5069 action_id: action.action_id.clone(),
5070 generation: action.generation,
5071 kind,
5072 text_frame,
5073 }
5074 }
5075
5076 fn transport_action(
5077 batch: &AccountTransportBatch,
5078 kind: AccountTransportActionKind,
5079 ) -> Result<AccountTransportAction, SoftchatError> {
5080 batch
5081 .actions
5082 .iter()
5083 .find(|action| action.kind == kind)
5084 .cloned()
5085 .ok_or(SoftchatError::UnsupportedSystemAction)
5086 }
5087
5088 fn neg_subscription_id(
5089 batch: &AccountTransportBatch,
5090 ) -> Result<String, Box<dyn std::error::Error>> {
5091 for action in &batch.actions {
5092 if action.kind != AccountTransportActionKind::SendText {
5093 continue;
5094 }
5095 let value = serde_json::from_str::<serde_json::Value>(&action.text_frame)?;
5096 if value.get(0).and_then(serde_json::Value::as_str) == Some("NEG-OPEN") {
5097 return value
5098 .get(1)
5099 .and_then(serde_json::Value::as_str)
5100 .map(ToOwned::to_owned)
5101 .ok_or_else(|| SoftchatError::InvalidSyncState.into());
5102 }
5103 }
5104 Err(SoftchatError::InvalidSyncState.into())
5105 }
5106
5107 fn delivery_send_actions(
5108 batch: &AccountTransportBatch,
5109 ) -> Result<Vec<(AccountTransportAction, Vec<String>)>, SoftchatError> {
5110 let mut deliveries = Vec::new();
5111 for action in &batch.actions {
5112 if action.kind != AccountTransportActionKind::SendText {
5113 continue;
5114 }
5115 let event_ids = match crate::parse_client_relay_frame(&action.text_frame) {
5116 Ok(crate::ClientRelayFrame::Event(event)) => vec![event.id().to_hex()],
5117 Ok(crate::ClientRelayFrame::Events(events)) => {
5118 events.iter().map(|event| event.id().to_hex()).collect()
5119 }
5120 Ok(_) | Err(_) => Vec::new(),
5121 };
5122 if !event_ids.is_empty() {
5123 deliveries.push((action.clone(), event_ids));
5124 }
5125 }
5126 Ok(deliveries)
5127 }
5128
5129 fn content(text: &str) -> MessageContentInput {
5130 MessageContentInput {
5131 text: text.to_owned(),
5132 attachments: Vec::new(),
5133 emoji_tags: Vec::new(),
5134 }
5135 }
5136
5137 #[test]
5138 fn draftless_command_hashes_preserve_the_released_shapes()
5139 -> Result<(), Box<dyn std::error::Error>> {
5140 let conversation_id = "ab".repeat(32);
5141 let message = content("legacy command");
5142 let reply_to_message_id = String::new();
5143 let sources = vec![crate::MediaPreparationInput {
5144 source_fingerprint: "legacy-source".to_owned(),
5145 }];
5146
5147 let legacy_send =
5148 command_hash(&("send", &conversation_id, &message, &reply_to_message_id))?;
5149 assert_eq!(
5150 send_message_command_hash(&conversation_id, &message, &reply_to_message_id, None,)?,
5151 legacy_send,
5152 );
5153
5154 let legacy_media = command_hash(&(
5155 "media-message",
5156 &conversation_id,
5157 &message,
5158 &reply_to_message_id,
5159 &sources,
5160 ))?;
5161 assert_eq!(
5162 media_message_command_hash(
5163 &conversation_id,
5164 &message,
5165 &reply_to_message_id,
5166 &sources,
5167 None,
5168 )?,
5169 legacy_media,
5170 );
5171
5172 let draft_match = product_draft_match(AccountDraft {
5173 text: "legacy command".to_owned(),
5174 emoji_tags: Vec::new(),
5175 spans: Vec::new(),
5176 reply_to_message_id: String::new(),
5177 attachment_operation_ids: Vec::new(),
5178 updated_at: 1_700_000_000,
5179 })?;
5180 let draft_send = send_message_command_hash(
5181 &conversation_id,
5182 &message,
5183 &reply_to_message_id,
5184 Some(&draft_match),
5185 );
5186 assert_eq!(
5187 draft_send?,
5188 command_hash(&(
5189 "send",
5190 &conversation_id,
5191 &message,
5192 &reply_to_message_id,
5193 &Some(draft_match.clone()),
5194 ))?,
5195 );
5196 assert_ne!(
5197 send_message_command_hash(
5198 &conversation_id,
5199 &message,
5200 &reply_to_message_id,
5201 Some(&draft_match),
5202 )?,
5203 legacy_send,
5204 );
5205 assert_eq!(
5206 media_message_command_hash(
5207 &conversation_id,
5208 &message,
5209 &reply_to_message_id,
5210 &sources,
5211 Some(&draft_match),
5212 )?,
5213 command_hash(&(
5214 "media-message",
5215 &conversation_id,
5216 &message,
5217 &reply_to_message_id,
5218 &sources,
5219 &Some(draft_match.clone()),
5220 ))?,
5221 );
5222 assert_ne!(
5223 media_message_command_hash(
5224 &conversation_id,
5225 &message,
5226 &reply_to_message_id,
5227 &sources,
5228 Some(&draft_match),
5229 )?,
5230 legacy_media,
5231 );
5232 Ok(())
5233 }
5234
5235 #[test]
5236 fn delete_message_rejects_an_oversized_id_before_command_work()
5237 -> Result<(), Box<dyn std::error::Error>> {
5238 let directory = temporary_directory("delete-message-bound")?;
5239 let account =
5240 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5241
5242 assert!(matches!(
5243 account.delete_message(
5244 "delete-message-oversized-id".to_owned(),
5245 "a".repeat(1_000_000),
5246 1_700_000_000,
5247 ),
5248 Err(SoftchatError::InvalidAccountOperation)
5249 ));
5250
5251 drop(account);
5252 std::fs::remove_dir_all(directory)?;
5253 Ok(())
5254 }
5255
5256 #[test]
5257 fn product_message_preserves_a_sticker_name_with_spaces()
5258 -> Result<(), Box<dyn std::error::Error>> {
5259 let directory = temporary_directory("sticker-message")?;
5260 let account =
5261 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5262 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5263 let conversation =
5264 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5265 let emoji_tags = vec![vec![
5266 "emoji".to_owned(),
5267 "Device sticker".to_owned(),
5268 "https://cdn.example/device-sticker.webp".to_owned(),
5269 ]];
5270
5271 let sent = account.send_message(
5272 "sticker-message".to_owned(),
5273 conversation.id,
5274 MessageContentInput {
5275 text: ":Device sticker:".to_owned(),
5276 attachments: Vec::new(),
5277 emoji_tags: emoji_tags.clone(),
5278 },
5279 String::new(),
5280 None,
5281 1_700_000_001,
5282 )?;
5283
5284 let sender_copy = sent
5285 .message
5286 .ok_or_else(|| std::io::Error::other("missing stored sender copy"))?;
5287 assert_eq!(sender_copy.emoji_tags, emoji_tags);
5288 drop(account);
5289 std::fs::remove_dir_all(directory)?;
5290 Ok(())
5291 }
5292
5293 #[test]
5294 fn signed_outbox_recovery_is_bounded_verified_and_idempotent()
5295 -> Result<(), Box<dyn std::error::Error>> {
5296 let directory = temporary_directory("signed-outbox-recovery")?;
5297 let account =
5298 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5299 let identity = crate::LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5300 let event = identity.sign_event(crate::NostrEventDraft::new(
5301 1_700_000_000,
5302 NostrEventKind::SHORT_TEXT_NOTE,
5303 Vec::new(),
5304 "recover me",
5305 )?)?;
5306 let event_value = serde_json::from_str::<serde_json::Value>(&event.to_json()?)?;
5307 let batch = serde_json::to_string(&vec![event_value.clone()])?;
5308
5309 let inserted = account.recover_signed_outbox_batch(
5310 "legacy-batch-1".to_owned(),
5311 batch.clone(),
5312 1_700_000_001,
5313 )?;
5314 assert!(inserted.inserted);
5315 let repeated = account.recover_signed_outbox_batch(
5316 "legacy-batch-1".to_owned(),
5317 batch,
5318 1_700_000_001,
5319 )?;
5320 assert!(!repeated.inserted);
5321 assert_eq!(inserted.operation, repeated.operation);
5322
5323 let mut forged = event_value;
5324 forged["content"] = serde_json::Value::String("forged".to_owned());
5325 assert!(matches!(
5326 account.recover_signed_outbox_batch(
5327 "legacy-batch-forged".to_owned(),
5328 serde_json::to_string(&vec![forged])?,
5329 1_700_000_001,
5330 ),
5331 Err(SoftchatError::InvalidEventId)
5332 ));
5333 let second = identity.sign_event(crate::NostrEventDraft::new(
5334 1_700_000_001,
5335 NostrEventKind::SHORT_TEXT_NOTE,
5336 Vec::new(),
5337 "different recovery",
5338 )?)?;
5339 assert!(matches!(
5340 account.recover_signed_outbox_batch(
5341 "legacy-batch-1".to_owned(),
5342 serde_json::to_string(&vec![serde_json::from_str::<serde_json::Value>(
5343 &second.to_json()?
5344 )?])?,
5345 1_700_000_002,
5346 ),
5347 Err(SoftchatError::CommandConflict)
5348 ));
5349 assert!(matches!(
5350 account.recover_signed_outbox_batch(
5351 "legacy-batch-oversized".to_owned(),
5352 " ".repeat(crate::MAX_NOSTR_EVENT_JSON_BYTES + 1),
5353 1_700_000_001,
5354 ),
5355 Err(SoftchatError::InvalidAccountOperation)
5356 ));
5357
5358 drop(account);
5359 std::fs::remove_dir_all(directory)?;
5360 Ok(())
5361 }
5362
5363 #[test]
5364 fn account_derives_path_rejects_duplicate_open_and_erases_on_close()
5365 -> Result<(), Box<dyn std::error::Error>> {
5366 let directory = temporary_directory("account-open")?;
5367 let first =
5368 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5369 assert_eq!(first.account_id(), first.public_key()?);
5370 assert!(first.database_filename().starts_with("softchat-account-"));
5371 assert!(first.database_filename().ends_with(".sqlite3"));
5372 assert!(matches!(
5373 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string()),
5374 Err(SoftchatError::AccountAlreadyOpen)
5375 ));
5376 first.close_account();
5377 assert!(matches!(
5378 first.public_key(),
5379 Err(SoftchatError::AccountClosed)
5380 ));
5381 drop(first);
5382 let reopened =
5383 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5384 drop(reopened);
5385 std::fs::remove_dir_all(directory)?;
5386 Ok(())
5387 }
5388
5389 #[test]
5390 fn send_is_payload_idempotent_and_returns_the_original_logical_message()
5391 -> Result<(), Box<dyn std::error::Error>> {
5392 let directory = temporary_directory("command-idempotency")?;
5393 let account =
5394 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5395 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5396 let conversation =
5397 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5398
5399 let first = account.send_message(
5400 "command-send-1".to_owned(),
5401 conversation.id.clone(),
5402 content("one durable message"),
5403 String::new(),
5404 None,
5405 1_700_000_001,
5406 )?;
5407 let retried = account.send_message(
5408 "command-send-1".to_owned(),
5409 conversation.id.clone(),
5410 content("one durable message"),
5411 String::new(),
5412 None,
5413 1_700_000_999,
5414 )?;
5415 assert!(first.inserted);
5416 assert!(!retried.inserted);
5417 assert_eq!(first.operation, retried.operation);
5418 assert_eq!(first.message, retried.message);
5419 assert_eq!(
5420 first.message.as_ref().map(|message| message.text.as_str()),
5421 Some("one durable message")
5422 );
5423 assert!(matches!(
5424 account.send_message(
5425 "command-send-1".to_owned(),
5426 conversation.id,
5427 content("different payload"),
5428 String::new(),
5429 None,
5430 1_700_001_000,
5431 ),
5432 Err(SoftchatError::CommandConflict)
5433 ));
5434 drop(account);
5435 std::fs::remove_dir_all(directory)?;
5436 Ok(())
5437 }
5438
5439 #[test]
5440 fn sending_can_consume_only_the_draft_owned_by_the_new_command()
5441 -> Result<(), Box<dyn std::error::Error>> {
5442 let directory = temporary_directory("send-consumes-draft")?;
5443 let account =
5444 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5445 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5446 let conversation =
5447 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5448 let first_draft = AccountDraft {
5449 text: "send this".to_owned(),
5450 emoji_tags: Vec::new(),
5451 spans: Vec::new(),
5452 reply_to_message_id: String::new(),
5453 attachment_operation_ids: Vec::new(),
5454 updated_at: 1_700_000_001,
5455 };
5456 account.save_conversation_draft(
5457 conversation.id.clone(),
5458 Some(first_draft.clone()),
5459 1_700_000_001,
5460 )?;
5461
5462 let sent = account.send_message(
5463 "send-consumes-draft-command".to_owned(),
5464 conversation.id.clone(),
5465 content("send this"),
5466 String::new(),
5467 Some(first_draft.clone()),
5468 1_700_000_002,
5469 )?;
5470 assert!(sent.inserted);
5471 assert!(
5472 account
5473 .conversation(conversation.id.clone())?
5474 .ok_or(SoftchatError::InvalidPersistenceResult)?
5475 .draft
5476 .is_none()
5477 );
5478
5479 let newer_draft = AccountDraft {
5480 text: "typed after send".to_owned(),
5481 emoji_tags: Vec::new(),
5482 spans: Vec::new(),
5483 reply_to_message_id: String::new(),
5484 attachment_operation_ids: Vec::new(),
5485 updated_at: 1_700_000_003,
5486 };
5487 account.save_conversation_draft(
5488 conversation.id.clone(),
5489 Some(newer_draft.clone()),
5490 1_700_000_003,
5491 )?;
5492 let raced = account.send_message(
5493 "send-preserves-raced-draft-command".to_owned(),
5494 conversation.id.clone(),
5495 content("send this"),
5496 String::new(),
5497 Some(first_draft.clone()),
5498 1_700_000_004,
5499 )?;
5500 assert!(raced.inserted);
5501 assert_eq!(
5502 account
5503 .conversation(conversation.id.clone())?
5504 .ok_or(SoftchatError::InvalidPersistenceResult)?
5505 .draft,
5506 Some(newer_draft.clone())
5507 );
5508 let replay = account.send_message(
5509 "send-consumes-draft-command".to_owned(),
5510 conversation.id.clone(),
5511 content("send this"),
5512 String::new(),
5513 Some(first_draft),
5514 1_700_000_005,
5515 )?;
5516 assert!(!replay.inserted);
5517 assert_eq!(
5518 account
5519 .conversation(conversation.id.clone())?
5520 .ok_or(SoftchatError::InvalidPersistenceResult)?
5521 .draft,
5522 Some(newer_draft)
5523 );
5524 assert!(matches!(
5525 account.send_message(
5526 "send-consumes-draft-command".to_owned(),
5527 conversation.id,
5528 content("send this"),
5529 String::new(),
5530 None,
5531 1_700_000_006,
5532 ),
5533 Err(SoftchatError::CommandConflict)
5534 ));
5535
5536 drop(account);
5537 std::fs::remove_dir_all(directory)?;
5538 Ok(())
5539 }
5540
5541 #[test]
5542 fn media_preparation_clears_only_the_matching_draft_snapshot()
5543 -> Result<(), Box<dyn std::error::Error>> {
5544 let directory = temporary_directory("media-message-draft-race")?;
5545 let account =
5546 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5547 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5548 let conversation =
5549 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5550 let expected = AccountDraft {
5551 text: "attachment".to_owned(),
5552 emoji_tags: Vec::new(),
5553 spans: Vec::new(),
5554 reply_to_message_id: String::new(),
5555 attachment_operation_ids: Vec::new(),
5556 updated_at: 1_700_000_001,
5557 };
5558 let newer = AccountDraft {
5559 text: "typed while staging".to_owned(),
5560 updated_at: 1_700_000_002,
5561 ..expected.clone()
5562 };
5563 account.save_conversation_draft(
5564 conversation.id.clone(),
5565 Some(newer.clone()),
5566 1_700_000_002,
5567 )?;
5568 let prepared = account.prepare_media_message(
5569 "media-message-draft-race-command".to_owned(),
5570 conversation.id.clone(),
5571 content("attachment"),
5572 String::new(),
5573 vec![crate::MediaPreparationInput {
5574 source_fingerprint: "media-message-draft-race-source".to_owned(),
5575 }],
5576 Some(expected),
5577 1_700_000_003,
5578 )?;
5579 assert_eq!(
5580 prepared.state,
5581 crate::PendingMediaMessageState::WaitingForUploads
5582 );
5583 assert_eq!(
5584 account
5585 .conversation(conversation.id)?
5586 .ok_or(SoftchatError::InvalidPersistenceResult)?
5587 .draft,
5588 Some(newer)
5589 );
5590
5591 drop(account);
5592 std::fs::remove_dir_all(directory)?;
5593 Ok(())
5594 }
5595
5596 #[test]
5597 fn delete_many_is_atomic_bounded_and_retry_idempotent() -> Result<(), Box<dyn std::error::Error>>
5598 {
5599 let directory = temporary_directory("delete-many")?;
5600 let account =
5601 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5602 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5603 let conversation =
5604 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5605 let first = account
5606 .send_message(
5607 "delete-many-first".to_owned(),
5608 conversation.id.clone(),
5609 content("first"),
5610 String::new(),
5611 None,
5612 1_700_000_001,
5613 )?
5614 .message
5615 .ok_or(SoftchatError::InvalidPersistenceResult)?;
5616 let second = account
5617 .send_message(
5618 "delete-many-second".to_owned(),
5619 conversation.id.clone(),
5620 content("second"),
5621 String::new(),
5622 None,
5623 1_700_000_002,
5624 )?
5625 .message
5626 .ok_or(SoftchatError::InvalidPersistenceResult)?;
5627 let targets = vec![first.id.clone(), second.id.clone()];
5628
5629 assert!(matches!(
5630 account.delete_messages(
5631 "delete-many-oversized-id".to_owned(),
5632 vec!["a".repeat(1024 * 1024)],
5633 1_700_000_003,
5634 ),
5635 Err(SoftchatError::InvalidAccountOperation)
5636 ));
5637 assert!(matches!(
5638 account.delete_messages(
5639 "delete-many-malformed-id".to_owned(),
5640 vec!["z".repeat(64)],
5641 1_700_000_003,
5642 ),
5643 Err(SoftchatError::InvalidEventId)
5644 ));
5645 assert!(matches!(
5646 account.delete_messages(
5647 "delete-many-duplicate".to_owned(),
5648 vec![first.id.clone(), first.id.clone()],
5649 1_700_000_003,
5650 ),
5651 Err(SoftchatError::InvalidAccountOperation)
5652 ));
5653 let deleted = account.delete_messages(
5654 "delete-many-command".to_owned(),
5655 targets.clone(),
5656 1_700_000_004,
5657 )?;
5658 assert!(deleted.inserted);
5659 let retried = account.delete_messages(
5660 "delete-many-command".to_owned(),
5661 targets.clone(),
5662 1_700_000_999,
5663 )?;
5664 assert!(!retried.inserted);
5665 assert_eq!(retried.operation, deleted.operation);
5666 assert!(matches!(
5667 account.delete_messages(
5668 "delete-many-command".to_owned(),
5669 targets.into_iter().rev().collect(),
5670 1_700_001_000,
5671 ),
5672 Err(SoftchatError::CommandConflict)
5673 ));
5674 let page = account.message_page(conversation.id, None, 50)?;
5675 assert!(
5676 page.messages
5677 .iter()
5678 .filter(|message| message.id == first.id || message.id == second.id)
5679 .all(|message| message.deleted)
5680 );
5681
5682 drop(account);
5683 std::fs::remove_dir_all(directory)?;
5684 Ok(())
5685 }
5686
5687 #[test]
5688 fn profile_patch_is_lossless_and_uses_the_account_owned_self_copy()
5689 -> Result<(), Box<dyn std::error::Error>> {
5690 let directory = temporary_directory("profile-patch")?;
5691 let account =
5692 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5693 account.update_profile(
5694 "profile-first".to_owned(),
5695 r#"{"name":"alice","picture":"https://cdn.example/avatar.png","website":"https://example.com"}"#.to_owned(),
5696 1_700_000_001,
5697 )?;
5698 account.update_profile(
5699 "profile-second".to_owned(),
5700 r#"{"about":"updated"}"#.to_owned(),
5701 1_700_000_002,
5702 )?;
5703 let profile = account.profile(account.public_key()?)?;
5704 assert_eq!(profile.name.as_deref(), Some("alice"));
5705 assert_eq!(profile.about.as_deref(), Some("updated"));
5706 assert_eq!(
5707 profile.picture.as_deref(),
5708 Some("https://cdn.example/avatar.png")
5709 );
5710 assert_eq!(profile.website.as_deref(), Some("https://example.com"));
5711
5712 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5713 account.replace_follows(
5714 "follow-first".to_owned(),
5715 vec![Contact {
5716 public_key: bob.public_key().to_hex(),
5717 relay_hint: String::new(),
5718 local_name: "Bob".to_owned(),
5719 }],
5720 1_700_000_003,
5721 )?;
5722 assert!(
5723 account
5724 .conversation_page(false, None, 100)?
5725 .conversations
5726 .is_empty(),
5727 "account-data p tags must not create product conversations"
5728 );
5729 account.replace_follows("follow-second".to_owned(), Vec::new(), 1_700_000_003)?;
5730 assert!(account.follows()?.is_empty());
5731 account.follow(
5732 "follow-atomic".to_owned(),
5733 Contact {
5734 public_key: bob.public_key().to_hex(),
5735 relay_hint: "wss://relay.example".to_owned(),
5736 local_name: "Robert".to_owned(),
5737 },
5738 1_700_000_003,
5739 )?;
5740 assert_eq!(account.follows()?.len(), 1);
5741 assert_eq!(account.follows()?[0].local_name, "Robert");
5742 account.unfollow(
5743 "unfollow-atomic".to_owned(),
5744 bob.public_key().to_hex(),
5745 1_700_000_003,
5746 )?;
5747 assert!(account.follows()?.is_empty());
5748
5749 account.apply_account_settings_patch(
5750 "settings-first".to_owned(),
5751 r#"{"quickReaction":"one"}"#.to_owned(),
5752 1_700_000_004,
5753 )?;
5754 let first_settings_at = account.database()?.connection.query_row(
5755 "SELECT event_created_at FROM app_data_effective WHERE context = 'app-settings'",
5756 [],
5757 |row| row.get::<_, i64>(0),
5758 )?;
5759 account.apply_account_settings_patch(
5760 "settings-second".to_owned(),
5761 r#"{"quickReaction":"two"}"#.to_owned(),
5762 1_700_000_004,
5763 )?;
5764 let second_settings_at = account.database()?.connection.query_row(
5765 "SELECT event_created_at FROM app_data_effective WHERE context = 'app-settings'",
5766 [],
5767 |row| row.get::<_, i64>(0),
5768 )?;
5769 assert!(second_settings_at > first_settings_at);
5770 assert_eq!(
5771 account.account_settings()?.quick_reaction.as_deref(),
5772 Some("two")
5773 );
5774
5775 let conversation =
5776 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_005)?;
5777 let message = account
5778 .send_message(
5779 "read-state-message".to_owned(),
5780 conversation.id.clone(),
5781 content("read state"),
5782 String::new(),
5783 None,
5784 1_700_000_006,
5785 )?
5786 .message
5787 .ok_or(SoftchatError::InvalidPersistenceResult)?;
5788 account.mark_conversation_read(
5789 "mark-read-first".to_owned(),
5790 conversation.id.clone(),
5791 MessageCursor {
5792 created_at: message.created_at,
5793 message_id: message.id,
5794 },
5795 1_700_000_007,
5796 )?;
5797 let read_at = account.account_read_state()?[0].updated_at;
5798 account.mark_conversation_unread(
5799 "mark-unread-second".to_owned(),
5800 conversation.id,
5801 1_700_000_007,
5802 )?;
5803 let unread = account.account_read_state()?.remove(0);
5804 assert!(unread.forced_unread);
5805 assert!(unread.updated_at > read_at);
5806 drop(account);
5807 std::fs::remove_dir_all(directory)?;
5808 Ok(())
5809 }
5810
5811 #[test]
5812 fn message_tuple_cursor_is_lossless_for_equal_timestamps()
5813 -> Result<(), Box<dyn std::error::Error>> {
5814 let directory = temporary_directory("message-cursor")?;
5815 let account =
5816 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5817 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5818 let conversation =
5819 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
5820 for (command, text) in [("equal-time-1", "alpha"), ("equal-time-2", "beta")] {
5821 account.send_message(
5822 command.to_owned(),
5823 conversation.id.clone(),
5824 content(text),
5825 String::new(),
5826 None,
5827 1_700_000_001,
5828 )?;
5829 }
5830 let first = account.message_page(conversation.id.clone(), None, 1)?;
5831 let second = account.message_page(conversation.id, first.next_cursor.clone(), 1)?;
5832 assert_eq!(first.messages.len(), 1);
5833 assert_eq!(second.messages.len(), 1);
5834 assert_ne!(first.messages[0].id, second.messages[0].id);
5835 assert_eq!(first.messages[0].created_at, second.messages[0].created_at);
5836 assert!(second.next_cursor.is_none());
5837 drop(account);
5838 std::fs::remove_dir_all(directory)?;
5839 Ok(())
5840 }
5841
5842 #[test]
5843 fn product_pages_batch_complete_effective_state() -> Result<(), Box<dyn std::error::Error>> {
5844 let directory = temporary_directory("batched-product-pages")?;
5845 let account =
5846 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
5847 let alice = crate::LocalIdentity::from_secret_hex(ALICE_SECRET)?;
5848 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
5849 let carol = crate::LocalIdentity::from_secret_hex(
5850 "0000000000000000000000000000000000000000000000000000000000000002",
5851 )?;
5852 let conversation = account.get_or_create_conversation(
5853 vec![bob.public_key().to_hex(), carol.public_key().to_hex()],
5854 1_700_000_000,
5855 )?;
5856 let first = account
5857 .send_message(
5858 "batch-first".to_owned(),
5859 conversation.id.clone(),
5860 content("first"),
5861 String::new(),
5862 None,
5863 1_700_000_001,
5864 )?
5865 .message
5866 .ok_or(SoftchatError::InvalidPersistenceResult)?;
5867 let second = account
5868 .send_message(
5869 "batch-second".to_owned(),
5870 conversation.id.clone(),
5871 content("second"),
5872 String::new(),
5873 None,
5874 1_700_000_002,
5875 )?
5876 .message
5877 .ok_or(SoftchatError::InvalidPersistenceResult)?;
5878 account.edit_message(
5879 "batch-edit".to_owned(),
5880 first.id.clone(),
5881 content("edited"),
5882 1_700_000_003,
5883 )?;
5884 let reaction = bob.create_reaction(ReactionDraft {
5885 participants: vec![alice.public_key(), carol.public_key()],
5886 parent_id: NostrEventId::from_hex(&first.id)?,
5887 parent_author: alice.public_key(),
5888 parent_kind: NostrEventKind::PRIVATE_DIRECT_MESSAGE,
5889 reaction: "👍".to_owned(),
5890 custom_emoji_url: None,
5891 created_at: 1_700_000_004,
5892 })?;
5893 let reaction_rumor = crate::NostrRumor::try_from(reaction.rumor)?;
5894 let reaction_wrap = bob.gift_wrap(
5895 &alice.public_key(),
5896 &reaction_rumor,
5897 Nip59EnvelopeKind::Durable,
5898 1_700_000_004,
5899 )?;
5900 account.ingest(vec![SignedEvent::from(&reaction_wrap)], 1_700_000_004)?;
5901 account.delete_message("batch-delete".to_owned(), second.id.clone(), 1_700_000_005)?;
5902 account.set_conversation_subject(
5903 "batch-subject".to_owned(),
5904 conversation.id.clone(),
5905 "Batched".to_owned(),
5906 None,
5907 Vec::new(),
5908 1_700_000_006,
5909 )?;
5910 account.update_profile(
5911 "batch-profile".to_owned(),
5912 r#"{"name":"alice"}"#.to_owned(),
5913 1_700_000_007,
5914 )?;
5915
5916 let messages = account.message_page(conversation.id.clone(), None, 50)?;
5917 let effective_first = messages
5918 .messages
5919 .iter()
5920 .find(|message| message.id == first.id)
5921 .ok_or(SoftchatError::InvalidPersistenceResult)?;
5922 assert_eq!(effective_first.text, "edited");
5923 assert!(effective_first.edited);
5924 assert_eq!(effective_first.reactions.len(), 1);
5925 assert_eq!(effective_first.reactions[0].value, "👍");
5926 assert!(
5927 messages
5928 .messages
5929 .iter()
5930 .any(|message| message.id == second.id && message.deleted)
5931 );
5932
5933 let exact = account.messages_by_ids(vec![
5935 first.id.to_uppercase(),
5936 "00".repeat(32),
5937 second.id.clone(),
5938 ])?;
5939 assert_eq!(exact.len(), 2);
5940 assert_eq!(&exact[0], effective_first);
5941 assert_eq!(exact[1].id, second.id);
5942 assert!(exact[1].deleted);
5943 assert!(account.messages_by_ids(Vec::new())?.is_empty());
5944 assert!(matches!(
5945 account.messages_by_ids(vec![first.id.clone(), first.id.to_uppercase()]),
5946 Err(SoftchatError::InvalidAccountOperation)
5947 ));
5948 let maximum_ids = (0..crate::MAX_PRODUCT_MESSAGE_LOOKUPS)
5949 .map(|index| format!("{index:064x}"))
5950 .collect::<Vec<_>>();
5951 assert!(account.messages_by_ids(maximum_ids)?.is_empty());
5952 assert!(matches!(
5953 account.messages_by_ids(vec![String::new(); crate::MAX_PRODUCT_MESSAGE_LOOKUPS + 1]),
5954 Err(SoftchatError::InvalidAccountOperation)
5955 ));
5956 assert!(account.messages_by_ids(vec!["invalid".to_owned()]).is_err());
5957
5958 let conversations = account.conversation_page(false, None, 50)?;
5959 assert_eq!(conversations.conversations.len(), 1);
5960 assert_eq!(conversations.conversations[0].subject, "Batched");
5961 assert_eq!(conversations.conversations[0].message_count, 1);
5962 assert_eq!(
5963 conversations.conversations[0]
5964 .last_message
5965 .as_ref()
5966 .map(|message| message.id.as_str()),
5967 Some(first.id.as_str())
5968 );
5969 let own_public_key = account.public_key()?;
5970 assert_eq!(
5971 account
5972 .profile_page(50, 0)?
5973 .into_iter()
5974 .find(|profile| profile.public_key == own_public_key)
5975 .and_then(|profile| profile.name),
5976 Some("alice".to_owned())
5977 );
5978 let bob_public_key = bob.public_key().to_hex();
5979 let exact_profiles =
5980 account.profiles(vec![bob_public_key.clone(), own_public_key.clone()])?;
5981 assert_eq!(
5982 exact_profiles
5983 .iter()
5984 .map(|profile| profile.public_key.as_str())
5985 .collect::<Vec<_>>(),
5986 vec![bob_public_key.as_str(), own_public_key.as_str()]
5987 );
5988 assert!(exact_profiles[0].name.is_none());
5989 assert_eq!(exact_profiles[1].name.as_deref(), Some("alice"));
5990 assert!(matches!(
5991 account.profiles(vec![own_public_key; crate::MAX_PRODUCT_PROFILE_LOOKUPS + 1]),
5992 Err(SoftchatError::InvalidAccountOperation)
5993 ));
5994
5995 drop(account);
5996 std::fs::remove_dir_all(directory)?;
5997 Ok(())
5998 }
5999
6000 #[test]
6001 fn message_local_extras_are_bounded_revisioned_and_account_local()
6002 -> Result<(), Box<dyn std::error::Error>> {
6003 let directory = temporary_directory("message-extras")?;
6004 let account =
6005 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6006 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6007 let conversation =
6008 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6009 let message = account
6010 .send_message(
6011 "extras-message".to_owned(),
6012 conversation.id,
6013 content("voice note"),
6014 String::new(),
6015 None,
6016 1_700_000_001,
6017 )?
6018 .message
6019 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6020 let before = account.database_info()?.revision;
6021 let mutation = account.set_message_local_extras(
6022 message.id.clone(),
6023 Some("local transcript".to_owned()),
6024 vec![0, 64, 255],
6025 1_700_000_002,
6026 )?;
6027 assert_eq!(mutation.revision, before + 1);
6028 let extras = account
6029 .message_local_extras(message.id.clone())?
6030 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6031 assert_eq!(extras.transcript.as_deref(), Some("local transcript"));
6032 assert_eq!(extras.waveform, vec![0, 64, 255]);
6033 assert!(matches!(
6034 account.set_message_local_extras(
6035 message.id,
6036 None,
6037 vec![0; crate::MAX_PRODUCT_WAVEFORM_SAMPLES + 1],
6038 1_700_000_003,
6039 ),
6040 Err(SoftchatError::InvalidAccountOperation)
6041 ));
6042 drop(account);
6043 std::fs::remove_dir_all(directory)?;
6044 Ok(())
6045 }
6046
6047 #[test]
6048 fn settings_and_media_are_revisioned_and_survive_reopen()
6049 -> Result<(), Box<dyn std::error::Error>> {
6050 let directory = temporary_directory("product-state")?;
6051 let filename;
6052 {
6053 let account =
6054 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6055 filename = account.database_filename();
6056 assert_eq!(
6057 account.media_upload_url()?,
6058 "https://it2.softchat.c84ad60.xyz/api/v2/files"
6059 );
6060 let revision_before_settings = account.database_info()?.revision;
6061 let settings = account.apply_account_settings_patch(
6062 "settings-1".to_owned(),
6063 r#"{"future":{"preserved":true},"mediaService":"https://media.example/upload","quickReaction":"🔥"}"#.to_owned(),
6064 1_700_000_001,
6065 )?;
6066 assert_eq!(settings.settings.quick_reaction.as_deref(), Some("🔥"));
6067 assert_eq!(account.media_upload_url()?, "https://media.example/upload");
6068 assert_eq!(settings.revision, revision_before_settings + 1);
6069 assert!(matches!(
6070 account.apply_account_settings_patch(
6071 "settings-1".to_owned(),
6072 r#"{"quickReaction":"different"}"#.to_owned(),
6073 1_700_000_002,
6074 ),
6075 Err(SoftchatError::CommandConflict)
6076 ));
6077 assert_eq!(
6078 account.account_settings()?.quick_reaction.as_deref(),
6079 Some("🔥")
6080 );
6081 let diagnostics = account.account_diagnostics()?;
6082 assert_eq!(diagnostics.revision, settings.revision);
6083 assert!(diagnostics.unfinished_delivery_count > 0);
6084 let media = account.prepare_media_operation(
6085 "media-1".to_owned(),
6086 String::new(),
6087 "upload".to_owned(),
6088 "sha256:fixture".to_owned(),
6089 1_700_000_003,
6090 )?;
6091 let other_media = account.prepare_media_operation(
6092 "media-2".to_owned(),
6093 String::new(),
6094 "upload".to_owned(),
6095 "sha256:other-fixture".to_owned(),
6096 1_700_000_003,
6097 )?;
6098 let caller_prefixed_media = account.prepare_media_operation(
6099 "conversation-icon-download-caller-owned".to_owned(),
6100 String::new(),
6101 "upload".to_owned(),
6102 "caller-owned-prefix".to_owned(),
6103 1_700_000_003,
6104 )?;
6105 let caller_prefixed_lease = account
6106 .claim_media_operation(
6107 caller_prefixed_media.id,
6108 "caller-owned-prefix-lease".to_owned(),
6109 1_700_000_004,
6110 300,
6111 )?
6112 .ok_or(SoftchatError::InvalidMediaOperation)?;
6113 assert_eq!(
6114 account
6115 .complete_media_operation(
6116 caller_prefixed_lease.operation.id,
6117 caller_prefixed_lease.lease_id,
6118 AttachmentMetadata {
6119 url: "https://media.example/caller-owned-prefix.bin".to_owned(),
6120 sha256: Some(hex::encode([3_u8; 32])),
6121 byte_size: Some(4),
6122 ..AttachmentMetadata::default()
6123 },
6124 4,
6125 1_700_000_005,
6126 )?
6127 .operation
6128 .state,
6129 crate::AccountMediaOperationState::Completed
6130 );
6131 assert_eq!(media.state, crate::AccountMediaOperationState::Prepared);
6132 let lease = account
6133 .claim_media_operation(
6134 media.id.clone(),
6135 "media-lease-1".to_owned(),
6136 1_700_000_004,
6137 300,
6138 )?
6139 .ok_or(SoftchatError::InvalidMediaOperation)?;
6140 assert_eq!(lease.operation.id, media.id);
6141 assert_eq!(
6142 account
6143 .claim_media_operation(
6144 other_media.id.clone(),
6145 "media-lease-2".to_owned(),
6146 1_700_000_004,
6147 300,
6148 )?
6149 .ok_or(SoftchatError::InvalidMediaOperation)?
6150 .operation
6151 .id,
6152 other_media.id
6153 );
6154 assert_eq!(
6155 lease.operation.state,
6156 crate::AccountMediaOperationState::Running
6157 );
6158 assert!(matches!(
6159 account.complete_media_operation(
6160 lease.operation.id.clone(),
6161 lease.lease_id.clone(),
6162 AttachmentMetadata {
6163 url: "http://media.example/insecure".to_owned(),
6164 ..AttachmentMetadata::default()
6165 },
6166 42,
6167 1_700_000_005,
6168 ),
6169 Err(SoftchatError::InvalidAttachmentMetadata)
6170 ));
6171 assert_eq!(
6172 account
6173 .media_operation(lease.operation.id.clone())?
6174 .ok_or(SoftchatError::InvalidPersistenceResult)?
6175 .state,
6176 crate::AccountMediaOperationState::Running
6177 );
6178 let retryable = account.finish_media_operation_lease(
6179 lease.operation.id,
6180 lease.lease_id,
6181 true,
6182 "network_unavailable".to_owned(),
6183 1_700_000_006,
6184 )?;
6185 assert_eq!(
6186 retryable.state,
6187 crate::AccountMediaOperationState::Retryable
6188 );
6189 }
6190 let reopened =
6191 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6192 assert_eq!(reopened.database_filename(), filename);
6193 assert_eq!(
6194 reopened.account_settings()?.quick_reaction.as_deref(),
6195 Some("🔥")
6196 );
6197 assert_eq!(reopened.media_operations(10)?.len(), 3);
6198 drop(reopened);
6199 std::fs::remove_dir_all(directory)?;
6200 Ok(())
6201 }
6202
6203 #[test]
6204 fn recoverable_media_pagination_does_not_drop_work_beyond_the_recent_limit()
6205 -> Result<(), Box<dyn std::error::Error>> {
6206 let directory = temporary_directory("recoverable-media-pages")?;
6207 let account =
6208 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6209 let mut expected_ids = Vec::new();
6210 for index in 0..(crate::MAX_PRODUCT_QUERY_PAGE + 5) {
6211 let operation = account.prepare_media_operation(
6212 format!("recoverable-command-{index}"),
6213 String::new(),
6214 "upload".to_owned(),
6215 format!("recoverable-source-{index}"),
6216 1_700_000_000 + i64::from(index),
6217 )?;
6218 expected_ids.push(operation.id);
6219 }
6220 let cancelled = expected_ids.remove(2);
6221 account.transition_media_operation(
6222 cancelled.clone(),
6223 "cancel".to_owned(),
6224 String::new(),
6225 1_700_001_000,
6226 )?;
6227 expected_ids.sort();
6228
6229 let first = account.recoverable_media_operations(None, crate::MAX_PRODUCT_QUERY_PAGE)?;
6230 assert_eq!(
6231 first.operations.len(),
6232 crate::MAX_PRODUCT_QUERY_PAGE as usize
6233 );
6234 let cursor = first
6235 .next_cursor
6236 .clone()
6237 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6238 let second =
6239 account.recoverable_media_operations(Some(cursor), crate::MAX_PRODUCT_QUERY_PAGE)?;
6240 assert_eq!(second.operations.len(), 4);
6241 assert!(second.next_cursor.is_none());
6242 let actual_ids = first
6243 .operations
6244 .into_iter()
6245 .chain(second.operations)
6246 .map(|operation| operation.id)
6247 .collect::<Vec<_>>();
6248 assert_eq!(actual_ids, expected_ids);
6249 let exact_ids = vec![
6250 expected_ids[expected_ids.len() - 1].clone(),
6251 cancelled.clone(),
6252 "0".repeat(64),
6253 expected_ids[0].clone(),
6254 ];
6255 let exact = account.media_operations_by_ids(exact_ids.clone())?;
6256 assert_eq!(
6257 exact
6258 .into_iter()
6259 .map(|operation| operation.id)
6260 .collect::<Vec<_>>(),
6261 vec![exact_ids[0].clone(), cancelled, exact_ids[3].clone()],
6262 );
6263 assert!(matches!(
6264 account.media_operations_by_ids(vec![expected_ids[0].clone(); 2]),
6265 Err(SoftchatError::InvalidMediaOperation)
6266 ));
6267 assert!(matches!(
6268 account.recoverable_media_operations(Some("not-an-operation".to_owned()), 10),
6269 Err(SoftchatError::InvalidMediaOperation)
6270 ));
6271
6272 drop(account);
6273 std::fs::remove_dir_all(directory)?;
6274 Ok(())
6275 }
6276
6277 #[test]
6278 fn media_diagnostics_preserve_completion_when_dependent_publication_fails()
6279 -> Result<(), Box<dyn std::error::Error>> {
6280 let directory = temporary_directory("media-commit-diagnostics")?;
6281 let account =
6282 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6283 account.set_log_level(AccountLogLevel::Info)?;
6284 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6285 let conversation =
6286 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6287 let pending = account.prepare_media_message(
6288 "diagnostic-media".to_owned(),
6289 conversation.id,
6290 content("private caption"),
6291 String::new(),
6292 vec![crate::MediaPreparationInput {
6293 source_fingerprint: "private-source".to_owned(),
6294 }],
6295 None,
6296 1_700_000_001,
6297 )?;
6298 let lease = account
6299 .claim_media_operation(
6300 pending.attachments[0].id.clone(),
6301 "diagnostic-lease".to_owned(),
6302 1_700_000_002,
6303 30,
6304 )?
6305 .ok_or(SoftchatError::InvalidMediaOperation)?;
6306 account.drain_log_events(64)?;
6307 account.database()?.connection.execute_batch(
6308 "CREATE TEMP TRIGGER reject_test_publication BEFORE INSERT ON outgoing_operations
6309 BEGIN SELECT RAISE(FAIL, 'test publication failure'); END;",
6310 )?;
6311 let result = account.complete_media_operation(
6312 lease.operation.id.clone(),
6313 lease.lease_id.clone(),
6314 AttachmentMetadata {
6315 url: "https://media.example/test".to_owned(),
6316 sha256: Some(hex::encode([7_u8; 32])),
6317 byte_size: Some(42),
6318 ..AttachmentMetadata::default()
6319 },
6320 42,
6321 1_700_000_003,
6322 );
6323 assert!(result.is_err());
6324 assert_eq!(
6325 account
6326 .database()?
6327 .product_media_operation(&lease.operation.id)?
6328 .ok_or(SoftchatError::InvalidMediaOperation)?
6329 .state,
6330 crate::AccountMediaOperationState::Completed
6331 );
6332 let events = account.drain_log_events(64)?.events;
6333 assert_eq!(
6334 events
6335 .iter()
6336 .filter(|event| event.operation == "media.upload.completed")
6337 .count(),
6338 1
6339 );
6340 assert!(
6341 events
6342 .iter()
6343 .any(|event| event.operation == "media.publication.deferred")
6344 );
6345 assert!(
6346 !events
6347 .iter()
6348 .any(|event| event.operation == "message.queued"
6349 || event.operation == "media.completion.failed")
6350 );
6351 assert!(!format!("{events:?}").contains("private"));
6352 account
6353 .database()?
6354 .connection
6355 .execute_batch("DROP TRIGGER reject_test_publication")?;
6356 assert_eq!(
6357 account
6358 .recover_pending_media_messages(10, 1_700_000_004)?
6359 .len(),
6360 1
6361 );
6362 let recovered = account.drain_log_events(64)?.events;
6363 assert!(
6364 recovered
6365 .iter()
6366 .any(|event| event.operation == "recovery.completed")
6367 );
6368 assert!(
6369 !recovered
6370 .iter()
6371 .any(|event| event.operation == "media.upload.completed")
6372 );
6373 assert!(
6374 account
6375 .recover_pending_media_messages(10, 1_700_000_005)?
6376 .is_empty()
6377 );
6378 assert!(account.drain_log_events(64)?.events.is_empty());
6379 drop(account);
6380 std::fs::remove_dir_all(directory)?;
6381 Ok(())
6382 }
6383
6384 #[test]
6385 fn media_message_dependencies_claim_publication_and_recover_after_reopen()
6386 -> Result<(), Box<dyn std::error::Error>> {
6387 let directory = temporary_directory("media-message-recovery")?;
6388 let command_id = "media-message-command".to_owned();
6389 {
6390 let account =
6391 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6392 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6393 let conversation = account
6394 .get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6395 let revision_before_rejection = account.database_info()?.revision;
6396 assert!(matches!(
6397 account.prepare_media_message(
6398 "media-message-too-large".to_owned(),
6399 conversation.id.clone(),
6400 content(&"x".repeat(crate::MAX_PRODUCT_MESSAGE_BYTES)),
6401 String::new(),
6402 vec![crate::MediaPreparationInput {
6403 source_fingerprint: "must-not-start-upload".to_owned(),
6404 }],
6405 None,
6406 1_700_000_001,
6407 ),
6408 Err(SoftchatError::InvalidMediaOperation)
6409 ));
6410 assert_eq!(account.database_info()?.revision, revision_before_rejection);
6411 assert!(
6412 account
6413 .pending_media_message("media-message-too-large".to_owned())?
6414 .is_none()
6415 );
6416 let draft = AccountDraft {
6417 text: "attachment".to_owned(),
6418 emoji_tags: Vec::new(),
6419 spans: Vec::new(),
6420 reply_to_message_id: String::new(),
6421 attachment_operation_ids: Vec::new(),
6422 updated_at: 1_700_000_001,
6423 };
6424 account.save_conversation_draft(
6425 conversation.id.clone(),
6426 Some(draft.clone()),
6427 1_700_000_001,
6428 )?;
6429 let pending = account.prepare_media_message(
6430 command_id.clone(),
6431 conversation.id.clone(),
6432 content("attachment"),
6433 String::new(),
6434 vec![crate::MediaPreparationInput {
6435 source_fingerprint: "staged-sha256-transform-v1".to_owned(),
6436 }],
6437 Some(draft),
6438 1_700_000_001,
6439 )?;
6440 assert!(
6441 account
6442 .conversation(conversation.id)?
6443 .ok_or(SoftchatError::InvalidPersistenceResult)?
6444 .draft
6445 .is_none()
6446 );
6447 assert_eq!(
6448 pending.state,
6449 crate::PendingMediaMessageState::WaitingForUploads
6450 );
6451 let failed_command = "media-message-failed".to_owned();
6452 let failed_pending = account.prepare_media_message(
6453 failed_command.clone(),
6454 pending.conversation_id.clone(),
6455 content("failed attachment"),
6456 String::new(),
6457 vec![crate::MediaPreparationInput {
6458 source_fingerprint: "failed-staged-source-v1".to_owned(),
6459 }],
6460 None,
6461 1_700_000_002,
6462 )?;
6463 let failed_lease = account
6464 .claim_media_operation(
6465 failed_pending.attachments[0].id.clone(),
6466 "failed-media-message-lease".to_owned(),
6467 1_700_000_003,
6468 300,
6469 )?
6470 .ok_or(SoftchatError::InvalidMediaOperation)?;
6471 account.finish_media_operation_lease(
6472 failed_lease.operation.id,
6473 failed_lease.lease_id,
6474 false,
6475 "upload_rejected".to_owned(),
6476 1_700_000_004,
6477 )?;
6478 assert_eq!(
6479 account
6480 .pending_media_message(failed_command.clone())?
6481 .ok_or(SoftchatError::InvalidPersistenceResult)?
6482 .state,
6483 crate::PendingMediaMessageState::Failed
6484 );
6485 let cancelled =
6486 account.cancel_pending_media_message(failed_command.clone(), 1_700_000_005)?;
6487 assert_eq!(cancelled.state, crate::PendingMediaMessageState::Cancelled);
6488 assert_eq!(
6489 cancelled.attachments[0].state,
6490 crate::AccountMediaOperationState::Cancelled
6491 );
6492 assert_eq!(
6493 account.cancel_pending_media_message(failed_command, 1_700_000_006)?,
6494 cancelled
6495 );
6496 let partially_cancelled_command = "media-message-partially-cancelled".to_owned();
6497 let partially_cancelled = account.prepare_media_message(
6498 partially_cancelled_command.clone(),
6499 pending.conversation_id.clone(),
6500 content("two attachments"),
6501 String::new(),
6502 vec![
6503 crate::MediaPreparationInput {
6504 source_fingerprint: "partially-cancelled-source-a".to_owned(),
6505 },
6506 crate::MediaPreparationInput {
6507 source_fingerprint: "partially-cancelled-source-b".to_owned(),
6508 },
6509 ],
6510 None,
6511 1_700_000_006,
6512 )?;
6513 account.transition_media_operation(
6514 partially_cancelled.attachments[0].id.clone(),
6515 "cancel".to_owned(),
6516 String::new(),
6517 1_700_000_007,
6518 )?;
6519 let partially_cancelled = account
6520 .pending_media_message(partially_cancelled_command.clone())?
6521 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6522 assert_eq!(
6523 partially_cancelled.state,
6524 crate::PendingMediaMessageState::Cancelled
6525 );
6526 assert_eq!(
6527 partially_cancelled.attachments[0].state,
6528 crate::AccountMediaOperationState::Cancelled
6529 );
6530 assert_eq!(
6531 partially_cancelled.attachments[1].state,
6532 crate::AccountMediaOperationState::Prepared
6533 );
6534 let fully_cancelled = account
6535 .cancel_pending_media_message(partially_cancelled_command.clone(), 1_700_000_008)?;
6536 assert!(
6537 fully_cancelled
6538 .attachments
6539 .iter()
6540 .all(|attachment| attachment.state
6541 == crate::AccountMediaOperationState::Cancelled)
6542 );
6543 assert_eq!(
6544 account.cancel_pending_media_message(partially_cancelled_command, 1_700_000_009,)?,
6545 fully_cancelled
6546 );
6547 let lease = account
6548 .claim_media_operation(
6549 pending.attachments[0].id.clone(),
6550 "media-message-lease".to_owned(),
6551 1_700_000_007,
6552 300,
6553 )?
6554 .ok_or(SoftchatError::InvalidMediaOperation)?;
6555 account.database()?.complete_product_media(
6556 &lease.operation.id,
6557 &lease.lease_id,
6558 AttachmentMetadata {
6559 url: "https://media.example/file".to_owned(),
6560 mime_type: Some("image/jpeg".to_owned()),
6561 sha256: Some(hex::encode([7_u8; 32])),
6562 byte_size: Some(42),
6563 ..AttachmentMetadata::default()
6564 },
6565 42,
6566 1_700_000_008,
6567 )?;
6568 assert_eq!(
6569 account
6570 .pending_media_message(command_id.clone())?
6571 .ok_or(SoftchatError::InvalidPersistenceResult)?
6572 .state,
6573 crate::PendingMediaMessageState::ReadyToPublish
6574 );
6575 assert!(
6576 account
6577 .database()?
6578 .claim_pending_media_publication(&command_id)?
6579 );
6580 assert!(matches!(
6581 account.cancel_pending_media_message(command_id.clone(), 1_700_000_009),
6582 Err(SoftchatError::InvalidMediaOperation)
6583 ));
6584 let ready = account
6585 .pending_media_message(command_id.clone())?
6586 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6587 let attachments = ready
6588 .attachments
6589 .iter()
6590 .map(|operation| {
6591 operation
6592 .attachment
6593 .clone()
6594 .ok_or(SoftchatError::InvalidPersistenceResult)
6595 })
6596 .collect::<Result<Vec<_>, _>>()?;
6597 let sent = account.send_message(
6598 ready.command_id,
6599 ready.conversation_id,
6600 MessageContentInput {
6601 text: ready.text,
6602 attachments,
6603 emoji_tags: ready.emoji_tags,
6604 },
6605 ready.reply_to_message_id,
6606 None,
6607 1_700_000_010,
6608 )?;
6609 assert!(sent.inserted);
6610 }
6611
6612 let reopened =
6613 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6614 let published = reopened.recover_pending_media_messages(10, 1_700_000_004)?;
6615 assert_eq!(published.len(), 1);
6616 assert!(!published[0].inserted);
6617 assert_eq!(
6618 published[0]
6619 .message
6620 .as_ref()
6621 .map(|message| message.attachments.len()),
6622 Some(1)
6623 );
6624 let published_message = published[0]
6625 .message
6626 .as_ref()
6627 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6628 let cleared_caption = reopened.edit_message(
6629 "media-message-clear-caption".to_owned(),
6630 published_message.id.clone(),
6631 MessageContentInput {
6632 text: String::new(),
6633 attachments: Vec::new(),
6634 emoji_tags: Vec::new(),
6635 },
6636 1_700_000_005,
6637 )?;
6638 let cleared_message = cleared_caption
6639 .message
6640 .as_ref()
6641 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6642 assert!(cleared_message.text.is_empty());
6643 assert_eq!(cleared_message.attachments, published_message.attachments);
6644 assert!(cleared_message.edited);
6645 let download = reopened.prepare_media_download(
6646 published_message.id.clone(),
6647 "https://media.example/file".to_owned(),
6648 1_700_000_006,
6649 )?;
6650 let repeated = reopened.prepare_media_download(
6651 published_message.id.clone(),
6652 "https://media.example/file".to_owned(),
6653 1_700_000_999,
6654 )?;
6655 assert_eq!(download, repeated);
6656 assert_eq!(download.source_message_id, published_message.id);
6657 assert_eq!(
6658 download
6659 .attachment
6660 .as_ref()
6661 .map(|attachment| attachment.url.as_str()),
6662 Some("https://media.example/file")
6663 );
6664 let download_lease = reopened
6665 .claim_media_operation(
6666 download.id.clone(),
6667 "media-download-lease".to_owned(),
6668 1_700_001_000,
6669 300,
6670 )?
6671 .ok_or(SoftchatError::InvalidMediaOperation)?;
6672 let completed_download = reopened.complete_media_operation(
6673 download.id.clone(),
6674 download_lease.lease_id,
6675 download
6676 .attachment
6677 .clone()
6678 .ok_or(SoftchatError::InvalidMediaOperation)?,
6679 42,
6680 1_700_001_001,
6681 )?;
6682 assert_eq!(
6683 completed_download.operation.state,
6684 crate::AccountMediaOperationState::Completed
6685 );
6686 let restarted = reopened.restart_media_download(download.id.clone(), 1_700_001_002)?;
6687 assert_eq!(restarted.id, download.id);
6688 assert_eq!(restarted.state, crate::AccountMediaOperationState::Prepared);
6689 assert_eq!(restarted.attachment, download.attachment);
6690 let cancelled = reopened.transition_media_operation(
6691 download.id.clone(),
6692 "cancel".to_owned(),
6693 String::new(),
6694 1_700_001_003,
6695 )?;
6696 assert_eq!(
6697 cancelled.state,
6698 crate::AccountMediaOperationState::Cancelled
6699 );
6700 let restarted_cancelled =
6701 reopened.restart_media_download(download.id.clone(), 1_700_001_004)?;
6702 assert_eq!(
6703 restarted_cancelled.state,
6704 crate::AccountMediaOperationState::Prepared
6705 );
6706 let failed_lease = reopened
6707 .claim_media_operation(
6708 download.id.clone(),
6709 "media-download-failed-lease".to_owned(),
6710 1_700_001_005,
6711 300,
6712 )?
6713 .ok_or(SoftchatError::InvalidMediaOperation)?;
6714 let failed = reopened.finish_media_operation_lease(
6715 download.id.clone(),
6716 failed_lease.lease_id,
6717 false,
6718 "download_rejected".to_owned(),
6719 1_700_001_006,
6720 )?;
6721 assert_eq!(failed.state, crate::AccountMediaOperationState::Failed);
6722 assert_eq!(
6723 reopened
6724 .restart_media_download(download.id.clone(), 1_700_001_007)?
6725 .state,
6726 crate::AccountMediaOperationState::Prepared
6727 );
6728 assert!(matches!(
6729 reopened.prepare_media_download(
6730 published_message.id.clone(),
6731 "https://media.example/other".to_owned(),
6732 1_700_000_005,
6733 ),
6734 Err(SoftchatError::InvalidMediaOperation)
6735 ));
6736 assert_eq!(
6737 reopened
6738 .pending_media_message(command_id)?
6739 .ok_or(SoftchatError::InvalidPersistenceResult)?
6740 .state,
6741 crate::PendingMediaMessageState::Published
6742 );
6743 drop(reopened);
6744 std::fs::remove_dir_all(directory)?;
6745 Ok(())
6746 }
6747
6748 #[test]
6749 fn public_asset_replacements_are_durable_idempotent_and_recoverable()
6750 -> Result<(), Box<dyn std::error::Error>> {
6751 let directory = temporary_directory("asset-replacement-recovery")?;
6752 let profile_command = "replace-profile-picture".to_owned();
6753 let conversation_id;
6754 {
6755 let account =
6756 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6757 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
6758 let conversation = account
6759 .get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
6760 conversation_id = conversation.id.clone();
6761 account.set_conversation_subject(
6762 "initial-subject".to_owned(),
6763 conversation.id,
6764 "Family".to_owned(),
6765 None,
6766 vec![vec![
6767 "emoji".to_owned(),
6768 "party".to_owned(),
6769 "https://media.example/party.png".to_owned(),
6770 ]],
6771 1_700_000_001,
6772 )?;
6773
6774 let pending = account.prepare_asset_replacement(
6775 profile_command.clone(),
6776 AssetReplacementTarget::ProfilePicture,
6777 String::new(),
6778 "profile-picture-source-v1".to_owned(),
6779 1_700_000_002,
6780 )?;
6781 assert_eq!(
6782 pending.operation.protection,
6783 crate::AccountMediaProtection::Public
6784 );
6785 assert_eq!(
6786 pending.state,
6787 crate::PendingAssetReplacementState::WaitingForUpload
6788 );
6789 assert_eq!(
6790 account.prepare_asset_replacement(
6791 profile_command.clone(),
6792 AssetReplacementTarget::ProfilePicture,
6793 String::new(),
6794 "profile-picture-source-v1".to_owned(),
6795 1_700_000_999,
6796 )?,
6797 pending
6798 );
6799 assert!(matches!(
6800 account.prepare_asset_replacement(
6801 profile_command.clone(),
6802 AssetReplacementTarget::ProfilePicture,
6803 String::new(),
6804 "different-source".to_owned(),
6805 1_700_000_003,
6806 ),
6807 Err(SoftchatError::CommandConflict)
6808 ));
6809 let lease = account
6810 .claim_media_operation(
6811 pending.operation.id,
6812 "profile-picture-lease".to_owned(),
6813 1_700_000_003,
6814 300,
6815 )?
6816 .ok_or(SoftchatError::InvalidMediaOperation)?;
6817 account.database()?.complete_product_media(
6818 &lease.operation.id,
6819 &lease.lease_id,
6820 AttachmentMetadata {
6821 url: "https://media.example/avatar.jpg".to_owned(),
6822 mime_type: Some("image/jpeg".to_owned()),
6823 sha256: Some(hex::encode([8_u8; 32])),
6824 byte_size: Some(42),
6825 ..AttachmentMetadata::default()
6826 },
6827 42,
6828 1_700_000_004,
6829 )?;
6830 assert_eq!(
6831 account
6832 .pending_asset_replacement(profile_command.clone())?
6833 .ok_or(SoftchatError::InvalidPersistenceResult)?
6834 .state,
6835 crate::PendingAssetReplacementState::ReadyToPublish
6836 );
6837 }
6838
6839 let reopened =
6840 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
6841 assert_eq!(
6842 reopened
6843 .recover_pending_asset_replacements(10, 1_700_000_005)?
6844 .len(),
6845 1
6846 );
6847 let own_public_key = reopened.public_key()?;
6848 assert_eq!(
6849 reopened.profile(own_public_key)?.picture.as_deref(),
6850 Some("https://media.example/avatar.jpg")
6851 );
6852 assert_eq!(
6853 reopened
6854 .pending_asset_replacement(profile_command)?
6855 .ok_or(SoftchatError::InvalidPersistenceResult)?
6856 .state,
6857 crate::PendingAssetReplacementState::Published
6858 );
6859
6860 let icon = reopened.prepare_asset_replacement(
6861 "replace-conversation-icon".to_owned(),
6862 AssetReplacementTarget::ConversationIcon,
6863 conversation_id.clone(),
6864 "conversation-icon-source-v1".to_owned(),
6865 1_700_000_006,
6866 )?;
6867 let icon_lease = reopened
6868 .claim_media_operation(
6869 icon.operation.id,
6870 "conversation-icon-lease".to_owned(),
6871 1_700_000_007,
6872 300,
6873 )?
6874 .ok_or(SoftchatError::InvalidMediaOperation)?;
6875 let icon_completion = reopened.complete_media_operation(
6876 icon_lease.operation.id,
6877 icon_lease.lease_id,
6878 AttachmentMetadata {
6879 url: "https://media.example/conversation.png".to_owned(),
6880 mime_type: Some("image/png".to_owned()),
6881 sha256: Some(hex::encode([9_u8; 32])),
6882 dimensions: Some("256x256".to_owned()),
6883 byte_size: Some(64),
6884 ..AttachmentMetadata::default()
6885 },
6886 64,
6887 1_700_000_008,
6888 )?;
6889 assert!(icon_completion.completed_operation.is_some());
6890 let conversation = reopened
6891 .conversation(conversation_id.clone())?
6892 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6893 assert_eq!(conversation.subject, "Family");
6894 assert_eq!(
6895 conversation
6896 .subject_icon
6897 .as_ref()
6898 .map(|value| value.url.as_str()),
6899 Some("https://media.example/conversation.png")
6900 );
6901 assert_eq!(
6902 conversation
6903 .subject_icon
6904 .as_ref()
6905 .and_then(|value| value.sha256.clone()),
6906 Some(hex::encode([9_u8; 32]))
6907 );
6908 let icon_download =
6909 reopened.prepare_conversation_icon_download(conversation_id.clone(), 1_700_000_009)?;
6910 assert_eq!(icon_download.direction, "download");
6911 assert_eq!(icon_download.conversation_id, conversation_id);
6912 assert!(icon_download.source_message_id.is_empty());
6913 assert_eq!(icon_download.attachment, conversation.subject_icon);
6914 let original_icon = conversation
6915 .subject_icon
6916 .clone()
6917 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6918 reopened.set_conversation_subject(
6919 "rename-conversation-without-icon".to_owned(),
6920 conversation_id.clone(),
6921 "Family renamed".to_owned(),
6922 None,
6923 Vec::new(),
6924 1_700_000_010,
6925 )?;
6926 let renamed = reopened
6927 .conversation(conversation_id.clone())?
6928 .ok_or(SoftchatError::InvalidPersistenceResult)?;
6929 assert_eq!(renamed.subject, "Family renamed");
6930 assert_eq!(renamed.subject_icon, Some(original_icon.clone()));
6931 assert_eq!(
6932 reopened.prepare_conversation_icon_download(
6933 icon_download.conversation_id.clone(),
6934 1_700_000_010,
6935 )?,
6936 icon_download
6937 );
6938 let inherited_icon_lease = reopened
6939 .claim_media_operation(
6940 icon_download.id.clone(),
6941 "inherited-conversation-icon-lease".to_owned(),
6942 1_700_000_011,
6943 300,
6944 )?
6945 .ok_or(SoftchatError::InvalidMediaOperation)?;
6946 let inherited_operation_id = inherited_icon_lease.operation.id.clone();
6947 let inherited_lease_id = inherited_icon_lease.lease_id.clone();
6948 assert_eq!(
6949 reopened
6950 .complete_media_operation(
6951 inherited_operation_id.clone(),
6952 inherited_lease_id.clone(),
6953 original_icon.clone(),
6954 64,
6955 1_700_000_012,
6956 )?
6957 .operation
6958 .state,
6959 crate::AccountMediaOperationState::Completed
6960 );
6961 let replacement_icon = AttachmentMetadata {
6962 url: "https://media.example/conversation-v2.png".to_owned(),
6963 mime_type: Some("image/png".to_owned()),
6964 sha256: Some(hex::encode([10_u8; 32])),
6965 dimensions: Some("512x512".to_owned()),
6966 byte_size: Some(128),
6967 ..AttachmentMetadata::default()
6968 };
6969 reopened.set_conversation_subject(
6970 "replace-conversation-icon-subject".to_owned(),
6971 conversation_id.clone(),
6972 "Family".to_owned(),
6973 Some(replacement_icon.clone()),
6974 Vec::new(),
6975 1_700_000_013,
6976 )?;
6977 assert_eq!(
6978 reopened
6979 .complete_media_operation(
6980 inherited_operation_id,
6981 inherited_lease_id,
6982 original_icon,
6983 64,
6984 1_700_000_014,
6985 )?
6986 .operation
6987 .state,
6988 crate::AccountMediaOperationState::Completed
6989 );
6990 let replacement_download =
6991 reopened.prepare_conversation_icon_download(conversation_id.clone(), 1_700_000_014)?;
6992 assert_ne!(replacement_download.id, icon_download.id);
6993 assert_eq!(
6994 replacement_download.attachment,
6995 Some(replacement_icon.clone())
6996 );
6997 let stale_icon_lease = reopened
6998 .claim_media_operation(
6999 replacement_download.id,
7000 "stale-conversation-icon-lease".to_owned(),
7001 1_700_000_015,
7002 300,
7003 )?
7004 .ok_or(SoftchatError::InvalidMediaOperation)?;
7005 let stale_operation_id = stale_icon_lease.operation.id.clone();
7006 let stale_lease_id = stale_icon_lease.lease_id.clone();
7007 reopened.set_conversation_subject(
7008 "replace-conversation-icon-again".to_owned(),
7009 conversation_id.clone(),
7010 "Family".to_owned(),
7011 Some(AttachmentMetadata {
7012 url: "https://media.example/conversation-v3.png".to_owned(),
7013 mime_type: Some("image/png".to_owned()),
7014 sha256: Some(hex::encode([11_u8; 32])),
7015 dimensions: Some("768x768".to_owned()),
7016 byte_size: Some(256),
7017 ..AttachmentMetadata::default()
7018 }),
7019 Vec::new(),
7020 1_700_000_016,
7021 )?;
7022 assert!(matches!(
7023 reopened.complete_media_operation(
7024 stale_operation_id.clone(),
7025 stale_lease_id.clone(),
7026 replacement_icon,
7027 128,
7028 1_700_000_017,
7029 ),
7030 Err(SoftchatError::InvalidMediaOperation)
7031 ));
7032 assert_eq!(
7033 reopened
7034 .finish_media_operation_lease(
7035 stale_operation_id.clone(),
7036 stale_lease_id,
7037 false,
7038 "stale_icon".to_owned(),
7039 1_700_000_018,
7040 )?
7041 .state,
7042 crate::AccountMediaOperationState::Failed
7043 );
7044 assert!(matches!(
7045 reopened.restart_media_download(stale_operation_id.clone(), 1_700_000_019),
7046 Err(SoftchatError::InvalidMediaOperation)
7047 ));
7048 assert_eq!(
7049 reopened
7050 .media_operation(stale_operation_id)?
7051 .ok_or(SoftchatError::InvalidPersistenceResult)?
7052 .state,
7053 crate::AccountMediaOperationState::Failed
7054 );
7055 reopened.set_conversation_subject(
7056 "replace-conversation-icon-ox-only".to_owned(),
7057 conversation_id.clone(),
7058 "Family".to_owned(),
7059 Some(AttachmentMetadata {
7060 url: "https://media.example/conversation-ox-only.png".to_owned(),
7061 original_sha256: Some(hex::encode([12_u8; 32])),
7062 ..AttachmentMetadata::default()
7063 }),
7064 Vec::new(),
7065 1_700_000_020,
7066 )?;
7067 assert!(matches!(
7068 reopened.prepare_conversation_icon_download(conversation_id.clone(), 1_700_000_021),
7069 Err(SoftchatError::InvalidMediaOperation)
7070 ));
7071
7072 let banner = reopened.prepare_asset_replacement(
7073 "replace-profile-banner".to_owned(),
7074 AssetReplacementTarget::ProfileBanner,
7075 String::new(),
7076 "profile-banner-source-v1".to_owned(),
7077 1_700_000_009,
7078 )?;
7079 let banner_lease = reopened
7080 .claim_media_operation(
7081 banner.operation.id,
7082 "profile-banner-lease".to_owned(),
7083 1_700_000_010,
7084 300,
7085 )?
7086 .ok_or(SoftchatError::InvalidMediaOperation)?;
7087 assert!(matches!(
7088 reopened.complete_media_operation(
7089 banner_lease.operation.id.clone(),
7090 banner_lease.lease_id.clone(),
7091 AttachmentMetadata {
7092 url: "https://media.example/banner.jpg".to_owned(),
7093 encryption: Some("encrypted-value".to_owned()),
7094 ..AttachmentMetadata::default()
7095 },
7096 64,
7097 1_700_000_011,
7098 ),
7099 Err(SoftchatError::InvalidMediaOperation)
7100 ));
7101 let failed = reopened.finish_media_operation_lease(
7102 banner_lease.operation.id,
7103 banner_lease.lease_id,
7104 false,
7105 "public_upload_rejected".to_owned(),
7106 1_700_000_012,
7107 )?;
7108 assert_eq!(failed.state, crate::AccountMediaOperationState::Failed);
7109 assert_eq!(
7110 reopened
7111 .pending_asset_replacement("replace-profile-banner".to_owned())?
7112 .ok_or(SoftchatError::InvalidPersistenceResult)?
7113 .state,
7114 crate::PendingAssetReplacementState::Failed
7115 );
7116
7117 drop(reopened);
7118 std::fs::remove_dir_all(directory)?;
7119 Ok(())
7120 }
7121
7122 #[test]
7123 fn settings_and_read_state_merge_incoming_ios_compatible_snapshots()
7124 -> Result<(), Box<dyn std::error::Error>> {
7125 let directory = temporary_directory("app-data-merge")?;
7126 let account =
7127 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7128 let alice = crate::LocalIdentity::from_secret_hex(ALICE_SECRET)?;
7129 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7130 let conversation =
7131 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7132 let sent = account.send_message(
7133 "read-message".to_owned(),
7134 conversation.id.clone(),
7135 content("read boundary"),
7136 String::new(),
7137 None,
7138 1_700_000_001,
7139 )?;
7140 let message = sent
7141 .message
7142 .ok_or(SoftchatError::InvalidPersistenceResult)?;
7143 let marked = account.mark_conversation_read(
7144 "read-command".to_owned(),
7145 conversation.id.clone(),
7146 MessageCursor {
7147 created_at: message.created_at,
7148 message_id: message.id,
7149 },
7150 1_700_000_002,
7151 )?;
7152 assert!(marked.inserted);
7153 assert!(!account.account_read_state()?[0].forced_unread);
7154
7155 let legacy_chat_key = [alice.public_key().to_hex(), bob.public_key().to_hex()]
7156 .into_iter()
7157 .collect::<BTreeSet<_>>()
7158 .into_iter()
7159 .collect::<Vec<_>>()
7160 .join(",");
7161 let unread = alice.create_app_data_sync(
7162 1_700_000_004,
7163 AppDataContext::ReadState,
7164 &serde_json::json!({
7165 "chats": [{
7166 "chatKey": legacy_chat_key,
7167 "readAt": null
7168 }]
7169 })
7170 .to_string(),
7171 )?;
7172 account.ingest(vec![unread.event], 1_700_000_005)?;
7173 let read_state = account.account_read_state()?;
7174 assert_eq!(read_state.len(), 1);
7175 assert!(read_state[0].forced_unread);
7176
7177 let settings = alice.create_app_data_sync(
7178 1_700_000_006,
7179 AppDataContext::AppSettings,
7180 r#"{
7181 "videoUploadPreset":"AVAssetExportPresetHighestQuality",
7182 "preserveHDROnUpload":false,
7183 "christmasTheme":true,
7184 "typingIndicatorsEnabled":false,
7185 "hideNotificationContent":true,
7186 "domainReplacementRules":[],
7187 "savedStickerURLs":["https://example.com/sticker.webp"]
7188 }"#,
7189 )?;
7190 account.ingest(vec![settings.event], 1_700_000_007)?;
7191 let effective = account.account_settings()?;
7192 assert_eq!(effective.video_upload_quality, "high");
7193 assert!(!effective.preserve_hdr);
7194 assert!(!effective.typing_indicators_enabled);
7195 assert_eq!(effective.notification_content_privacy, "hidden");
7196 assert_eq!(effective.seasonal_appearance.as_deref(), Some("christmas"));
7197 assert_eq!(
7198 effective.saved_sticker_references,
7199 vec!["https://example.com/sticker.webp"]
7200 );
7201
7202 drop(account);
7203 std::fs::remove_dir_all(directory)?;
7204 Ok(())
7205 }
7206
7207 #[test]
7208 fn operation_retry_cancel_and_relay_rotation_are_durable()
7209 -> Result<(), Box<dyn std::error::Error>> {
7210 let directory = temporary_directory("operation-recovery")?;
7211 let account =
7212 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7213 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7214 let conversation =
7215 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7216
7217 account.add_account_relay("wss://relay-a.example".to_owned(), 1_700_000_001)?;
7218 account.add_account_relay("wss://relay-b.example".to_owned(), 1_700_000_002)?;
7219 account.set_account_active_relay("wss://relay-a.example/".to_owned(), 1_700_000_003)?;
7220 let first = account.send_message(
7221 "recover-first".to_owned(),
7222 conversation.id.clone(),
7223 content("retry me"),
7224 String::new(),
7225 None,
7226 1_700_000_004,
7227 )?;
7228 let claim = account
7229 .claim_deliveries(
7230 "lease-recovery".to_owned(),
7231 "wss://relay-a.example/".to_owned(),
7232 1_700_000_005,
7233 30,
7234 )?
7235 .ok_or(SoftchatError::InvalidDeliveryState)?;
7236 let intent_ids = claim
7237 .claim
7238 .intents
7239 .iter()
7240 .map(|intent| intent.intent_id.clone())
7241 .collect::<Vec<_>>();
7242 account.mark_socket_written(claim.claim.clone(), intent_ids.clone())?;
7243 account.apply_relay_results(
7244 claim.claim,
7245 intent_ids
7246 .into_iter()
7247 .map(|intent_id| RelayDeliveryResult {
7248 intent_id,
7249 kind: crate::RelayDeliveryResultKind::Rejected,
7250 category: "relay_rejected".to_owned(),
7251 })
7252 .collect(),
7253 )?;
7254 assert_eq!(
7255 account
7256 .operation(first.operation.operation_id.clone())?
7257 .state,
7258 crate::OperationState::Failed
7259 );
7260 assert_eq!(
7261 account
7262 .retry_operation(first.operation.operation_id.clone())?
7263 .operation
7264 .state,
7265 crate::OperationState::Queued
7266 );
7267
7268 let second = account.send_message(
7269 "recover-second".to_owned(),
7270 conversation.id,
7271 content("cancel me"),
7272 String::new(),
7273 None,
7274 1_700_000_006,
7275 )?;
7276 assert_eq!(
7277 account
7278 .cancel_operation(second.operation.operation_id)?
7279 .operation
7280 .state,
7281 crate::OperationState::Cancelled
7282 );
7283
7284 let rotated = account
7285 .rotate_failed_account_relay("wss://relay-a.example/".to_owned(), 1_700_000_007)?;
7286 assert_ne!(rotated.active_relay_url, "wss://relay-a.example/");
7287 assert!(rotated.rebound_intent_count > 0);
7288 assert_eq!(
7289 account
7290 .relay_catalog()?
7291 .into_iter()
7292 .filter(|entry| entry.is_active)
7293 .count(),
7294 1
7295 );
7296 drop(account);
7297 std::fs::remove_dir_all(directory)?;
7298 Ok(())
7299 }
7300
7301 #[test]
7302 fn account_clear_resets_durable_and_process_local_state_without_crossing_accounts()
7303 -> Result<(), Box<dyn std::error::Error>> {
7304 let directory = temporary_directory("complete-account-clear")?;
7305 let alice =
7306 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7307 let bob = AccountRuntimeHandle::open(secret(BOB_SECRET)?, directory.display().to_string())?;
7308 assert_ne!(alice.account_id(), bob.account_id());
7309
7310 {
7311 let database = lock(&alice.database);
7312 database.connection.execute(
7313 "INSERT INTO app_data_effective
7314 (context, canonical_json, event_created_at, event_id)
7315 VALUES ('app-settings', '{\"quickReaction\":\"retained\"}', 1, 'event')",
7316 [],
7317 )?;
7318 }
7319 bob.put_local_contact(LocalContactRecord {
7320 public_key: alice.account_id(),
7321 name: Some("Alice survives Bob isolation".to_owned()),
7322 updated_at_millis: 1,
7323 })?;
7324 alice.start_sync(
7325 "manual-reset-sync".to_owned(),
7326 "manual-reset-neg".to_owned(),
7327 "manual-reset-events".to_owned(),
7328 1_700_000_000,
7329 0,
7330 crate::MIN_NEGENTROPY_FRAME_BYTES,
7331 )?;
7332 let first_transport = alice.start_account_transport("clear-run-before".to_owned())?;
7333 assert_eq!(first_transport.actions.len(), 1);
7334 lock(&alice.typing_authored).insert(
7335 hex::encode([0x44; 32]),
7336 (1_700_000_000, "typing-command".to_owned()),
7337 );
7338
7339 let cleared = alice.clear_account()?;
7340 assert!(cleared.revision > 0);
7341 assert!(lock(&alice.sync_engines).is_empty());
7342 assert!(lock(&alice.transport_run).is_none());
7343 assert!(lock(&alice.typing_authored).is_empty());
7344 assert_eq!(
7345 alice.sync_snapshot("manual-reset-sync".to_owned()),
7346 Err(SoftchatError::InvalidSyncState)
7347 );
7348 assert_eq!(
7349 lock(&alice.database).connection.query_row(
7350 "SELECT COUNT(*) FROM app_data_effective",
7351 [],
7352 |row| row.get::<_, i64>(0),
7353 )?,
7354 0
7355 );
7356 assert_eq!(bob.local_contacts(20, 0)?.len(), 1);
7357
7358 alice.start_sync(
7359 "manual-reset-sync".to_owned(),
7360 "manual-reset-neg-after".to_owned(),
7361 "manual-reset-events-after".to_owned(),
7362 1_700_000_001,
7363 0,
7364 crate::MIN_NEGENTROPY_FRAME_BYTES,
7365 )?;
7366 let restarted = alice.start_account_transport("clear-run-after".to_owned())?;
7367 assert_eq!(
7368 restarted.actions[0].kind,
7369 AccountTransportActionKind::Connect
7370 );
7371 assert_eq!(
7372 restarted.actions[0].configured_url,
7373 "wss://it2.softchat.c84ad60.xyz/"
7374 );
7375 alice.cancel_account_transport()?;
7376 alice.cancel_sync("manual-reset-sync".to_owned())?;
7377 drop(alice);
7378 drop(bob);
7379 std::fs::remove_dir_all(directory)?;
7380 Ok(())
7381 }
7382
7383 #[test]
7384 fn correlated_transport_results_are_idempotent_and_cancel_releases_work()
7385 -> Result<(), Box<dyn std::error::Error>> {
7386 let directory = temporary_directory("transport-actions")?;
7387 let account =
7388 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7389 account.set_log_level(AccountLogLevel::Debug)?;
7390 let started = account.start_account_transport("android-run-1".to_owned())?;
7391 assert_eq!(started.actions.len(), 1);
7392 assert_eq!(started.actions[0].kind, AccountTransportActionKind::Connect);
7393 let connected = AccountTransportResult {
7394 run_id: started.actions[0].run_id.clone(),
7395 action_id: started.actions[0].action_id.clone(),
7396 generation: started.actions[0].generation,
7397 kind: AccountTransportResultKind::Connected,
7398 text_frame: String::new(),
7399 };
7400 let first = account.apply_account_transport_result(connected.clone(), 1_700_000_000)?;
7401 account.logs.flush();
7402 account.drain_log_events(64)?;
7403 let duplicate = account.apply_account_transport_result(connected, 1_700_000_001)?;
7404 assert!(
7405 account.drain_log_events(64)?.events.is_empty(),
7406 "Replayed transport results are silent"
7407 );
7408 for _ in 0..30 {
7409 account.wake_account_transport(1_700_000_001)?;
7410 }
7411 let receive = transport_action(&first, AccountTransportActionKind::ReceiveText)?;
7412 account.apply_account_transport_result(
7413 transport_result(
7414 &receive,
7415 AccountTransportResultKind::TimedOut,
7416 String::new(),
7417 ),
7418 1_700_000_001,
7419 )?;
7420 assert!(
7421 account.drain_log_events(64)?.events.is_empty(),
7422 "Idle wake and receive timeout are silent at DEBUG"
7423 );
7424 assert_eq!(first.actions, duplicate.actions);
7425 assert_eq!(first.revision, duplicate.revision);
7426 assert_eq!(
7427 first.sync.as_ref().map(|sync| sync.phase),
7428 Some(SyncPhase::Reconciling)
7429 );
7430 assert_eq!(first.sync, duplicate.sync);
7431 assert!(
7432 first
7433 .actions
7434 .iter()
7435 .any(|action| action.kind == AccountTransportActionKind::ReceiveText)
7436 );
7437 let unknown = AccountTransportResult {
7438 run_id: started.actions[0].run_id.clone(),
7439 action_id: "unknown-stale-action".to_owned(),
7440 generation: started.actions[0].generation,
7441 kind: AccountTransportResultKind::Connected,
7442 text_frame: String::new(),
7443 };
7444 assert_eq!(
7445 account.apply_account_transport_result(unknown, 1_700_000_002),
7446 Err(SoftchatError::UnsupportedSystemAction)
7447 );
7448 assert!(!account.wake_account_transport(1_700_000_002)?.idle);
7449 let reset = account.reset_account_sync(1_700_000_002)?;
7450 assert!(reset.sync.is_none());
7451 assert_eq!(reset.actions.len(), 1);
7452 assert_eq!(reset.actions[0].kind, AccountTransportActionKind::Connect);
7453 assert!(reset.actions[0].generation > started.actions[0].generation);
7454
7455 let cancelled = account.cancel_account_transport()?;
7456 assert_eq!(cancelled.connection_state, RelayConnectionState::Cancelled);
7457 drop(account);
7458 std::fs::remove_dir_all(directory)?;
7459 Ok(())
7460 }
7461
7462 #[test]
7463 fn account_transport_replays_a_socket_written_profile_after_authentication()
7464 -> Result<(), Box<dyn std::error::Error>> {
7465 let directory = temporary_directory("transport-profile-during-connect")?;
7466 let account =
7467 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7468 let started = account.start_account_transport("profile-connect-run".to_owned())?;
7469 account.update_profile(
7470 "profile-during-connect".to_owned(),
7471 serde_json::json!({ "displayName": "Alice" }).to_string(),
7472 1_700_000_000,
7473 )?;
7474
7475 let connected = account.apply_account_transport_result(
7476 transport_result(
7477 &started.actions[0],
7478 AccountTransportResultKind::Connected,
7479 String::new(),
7480 ),
7481 1_700_000_001,
7482 )?;
7483 for action in connected
7484 .actions
7485 .iter()
7486 .filter(|action| action.kind == AccountTransportActionKind::SendText)
7487 {
7488 account.apply_account_transport_result(
7489 transport_result(
7490 action,
7491 AccountTransportResultKind::FrameWritten,
7492 String::new(),
7493 ),
7494 1_700_000_002,
7495 )?;
7496 }
7497
7498 let receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7499 let authenticating = account.apply_account_transport_result(
7500 transport_result(
7501 &receive,
7502 AccountTransportResultKind::FrameReceived,
7503 RelayResponseFrame::Auth("profile-challenge".to_owned()).to_json()?,
7504 ),
7505 1_700_000_003,
7506 )?;
7507 let authentication_send =
7508 transport_action(&authenticating, AccountTransportActionKind::SendText)?;
7509 let authentication_event_id =
7510 match crate::parse_client_relay_frame(&authentication_send.text_frame)? {
7511 crate::ClientRelayFrame::Auth(event) => event.id().to_hex(),
7512 _ => return Err(SoftchatError::InvalidRelayAuthentication.into()),
7513 };
7514 account.apply_account_transport_result(
7515 transport_result(
7516 &authentication_send,
7517 AccountTransportResultKind::FrameWritten,
7518 String::new(),
7519 ),
7520 1_700_000_004,
7521 )?;
7522 let authentication_receive =
7523 transport_action(&authenticating, AccountTransportActionKind::ReceiveText)?;
7524 let ready = account.apply_account_transport_result(
7525 transport_result(
7526 &authentication_receive,
7527 AccountTransportResultKind::FrameReceived,
7528 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7529 event_id: authentication_event_id,
7530 accepted: true,
7531 message: String::new(),
7532 })
7533 .to_json()?,
7534 ),
7535 1_700_000_005,
7536 )?;
7537 for action in ready
7538 .actions
7539 .iter()
7540 .filter(|action| action.kind == AccountTransportActionKind::SendText)
7541 {
7542 account.apply_account_transport_result(
7543 transport_result(
7544 action,
7545 AccountTransportResultKind::FrameWritten,
7546 String::new(),
7547 ),
7548 1_700_000_006,
7549 )?;
7550 }
7551
7552 account.cancel_account_transport()?;
7553 drop(account);
7554 std::fs::remove_dir_all(directory)?;
7555 Ok(())
7556 }
7557
7558 #[test]
7559 fn account_transport_authentication_fence_drains_stale_sync_and_restarts_fresh()
7560 -> Result<(), Box<dyn std::error::Error>> {
7561 let directory = temporary_directory("transport-authentication-fence")?;
7562 let account =
7563 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7564 let started = account.start_account_transport("android-auth-run".to_owned())?;
7565 let connected = account.apply_account_transport_result(
7566 transport_result(
7567 &started.actions[0],
7568 AccountTransportResultKind::Connected,
7569 String::new(),
7570 ),
7571 1_700_000_000,
7572 )?;
7573 let initial_neg_subscription = neg_subscription_id(&connected)?;
7574 let stale_send = transport_action(&connected, AccountTransportActionKind::SendText)?;
7575 let initial_receive =
7576 transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7577
7578 let authenticating = account.apply_account_transport_result(
7579 transport_result(
7580 &initial_receive,
7581 AccountTransportResultKind::FrameReceived,
7582 RelayResponseFrame::Auth("device-challenge".to_owned()).to_json()?,
7583 ),
7584 1_700_000_001,
7585 )?;
7586 assert_eq!(
7587 authenticating.connection_state,
7588 RelayConnectionState::Authenticating
7589 );
7590 assert!(authenticating.sync.is_none());
7591 let authentication_send =
7592 transport_action(&authenticating, AccountTransportActionKind::SendText)?;
7593 let mut receive =
7594 transport_action(&authenticating, AccountTransportActionKind::ReceiveText)?;
7595 let authentication_event_id =
7596 match crate::parse_client_relay_frame(&authentication_send.text_frame)? {
7597 crate::ClientRelayFrame::Auth(event) => event.id().to_hex(),
7598 _ => return Err(SoftchatError::InvalidRelayAuthentication.into()),
7599 };
7600
7601 account.apply_account_transport_result(
7602 transport_result(
7603 &stale_send,
7604 AccountTransportResultKind::FrameWritten,
7605 String::new(),
7606 ),
7607 1_700_000_002,
7608 )?;
7609 let after_notice = account.apply_account_transport_result(
7610 transport_result(
7611 &receive,
7612 AccountTransportResultKind::FrameReceived,
7613 RelayResponseFrame::Notice("authentication required".to_owned()).to_json()?,
7614 ),
7615 1_700_000_003,
7616 )?;
7617 receive = transport_action(&after_notice, AccountTransportActionKind::ReceiveText)?;
7618 let after_closed = account.apply_account_transport_result(
7619 transport_result(
7620 &receive,
7621 AccountTransportResultKind::FrameReceived,
7622 RelayResponseFrame::Closed {
7623 subscription_id: initial_neg_subscription.clone(),
7624 message: "authentication required".to_owned(),
7625 }
7626 .to_json()?,
7627 ),
7628 1_700_000_004,
7629 )?;
7630 receive = transport_action(&after_closed, AccountTransportActionKind::ReceiveText)?;
7631 let after_neg_error = account.apply_account_transport_result(
7632 transport_result(
7633 &receive,
7634 AccountTransportResultKind::FrameReceived,
7635 serde_json::json!([
7636 "NEG-ERR",
7637 initial_neg_subscription,
7638 "authentication required",
7639 500_u64
7640 ])
7641 .to_string(),
7642 ),
7643 1_700_000_005,
7644 )?;
7645 receive = transport_action(&after_neg_error, AccountTransportActionKind::ReceiveText)?;
7646 account.apply_account_transport_result(
7647 transport_result(
7648 &authentication_send,
7649 AccountTransportResultKind::FrameWritten,
7650 String::new(),
7651 ),
7652 1_700_000_006,
7653 )?;
7654
7655 let ready = account.apply_account_transport_result(
7656 transport_result(
7657 &receive,
7658 AccountTransportResultKind::FrameReceived,
7659 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7660 event_id: authentication_event_id,
7661 accepted: true,
7662 message: String::new(),
7663 })
7664 .to_json()?,
7665 ),
7666 1_700_000_007,
7667 )?;
7668 assert_eq!(ready.connection_state, RelayConnectionState::Ready);
7669 assert_eq!(
7670 ready.sync.as_ref().map(|snapshot| snapshot.phase),
7671 Some(SyncPhase::Reconciling)
7672 );
7673 let fresh_neg_subscription = neg_subscription_id(&ready)?;
7674 assert_ne!(fresh_neg_subscription, initial_neg_subscription);
7675
7676 receive = transport_action(&ready, AccountTransportActionKind::ReceiveText)?;
7677 let stale_close_after_auth = account.apply_account_transport_result(
7678 transport_result(
7679 &receive,
7680 AccountTransportResultKind::FrameReceived,
7681 serde_json::json!(["NEG-CLOSE", initial_neg_subscription.clone()]).to_string(),
7682 ),
7683 1_700_000_008,
7684 )?;
7685 assert_eq!(
7686 stale_close_after_auth.connection_state,
7687 RelayConnectionState::Ready
7688 );
7689 receive = transport_action(
7690 &stale_close_after_auth,
7691 AccountTransportActionKind::ReceiveText,
7692 )?;
7693 let stale_after_auth = account.apply_account_transport_result(
7694 transport_result(
7695 &receive,
7696 AccountTransportResultKind::FrameReceived,
7697 serde_json::json!(["NEG-ERR", initial_neg_subscription, "stale", 500_u64])
7698 .to_string(),
7699 ),
7700 1_700_000_009,
7701 )?;
7702 assert_eq!(
7703 stale_after_auth.connection_state,
7704 RelayConnectionState::Ready
7705 );
7706 receive = transport_action(&stale_after_auth, AccountTransportActionKind::ReceiveText)?;
7707 let reconnecting = account.apply_account_transport_result(
7708 transport_result(
7709 &receive,
7710 AccountTransportResultKind::FrameReceived,
7711 serde_json::json!(["NEG-ERR", fresh_neg_subscription, "current", 500_u64])
7712 .to_string(),
7713 ),
7714 1_700_000_010,
7715 )?;
7716 assert_eq!(
7717 reconnecting.actions[0].kind,
7718 AccountTransportActionKind::Connect
7719 );
7720 assert!(reconnecting.actions[0].generation > started.actions[0].generation);
7721
7722 account.cancel_account_transport()?;
7723 drop(account);
7724 std::fs::remove_dir_all(directory)?;
7725 Ok(())
7726 }
7727
7728 #[test]
7729 fn account_transport_quarantines_bad_frames_and_keeps_the_same_connection()
7730 -> Result<(), Box<dyn std::error::Error>> {
7731 let directory = temporary_directory("transport-bad-frame-recovery")?;
7732 let account =
7733 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7734 let started = account.start_account_transport("bad-frame-run".to_owned())?;
7735 let connected = account.apply_account_transport_result(
7736 transport_result(
7737 &started.actions[0],
7738 AccountTransportResultKind::Connected,
7739 String::new(),
7740 ),
7741 1_700_000_000,
7742 )?;
7743 let generation = started.actions[0].generation;
7744 let mut receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7745 let bad_frames = [
7746 "{not-json".to_owned(),
7747 serde_json::json!(["FUTURE", { "extension": true }]).to_string(),
7748 serde_json::json!(["EVENT", "softchat-live-0", { "id": "00" }]).to_string(),
7749 RelayResponseFrame::Eose {
7750 subscription_id: "unknown-subscription".to_owned(),
7751 }
7752 .to_json()?,
7753 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7754 event_id: "00".repeat(32),
7755 accepted: true,
7756 message: String::new(),
7757 })
7758 .to_json()?,
7759 ];
7760
7761 for (index, bad_frame) in bad_frames.into_iter().enumerate() {
7762 let after_bad = account.apply_account_transport_result(
7763 transport_result(
7764 &receive,
7765 AccountTransportResultKind::FrameReceived,
7766 bad_frame,
7767 ),
7768 1_700_000_001 + i64::try_from(index)? * 2,
7769 )?;
7770 assert_eq!(after_bad.connection_state, RelayConnectionState::Ready);
7771 assert!(
7772 after_bad
7773 .actions
7774 .iter()
7775 .all(|action| action.generation == generation)
7776 );
7777 receive = transport_action(&after_bad, AccountTransportActionKind::ReceiveText)?;
7778
7779 let after_valid = account.apply_account_transport_result(
7780 transport_result(
7781 &receive,
7782 AccountTransportResultKind::FrameReceived,
7783 RelayResponseFrame::Notice("valid-next-frame".to_owned()).to_json()?,
7784 ),
7785 1_700_000_002 + i64::try_from(index)? * 2,
7786 )?;
7787 assert_eq!(after_valid.connection_state, RelayConnectionState::Ready);
7788 receive = transport_action(&after_valid, AccountTransportActionKind::ReceiveText)?;
7789 assert_eq!(receive.generation, generation);
7790 }
7791
7792 account.cancel_account_transport()?;
7793 drop(account);
7794 std::fs::remove_dir_all(directory)?;
7795 Ok(())
7796 }
7797
7798 #[test]
7799 fn account_transport_reconnects_when_relay_closes_a_live_subscription()
7800 -> Result<(), Box<dyn std::error::Error>> {
7801 let directory = temporary_directory("transport-live-closed")?;
7802 let account =
7803 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7804 let started = account.start_account_transport("closed-live-run".to_owned())?;
7805 let connected = account.apply_account_transport_result(
7806 transport_result(
7807 &started.actions[0],
7808 AccountTransportResultKind::Connected,
7809 String::new(),
7810 ),
7811 1_700_000_000,
7812 )?;
7813 let receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7814
7815 let reconnecting = account.apply_account_transport_result(
7816 transport_result(
7817 &receive,
7818 AccountTransportResultKind::FrameReceived,
7819 RelayResponseFrame::Closed {
7820 subscription_id: "softchat-live-0".to_owned(),
7821 message: "rate-limited".to_owned(),
7822 }
7823 .to_json()?,
7824 ),
7825 1_700_000_001,
7826 )?;
7827 let connect = transport_action(&reconnecting, AccountTransportActionKind::Connect)?;
7828 assert!(connect.generation > started.actions[0].generation);
7829 assert_eq!(
7830 reconnecting.connection_state,
7831 RelayConnectionState::Connecting
7832 );
7833
7834 account.cancel_account_transport()?;
7835 drop(account);
7836 std::fs::remove_dir_all(directory)?;
7837 Ok(())
7838 }
7839
7840 #[test]
7841 fn account_transport_accepts_delivery_ack_before_socket_write()
7842 -> Result<(), Box<dyn std::error::Error>> {
7843 let directory = temporary_directory("transport-ack-before-write")?;
7844 let account =
7845 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7846 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7847 let conversation =
7848 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7849 let sent = account.send_message(
7850 "ack-before-write".to_owned(),
7851 conversation.id,
7852 content("delivery ordering"),
7853 String::new(),
7854 None,
7855 1_700_000_001,
7856 )?;
7857 let operation_id = sent.operation.operation_id;
7858
7859 let started = account.start_account_transport("ack-order-run".to_owned())?;
7860 let connected = account.apply_account_transport_result(
7861 transport_result(
7862 &started.actions[0],
7863 AccountTransportResultKind::Connected,
7864 String::new(),
7865 ),
7866 1_700_000_002,
7867 )?;
7868 let delivery_sends = delivery_send_actions(&connected)?;
7869 assert!(!delivery_sends.is_empty());
7870 let first_event_id = delivery_sends[0].1[0].clone();
7871 let mut receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7872
7873 for (_, event_ids) in &delivery_sends {
7874 for event_id in event_ids {
7875 let acknowledged = account.apply_account_transport_result(
7876 transport_result(
7877 &receive,
7878 AccountTransportResultKind::FrameReceived,
7879 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7880 event_id: event_id.clone(),
7881 accepted: true,
7882 message: String::new(),
7883 })
7884 .to_json()?,
7885 ),
7886 1_700_000_003,
7887 )?;
7888 receive = transport_action(&acknowledged, AccountTransportActionKind::ReceiveText)?;
7889 }
7890 }
7891 for (action, _) in &delivery_sends {
7892 account.apply_account_transport_result(
7893 transport_result(
7894 action,
7895 AccountTransportResultKind::FrameWritten,
7896 String::new(),
7897 ),
7898 1_700_000_004,
7899 )?;
7900 }
7901 assert_eq!(
7902 account.operation(operation_id.clone())?.state,
7903 crate::OperationState::Sent
7904 );
7905
7906 let duplicate = account.apply_account_transport_result(
7907 transport_result(
7908 &receive,
7909 AccountTransportResultKind::FrameReceived,
7910 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7911 event_id: first_event_id.clone(),
7912 accepted: true,
7913 message: String::new(),
7914 })
7915 .to_json()?,
7916 ),
7917 1_700_000_005,
7918 )?;
7919 let conflicting_receive =
7920 transport_action(&duplicate, AccountTransportActionKind::ReceiveText)?;
7921 let after_conflict = account.apply_account_transport_result(
7922 transport_result(
7923 &conflicting_receive,
7924 AccountTransportResultKind::FrameReceived,
7925 RelayResponseFrame::Ok(crate::BatchAcknowledgement {
7926 event_id: first_event_id,
7927 accepted: false,
7928 message: "blocked".to_owned(),
7929 })
7930 .to_json()?,
7931 ),
7932 1_700_000_006,
7933 )?;
7934 assert_eq!(after_conflict.connection_state, RelayConnectionState::Ready);
7935 transport_action(&after_conflict, AccountTransportActionKind::ReceiveText)?;
7936 assert_eq!(
7937 account.operation(operation_id)?.state,
7938 crate::OperationState::Sent
7939 );
7940
7941 assert_eq!(
7942 account.cancel_account_transport()?.connection_state,
7943 RelayConnectionState::Cancelled
7944 );
7945 drop(account);
7946 std::fs::remove_dir_all(directory)?;
7947 Ok(())
7948 }
7949
7950 #[test]
7951 fn account_transport_reclaims_a_delivery_once_after_reconnect()
7952 -> Result<(), Box<dyn std::error::Error>> {
7953 let directory = temporary_directory("transport-delivery-reconnect")?;
7954 let account =
7955 AccountRuntimeHandle::open(secret(ALICE_SECRET)?, directory.display().to_string())?;
7956 let bob = crate::LocalIdentity::from_secret_hex(BOB_SECRET)?;
7957 let conversation =
7958 account.get_or_create_conversation(vec![bob.public_key().to_hex()], 1_700_000_000)?;
7959 let sent = account.send_message(
7960 "delivery-reconnect".to_owned(),
7961 conversation.id,
7962 content("retry after disconnect"),
7963 String::new(),
7964 None,
7965 1_700_000_001,
7966 )?;
7967
7968 let started = account.start_account_transport("reconnect-run".to_owned())?;
7969 let connected = account.apply_account_transport_result(
7970 transport_result(
7971 &started.actions[0],
7972 AccountTransportResultKind::Connected,
7973 String::new(),
7974 ),
7975 1_700_000_002,
7976 )?;
7977 let delivery_sends = delivery_send_actions(&connected)?;
7978 assert!(!delivery_sends.is_empty());
7979 let expected_event_ids = delivery_sends
7980 .iter()
7981 .flat_map(|(_, event_ids)| event_ids.iter().cloned())
7982 .collect::<BTreeSet<_>>();
7983 for (action, _) in &delivery_sends {
7984 account.apply_account_transport_result(
7985 transport_result(
7986 action,
7987 AccountTransportResultKind::FrameWritten,
7988 String::new(),
7989 ),
7990 1_700_000_003,
7991 )?;
7992 }
7993 let receive = transport_action(&connected, AccountTransportActionKind::ReceiveText)?;
7994 let reconnecting = account.apply_account_transport_result(
7995 transport_result(
7996 &receive,
7997 AccountTransportResultKind::Disconnected,
7998 String::new(),
7999 ),
8000 1_700_000_004,
8001 )?;
8002 let reconnect = transport_action(&reconnecting, AccountTransportActionKind::Connect)?;
8003
8004 let reconnected = account.apply_account_transport_result(
8005 transport_result(
8006 &reconnect,
8007 AccountTransportResultKind::Connected,
8008 String::new(),
8009 ),
8010 1_700_000_005,
8011 )?;
8012 assert_eq!(reconnected.connection_state, RelayConnectionState::Ready);
8013 let reclaimed_event_ids = delivery_send_actions(&reconnected)?
8014 .into_iter()
8015 .flat_map(|(_, event_ids)| event_ids)
8016 .collect::<Vec<_>>();
8017 assert_eq!(
8018 reclaimed_event_ids.iter().collect::<BTreeSet<_>>(),
8019 expected_event_ids.iter().collect::<BTreeSet<_>>(),
8020 );
8021 assert_eq!(reclaimed_event_ids.len(), expected_event_ids.len());
8022 assert_eq!(
8023 account.operation(sent.operation.operation_id)?.state,
8024 crate::OperationState::Sending,
8025 );
8026
8027 account.cancel_account_transport()?;
8028 drop(account);
8029 std::fs::remove_dir_all(directory)?;
8030 Ok(())
8031 }
8032}