1use crate::event_ext::FinalizeUnsignedWithId;
8use std::ops::Range;
9use std::sync::Arc;
10use std::sync::atomic::{AtomicBool, Ordering};
11use nostr_sdk::prelude::*;
12
13pub const NIP59_RANDOM_TIMESTAMP_TWEAK: Range<u64> = 0..172_800;
19
20pub fn tweaked_timestamp() -> Timestamp {
24 use ::rand::Rng;
25 let secs: u64 = ::rand::thread_rng().gen_range(NIP59_RANDOM_TIMESTAMP_TWEAK);
26 Timestamp::from_secs(Timestamp::now().as_secs().saturating_sub(secs))
27}
28
29use crate::state::{nostr_client, my_public_key, STATE};
30use crate::types::{Message, Attachment};
31use crate::crypto;
32
33pub trait SendCallback: Send + Sync {
45 fn on_pending(&self, _chat_id: &str, _msg: &Message) {}
47
48 fn on_upload_progress(
50 &self,
51 _pending_id: &str,
52 _percentage: u8,
53 _bytes_sent: u64,
54 ) -> Result<(), String> {
55 Ok(())
56 }
57
58 fn on_upload_complete(&self, _chat_id: &str, _pending_id: &str, _attachment_id: &str, _url: &str) {}
60
61 fn on_sent(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
64
65 fn on_failed(&self, _chat_id: &str, _old_id: &str, _msg: &Message) {}
67
68 fn on_persist(&self, _chat_id: &str, _msg: &Message) {}
71}
72
73pub struct NoOpSendCallback;
75impl SendCallback for NoOpSendCallback {}
76
77pub struct SendConfig {
83 pub max_send_attempts: u32,
85 pub retry_delay: std::time::Duration,
87 pub self_send: bool,
89 pub cancel_token: Option<Arc<AtomicBool>>,
91 pub upload_retries: u32,
93 pub upload_retry_delay: std::time::Duration,
95 pub expiration: Option<u64>,
99}
100
101impl Default for SendConfig {
102 fn default() -> Self {
103 Self {
104 max_send_attempts: 1,
105 retry_delay: std::time::Duration::from_secs(5),
106 self_send: false,
107 cancel_token: None,
108 upload_retries: 3,
109 upload_retry_delay: std::time::Duration::from_secs(2),
110 expiration: None,
111 }
112 }
113}
114
115impl SendConfig {
116 pub fn gui() -> Self {
118 Self {
119 max_send_attempts: 12,
120 self_send: true,
121 ..Default::default()
122 }
123 }
124
125 pub fn headless() -> Self {
127 Self {
128 max_send_attempts: 3,
129 self_send: true,
130 ..Default::default()
131 }
132 }
133}
134
135#[derive(serde::Serialize, Clone, Debug)]
141pub struct SendResult {
142 pub pending_id: String,
144 pub event_id: Option<String>,
146 pub chat_id: String,
148}
149
150struct WrapConfirm {
167 wrap_id: EventId,
168 chat_id: String,
169 pending_id: String,
170 rumor_event_id: String,
172 rumor: UnsignedEvent,
173 callback: Arc<dyn SendCallback>,
174 self_send: bool,
175 confirmed: AtomicBool,
176 rescued: AtomicBool,
179 loop_exited: AtomicBool,
182 notify: tokio::sync::Notify,
183 session: crate::state::SessionGuard,
184 registered_at: std::time::Instant,
185}
186
187static WRAP_CONFIRMS: std::sync::LazyLock<
188 std::sync::Mutex<std::collections::HashMap<EventId, Arc<WrapConfirm>>>,
189> = std::sync::LazyLock::new(|| std::sync::Mutex::new(std::collections::HashMap::new()));
190
191const WRAP_CONFIRM_TTL: std::time::Duration = std::time::Duration::from_secs(15 * 60);
194
195fn register_wrap_confirm(entry: Arc<WrapConfirm>) {
196 let mut map = WRAP_CONFIRMS.lock().unwrap();
197 map.retain(|_, e| e.registered_at.elapsed() < WRAP_CONFIRM_TTL);
198 map.insert(entry.wrap_id, entry);
199}
200
201fn remove_wrap_confirm(wrap_id: &EventId) {
202 WRAP_CONFIRMS.lock().unwrap().remove(wrap_id);
203}
204
205pub fn clear_wrap_confirms() {
207 WRAP_CONFIRMS.lock().unwrap().clear();
208}
209
210pub fn note_relay_ok(event_id: &EventId, accepted: bool) {
216 if !accepted {
217 return;
218 }
219 let entry = {
220 let map = WRAP_CONFIRMS.lock().unwrap();
221 map.get(event_id).cloned()
222 };
223 let Some(entry) = entry else { return };
224 entry.confirmed.store(true, Ordering::SeqCst);
225 entry.notify.notify_one();
226 if !entry.loop_exited.load(Ordering::SeqCst)
227 || entry.rescued.swap(true, Ordering::SeqCst)
228 {
229 return;
230 }
231 if !entry.session.is_valid() {
232 remove_wrap_confirm(&entry.wrap_id);
233 return;
234 }
235 tokio::spawn(async move {
236 rescue_failed_as_sent(&entry).await;
237 remove_wrap_confirm(&entry.wrap_id);
238 });
239}
240
241async fn rescue_failed_as_sent(entry: &WrapConfirm) {
244 if !entry.session.is_valid() {
245 return;
246 }
247 let finalized = {
248 let mut state = STATE.lock().await;
249 state.update_message(&entry.pending_id, |msg| {
250 msg.set_failed(false);
251 });
252 state.finalize_pending_message(&entry.chat_id, &entry.pending_id, &entry.rumor_event_id)
253 };
254 let Some((_old_id, ref msg)) = finalized else { return };
255 crate::log_info!(
256 "[Send] late relay OK confirmed wrap {} — message {} rescued to sent",
257 entry.wrap_id,
258 entry.rumor_event_id,
259 );
260 entry.callback.on_sent(&entry.chat_id, &entry.pending_id, msg);
261 entry.callback.on_persist(&entry.chat_id, msg);
262 if let Some(rid) = entry.rumor.id {
264 let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
265 }
266 if entry.self_send {
267 if let (Some(client), Some(my_pk)) = (nostr_client(), my_public_key()) {
268 spawn_self_send(client, my_pk, entry.rumor.clone());
269 }
270 }
271}
272
273fn spawn_self_send(client: Client, my_pk: PublicKey, rumor: UnsignedEvent) {
277 let rid_for_self = rumor.id;
278 let session = crate::state::SessionGuard::capture();
279 tokio::spawn(async move {
280 if !session.is_valid() { return; }
281 match crate::inbox_relays::send_gift_wrap_retained(
282 &client, &my_pk, rumor, [],
283 ).await {
284 Ok(self_outcome) if !self_outcome.output.success.is_empty() => {
285 if !session.is_valid() { return; }
286 if let Some(rid) = rid_for_self {
287 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
288 &self_outcome.wrap_event_id,
289 &rid,
290 &my_pk,
291 crate::db::nip17_keys::WrapRole::SelfSend,
292 &self_outcome.wrap_secret,
293 &self_outcome.targeted_relays,
294 ) {
295 eprintln!("[NIP-17] failed to persist self-wrap key: {}", e);
296 }
297 }
298 }
299 _ => {}
300 }
301 });
302}
303
304async fn retry_send_gift_wrap(
319 client: &Client,
320 receiver: &PublicKey,
321 receiver_npub: &str,
322 pending_id: &str,
323 rumor: UnsignedEvent,
324 event_id: &str,
325 config: &SendConfig,
326 callback: Arc<dyn SendCallback>,
327 prebuilt: Option<crate::inbox_relays::BuiltGiftWrap>,
331) -> Result<SendResult, String> {
332 let my_pk = my_public_key().ok_or("Public key not set")?;
333 let inner_rumor_id = rumor.id;
334
335 let is_resend = prebuilt.is_some();
338 let mut built: Option<crate::inbox_relays::BuiltGiftWrap> = prebuilt;
341 let mut targets: Option<crate::inbox_relays::GiftWrapTargets> = None;
344 let mut confirm: Option<Arc<WrapConfirm>> = None;
345 let mut last_error: Option<String> = None;
346
347 let max_attempts = config.max_send_attempts.max(1);
348
349 for attempt in 0..max_attempts {
350 if built.is_none() {
351 let wrap_extra: Vec<Tag> = rumor.tags.iter()
354 .find(|t| t.as_slice().first().map(|k| k.as_str() == "expiration").unwrap_or(false))
355 .cloned()
356 .into_iter()
357 .collect();
358 match crate::inbox_relays::build_gift_wrap_retained(
359 client, receiver, rumor.clone(), wrap_extra,
360 ).await {
361 Ok(b) => built = Some(b),
362 Err(e) => {
363 crate::log_warn!(
364 "[Send] attempt {}/{} — building gift-wrap failed: {}",
365 attempt + 1, max_attempts, e,
366 );
367 last_error = Some(e);
368 if attempt + 1 < max_attempts {
369 tokio::time::sleep(config.retry_delay).await;
370 }
371 continue;
372 }
373 }
374 }
375 let wrap = built.as_ref().unwrap();
376
377 if confirm.is_none() {
378 let entry = Arc::new(WrapConfirm {
379 wrap_id: wrap.event.id,
380 chat_id: receiver_npub.to_string(),
381 pending_id: pending_id.to_string(),
382 rumor_event_id: event_id.to_string(),
383 rumor: rumor.clone(),
384 callback: callback.clone(),
385 self_send: config.self_send,
386 confirmed: AtomicBool::new(false),
387 rescued: AtomicBool::new(false),
388 loop_exited: AtomicBool::new(false),
389 notify: tokio::sync::Notify::new(),
390 session: crate::state::SessionGuard::capture(),
391 registered_at: std::time::Instant::now(),
392 });
393 register_wrap_confirm(entry.clone());
394 confirm = Some(entry);
395 }
396 let confirm_ref = confirm.as_ref().unwrap();
397
398 if targets.is_none() {
399 let t = crate::inbox_relays::resolve_gift_wrap_targets(client, receiver).await;
400 if !is_resend {
404 if let Some(rid) = inner_rumor_id {
405 if let Err(e) = crate::db::nip17_keys::store_wrap_key(
406 &wrap.event.id,
407 &rid,
408 receiver,
409 crate::db::nip17_keys::WrapRole::Recipient,
410 &wrap.secret,
411 &t.targeted_relays,
412 ) {
413 eprintln!("[NIP-17] failed to persist wrap key: {}", e);
414 }
415 if let Err(e) = crate::db::nip17_keys::stash_resend_payload(
416 &wrap.event.id, pending_id, &wrap.event, &rumor,
417 ) {
418 eprintln!("[NIP-17] failed to retain resend payload: {}", e);
419 }
420 }
421 }
422 targets = Some(t);
423 } else {
424 crate::inbox_relays::reconnect_gift_wrap_targets(targets.as_ref().unwrap()).await;
425 }
426 let targets_ref = targets.as_ref().unwrap();
427
428 match crate::inbox_relays::publish_gift_wrap_to_targets(
429 client, targets_ref, &wrap.event,
430 ).await {
431 Ok(output) if !output.success.is_empty() => {
432 return Ok(finalize_gift_wrap_sent(
433 client, my_pk, receiver_npub, pending_id, event_id,
434 &rumor, config, &callback, confirm_ref, targets_ref,
435 ).await);
436 }
437 Ok(output) => {
438 let failures: Vec<String> = output.failed.iter()
443 .map(|(url, err)| format!("{}: {}", url, err))
444 .collect();
445 crate::log_warn!(
446 "[Send] attempt {}/{} — 0 of {} relays accepted (targeted: {}). Per-relay errors: {}",
447 attempt + 1,
448 max_attempts,
449 targets_ref.targeted_relays.len(),
450 targets_ref.targeted_relays.join(", "),
451 if failures.is_empty() {
452 "(none reported — likely all timed out before responding)".to_string()
453 } else {
454 failures.join(" | ")
455 },
456 );
457 last_error = None;
458 }
459 Err(e) => {
460 crate::log_warn!(
461 "[Send] attempt {}/{} — publish errored: {}",
462 attempt + 1, max_attempts, e,
463 );
464 last_error = Some(e);
465 }
466 }
467
468 if confirm_ref.confirmed.load(Ordering::SeqCst) {
471 return Ok(finalize_gift_wrap_sent(
472 client, my_pk, receiver_npub, pending_id, event_id,
473 &rumor, config, &callback, confirm_ref, targets_ref,
474 ).await);
475 }
476
477 if attempt + 1 < max_attempts {
478 let _ = tokio::time::timeout(
482 config.retry_delay,
483 confirm_ref.notify.notified(),
484 ).await;
485 if confirm_ref.confirmed.load(Ordering::SeqCst) {
486 return Ok(finalize_gift_wrap_sent(
487 client, my_pk, receiver_npub, pending_id, event_id,
488 &rumor, config, &callback, confirm_ref, targets_ref,
489 ).await);
490 }
491 }
492 }
493
494 if let Some(t) = targets.as_ref() {
498 crate::inbox_relays::teardown_gift_wrap_targets(client, t).await;
499 }
500 let failed_msg = {
501 let mut state = STATE.lock().await;
502 state.update_message(pending_id, |msg| {
503 msg.set_failed(true);
504 msg.set_pending(false);
505 })
506 };
507 if let Some((_chat_id, ref msg)) = failed_msg {
508 callback.on_failed(receiver_npub, pending_id, msg);
509 callback.on_persist(receiver_npub, msg);
510 }
511 if let Some(entry) = confirm.as_ref() {
512 entry.loop_exited.store(true, Ordering::SeqCst);
513 if entry.confirmed.load(Ordering::SeqCst)
516 && !entry.rescued.swap(true, Ordering::SeqCst)
517 {
518 rescue_failed_as_sent(entry).await;
519 remove_wrap_confirm(&entry.wrap_id);
520 return Ok(SendResult {
521 pending_id: pending_id.to_string(),
522 event_id: Some(event_id.to_string()),
523 chat_id: receiver_npub.to_string(),
524 });
525 }
526 }
527 match last_error {
528 Some(e) => Err(format!("Failed to send DM after {} attempts: {}", max_attempts, e)),
529 None => Err(format!(
530 "Failed to send DM after {} attempts (no relays accepted the gift-wrap)",
531 max_attempts
532 )),
533 }
534}
535
536async fn finalize_gift_wrap_sent(
539 client: &Client,
540 my_pk: PublicKey,
541 receiver_npub: &str,
542 pending_id: &str,
543 event_id: &str,
544 rumor: &UnsignedEvent,
545 config: &SendConfig,
546 callback: &Arc<dyn SendCallback>,
547 confirm: &Arc<WrapConfirm>,
548 targets: &crate::inbox_relays::GiftWrapTargets,
549) -> SendResult {
550 remove_wrap_confirm(&confirm.wrap_id);
551 crate::inbox_relays::teardown_gift_wrap_targets(client, targets).await;
552
553 let finalized = {
554 let mut state = STATE.lock().await;
555 state.finalize_pending_message(receiver_npub, pending_id, event_id)
556 };
557 if let Some((_old_id, ref finalized_msg)) = finalized {
558 callback.on_sent(receiver_npub, pending_id, finalized_msg);
559 callback.on_persist(receiver_npub, finalized_msg);
560 }
561
562 if let Some(rid) = rumor.id {
565 let _ = crate::db::nip17_keys::clear_resend_payload(&rid);
566 }
567
568 if config.self_send {
569 spawn_self_send(client.clone(), my_pk, rumor.clone());
570 }
571
572 SendResult {
573 pending_id: pending_id.to_string(),
574 event_id: Some(event_id.to_string()),
575 chat_id: receiver_npub.to_string(),
576 }
577}
578
579pub async fn send_dm(
588 receiver_npub: &str,
589 content: &str,
590 reply_to: Option<&str>,
591 config: &SendConfig,
592 callback: Arc<dyn SendCallback>,
593) -> Result<SendResult, String> {
594 let client = nostr_client().ok_or("Not logged in")?;
595 let my_pk = my_public_key().ok_or("Public key not set")?;
596
597 let now = std::time::SystemTime::now()
598 .duration_since(std::time::UNIX_EPOCH).unwrap();
599 let pending_id = format!("pending-{}", now.as_nanos());
600
601 let receiver = PublicKey::from_bech32(receiver_npub)
602 .map_err(|e| format!("Invalid npub: {}", e))?;
603
604 let emoji_tags = crate::emoji_packs::resolve_outbound_emoji_tags(content);
609
610 let msg = Message {
612 id: pending_id.clone(),
613 content: content.to_string(),
614 replied_to: reply_to.unwrap_or("").to_string(),
615 at: now.as_millis() as u64,
616 pending: true,
617 mine: true,
618 npub: my_pk.to_bech32().ok(),
619 emoji_tags: emoji_tags.clone(),
620 expiration: config.expiration,
621 ..Default::default()
622 };
623
624 {
625 let mut state = STATE.lock().await;
626 state.add_message_to_participant(receiver_npub, &msg);
627 }
628
629 callback.on_pending(receiver_npub, &msg);
630
631 let milliseconds = now.as_millis() % 1000;
633 let mut rumor = EventBuilder::new(Kind::PrivateDirectMessage, content)
635 .tag(Tag::public_key(receiver));
636
637 if let Some(reply_id) = reply_to {
638 if !reply_id.is_empty() {
639 rumor = rumor.tag(Tag::custom(
640 "e",
641 [reply_id.to_string(), String::new(), "reply".to_string()],
642 ));
643 }
644 }
645
646 let mut rumor = rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
647 for et in &emoji_tags {
648 rumor = rumor.tag(Tag::custom(
649 "emoji",
650 [et.shortcode.clone(), et.url.clone()],
651 ));
652 }
653 if let Some(exp) = config.expiration {
656 rumor = rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
657 }
658 let built_rumor = rumor.finalize_unsigned_with_id(my_pk);
659 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
660
661 retry_send_gift_wrap(
663 &client, &receiver, receiver_npub, &pending_id,
664 built_rumor, &event_id, config, callback, None,
665 ).await
666}
667
668pub async fn send_rumor_dm(
676 receiver_npub: &str,
677 pending_id: &str,
678 rumor: UnsignedEvent,
679 config: &SendConfig,
680 callback: Arc<dyn SendCallback>,
681) -> Result<SendResult, String> {
682 let client = nostr_client().ok_or("Not logged in")?;
683
684 let receiver = PublicKey::from_bech32(receiver_npub)
685 .map_err(|e| format!("Invalid npub: {}", e))?;
686
687 let event_id = rumor.id.ok_or("Rumor has no id")?.to_hex();
688
689 retry_send_gift_wrap(
690 &client, &receiver, receiver_npub, pending_id,
691 rumor, &event_id, config, callback, None,
692 ).await
693}
694
695pub async fn resend_failed_dm(
708 receiver_npub: &str,
709 failed_msg_id: &str,
710 config: &SendConfig,
711 callback: Arc<dyn SendCallback>,
712) -> Result<bool, String> {
713 let session = crate::state::SessionGuard::capture();
718 let payload = match crate::db::nip17_keys::get_resend_payload_by_pending(failed_msg_id)? {
719 Some(p) => p,
720 None => return Ok(false),
721 };
722 let client = nostr_client().ok_or("Not logged in")?;
723 let receiver = PublicKey::from_bech32(receiver_npub)
724 .map_err(|e| format!("Invalid npub: {}", e))?;
725 if !session.is_valid() {
726 return Ok(true);
727 }
728
729 let repending = {
732 let mut state = STATE.lock().await;
733 state.update_message(failed_msg_id, |msg| {
734 msg.set_failed(false);
735 msg.set_pending(true);
736 })
737 };
738 if let Some((_chat_id, ref msg)) = repending {
739 callback.on_pending(receiver_npub, msg);
740 }
741
742 let event_id = payload.rumor.id.ok_or("Retained rumor has no id")?.to_hex();
743 let built = crate::inbox_relays::BuiltGiftWrap {
744 event: payload.wrap_event,
745 secret: payload.secret,
746 };
747 if let Err(e) = retry_send_gift_wrap(
749 &client, &receiver, receiver_npub, failed_msg_id,
750 payload.rumor, &event_id, config, callback, Some(built),
751 )
752 .await
753 {
754 crate::log_warn!("[Send] idempotent resend of {} did not land: {}", failed_msg_id, e);
758 }
759 Ok(true)
760}
761
762pub async fn send_file_dm(
770 receiver_npub: &str,
771 file_bytes: Arc<Vec<u8>>,
772 filename: &str,
773 extension: &str,
774 content: Option<&str>,
775 config: &SendConfig,
776 callback: Arc<dyn SendCallback>,
777) -> Result<SendResult, String> {
778 let client = nostr_client().ok_or("Not logged in")?;
779 let my_pk = my_public_key().ok_or("Public key not set")?;
780 let signer = crate::signer::active_signer()
784 .map_err(|e| format!("Signer unavailable: {}", e))?;
785
786 let now = std::time::SystemTime::now()
787 .duration_since(std::time::UNIX_EPOCH).unwrap();
788 let pending_id = format!("pending-{}", now.as_nanos());
789 let milliseconds = now.as_millis() % 1000;
790
791 let receiver = PublicKey::from_bech32(receiver_npub)
792 .map_err(|e| format!("Invalid npub: {}", e))?;
793
794 let file_hash = crypto::sha256_hex(&file_bytes);
795 let mime_type = crypto::mime_from_extension(extension);
796
797 let webxdc_topic = (extension.eq_ignore_ascii_case("xdc"))
802 .then(|| crate::webxdc::mint_topic_id(&file_hash, &my_pk.to_hex()));
803
804 let download_dir = crate::db::get_download_dir();
806 let _ = std::fs::create_dir_all(&download_dir);
807 let local_name = if filename.is_empty() {
814 format!("{}.{}", &file_hash, extension)
815 } else {
816 let stem = filename.rsplit_once('.').map(|(s, _)| s).unwrap_or(filename);
817 format!("{}.{}", stem, extension)
818 };
819 let local_path = crypto::resolve_unique_filename(&download_dir, &local_name);
821 let tmp = download_dir.join(format!(".{}.tmp", &file_hash));
823 let _ = std::fs::write(&tmp, &*file_bytes);
824 let _ = std::fs::rename(&tmp, &local_path);
825 let local_path_str = local_path.to_string_lossy().to_string();
826
827 let img_meta = crypto::generate_image_metadata(&file_bytes);
829
830 let params = crypto::generate_encryption_params();
832 let encrypted = crypto::encrypt_data(&file_bytes, ¶ms)?;
833 let encrypted_size = encrypted.len() as u64;
834
835 let attachment = Attachment {
836 id: file_hash.clone(), key: params.key.clone(), nonce: params.nonce.clone(),
837 extension: extension.to_string(), name: filename.to_string(),
838 url: String::new(), path: local_path_str.clone(), size: encrypted_size,
839 img_meta: img_meta.clone(), downloading: false, downloaded: true,
840 webxdc_topic: webxdc_topic.clone(),
841 ..Default::default()
842 };
843 let msg = Message {
844 id: pending_id.clone(), content: content.unwrap_or("").to_string(),
845 at: now.as_millis() as u64, pending: true, mine: true,
846 npub: my_pk.to_bech32().ok(), attachments: vec![attachment],
847 expiration: config.expiration,
848 ..Default::default()
849 };
850 {
851 let mut state = STATE.lock().await;
852 state.add_message_to_participant(receiver_npub, &msg);
853 }
854 callback.on_pending(receiver_npub, &msg);
855
856 let servers = crate::state::get_blossom_servers();
858 let cb_for_progress = callback.clone();
859 let pid_for_progress = pending_id.clone();
860 let progress_cb: crate::blossom::ProgressCallback = Arc::new(move |percentage, bytes| {
861 cb_for_progress.on_upload_progress(
862 &pid_for_progress,
863 percentage.unwrap_or(0),
864 bytes.unwrap_or(0),
865 )
866 });
867
868 let upload_url = match crate::blossom::upload_blob_with_progress_and_failover(
872 signer.clone(), servers, Arc::new(encrypted), Some(mime_type),
873 true,
874 progress_cb, Some(config.upload_retries), Some(config.upload_retry_delay),
875 config.cancel_token.clone(),
876 ).await {
877 Ok(url) => url,
878 Err(e) => {
879 let failed_msg = {
880 let mut state = STATE.lock().await;
881 state.update_message(&pending_id, |msg| {
882 msg.set_failed(true);
883 msg.set_pending(false);
884 })
885 };
886 if let Some((_chat_id, ref msg)) = failed_msg {
887 callback.on_failed(receiver_npub, &pending_id, msg);
888 callback.on_persist(receiver_npub, msg);
889 }
890 return Err(format!("Upload failed: {}", e));
891 }
892 };
893
894 {
895 let mut state = STATE.lock().await;
896 state.update_message(&pending_id, |msg| {
897 if let Some(att) = msg.attachments.last_mut() {
898 att.url = upload_url.clone().into_boxed_str();
899 }
900 });
901 }
902 callback.on_upload_complete(receiver_npub, &pending_id, &file_hash, &upload_url);
903
904 let mirror_urls = crate::blossom::mirror_blob_to_servers(
908 signer.clone(),
909 &upload_url,
910 crate::state::get_blossom_servers(),
911 2,
912 std::time::Duration::from_secs(5),
913 ).await;
914 if !mirror_urls.is_empty() {
915 let mut state = STATE.lock().await;
916 state.update_message(&pending_id, |msg| {
917 if let Some(att) = msg.attachments.last_mut() {
918 att.fallback_urls = Some(mirror_urls.iter().map(|s| s.as_str().into()).collect());
919 }
920 });
921 }
922
923 let mut file_rumor = EventBuilder::new(Kind::from_u16(15), &upload_url)
925 .tag(Tag::public_key(receiver))
926 .tag(Tag::custom("file-type", [mime_type]))
927 .tag(Tag::custom("size", [encrypted_size.to_string()]))
928 .tag(Tag::custom("encryption-algorithm", ["aes-gcm"]))
929 .tag(Tag::custom("decryption-key", [params.key.as_str()]))
930 .tag(Tag::custom("decryption-nonce", [params.nonce.as_str()]))
931 .tag(Tag::custom("ox", [file_hash.clone()]));
932 for fb in &mirror_urls {
933 file_rumor = file_rumor.tag(Tag::custom("fallback", [fb.as_str()]));
934 }
935 if !filename.is_empty() {
936 file_rumor = file_rumor.tag(Tag::custom("name", [filename]));
937 }
938 if let Some(ref topic) = webxdc_topic {
939 file_rumor = file_rumor.tag(Tag::custom("webxdc-topic", [topic.as_str()]));
940 }
941 if let Some(ref meta) = img_meta {
943 if !meta.thumbhash.is_empty() {
944 file_rumor = file_rumor.tag(Tag::custom("thumbhash", [meta.thumbhash.as_str()]));
947 }
948 file_rumor = file_rumor.tag(Tag::custom("dim", [format!("{}x{}", meta.width, meta.height)]));
949 }
950 file_rumor = file_rumor.tag(Tag::custom("ms", [milliseconds.to_string()]));
951 if let Some(exp) = config.expiration {
952 file_rumor = file_rumor.tag(Tag::expiration(Timestamp::from_secs(exp)));
953 }
954
955 let built_rumor = file_rumor.finalize_unsigned_with_id(my_pk);
956 let event_id = built_rumor.id.ok_or("Rumor has no id")?.to_hex();
957
958 retry_send_gift_wrap(
959 &client, &receiver, receiver_npub, &pending_id,
960 built_rumor, &event_id, config, callback, None,
961 ).await
962}
963
964#[cfg(test)]
969mod tests {
970 use super::*;
971 use std::sync::Mutex;
972
973 #[derive(Debug, Clone, PartialEq)]
974 enum CbEvent {
975 Pending(String),
976 UploadProgress(String, u8, u64),
977 UploadComplete(String, String),
978 Sent(String, String),
979 Failed(String, String),
980 Persist(String),
981 }
982
983 struct MockCallback {
984 events: Mutex<Vec<CbEvent>>,
985 cancel_at: Option<u8>,
986 }
987
988 impl MockCallback {
989 fn new() -> Self { Self { events: Mutex::new(vec![]), cancel_at: None } }
990 fn with_cancel(pct: u8) -> Self { Self { events: Mutex::new(vec![]), cancel_at: Some(pct) } }
991 fn events(&self) -> Vec<CbEvent> { self.events.lock().unwrap().clone() }
992 }
993
994 impl SendCallback for MockCallback {
995 fn on_pending(&self, cid: &str, _: &Message) {
996 self.events.lock().unwrap().push(CbEvent::Pending(cid.into()));
997 }
998 fn on_upload_progress(&self, pid: &str, pct: u8, bytes: u64) -> Result<(), String> {
999 self.events.lock().unwrap().push(CbEvent::UploadProgress(pid.into(), pct, bytes));
1000 if self.cancel_at.map_or(false, |c| pct >= c) { return Err("Cancelled".into()); }
1001 Ok(())
1002 }
1003 fn on_upload_complete(&self, cid: &str, _: &str, _: &str, url: &str) {
1004 self.events.lock().unwrap().push(CbEvent::UploadComplete(cid.into(), url.into()));
1005 }
1006 fn on_sent(&self, cid: &str, old: &str, _: &Message) {
1007 self.events.lock().unwrap().push(CbEvent::Sent(cid.into(), old.into()));
1008 }
1009 fn on_failed(&self, cid: &str, old: &str, _: &Message) {
1010 self.events.lock().unwrap().push(CbEvent::Failed(cid.into(), old.into()));
1011 }
1012 fn on_persist(&self, cid: &str, _: &Message) {
1013 self.events.lock().unwrap().push(CbEvent::Persist(cid.into()));
1014 }
1015 }
1016
1017 #[test]
1018 fn config_default() {
1019 let c = SendConfig::default();
1020 assert_eq!(c.max_send_attempts, 1);
1021 assert!(!c.self_send);
1022 assert!(c.cancel_token.is_none());
1023 assert_eq!(c.upload_retries, 3);
1024 }
1025
1026 #[test]
1027 fn config_gui() {
1028 let c = SendConfig::gui();
1029 assert_eq!(c.max_send_attempts, 12);
1030 assert!(c.self_send);
1031 }
1032
1033 #[test]
1034 fn config_headless() {
1035 let c = SendConfig::headless();
1036 assert_eq!(c.max_send_attempts, 3);
1037 assert!(c.self_send);
1038 }
1039
1040 #[test]
1041 fn config_custom_override() {
1042 let c = SendConfig { max_send_attempts: 5, ..SendConfig::gui() };
1043 assert_eq!(c.max_send_attempts, 5);
1044 assert!(c.self_send);
1045 }
1046
1047 #[test]
1048 fn noop_callback_all_methods() {
1049 let cb = NoOpSendCallback;
1050 let msg = Message::default();
1051 cb.on_pending("c", &msg);
1052
1053 assert!(cb.on_upload_progress("p", 50, 1024).is_ok());
1054 cb.on_upload_complete("c", "p", "a", "url");
1055 cb.on_sent("c", "o", &msg);
1056 cb.on_failed("c", "o", &msg);
1057 cb.on_persist("c", &msg);
1058 }
1059
1060 #[test]
1061 fn text_dm_sequence() {
1062 let cb = MockCallback::new();
1063 let msg = Message::default();
1064 cb.on_pending("r", &msg);
1065 cb.on_sent("r", "p-1", &msg);
1066 cb.on_persist("r", &msg);
1067 assert_eq!(cb.events(), vec![
1068 CbEvent::Pending("r".into()),
1069 CbEvent::Sent("r".into(), "p-1".into()),
1070 CbEvent::Persist("r".into()),
1071 ]);
1072 }
1073
1074 #[test]
1075 fn file_dm_sequence() {
1076 let cb = MockCallback::new();
1077 let msg = Message::default();
1078 cb.on_pending("r", &msg);
1079
1080 cb.on_upload_progress("p", 0, 0).ok();
1081 cb.on_upload_progress("p", 50, 5000).ok();
1082 cb.on_upload_progress("p", 100, 10000).ok();
1083 cb.on_upload_complete("r", "p", "h", "https://blossom/h");
1084 cb.on_sent("r", "p", &msg);
1085 cb.on_persist("r", &msg);
1086 let e = cb.events();
1087 assert_eq!(e.len(), 7);
1088 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.contains("blossom")));
1089 }
1090
1091 #[test]
1092 fn failed_sequence() {
1093 let cb = MockCallback::new();
1094 let msg = Message::default();
1095 cb.on_pending("r", &msg);
1096 cb.on_failed("r", "p", &msg);
1097 cb.on_persist("r", &msg);
1098 assert_eq!(cb.events(), vec![
1099 CbEvent::Pending("r".into()),
1100 CbEvent::Failed("r".into(), "p".into()),
1101 CbEvent::Persist("r".into()),
1102 ]);
1103 }
1104
1105 #[test]
1106 fn cancel_upload_at_threshold() {
1107 let cb = MockCallback::with_cancel(50);
1108 assert!(cb.on_upload_progress("p", 25, 512).is_ok());
1109 assert!(cb.on_upload_progress("p", 50, 1024).is_err());
1110 assert_eq!(cb.events().len(), 2);
1111 }
1112
1113 #[test]
1114 fn cancel_triggers_failed() {
1115 let cb = MockCallback::with_cancel(30);
1116 let msg = Message::default();
1117 cb.on_pending("r", &msg);
1118
1119 cb.on_upload_progress("p", 10, 1000).ok();
1120 assert!(cb.on_upload_progress("p", 30, 3000).is_err());
1121 cb.on_failed("r", "p", &msg);
1122 assert!(matches!(cb.events().last(), Some(CbEvent::Failed(..))));
1123 }
1124
1125 #[test]
1126 fn send_result_serialize() {
1127 let r = SendResult { pending_id: "p".into(), event_id: Some("e".into()), chat_id: "c".into() };
1128 let j = serde_json::to_string(&r).unwrap();
1129 assert!(j.contains("\"pending_id\":\"p\""));
1130 }
1131
1132 #[test]
1133 fn send_result_none_event() {
1134 let r = SendResult { pending_id: "p".into(), event_id: None, chat_id: "c".into() };
1135 let j = serde_json::to_string(&r).unwrap();
1136 assert!(j.contains("null"));
1137 }
1138
1139 #[test]
1144 fn file_dm_fresh_upload_full_sequence() {
1145 let cb = MockCallback::new();
1146 let msg = Message::default();
1147
1148 cb.on_pending("npub1recv", &msg);
1150 cb.on_upload_progress("pending-42", 0, 0).unwrap();
1154 cb.on_upload_progress("pending-42", 25, 2500).unwrap();
1155 cb.on_upload_progress("pending-42", 50, 5000).unwrap();
1156 cb.on_upload_progress("pending-42", 75, 7500).unwrap();
1157 cb.on_upload_progress("pending-42", 100, 10000).unwrap();
1158 cb.on_upload_complete("npub1recv", "pending-42", "deadbeef", "https://blossom.example/deadbeef");
1160 cb.on_sent("npub1recv", "pending-42", &msg);
1162 cb.on_persist("npub1recv", &msg);
1164
1165 let e = cb.events();
1166 assert_eq!(e.len(), 9);
1167 assert!(matches!(&e[0], CbEvent::Pending(c) if c == "npub1recv"));
1168 assert!(matches!(&e[1], CbEvent::UploadProgress(_, 0, 0)));
1169 assert!(matches!(&e[5], CbEvent::UploadProgress(_, 100, 10000)));
1170 assert!(matches!(&e[6], CbEvent::UploadComplete(_, url) if url.contains("deadbeef")));
1171 assert!(matches!(&e[7], CbEvent::Sent(..)));
1172 assert!(matches!(&e[8], CbEvent::Persist(..)));
1173 }
1174
1175 #[test]
1176 fn file_dm_skip_upload_sequence() {
1177 let cb = MockCallback::new();
1178 let msg = Message::default();
1179
1180 cb.on_pending("npub1recv", &msg);
1183 cb.on_upload_complete("npub1recv", "pending-99", "existinghash", "https://blossom.example/existing");
1187 cb.on_sent("npub1recv", "pending-99", &msg);
1189 cb.on_persist("npub1recv", &msg);
1191
1192 let e = cb.events();
1193 assert_eq!(e.len(), 4);
1194 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadProgress(..))));
1196 assert!(matches!(&e[1], CbEvent::UploadComplete(..)));
1197 }
1198
1199 #[test]
1200 fn file_dm_upload_cancelled_at_30pct() {
1201 let cb = MockCallback::with_cancel(30);
1202 let msg = Message::default();
1203
1204 cb.on_pending("npub1recv", &msg);
1205
1206 assert!(cb.on_upload_progress("p", 10, 1000).is_ok());
1207 assert!(cb.on_upload_progress("p", 20, 2000).is_ok());
1208 let err = cb.on_upload_progress("p", 30, 3000);
1210 assert!(err.is_err());
1211 assert!(err.unwrap_err().contains("Cancelled"));
1212 cb.on_failed("npub1recv", "p", &msg);
1214
1215 let e = cb.events();
1216 assert_eq!(e.len(), 5);
1217 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1219 assert!(matches!(e.last(), Some(CbEvent::Failed(..))));
1220 }
1221
1222 #[test]
1223 fn file_dm_upload_fails_marks_failed() {
1224 let cb = MockCallback::new();
1225 let msg = Message::default();
1226
1227 cb.on_pending("npub1recv", &msg);
1228
1229 cb.on_upload_progress("p", 0, 0).ok();
1230 cb.on_upload_progress("p", 10, 500).ok();
1231 cb.on_failed("npub1recv", "p", &msg);
1233 cb.on_persist("npub1recv", &msg);
1234
1235 let e = cb.events();
1236 assert_eq!(e.len(), 5);
1237 assert!(matches!(&e[3], CbEvent::Failed(..)));
1238 assert!(matches!(&e[4], CbEvent::Persist(..)));
1239 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::UploadComplete(..))));
1241 assert!(!e.iter().any(|ev| matches!(ev, CbEvent::Sent(..))));
1242 }
1243
1244 #[test]
1245 fn file_dm_gift_wrap_fails_after_upload() {
1246 let cb = MockCallback::new();
1247 let msg = Message::default();
1248
1249 cb.on_pending("npub1recv", &msg);
1251
1252 cb.on_upload_progress("p", 100, 10000).ok();
1253 cb.on_upload_complete("npub1recv", "p", "hash", "https://blossom/hash");
1254 cb.on_failed("npub1recv", "p", &msg);
1256 cb.on_persist("npub1recv", &msg);
1257
1258 let e = cb.events();
1259 assert_eq!(e.len(), 5);
1260 assert!(matches!(&e[2], CbEvent::UploadComplete(..)));
1262 assert!(matches!(&e[3], CbEvent::Failed(..)));
1263 }
1264
1265 #[test]
1266 fn file_dm_with_image_metadata_sequence() {
1267 let cb = MockCallback::new();
1268 let msg = Message::default();
1269
1270 cb.on_pending("npub1recv", &msg);
1272
1273 cb.on_upload_progress("p", 0, 0).ok();
1274 cb.on_upload_progress("p", 50, 50000).ok();
1275 cb.on_upload_progress("p", 100, 100000).ok();
1276 cb.on_upload_complete("npub1recv", "p", "imghash", "https://blossom/imghash.jpg");
1277 cb.on_sent("npub1recv", "p", &msg);
1278 cb.on_persist("npub1recv", &msg);
1279
1280 let e = cb.events();
1281 assert_eq!(e.len(), 7);
1282 assert!(matches!(&e[0], CbEvent::Pending(..)));
1284 assert!(matches!(&e[4], CbEvent::UploadComplete(_, url) if url.ends_with(".jpg")));
1285 assert!(matches!(&e[5], CbEvent::Sent(..)));
1286 }
1287
1288 #[test]
1289 fn cancel_token_config_with_upload() {
1290 let token = Arc::new(std::sync::atomic::AtomicBool::new(false));
1291 let c = SendConfig {
1292 cancel_token: Some(token.clone()),
1293 ..SendConfig::gui()
1294 };
1295 assert!(c.cancel_token.is_some());
1296 assert!(!token.load(std::sync::atomic::Ordering::Relaxed));
1297
1298 token.store(true, std::sync::atomic::Ordering::Relaxed);
1300 assert!(c.cancel_token.as_ref().unwrap().load(std::sync::atomic::Ordering::Relaxed));
1301 }
1302}