1use crate::tags::TagsExt;
28use std::path::Path;
29use nostr_sdk::prelude::*;
30use crate::types::{Message, Attachment, ImageMetadata, Reaction};
31use crate::stored_event::{StoredEvent, StoredEventBuilder, event_kind};
32use crate::crypto::{extension_from_mime, sanitize_filename};
33
34#[derive(Debug, Clone)]
36pub struct RumorEvent {
37 pub id: EventId,
38 pub kind: Kind,
39 pub content: String,
40 pub tags: Tags,
41 pub created_at: Timestamp,
42 pub pubkey: PublicKey,
43}
44
45#[derive(Debug, Clone)]
50pub struct RumorContext {
51 pub sender: PublicKey,
53 pub is_mine: bool,
55 pub conversation_id: String,
57 pub conversation_type: ConversationType,
59}
60
61#[derive(Debug, Clone, PartialEq)]
64pub enum ConversationType {
65 DirectMessage,
67 Community,
69}
70
71impl RumorContext {
72 pub fn author_npub(&self, author: &PublicKey) -> Option<String> {
75 match self.conversation_type {
76 ConversationType::Community => author.to_bech32().ok(),
77 ConversationType::DirectMessage => None,
78 }
79 }
80}
81
82#[derive(Debug, Clone)]
88pub enum RumorProcessingResult {
89 TextMessage(Message),
91 FileAttachment(Message),
93 Reaction(Reaction),
95 TypingIndicator {
97 profile_id: String,
98 until: u64,
99 },
100 LeaveRequest {
102 event_id: String,
104 member_pubkey: String,
106 },
107 WebxdcPeerAdvertisement {
109 event_id: String,
110 topic_id: String,
111 node_addr: String,
112 sender_npub: String,
113 created_at: u64,
114 },
115 WebxdcPeerLeft {
117 event_id: String,
118 topic_id: String,
119 sender_npub: String,
120 created_at: u64,
121 },
122 UnknownEvent(StoredEvent),
125 PivxPayment {
127 gift_code: String,
129 amount_piv: f64,
131 address: Option<String>,
133 message_id: String,
135 event: StoredEvent,
137 },
138 WallpaperChanged {
143 sender_npub: String,
145 created_at: u64,
147 url: String,
149 decryption_key: String,
151 decryption_nonce: String,
153 plaintext_hash: Option<String>,
155 mime: Option<String>,
157 blur: Option<u8>,
159 dim: Option<u8>,
161 event_id: String,
163 },
164 Ignored,
166 DeletionRequest {
171 target_event_id: String,
173 },
174 Edit {
176 message_id: String,
178 new_content: String,
180 edited_at: u64,
182 emoji_tags: Vec<crate::types::EmojiTag>,
184 event: StoredEvent,
186 },
187}
188
189pub fn process_rumor(
206 rumor: RumorEvent,
207 context: RumorContext,
208 download_dir: &Path,
209) -> Result<RumorProcessingResult, String> {
210 match rumor.kind {
211 Kind::PrivateDirectMessage => {
213 process_text_message(rumor, context)
214 }
215 k if k.as_u16() == 15 => {
217 process_file_attachment(rumor, context, download_dir)
218 }
219 k if k.as_u16() == event_kind::MESSAGE_EDIT => {
221 process_edit_event(rumor, context)
222 }
223 Kind::Reaction => {
225 process_reaction(rumor, context)
226 }
227 Kind::ApplicationSpecificData => {
229 process_app_specific(rumor, context)
230 }
231 Kind::EventDeletion => {
238 process_deletion(rumor, context)
239 }
240 _ => {
242 process_unknown_event(rumor, context)
243 }
244 }
245}
246
247fn process_unknown_event(
252 rumor: RumorEvent,
253 context: RumorContext,
254) -> Result<RumorProcessingResult, String> {
255 let tags: Vec<Vec<String>> = rumor.tags.iter()
257 .map(|tag| {
258 tag.as_slice().iter().map(|s| s.to_string()).collect()
259 })
260 .collect();
261
262 let reference_id = rumor.tags
264 .find_kind("e")
265 .and_then(|tag| tag.content())
266 .map(|s| s.to_string());
267
268 let event = StoredEventBuilder::new()
269 .id(rumor.id.to_hex())
270 .kind(rumor.kind.as_u16())
271 .content(rumor.content)
272 .tags(tags)
273 .reference_id(reference_id)
274 .created_at(rumor.created_at.as_secs())
275 .mine(context.is_mine)
276 .npub(rumor.pubkey.to_bech32().ok())
277 .build();
278
279 Ok(RumorProcessingResult::UnknownEvent(event))
280}
281
282fn process_text_message(
286 rumor: RumorEvent,
287 context: RumorContext,
288) -> Result<RumorProcessingResult, String> {
289 let replied_to = extract_reply_reference(&rumor);
291
292 let ms_timestamp = extract_millisecond_timestamp(&rumor);
294
295 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
296 let addressed_bots = crate::bot_interface::addressed_bots(rumor.tags.iter());
297 let npub = context.author_npub(&rumor.pubkey);
299
300 let expiration = extract_nip40_expiration(&rumor);
302 if already_expired(expiration) {
305 return Ok(RumorProcessingResult::Ignored);
306 }
307 let msg = Message {
308 expiration,
309 id: rumor.id.to_hex(),
310 content: rumor.content,
311 replied_to,
312 replied_to_content: None, replied_to_npub: None,
314 replied_to_has_attachment: None,
315 replied_to_attachment_extension: None,
316 preview_metadata: None,
317 at: ms_timestamp,
318 attachments: Vec::new(),
319 reactions: Vec::new(),
320 mine: context.is_mine,
321 pending: false,
322 failed: false,
323 npub,
324 wrapper_event_id: None, edited: false,
326 edit_history: None,
327 emoji_tags,
328 addressed_bots,
329 };
330
331 Ok(RumorProcessingResult::TextMessage(msg))
332}
333
334pub fn extract_hash_from_blossom_url(url: &str) -> Option<String> {
338 let path = url.split('/').last()?;
339 let hash_part = path.split('.').next()?;
340 if hash_part.len() == 64 && hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
341 Some(hash_part.to_string())
342 } else {
343 None
344 }
345}
346
347fn process_file_attachment(
355 rumor: RumorEvent,
356 context: RumorContext,
357 download_dir: &Path,
358) -> Result<RumorProcessingResult, String> {
359 let decryption_key = rumor.tags
361 .find_kind("decryption-key")
362 .and_then(|tag| tag.content())
363 .ok_or("Missing decryption-key tag")?
364 .to_string();
365
366 let decryption_nonce = rumor.tags
367 .find_kind("decryption-nonce")
368 .and_then(|tag| tag.content())
369 .ok_or("Missing decryption-nonce tag")?
370 .to_string();
371
372 let original_file_hash = rumor.tags
374 .find_kind("ox")
375 .and_then(|tag| tag.content())
376 .map(|s| s.to_string());
377
378 let content_url = rumor.content.clone();
380
381 const EMPTY_FILE_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
383 if content_url.contains(EMPTY_FILE_HASH) {
384 eprintln!("Skipping attachment with empty file hash in URL: {}", content_url);
385 return Err("Attachment contains empty file hash - skipping".to_string());
386 }
387
388 let img_meta: Option<ImageMetadata> = {
390 let thumbhash_opt = rumor.tags
395 .find_kind("thumbhash")
396 .or_else(|| rumor.tags.find_kind("thumb"))
397 .and_then(|tag| tag.content())
398 .map(|s| s.to_string());
399
400 let dimensions_opt = rumor.tags
401 .find_kind("dim")
402 .and_then(|tag| tag.content())
403 .and_then(|s| {
404 let parts: Vec<&str> = s.split('x').collect();
405 if parts.len() == 2 {
406 let width = parts[0].parse::<u32>().ok()?;
407 let height = parts[1].parse::<u32>().ok()?;
408 Some((width, height))
409 } else {
410 None
411 }
412 });
413
414 match (thumbhash_opt, dimensions_opt) {
415 (Some(thumbhash), Some((width, height))) => {
416 Some(ImageMetadata {
417 thumbhash,
418 width,
419 height,
420 })
421 },
422 _ => None
423 }
424 };
425
426 let mime_type = rumor.tags
428 .find_kind("file-type")
429 .and_then(|tag| tag.content())
430 .ok_or("Missing file-type tag")?;
431 let mime_extension = extension_from_mime(mime_type);
432
433 let file_name = rumor.tags
435 .find_kind("name")
436 .and_then(|tag| tag.content())
437 .map(|s| sanitize_filename(s))
438 .unwrap_or_default();
439
440 let extension = if !file_name.is_empty() {
443 file_name.rsplit('.').next()
444 .filter(|e| !e.is_empty() && *e != file_name)
445 .map(|e| e.to_lowercase())
446 .unwrap_or(mime_extension)
447 } else {
448 mime_extension
449 };
450
451 let reported_size = rumor.tags
453 .find_kind("size")
454 .and_then(|tag| tag.content())
455 .and_then(|s| s.parse::<u64>().ok())
456 .unwrap_or(0);
457
458 let valid_path_basis =
465 |s: &str| !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit());
466 let original_file_hash = original_file_hash.filter(|h| valid_path_basis(h));
467 if !valid_path_basis(&decryption_nonce) {
468 return Err("Invalid decryption-nonce tag".to_string());
469 }
470 let file_hash = crate::crypto::attachment_identity_basis(
471 original_file_hash.as_deref(),
472 &decryption_nonce,
473 &content_url,
474 );
475 let hash_file_path = download_dir.join(format!("{}.{}", file_hash, extension));
476 let downloaded = false;
481 let file_path = hash_file_path.to_string_lossy().to_string();
482
483 let replied_to = extract_reply_reference(&rumor);
485
486 let ms_timestamp = extract_millisecond_timestamp(&rumor);
488
489 let webxdc_topic = rumor.tags
493 .find_kind("webxdc-topic")
494 .and_then(|tag| tag.content())
495 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
496 .map(|s| s.to_string());
497
498 let mut fallback_urls: Vec<String> = Vec::new();
502 for tag in rumor.tags.iter() {
503 let parts = tag.as_slice();
504 if parts.first().map(String::as_str) != Some("fallback") {
505 continue;
506 }
507 let Some(u) = parts.get(1) else { continue };
508 if !u.starts_with("https://")
509 || u.contains(char::is_whitespace)
510 || *u == content_url
511 || fallback_urls.contains(u)
512 {
513 continue;
514 }
515 fallback_urls.push(u.clone());
516 if fallback_urls.len() >= 4 {
517 break;
518 }
519 }
520
521 let attachment = Attachment {
523 id: file_hash.clone(),
524 key: decryption_key,
525 nonce: decryption_nonce,
526 extension: extension.to_string(),
527 name: file_name,
528 url: content_url,
529 path: file_path,
530 size: reported_size,
531 img_meta,
532 downloading: false,
533 downloaded,
534 webxdc_topic,
535 group_id: None, original_hash: original_file_hash, fallback_urls,
538 };
539
540 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
541 let npub = context.author_npub(&rumor.pubkey);
543
544 let expiration = extract_nip40_expiration(&rumor);
546 if already_expired(expiration) {
548 return Ok(RumorProcessingResult::Ignored);
549 }
550 let msg = Message {
551 expiration,
552 id: rumor.id.to_hex(),
553 content: String::new(),
554 replied_to,
555 replied_to_content: None, replied_to_npub: None,
557 replied_to_has_attachment: None,
558 replied_to_attachment_extension: None,
559 preview_metadata: None,
560 at: ms_timestamp,
561 attachments: vec![attachment],
562 reactions: Vec::new(),
563 mine: context.is_mine,
564 pending: false,
565 failed: false,
566 npub,
567 wrapper_event_id: None, edited: false,
569 edit_history: None,
570 emoji_tags,
571 addressed_bots: crate::bot_interface::addressed_bots(rumor.tags.iter()),
572 };
573
574 Ok(RumorProcessingResult::FileAttachment(msg))
575}
576
577fn unique_event_ref(rumor: &RumorEvent) -> Option<String> {
587 let mut matches = rumor.tags.iter().filter(|t| t.kind() == "e");
588 let first = matches.next()?;
589 if matches.next().is_some() {
590 return None;
591 }
592 first.content().map(|s| s.to_string())
593}
594
595fn extract_nip40_expiration(rumor: &RumorEvent) -> Option<u64> {
598 rumor.tags.iter().find_map(|tag| {
599 let s = tag.as_slice();
600 if s.len() >= 2 && s[0] == "expiration" {
601 s[1].parse::<u64>().ok()
602 } else {
603 None
604 }
605 })
606}
607
608fn already_expired(expiration: Option<u64>) -> bool {
610 match expiration {
611 Some(exp) => std::time::SystemTime::now()
612 .duration_since(std::time::UNIX_EPOCH)
613 .map(|d| exp <= d.as_secs())
614 .unwrap_or(false),
615 None => false,
616 }
617}
618
619fn process_deletion(
620 rumor: RumorEvent,
621 _context: RumorContext,
622) -> Result<RumorProcessingResult, String> {
623 let target_event_id = unique_event_ref(&rumor)
624 .ok_or("Deletion target tag missing or ambiguous")?;
625 Ok(RumorProcessingResult::DeletionRequest { target_event_id })
626}
627
628fn is_renderable_reaction(content: &str) -> bool {
633 if content.is_empty() || content == "+" || content == "-" {
635 return true;
636 }
637 if let Some(inner) = content.strip_prefix(':').and_then(|s| s.strip_suffix(':')) {
640 if !inner.is_empty()
641 && inner.len() <= 48
642 && inner.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '~' | '-' | '+'))
643 {
644 return true;
645 }
646 }
647 content.chars().count() <= 12
651 && !content.chars().any(char::is_whitespace)
652 && !content.contains("://")
653}
654
655fn process_reaction(
659 rumor: RumorEvent,
660 _context: RumorContext,
661) -> Result<RumorProcessingResult, String> {
662 let reference_id = unique_event_ref(&rumor)
663 .ok_or("Reaction reference tag missing or ambiguous")?;
664
665 if !is_renderable_reaction(&rumor.content) {
668 return Ok(RumorProcessingResult::Ignored);
669 }
670
671 let emoji_url = if rumor.content.starts_with(':') && rumor.content.ends_with(':')
674 && rumor.content.len() >= 3
675 {
676 let sc = &rumor.content[1..rumor.content.len() - 1];
677 rumor.tags.iter().find_map(|tag| {
678 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
679 if parts.len() >= 3 && parts[0] == "emoji" && parts[1] == sc {
680 Some(parts[2].to_string())
681 } else {
682 None
683 }
684 })
685 } else {
686 None
687 };
688
689 let reaction = Reaction {
690 id: rumor.id.to_hex(),
691 reference_id,
692 author_id: rumor.pubkey.to_bech32().unwrap_or_else(|_| rumor.pubkey.to_hex()),
693 emoji: rumor.content,
694 emoji_url,
695 };
696
697 Ok(RumorProcessingResult::Reaction(reaction))
698}
699
700fn process_edit_event(
704 rumor: RumorEvent,
705 context: RumorContext,
706) -> Result<RumorProcessingResult, String> {
707 let message_id = unique_event_ref(&rumor)
708 .ok_or("Edit reference tag missing or ambiguous")?;
709
710 let edited_at = extract_millisecond_timestamp(&rumor);
711
712 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
715
716 let tags: Vec<Vec<String>> = rumor.tags.iter()
717 .map(|tag| {
718 tag.as_slice().iter().map(|s| s.to_string()).collect()
719 })
720 .collect();
721
722 let event = StoredEventBuilder::new()
723 .id(rumor.id.to_hex())
724 .kind(event_kind::MESSAGE_EDIT)
725 .content(rumor.content.clone())
726 .tags(tags)
727 .reference_id(Some(message_id.clone()))
728 .created_at(rumor.created_at.as_secs())
729 .mine(context.is_mine)
730 .npub(rumor.pubkey.to_bech32().ok())
731 .build();
732
733 Ok(RumorProcessingResult::Edit {
734 message_id,
735 new_content: rumor.content,
736 edited_at,
737 emoji_tags,
738 event,
739 })
740}
741
742fn process_app_specific(
744 rumor: RumorEvent,
745 context: RumorContext,
746) -> Result<RumorProcessingResult, String> {
747 if is_typing_indicator(&rumor) {
749 let expiry_tag = rumor.tags
750 .find_kind("expiration")
751 .ok_or("Typing indicator missing expiration tag")?;
752
753 let expiry_timestamp: u64 = expiry_tag.content()
754 .ok_or("Expiration tag has no content")?
755 .parse()
756 .map_err(|_| "Invalid expiration timestamp")?;
757
758 let current_timestamp = std::time::SystemTime::now()
759 .duration_since(std::time::UNIX_EPOCH)
760 .map_err(|e| format!("System time error: {}", e))?
761 .as_secs();
762
763 if expiry_timestamp <= current_timestamp || expiry_timestamp > current_timestamp + 30 {
764 return Ok(RumorProcessingResult::Ignored);
765 }
766
767 let profile_id = rumor.pubkey.to_bech32()
768 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
769
770 return Ok(RumorProcessingResult::TypingIndicator {
771 profile_id,
772 until: expiry_timestamp,
773 });
774 }
775
776 if is_leave_request(&rumor) {
778 let member_pubkey = rumor.pubkey.to_bech32()
779 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
780
781 return Ok(RumorProcessingResult::LeaveRequest {
782 event_id: rumor.id.to_hex(),
783 member_pubkey,
784 });
785 }
786
787 if is_pivx_payment(&rumor) {
789 let gift_code = rumor.tags
790 .find_kind("gift-code")
791 .and_then(|tag| tag.content())
792 .ok_or("PIVX payment missing gift-code tag")?
793 .to_string();
794
795 let amount_str = rumor.tags
796 .find_kind("amount")
797 .and_then(|tag| tag.content())
798 .unwrap_or("0");
799 let amount_piv = amount_str.parse::<u64>().unwrap_or(0) as f64 / 100_000_000.0;
800
801 let address = rumor.tags
802 .find_kind("address")
803 .and_then(|tag| tag.content())
804 .map(|s| s.to_string());
805
806 let message_id = rumor.id.to_hex();
807
808 let tags: Vec<Vec<String>> = rumor.tags.iter()
809 .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
810 .collect();
811
812 let event = StoredEventBuilder::new()
813 .id(&message_id)
814 .kind(event_kind::APPLICATION_SPECIFIC)
815 .chat_id(0) .content(&rumor.content)
817 .tags(tags)
818 .created_at(rumor.created_at.as_secs())
819 .mine(context.is_mine)
820 .npub(Some(rumor.pubkey.to_bech32().unwrap_or_default()))
821 .build();
822
823 return Ok(RumorProcessingResult::PivxPayment {
824 gift_code,
825 amount_piv,
826 address,
827 message_id,
828 event,
829 });
830 }
831
832 if is_wallpaper_change(&rumor) {
836 let url = rumor.tags
841 .find_kind("url")
842 .and_then(|tag| tag.content())
843 .unwrap_or_default()
844 .to_string();
845 let decryption_key = rumor.tags
846 .find_kind("decryption-key")
847 .and_then(|tag| tag.content())
848 .unwrap_or_default()
849 .to_string();
850 let decryption_nonce = rumor.tags
851 .find_kind("decryption-nonce")
852 .and_then(|tag| tag.content())
853 .unwrap_or_default()
854 .to_string();
855 let plaintext_hash = rumor.tags
856 .find_kind("x")
857 .and_then(|tag| tag.content())
858 .map(|s| s.to_string());
859 let mime = rumor.tags
860 .find_kind("m")
861 .and_then(|tag| tag.content())
862 .map(|s| s.to_string());
863 let blur = rumor.tags
864 .find_kind("blur")
865 .and_then(|tag| tag.content())
866 .and_then(|s| s.parse::<u32>().ok())
867 .map(|n| n.min(30) as u8);
868 let dim = rumor.tags
869 .find_kind("dim")
870 .and_then(|tag| tag.content())
871 .and_then(|s| s.parse::<u32>().ok())
872 .map(|n| n.min(100) as u8);
873
874 return Ok(RumorProcessingResult::WallpaperChanged {
875 sender_npub: rumor.pubkey.to_bech32().unwrap_or_default(),
876 created_at: rumor.created_at.as_secs(),
877 url,
878 decryption_key,
879 decryption_nonce,
880 plaintext_hash,
881 mime,
882 blur,
883 dim,
884 event_id: rumor.id.to_hex(),
885 });
886 }
887
888 if is_webxdc_peer_advertisement(&rumor) {
890 log_info!("[WEBXDC] Found peer advertisement rumor, is_mine={}, sender={}",
891 context.is_mine,
892 rumor.pubkey.to_bech32().unwrap_or_else(|_| "unknown".to_string()));
893
894 if context.is_mine {
895 log_info!("[WEBXDC] Ignoring our own peer advertisement");
896 return Ok(RumorProcessingResult::Ignored);
897 }
898
899 log_info!("[WEBXDC] Detected peer advertisement in rumor from another device");
900
901 let topic_id = rumor.tags
902 .find_kind("webxdc-topic")
903 .and_then(|tag| tag.content())
904 .ok_or("Peer advertisement missing webxdc-topic tag")?
905 .to_string();
906
907 let node_addr = rumor.tags
908 .find_kind("webxdc-node-addr")
909 .and_then(|tag| tag.content())
910 .ok_or("Peer advertisement missing webxdc-node-addr tag")?
911 .to_string();
912
913 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
914 return Ok(RumorProcessingResult::WebxdcPeerAdvertisement {
915 event_id: rumor.id.to_hex(),
916 topic_id,
917 node_addr,
918 sender_npub,
919 created_at: rumor.created_at.as_secs(),
920 });
921 }
922
923 if is_webxdc_peer_left(&rumor) {
925 if context.is_mine {
926 return Ok(RumorProcessingResult::Ignored);
927 }
928
929 log_info!("[WEBXDC] Detected peer-left signal from another device");
930
931 let topic_id = rumor.tags
932 .find_kind("webxdc-topic")
933 .and_then(|tag| tag.content())
934 .ok_or("Peer-left missing webxdc-topic tag")?
935 .to_string();
936
937 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
938 return Ok(RumorProcessingResult::WebxdcPeerLeft {
939 event_id: rumor.id.to_hex(),
940 topic_id,
941 sender_npub,
942 created_at: rumor.created_at.as_secs(),
943 });
944 }
945
946 Ok(RumorProcessingResult::Ignored)
948}
949
950fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool {
952 rumor.content == "peer-advertisement"
953 && rumor.tags.find_kind("webxdc-topic").is_some()
954 && rumor.tags.find_kind("webxdc-node-addr").is_some()
955}
956
957fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool {
959 rumor.content == "peer-left"
960 && rumor.tags.find_kind("webxdc-topic").is_some()
961}
962
963fn is_pivx_payment(rumor: &RumorEvent) -> bool {
965 rumor.tags
966 .find_kind("d")
967 .and_then(|tag| tag.content())
968 .map(|content| content == "pivx-payment")
969 .unwrap_or(false)
970 && rumor.tags.find_kind("gift-code").is_some()
971}
972
973fn extract_millisecond_timestamp(rumor: &RumorEvent) -> u64 {
982 let ms_tag = rumor.tags
983 .find_kind("ms")
984 .and_then(|t| t.content());
985 resolve_message_timestamp(rumor.created_at.as_secs(), ms_tag)
986}
987
988pub fn resolve_message_timestamp(created_at_secs: u64, ms_tag: Option<&str>) -> u64 {
999 const FUTURE_GRACE_MS: u64 = 5 * 60 * 1000;
1000 let base = created_at_secs.saturating_mul(1000);
1001 let at = match ms_tag.and_then(|s| s.parse::<u64>().ok()) {
1002 Some(offset) if offset <= 999 => base.saturating_add(offset),
1003 _ => base,
1004 };
1005 let now_ms = std::time::SystemTime::now()
1006 .duration_since(std::time::UNIX_EPOCH)
1007 .map(|d| d.as_millis() as u64)
1008 .unwrap_or(u64::MAX);
1009 if at > now_ms.saturating_add(FUTURE_GRACE_MS) { now_ms } else { at }
1010}
1011
1012fn extract_reply_reference(rumor: &RumorEvent) -> String {
1017 match rumor.tags.find_kind("e") {
1018 Some(tag) if tag.as_slice().get(3).is_some_and(|s| s == "reply") => {
1020 tag.content().unwrap_or("").to_string()
1021 }
1022 _ => String::new(),
1023 }
1024}
1025
1026fn is_typing_indicator(rumor: &RumorEvent) -> bool {
1028 let has_vector_tag = rumor.tags
1029 .find_kind("d")
1030 .and_then(|tag| tag.content())
1031 .map(|content| content == "vector")
1032 .unwrap_or(false);
1033
1034 let is_typing_content = rumor.content == "typing";
1035
1036 has_vector_tag && is_typing_content
1037}
1038
1039fn is_wallpaper_change(rumor: &RumorEvent) -> bool {
1041 rumor.tags
1042 .find_kind("d")
1043 .and_then(|tag| tag.content())
1044 .map(|content| content == "vector-wallpaper")
1045 .unwrap_or(false)
1046}
1047
1048fn is_leave_request(rumor: &RumorEvent) -> bool {
1050 let has_vector_tag = rumor.tags
1051 .find_kind("d")
1052 .and_then(|tag| tag.content())
1053 .map(|content| content == "vector")
1054 .unwrap_or(false);
1055
1056 let is_leave_content = rumor.content == "leave";
1057
1058 has_vector_tag && is_leave_content
1059}
1060
1061#[cfg(test)]
1062mod tests {
1063 use super::*;
1064
1065 #[test]
1069 fn ms_resolver_applies_offset_enforces_sub_second_and_clamps_future() {
1070 assert_eq!(resolve_message_timestamp(1500, Some("242")), 1_500_242);
1072 assert_eq!(resolve_message_timestamp(1500, None), 1_500_000);
1074 assert_eq!(resolve_message_timestamp(1500, Some("4242")), 1_500_000);
1076 assert_eq!(resolve_message_timestamp(1500, Some("nope")), 1_500_000);
1077 let now = std::time::SystemTime::now()
1079 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1080 let clamped = resolve_message_timestamp(253_402_300_800, Some("5"));
1081 assert!(clamped <= (now + 3600) * 1000, "implausible-future ms must clamp to ~now");
1082 }
1083
1084 #[test]
1088 fn ambiguous_target_is_rejected_for_reaction_edit_delete() {
1089 let keys = test_keypair();
1090 let two_e = || tags(vec![
1091 Tag::custom("e", ["aa".repeat(32)]),
1092 Tag::custom("e", ["bb".repeat(32)]),
1093 ]);
1094 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", two_e()), dm_context(&keys), &temp_dir()).is_err());
1095 assert!(process_rumor(make_rumor(&keys, Kind::EventDeletion, "", two_e()), dm_context(&keys), &temp_dir()).is_err());
1096 assert!(process_rumor(make_rumor(&keys, Kind::from(event_kind::MESSAGE_EDIT), "edited", two_e()), dm_context(&keys), &temp_dir()).is_err());
1097 let one_e = tags(vec![Tag::custom("e", ["aa".repeat(32)])]);
1098 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", one_e), dm_context(&keys), &temp_dir()).is_ok());
1099 }
1100
1101 fn test_keypair() -> Keys {
1102 Keys::generate()
1103 }
1104
1105 fn tags(items: Vec<Tag>) -> Tags {
1107 let mut t = Tags::new();
1108 for item in items {
1109 t.push(item);
1110 }
1111 t
1112 }
1113
1114 fn custom_tag(key: &str, values: &[&str]) -> Tag {
1116 let owned: Vec<String> = values.iter().map(|s| s.to_string()).collect();
1117 Tag::custom(key.to_string(), owned)
1118 }
1119
1120 fn make_rumor(keys: &Keys, kind: Kind, content: &str, t: Tags) -> RumorEvent {
1121 RumorEvent {
1122 id: EventId::from_byte_array([0u8; 32]),
1123 kind,
1124 content: content.to_string(),
1125 tags: t,
1126 created_at: Timestamp::from_secs(1700000000),
1127 pubkey: keys.public_key(),
1128 }
1129 }
1130
1131 fn dm_context(keys: &Keys) -> RumorContext {
1132 RumorContext {
1133 sender: keys.public_key(),
1134 is_mine: false,
1135 conversation_id: "npub1test".to_string(),
1136 conversation_type: ConversationType::DirectMessage,
1137 }
1138 }
1139
1140 fn temp_dir() -> std::path::PathBuf {
1141 std::env::temp_dir().join("vector-rumor-test")
1142 }
1143
1144 #[test]
1149 fn test_text_message_dm() {
1150 let keys = test_keypair();
1151 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Hello world!", Tags::new());
1152 let ctx = dm_context(&keys);
1153 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1154
1155 match result {
1156 RumorProcessingResult::TextMessage(msg) => {
1157 assert_eq!(msg.content, "Hello world!");
1158 assert!(!msg.mine);
1159 assert!(msg.npub.is_none());
1160 assert!(msg.attachments.is_empty());
1161 }
1162 _ => panic!("Expected TextMessage"),
1163 }
1164 }
1165
1166 #[test]
1167 fn test_text_message_mine() {
1168 let keys = test_keypair();
1169 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "My own message", Tags::new());
1170 let ctx = RumorContext {
1171 sender: keys.public_key(),
1172 is_mine: true,
1173 conversation_id: "npub1test".to_string(),
1174 conversation_type: ConversationType::DirectMessage,
1175 };
1176 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1177
1178 match result {
1179 RumorProcessingResult::TextMessage(msg) => {
1180 assert!(msg.mine);
1181 }
1182 _ => panic!("Expected TextMessage"),
1183 }
1184 }
1185
1186 #[test]
1187 fn test_text_message_with_reply() {
1188 let keys = test_keypair();
1189 let t = tags(vec![
1190 Tag::custom("e", ["abc123def456".to_string(), String::new(), "reply".to_string()]),
1191 ]);
1192 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Reply text", t);
1193 let ctx = dm_context(&keys);
1194 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1195
1196 match result {
1197 RumorProcessingResult::TextMessage(msg) => {
1198 assert_eq!(msg.replied_to, "abc123def456");
1199 }
1200 _ => panic!("Expected TextMessage"),
1201 }
1202 }
1203
1204 #[test]
1205 fn test_text_message_with_ms_timestamp() {
1206 let keys = test_keypair();
1207 let t = tags(vec![custom_tag("ms", &["456"])]);
1208 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Precise time", t);
1209 let ctx = dm_context(&keys);
1210 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1211
1212 match result {
1213 RumorProcessingResult::TextMessage(msg) => {
1214 assert_eq!(msg.at, 1700000000 * 1000 + 456);
1215 }
1216 _ => panic!("Expected TextMessage"),
1217 }
1218 }
1219
1220 #[test]
1225 fn test_reaction() {
1226 let keys = test_keypair();
1227 let t = tags(vec![custom_tag("e", &["target_msg_id_hex"])]);
1228 let rumor = make_rumor(&keys, Kind::Reaction, "👍", t);
1229 let ctx = dm_context(&keys);
1230 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1231
1232 match result {
1233 RumorProcessingResult::Reaction(reaction) => {
1234 assert_eq!(reaction.emoji, "👍");
1235 assert_eq!(reaction.reference_id, "target_msg_id_hex");
1236 }
1237 _ => panic!("Expected Reaction"),
1238 }
1239 }
1240
1241 #[test]
1242 fn test_reaction_missing_e_tag() {
1243 let keys = test_keypair();
1244 let rumor = make_rumor(&keys, Kind::Reaction, "👍", Tags::new());
1245 let ctx = dm_context(&keys);
1246 let result = process_rumor(rumor, ctx, &temp_dir());
1247 assert!(result.is_err());
1248 }
1249
1250 #[test]
1251 fn junk_reaction_content_is_dropped_clean_ones_kept() {
1252 for ok in ["👍", "+", "-", "", "👨\u{200d}👩\u{200d}👧\u{200d}👦", ":thugamy:"] {
1254 assert!(is_renderable_reaction(ok), "{ok:?} should be renderable");
1255 }
1256 for junk in [
1258 ":thugamy:https://image.nostr.build/ccc22.png",
1259 "https://example.com/x.png",
1260 "lorem ipsum dolor",
1261 ] {
1262 assert!(!is_renderable_reaction(junk), "{junk:?} should be dropped");
1263 }
1264 assert!(!is_renderable_reaction(&"x".repeat(64)));
1265
1266 let keys = test_keypair();
1268 let t = tags(vec![custom_tag("e", &["target"])]);
1269 let rumor = make_rumor(&keys, Kind::Reaction, ":thugamy:https://image.nostr.build/ccc22.png", t);
1270 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1271 assert!(matches!(result, RumorProcessingResult::Ignored), "junk reaction should be Ignored");
1272 }
1273
1274 #[test]
1279 fn test_edit_event() {
1280 let keys = test_keypair();
1281 let t = tags(vec![custom_tag("e", &["original_msg_id"])]);
1282 let rumor = make_rumor(&keys, Kind::from_u16(16), "Edited content", t);
1283 let ctx = dm_context(&keys);
1284 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1285
1286 match result {
1287 RumorProcessingResult::Edit { message_id, new_content, event, .. } => {
1288 assert_eq!(message_id, "original_msg_id");
1289 assert_eq!(new_content, "Edited content");
1290 assert_eq!(event.kind, event_kind::MESSAGE_EDIT);
1291 }
1292 _ => panic!("Expected Edit"),
1293 }
1294 }
1295
1296 #[test]
1301 fn test_typing_indicator_valid() {
1302 let keys = test_keypair();
1303 let future_ts = std::time::SystemTime::now()
1304 .duration_since(std::time::UNIX_EPOCH).unwrap()
1305 .as_secs() + 10;
1306 let t = tags(vec![
1307 Tag::identifier("vector"),
1308 Tag::expiration(Timestamp::from_secs(future_ts)),
1309 ]);
1310 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1311 let ctx = dm_context(&keys);
1312 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1313
1314 match result {
1315 RumorProcessingResult::TypingIndicator { until, .. } => {
1316 assert_eq!(until, future_ts);
1317 }
1318 _ => panic!("Expected TypingIndicator"),
1319 }
1320 }
1321
1322 #[test]
1323 fn test_typing_indicator_expired() {
1324 let keys = test_keypair();
1325 let t = tags(vec![
1326 Tag::identifier("vector"),
1327 Tag::expiration(Timestamp::from_secs(1000000000)),
1328 ]);
1329 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1330 let ctx = dm_context(&keys);
1331 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1332
1333 assert!(matches!(result, RumorProcessingResult::Ignored));
1334 }
1335
1336 #[test]
1341 fn test_expired_text_message_is_dropped_on_receipt() {
1342 let keys = test_keypair();
1343 let t = tags(vec![Tag::expiration(Timestamp::from_secs(1000000000))]);
1344 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "too late", t);
1345 let ctx = dm_context(&keys);
1346 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1347 assert!(matches!(result, RumorProcessingResult::Ignored));
1348 }
1349
1350 #[test]
1351 fn test_expired_file_message_is_dropped_on_receipt() {
1352 let keys = test_keypair();
1353 let ox_hash = "deadbeef".repeat(8);
1354 let t = tags(vec![
1355 custom_tag("decryption-key", &["aabbccdd"]),
1356 custom_tag("decryption-nonce", &["11223344"]),
1357 custom_tag("ox", &[&ox_hash]),
1358 custom_tag("file-type", &["image/jpeg"]),
1359 Tag::expiration(Timestamp::from_secs(1000000000)),
1360 ]);
1361 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1362 let ctx = dm_context(&keys);
1363 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1364 assert!(matches!(result, RumorProcessingResult::Ignored));
1365 }
1366
1367 #[test]
1368 fn test_future_expiration_still_processes() {
1369 let keys = test_keypair();
1370 let future_ts = std::time::SystemTime::now()
1371 .duration_since(std::time::UNIX_EPOCH).unwrap()
1372 .as_secs() + 600;
1373 let t = tags(vec![Tag::expiration(Timestamp::from_secs(future_ts))]);
1374 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "still alive", t);
1375 let ctx = dm_context(&keys);
1376 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1377 match result {
1378 RumorProcessingResult::TextMessage(msg) => {
1379 assert_eq!(msg.expiration, Some(future_ts));
1380 }
1381 _ => panic!("Expected TextMessage"),
1382 }
1383 }
1384
1385 #[test]
1390 fn test_leave_request() {
1391 let keys = test_keypair();
1392 let t = tags(vec![Tag::identifier("vector")]);
1393 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "leave", t);
1394 let ctx = dm_context(&keys);
1395 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1396
1397 match result {
1398 RumorProcessingResult::LeaveRequest { member_pubkey, .. } => {
1399 assert!(!member_pubkey.is_empty());
1400 assert!(member_pubkey.starts_with("npub1"));
1401 }
1402 _ => panic!("Expected LeaveRequest"),
1403 }
1404 }
1405
1406 #[test]
1411 fn test_pivx_payment() {
1412 let keys = test_keypair();
1413 let t = tags(vec![
1414 Tag::identifier("pivx-payment"),
1415 custom_tag("gift-code", &["ABC12"]),
1416 custom_tag("amount", &["100000000"]),
1417 custom_tag("address", &["DTest123"]),
1418 ]);
1419 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "pivx-payment", t);
1420 let ctx = dm_context(&keys);
1421 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1422
1423 match result {
1424 RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, .. } => {
1425 assert_eq!(gift_code, "ABC12");
1426 assert!((amount_piv - 1.0).abs() < f64::EPSILON);
1427 assert_eq!(address, Some("DTest123".to_string()));
1428 }
1429 _ => panic!("Expected PivxPayment"),
1430 }
1431 }
1432
1433 #[test]
1438 fn test_webxdc_peer_advertisement() {
1439 let keys = test_keypair();
1440 let t = tags(vec![
1441 custom_tag("webxdc-topic", &["topic123"]),
1442 custom_tag("webxdc-node-addr", &["addr456"]),
1443 ]);
1444 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1445 let ctx = dm_context(&keys);
1446 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1447
1448 match result {
1449 RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr, .. } => {
1450 assert_eq!(topic_id, "topic123");
1451 assert_eq!(node_addr, "addr456");
1452 }
1453 _ => panic!("Expected WebxdcPeerAdvertisement"),
1454 }
1455 }
1456
1457 #[test]
1458 fn test_webxdc_peer_advertisement_own_ignored() {
1459 let keys = test_keypair();
1460 let t = tags(vec![
1461 custom_tag("webxdc-topic", &["topic123"]),
1462 custom_tag("webxdc-node-addr", &["addr456"]),
1463 ]);
1464 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1465 let ctx = RumorContext {
1466 sender: keys.public_key(),
1467 is_mine: true,
1468 conversation_id: "npub1test".to_string(),
1469 conversation_type: ConversationType::DirectMessage,
1470 };
1471 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1472 assert!(matches!(result, RumorProcessingResult::Ignored));
1473 }
1474
1475 #[test]
1476 fn test_webxdc_peer_left() {
1477 let keys = test_keypair();
1478 let t = tags(vec![custom_tag("webxdc-topic", &["topic123"])]);
1479 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-left", t);
1480 let ctx = dm_context(&keys);
1481 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1482
1483 match result {
1484 RumorProcessingResult::WebxdcPeerLeft { topic_id, .. } => {
1485 assert_eq!(topic_id, "topic123");
1486 }
1487 _ => panic!("Expected WebxdcPeerLeft"),
1488 }
1489 }
1490
1491 #[test]
1496 fn test_unknown_kind() {
1497 let keys = test_keypair();
1498 let rumor = make_rumor(&keys, Kind::from_u16(65535), "Mystery event", Tags::new());
1499 let ctx = dm_context(&keys);
1500 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1501
1502 match result {
1503 RumorProcessingResult::UnknownEvent(event) => {
1504 assert_eq!(event.kind, 65535);
1505 assert_eq!(event.content, "Mystery event");
1506 }
1507 _ => panic!("Expected UnknownEvent"),
1508 }
1509 }
1510
1511 #[test]
1516 fn test_file_attachment() {
1517 let keys = test_keypair();
1518 let ox_hash = "deadbeef".repeat(8); let t = tags(vec![
1520 custom_tag("decryption-key", &["aabbccdd"]),
1521 custom_tag("decryption-nonce", &["11223344"]),
1522 custom_tag("ox", &[&ox_hash]),
1523 custom_tag("file-type", &["image/jpeg"]),
1524 custom_tag("name", &["photo.jpg"]),
1525 custom_tag("size", &["12345"]),
1526 ]);
1527 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1528 let ctx = dm_context(&keys);
1529 let dir = temp_dir();
1530 let result = process_rumor(rumor, ctx, &dir).unwrap();
1531
1532 match result {
1533 RumorProcessingResult::FileAttachment(msg) => {
1534 assert_eq!(msg.attachments.len(), 1);
1535 let att = &msg.attachments[0];
1536 assert_eq!(att.key, "aabbccdd");
1537 assert_eq!(att.nonce, "11223344");
1538 assert_eq!(att.extension, "jpg");
1539 assert_eq!(att.name, "photo.jpg");
1540 assert_eq!(att.size, 12345);
1541 assert!(!att.downloaded);
1542 }
1543 _ => panic!("Expected FileAttachment"),
1544 }
1545 }
1546
1547 #[test]
1548 fn test_file_attachment_fallback_mirrors() {
1549 let keys = test_keypair();
1550 let ox_hash = "deadbeef".repeat(8);
1551 let t = tags(vec![
1552 custom_tag("decryption-key", &["aabbccdd"]),
1553 custom_tag("decryption-nonce", &["11223344"]),
1554 custom_tag("ox", &[&ox_hash]),
1555 custom_tag("file-type", &["image/jpeg"]),
1556 custom_tag("fallback", &["https://mirror-one.example/deadbeef.jpg"]),
1557 custom_tag("fallback", &["https://blossom.example/deadbeef.jpg"]),
1560 custom_tag("fallback", &["http://insecure.example/deadbeef.jpg"]),
1561 custom_tag("fallback", &["https://sneaky.example/a b.jpg"]),
1562 custom_tag("fallback", &["https://mirror-one.example/deadbeef.jpg"]),
1563 custom_tag("fallback", &["https://mirror-two.example/deadbeef.jpg"]),
1564 ]);
1565 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1566 let ctx = dm_context(&keys);
1567 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1568
1569 match result {
1570 RumorProcessingResult::FileAttachment(msg) => {
1571 let att = &msg.attachments[0];
1572 assert_eq!(att.fallback_urls, vec![
1573 "https://mirror-one.example/deadbeef.jpg".to_string(),
1574 "https://mirror-two.example/deadbeef.jpg".to_string(),
1575 ]);
1576 }
1577 _ => panic!("Expected FileAttachment"),
1578 }
1579 }
1580
1581 #[test]
1582 fn test_file_attachment_hostile_path_basis_rejected() {
1583 let keys = test_keypair();
1584 let dir = temp_dir();
1585 let ctx = || dm_context(&keys);
1586
1587 let t = tags(vec![
1591 custom_tag("decryption-key", &["aabbccdd"]),
1592 custom_tag("decryption-nonce", &["11223344"]),
1593 custom_tag("ox", &["../../../etc/passwd"]),
1594 custom_tag("file-type", &["image/jpeg"]),
1595 custom_tag("name", &["x.jpg"]),
1596 ]);
1597 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/x.jpg", t);
1598 let expected_id = crate::crypto::attachment_identity_basis(None, "11223344", "https://blossom.example/x.jpg");
1599 match process_rumor(rumor, ctx(), &dir).unwrap() {
1600 RumorProcessingResult::FileAttachment(msg) => {
1601 let att = &msg.attachments[0];
1602 assert!(!att.path.contains(".."), "traversal basis must not reach the path: {}", att.path);
1603 assert_eq!(att.id, expected_id, "id falls back to the nonce+url digest");
1604 }
1605 _ => panic!("Expected FileAttachment"),
1606 }
1607
1608 let t = tags(vec![
1610 custom_tag("decryption-key", &["aabbccdd"]),
1611 custom_tag("decryption-nonce", &["../../../etc/cron.d/evil"]),
1612 custom_tag("file-type", &["image/jpeg"]),
1613 ]);
1614 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/y.jpg", t);
1615 assert!(process_rumor(rumor, ctx(), &dir).is_err());
1616 }
1617
1618 #[test]
1619 fn test_file_attachment_empty_hash_rejected() {
1620 let keys = test_keypair();
1621 let t = tags(vec![
1622 custom_tag("decryption-key", &["aabbccdd"]),
1623 custom_tag("decryption-nonce", &["11223344"]),
1624 custom_tag("file-type", &["image/jpeg"]),
1625 ]);
1626 let empty_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1627 let rumor = make_rumor(&keys, Kind::from_u16(15), &format!("https://blossom.example/{}", empty_hash), t);
1628 let ctx = dm_context(&keys);
1629 let result = process_rumor(rumor, ctx, &temp_dir());
1630 assert!(result.is_err());
1631 }
1632
1633 #[test]
1634 fn test_file_attachment_with_image_meta() {
1635 let keys = test_keypair();
1636 let ox_hash = "a".repeat(64);
1637 let t = tags(vec![
1638 custom_tag("decryption-key", &["aabbccdd"]),
1639 custom_tag("decryption-nonce", &["11223344"]),
1640 custom_tag("ox", &[&ox_hash]),
1641 custom_tag("file-type", &["image/png"]),
1642 custom_tag("thumbhash", &["base64data"]),
1643 custom_tag("dim", &["1920x1080"]),
1644 custom_tag("size", &["5000"]),
1645 ]);
1646 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/aaa.png", t);
1647 let ctx = dm_context(&keys);
1648 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1649
1650 match result {
1651 RumorProcessingResult::FileAttachment(msg) => {
1652 let att = &msg.attachments[0];
1653 let meta = att.img_meta.as_ref().unwrap();
1654 assert_eq!(meta.width, 1920);
1655 assert_eq!(meta.height, 1080);
1656 assert_eq!(meta.thumbhash, "base64data");
1657 }
1658 _ => panic!("Expected FileAttachment"),
1659 }
1660 }
1661
1662 #[test]
1668 fn test_file_attachment_thumb_tag_is_read() {
1669 let keys = test_keypair();
1670 let ox_hash = "b".repeat(64);
1671 let t = tags(vec![
1672 custom_tag("decryption-key", &["aabbccdd"]),
1673 custom_tag("decryption-nonce", &["11223344"]),
1674 custom_tag("ox", &[&ox_hash]),
1675 custom_tag("file-type", &["image/png"]),
1676 custom_tag("thumb", &["realwiretag"]),
1677 custom_tag("dim", &["800x600"]),
1678 custom_tag("size", &["5000"]),
1679 ]);
1680 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/bbb.png", t);
1681 let ctx = dm_context(&keys);
1682 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1683
1684 match result {
1685 RumorProcessingResult::FileAttachment(msg) => {
1686 let meta = msg.attachments[0].img_meta.as_ref()
1687 .expect("img_meta must be populated from the `thumb` tag");
1688 assert_eq!(meta.thumbhash, "realwiretag");
1689 assert_eq!(meta.width, 800);
1690 assert_eq!(meta.height, 600);
1691 }
1692 _ => panic!("Expected FileAttachment"),
1693 }
1694 }
1695
1696 #[test]
1701 fn test_extract_hash_from_blossom_url() {
1702 let hash = "a".repeat(64);
1703 let url = format!("https://blossom.example/{}.jpg", hash);
1704 assert_eq!(extract_hash_from_blossom_url(&url), Some(hash));
1705
1706 assert_eq!(extract_hash_from_blossom_url("https://example.com/short"), None);
1707 assert_eq!(extract_hash_from_blossom_url("https://example.com/not-hex-at-all-but-exactly-sixty-four-characters-long-string-here!"), None);
1708 }
1709
1710 #[test]
1711 fn test_unknown_app_specific_ignored() {
1712 let keys = test_keypair();
1713 let t = tags(vec![Tag::identifier("some-other-app")]);
1714 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "unknown-content", t);
1715 let ctx = dm_context(&keys);
1716 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1717 assert!(matches!(result, RumorProcessingResult::Ignored));
1718 }
1719}