1use std::borrow::Cow;
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(TagKind::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 npub = context.author_npub(&rumor.pubkey);
298
299 let msg = Message {
301 id: rumor.id.to_hex(),
302 content: rumor.content,
303 replied_to,
304 replied_to_content: None, replied_to_npub: None,
306 replied_to_has_attachment: None,
307 replied_to_attachment_extension: None,
308 preview_metadata: None,
309 at: ms_timestamp,
310 attachments: Vec::new(),
311 reactions: Vec::new(),
312 mine: context.is_mine,
313 pending: false,
314 failed: false,
315 npub,
316 wrapper_event_id: None, edited: false,
318 edit_history: None,
319 emoji_tags,
320 };
321
322 Ok(RumorProcessingResult::TextMessage(msg))
323}
324
325pub fn extract_hash_from_blossom_url(url: &str) -> Option<String> {
329 let path = url.split('/').last()?;
330 let hash_part = path.split('.').next()?;
331 if hash_part.len() == 64 && hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
332 Some(hash_part.to_string())
333 } else {
334 None
335 }
336}
337
338fn process_file_attachment(
346 rumor: RumorEvent,
347 context: RumorContext,
348 download_dir: &Path,
349) -> Result<RumorProcessingResult, String> {
350 let decryption_key = rumor.tags
352 .find(TagKind::Custom(Cow::Borrowed("decryption-key")))
353 .and_then(|tag| tag.content())
354 .ok_or("Missing decryption-key tag")?
355 .to_string();
356
357 let decryption_nonce = rumor.tags
358 .find(TagKind::Custom(Cow::Borrowed("decryption-nonce")))
359 .and_then(|tag| tag.content())
360 .ok_or("Missing decryption-nonce tag")?
361 .to_string();
362
363 let original_file_hash = rumor.tags
365 .find(TagKind::Custom(Cow::Borrowed("ox")))
366 .and_then(|tag| tag.content())
367 .map(|s| s.to_string());
368
369 let content_url = rumor.content.clone();
371
372 const EMPTY_FILE_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
374 if content_url.contains(EMPTY_FILE_HASH) {
375 eprintln!("Skipping attachment with empty file hash in URL: {}", content_url);
376 return Err("Attachment contains empty file hash - skipping".to_string());
377 }
378
379 let img_meta: Option<ImageMetadata> = {
381 let thumbhash_opt = rumor.tags
387 .find(TagKind::Custom(Cow::Borrowed("thumb")))
388 .or_else(|| rumor.tags.find(TagKind::Custom(Cow::Borrowed("thumbhash"))))
389 .and_then(|tag| tag.content())
390 .map(|s| s.to_string());
391
392 let dimensions_opt = rumor.tags
393 .find(TagKind::Custom(Cow::Borrowed("dim")))
394 .and_then(|tag| tag.content())
395 .and_then(|s| {
396 let parts: Vec<&str> = s.split('x').collect();
397 if parts.len() == 2 {
398 let width = parts[0].parse::<u32>().ok()?;
399 let height = parts[1].parse::<u32>().ok()?;
400 Some((width, height))
401 } else {
402 None
403 }
404 });
405
406 match (thumbhash_opt, dimensions_opt) {
407 (Some(thumbhash), Some((width, height))) => {
408 Some(ImageMetadata {
409 thumbhash,
410 width,
411 height,
412 })
413 },
414 _ => None
415 }
416 };
417
418 let mime_type = rumor.tags
420 .find(TagKind::Custom(Cow::Borrowed("file-type")))
421 .and_then(|tag| tag.content())
422 .ok_or("Missing file-type tag")?;
423 let mime_extension = extension_from_mime(mime_type);
424
425 let file_name = rumor.tags
427 .find(TagKind::Custom(Cow::Borrowed("name")))
428 .and_then(|tag| tag.content())
429 .map(|s| sanitize_filename(s))
430 .unwrap_or_default();
431
432 let extension = if !file_name.is_empty() {
435 file_name.rsplit('.').next()
436 .filter(|e| !e.is_empty() && *e != file_name)
437 .map(|e| e.to_lowercase())
438 .unwrap_or(mime_extension)
439 } else {
440 mime_extension
441 };
442
443 let reported_size = rumor.tags
445 .find(TagKind::Custom(Cow::Borrowed("size")))
446 .and_then(|tag| tag.content())
447 .and_then(|s| s.parse::<u64>().ok())
448 .unwrap_or(0);
449
450 let valid_path_basis =
457 |s: &str| !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit());
458 let original_file_hash = original_file_hash.filter(|h| valid_path_basis(h));
459 if !valid_path_basis(&decryption_nonce) {
460 return Err("Invalid decryption-nonce tag".to_string());
461 }
462 let file_hash = crate::crypto::attachment_identity_basis(
463 original_file_hash.as_deref(),
464 &decryption_nonce,
465 &content_url,
466 );
467 let hash_file_path = download_dir.join(format!("{}.{}", file_hash, extension));
468 let downloaded = false;
473 let file_path = hash_file_path.to_string_lossy().to_string();
474
475 let replied_to = extract_reply_reference(&rumor);
477
478 let ms_timestamp = extract_millisecond_timestamp(&rumor);
480
481 let webxdc_topic = rumor.tags
485 .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
486 .and_then(|tag| tag.content())
487 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
488 .map(|s| s.to_string());
489
490 let attachment = Attachment {
492 id: file_hash.clone(),
493 key: decryption_key,
494 nonce: decryption_nonce,
495 extension: extension.to_string(),
496 name: file_name,
497 url: content_url,
498 path: file_path,
499 size: reported_size,
500 img_meta,
501 downloading: false,
502 downloaded,
503 webxdc_topic,
504 group_id: None, original_hash: original_file_hash, scheme_version: None, mls_filename: None, };
509
510 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
511 let npub = context.author_npub(&rumor.pubkey);
513
514 let msg = Message {
516 id: rumor.id.to_hex(),
517 content: String::new(),
518 replied_to,
519 replied_to_content: None, replied_to_npub: None,
521 replied_to_has_attachment: None,
522 replied_to_attachment_extension: None,
523 preview_metadata: None,
524 at: ms_timestamp,
525 attachments: vec![attachment],
526 reactions: Vec::new(),
527 mine: context.is_mine,
528 pending: false,
529 failed: false,
530 npub,
531 wrapper_event_id: None, edited: false,
533 edit_history: None,
534 emoji_tags,
535 };
536
537 Ok(RumorProcessingResult::FileAttachment(msg))
538}
539
540fn unique_event_ref(rumor: &RumorEvent) -> Option<String> {
550 let mut matches = rumor.tags.iter().filter(|t| t.kind() == TagKind::e());
551 let first = matches.next()?;
552 if matches.next().is_some() {
553 return None;
554 }
555 first.content().map(|s| s.to_string())
556}
557
558fn process_deletion(
559 rumor: RumorEvent,
560 _context: RumorContext,
561) -> Result<RumorProcessingResult, String> {
562 let target_event_id = unique_event_ref(&rumor)
563 .ok_or("Deletion target tag missing or ambiguous")?;
564 Ok(RumorProcessingResult::DeletionRequest { target_event_id })
565}
566
567fn process_reaction(
571 rumor: RumorEvent,
572 _context: RumorContext,
573) -> Result<RumorProcessingResult, String> {
574 let reference_id = unique_event_ref(&rumor)
575 .ok_or("Reaction reference tag missing or ambiguous")?;
576
577 let emoji_url = if rumor.content.starts_with(':') && rumor.content.ends_with(':')
580 && rumor.content.len() >= 3
581 {
582 let sc = &rumor.content[1..rumor.content.len() - 1];
583 rumor.tags.iter().find_map(|tag| {
584 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
585 if parts.len() >= 3 && parts[0] == "emoji" && parts[1] == sc {
586 Some(parts[2].to_string())
587 } else {
588 None
589 }
590 })
591 } else {
592 None
593 };
594
595 let reaction = Reaction {
596 id: rumor.id.to_hex(),
597 reference_id,
598 author_id: rumor.pubkey.to_bech32().unwrap_or_else(|_| rumor.pubkey.to_hex()),
599 emoji: rumor.content,
600 emoji_url,
601 };
602
603 Ok(RumorProcessingResult::Reaction(reaction))
604}
605
606fn process_edit_event(
610 rumor: RumorEvent,
611 context: RumorContext,
612) -> Result<RumorProcessingResult, String> {
613 let message_id = unique_event_ref(&rumor)
614 .ok_or("Edit reference tag missing or ambiguous")?;
615
616 let edited_at = extract_millisecond_timestamp(&rumor);
617
618 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
621
622 let tags: Vec<Vec<String>> = rumor.tags.iter()
623 .map(|tag| {
624 tag.as_slice().iter().map(|s| s.to_string()).collect()
625 })
626 .collect();
627
628 let event = StoredEventBuilder::new()
629 .id(rumor.id.to_hex())
630 .kind(event_kind::MESSAGE_EDIT)
631 .content(rumor.content.clone())
632 .tags(tags)
633 .reference_id(Some(message_id.clone()))
634 .created_at(rumor.created_at.as_secs())
635 .mine(context.is_mine)
636 .npub(rumor.pubkey.to_bech32().ok())
637 .build();
638
639 Ok(RumorProcessingResult::Edit {
640 message_id,
641 new_content: rumor.content,
642 edited_at,
643 emoji_tags,
644 event,
645 })
646}
647
648fn process_app_specific(
650 rumor: RumorEvent,
651 context: RumorContext,
652) -> Result<RumorProcessingResult, String> {
653 if is_typing_indicator(&rumor) {
655 let expiry_tag = rumor.tags
656 .find(TagKind::Expiration)
657 .ok_or("Typing indicator missing expiration tag")?;
658
659 let expiry_timestamp: u64 = expiry_tag.content()
660 .ok_or("Expiration tag has no content")?
661 .parse()
662 .map_err(|_| "Invalid expiration timestamp")?;
663
664 let current_timestamp = std::time::SystemTime::now()
665 .duration_since(std::time::UNIX_EPOCH)
666 .map_err(|e| format!("System time error: {}", e))?
667 .as_secs();
668
669 if expiry_timestamp <= current_timestamp || expiry_timestamp > current_timestamp + 30 {
670 return Ok(RumorProcessingResult::Ignored);
671 }
672
673 let profile_id = rumor.pubkey.to_bech32()
674 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
675
676 return Ok(RumorProcessingResult::TypingIndicator {
677 profile_id,
678 until: expiry_timestamp,
679 });
680 }
681
682 if is_leave_request(&rumor) {
684 let member_pubkey = rumor.pubkey.to_bech32()
685 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
686
687 return Ok(RumorProcessingResult::LeaveRequest {
688 event_id: rumor.id.to_hex(),
689 member_pubkey,
690 });
691 }
692
693 if is_pivx_payment(&rumor) {
695 let gift_code = rumor.tags
696 .find(TagKind::Custom(Cow::Borrowed("gift-code")))
697 .and_then(|tag| tag.content())
698 .ok_or("PIVX payment missing gift-code tag")?
699 .to_string();
700
701 let amount_str = rumor.tags
702 .find(TagKind::Custom(Cow::Borrowed("amount")))
703 .and_then(|tag| tag.content())
704 .unwrap_or("0");
705 let amount_piv = amount_str.parse::<u64>().unwrap_or(0) as f64 / 100_000_000.0;
706
707 let address = rumor.tags
708 .find(TagKind::Custom(Cow::Borrowed("address")))
709 .and_then(|tag| tag.content())
710 .map(|s| s.to_string());
711
712 let message_id = rumor.id.to_hex();
713
714 let tags: Vec<Vec<String>> = rumor.tags.iter()
715 .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
716 .collect();
717
718 let event = StoredEventBuilder::new()
719 .id(&message_id)
720 .kind(event_kind::APPLICATION_SPECIFIC)
721 .chat_id(0) .content(&rumor.content)
723 .tags(tags)
724 .created_at(rumor.created_at.as_secs())
725 .mine(context.is_mine)
726 .npub(Some(rumor.pubkey.to_bech32().unwrap_or_default()))
727 .build();
728
729 return Ok(RumorProcessingResult::PivxPayment {
730 gift_code,
731 amount_piv,
732 address,
733 message_id,
734 event,
735 });
736 }
737
738 if is_wallpaper_change(&rumor) {
742 let url = rumor.tags
747 .find(TagKind::Custom(Cow::Borrowed("url")))
748 .and_then(|tag| tag.content())
749 .unwrap_or_default()
750 .to_string();
751 let decryption_key = rumor.tags
752 .find(TagKind::Custom(Cow::Borrowed("decryption-key")))
753 .and_then(|tag| tag.content())
754 .unwrap_or_default()
755 .to_string();
756 let decryption_nonce = rumor.tags
757 .find(TagKind::Custom(Cow::Borrowed("decryption-nonce")))
758 .and_then(|tag| tag.content())
759 .unwrap_or_default()
760 .to_string();
761 let plaintext_hash = rumor.tags
762 .find(TagKind::Custom(Cow::Borrowed("x")))
763 .and_then(|tag| tag.content())
764 .map(|s| s.to_string());
765 let mime = rumor.tags
766 .find(TagKind::Custom(Cow::Borrowed("m")))
767 .and_then(|tag| tag.content())
768 .map(|s| s.to_string());
769 let blur = rumor.tags
770 .find(TagKind::Custom(Cow::Borrowed("blur")))
771 .and_then(|tag| tag.content())
772 .and_then(|s| s.parse::<u32>().ok())
773 .map(|n| n.min(30) as u8);
774 let dim = rumor.tags
775 .find(TagKind::Custom(Cow::Borrowed("dim")))
776 .and_then(|tag| tag.content())
777 .and_then(|s| s.parse::<u32>().ok())
778 .map(|n| n.min(100) as u8);
779
780 return Ok(RumorProcessingResult::WallpaperChanged {
781 sender_npub: rumor.pubkey.to_bech32().unwrap_or_default(),
782 created_at: rumor.created_at.as_secs(),
783 url,
784 decryption_key,
785 decryption_nonce,
786 plaintext_hash,
787 mime,
788 blur,
789 dim,
790 event_id: rumor.id.to_hex(),
791 });
792 }
793
794 if is_webxdc_peer_advertisement(&rumor) {
796 log_info!("[WEBXDC] Found peer advertisement rumor, is_mine={}, sender={}",
797 context.is_mine,
798 rumor.pubkey.to_bech32().unwrap_or_else(|_| "unknown".to_string()));
799
800 if context.is_mine {
801 log_info!("[WEBXDC] Ignoring our own peer advertisement");
802 return Ok(RumorProcessingResult::Ignored);
803 }
804
805 log_info!("[WEBXDC] Detected peer advertisement in rumor from another device");
806
807 let topic_id = rumor.tags
808 .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
809 .and_then(|tag| tag.content())
810 .ok_or("Peer advertisement missing webxdc-topic tag")?
811 .to_string();
812
813 let node_addr = rumor.tags
814 .find(TagKind::Custom(Cow::Borrowed("webxdc-node-addr")))
815 .and_then(|tag| tag.content())
816 .ok_or("Peer advertisement missing webxdc-node-addr tag")?
817 .to_string();
818
819 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
820 return Ok(RumorProcessingResult::WebxdcPeerAdvertisement {
821 event_id: rumor.id.to_hex(),
822 topic_id,
823 node_addr,
824 sender_npub,
825 created_at: rumor.created_at.as_secs(),
826 });
827 }
828
829 if is_webxdc_peer_left(&rumor) {
831 if context.is_mine {
832 return Ok(RumorProcessingResult::Ignored);
833 }
834
835 log_info!("[WEBXDC] Detected peer-left signal from another device");
836
837 let topic_id = rumor.tags
838 .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
839 .and_then(|tag| tag.content())
840 .ok_or("Peer-left missing webxdc-topic tag")?
841 .to_string();
842
843 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
844 return Ok(RumorProcessingResult::WebxdcPeerLeft {
845 event_id: rumor.id.to_hex(),
846 topic_id,
847 sender_npub,
848 created_at: rumor.created_at.as_secs(),
849 });
850 }
851
852 Ok(RumorProcessingResult::Ignored)
854}
855
856fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool {
858 rumor.content == "peer-advertisement"
859 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-topic"))).is_some()
860 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-node-addr"))).is_some()
861}
862
863fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool {
865 rumor.content == "peer-left"
866 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-topic"))).is_some()
867}
868
869fn is_pivx_payment(rumor: &RumorEvent) -> bool {
871 rumor.tags
872 .find(TagKind::d())
873 .and_then(|tag| tag.content())
874 .map(|content| content == "pivx-payment")
875 .unwrap_or(false)
876 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("gift-code"))).is_some()
877}
878
879fn extract_millisecond_timestamp(rumor: &RumorEvent) -> u64 {
888 let ms_tag = rumor.tags
889 .find(TagKind::Custom(Cow::Borrowed("ms")))
890 .and_then(|t| t.content());
891 resolve_message_timestamp(rumor.created_at.as_secs(), ms_tag)
892}
893
894pub fn resolve_message_timestamp(created_at_secs: u64, ms_tag: Option<&str>) -> u64 {
905 const FUTURE_GRACE_MS: u64 = 5 * 60 * 1000;
906 let base = created_at_secs.saturating_mul(1000);
907 let at = match ms_tag.and_then(|s| s.parse::<u64>().ok()) {
908 Some(offset) if offset <= 999 => base.saturating_add(offset),
909 _ => base,
910 };
911 let now_ms = std::time::SystemTime::now()
912 .duration_since(std::time::UNIX_EPOCH)
913 .map(|d| d.as_millis() as u64)
914 .unwrap_or(u64::MAX);
915 if at > now_ms.saturating_add(FUTURE_GRACE_MS) { now_ms } else { at }
916}
917
918fn extract_reply_reference(rumor: &RumorEvent) -> String {
923 match rumor.tags.find(TagKind::e()) {
924 Some(tag) => {
925 if tag.is_reply() {
928 tag.content().unwrap_or("").to_string()
929 } else {
930 let slice = tag.as_slice();
931 if slice.get(3).map(|s| s == "reply").unwrap_or(false) {
932 tag.content().unwrap_or("").to_string()
933 } else {
934 String::new()
935 }
936 }
937 }
938 None => String::new(),
939 }
940}
941
942fn is_typing_indicator(rumor: &RumorEvent) -> bool {
944 let has_vector_tag = rumor.tags
945 .find(TagKind::d())
946 .and_then(|tag| tag.content())
947 .map(|content| content == "vector")
948 .unwrap_or(false);
949
950 let is_typing_content = rumor.content == "typing";
951
952 has_vector_tag && is_typing_content
953}
954
955fn is_wallpaper_change(rumor: &RumorEvent) -> bool {
957 rumor.tags
958 .find(TagKind::d())
959 .and_then(|tag| tag.content())
960 .map(|content| content == "vector-wallpaper")
961 .unwrap_or(false)
962}
963
964fn is_leave_request(rumor: &RumorEvent) -> bool {
966 let has_vector_tag = rumor.tags
967 .find(TagKind::d())
968 .and_then(|tag| tag.content())
969 .map(|content| content == "vector")
970 .unwrap_or(false);
971
972 let is_leave_content = rumor.content == "leave";
973
974 has_vector_tag && is_leave_content
975}
976
977#[cfg(test)]
978mod tests {
979 use super::*;
980
981 #[test]
985 fn ms_resolver_applies_offset_enforces_sub_second_and_clamps_future() {
986 assert_eq!(resolve_message_timestamp(1500, Some("242")), 1_500_242);
988 assert_eq!(resolve_message_timestamp(1500, None), 1_500_000);
990 assert_eq!(resolve_message_timestamp(1500, Some("4242")), 1_500_000);
992 assert_eq!(resolve_message_timestamp(1500, Some("nope")), 1_500_000);
993 let now = std::time::SystemTime::now()
995 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
996 let clamped = resolve_message_timestamp(253_402_300_800, Some("5"));
997 assert!(clamped <= (now + 3600) * 1000, "implausible-future ms must clamp to ~now");
998 }
999
1000 #[test]
1004 fn ambiguous_target_is_rejected_for_reaction_edit_delete() {
1005 let keys = test_keypair();
1006 let two_e = || tags(vec![
1007 Tag::custom(TagKind::e(), ["aa".repeat(32)]),
1008 Tag::custom(TagKind::e(), ["bb".repeat(32)]),
1009 ]);
1010 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", two_e()), dm_context(&keys), &temp_dir()).is_err());
1011 assert!(process_rumor(make_rumor(&keys, Kind::EventDeletion, "", two_e()), dm_context(&keys), &temp_dir()).is_err());
1012 assert!(process_rumor(make_rumor(&keys, Kind::from(event_kind::MESSAGE_EDIT), "edited", two_e()), dm_context(&keys), &temp_dir()).is_err());
1013 let one_e = tags(vec![Tag::custom(TagKind::e(), ["aa".repeat(32)])]);
1014 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", one_e), dm_context(&keys), &temp_dir()).is_ok());
1015 }
1016
1017 fn test_keypair() -> Keys {
1018 Keys::generate()
1019 }
1020
1021 fn tags(items: Vec<Tag>) -> Tags {
1023 let mut t = Tags::new();
1024 for item in items {
1025 t.push(item);
1026 }
1027 t
1028 }
1029
1030 fn custom_tag(key: &str, values: &[&str]) -> Tag {
1032 let owned: Vec<String> = values.iter().map(|s| s.to_string()).collect();
1033 Tag::custom(TagKind::custom(key.to_string()), owned)
1034 }
1035
1036 fn make_rumor(keys: &Keys, kind: Kind, content: &str, t: Tags) -> RumorEvent {
1037 RumorEvent {
1038 id: EventId::all_zeros(),
1039 kind,
1040 content: content.to_string(),
1041 tags: t,
1042 created_at: Timestamp::from_secs(1700000000),
1043 pubkey: keys.public_key(),
1044 }
1045 }
1046
1047 fn dm_context(keys: &Keys) -> RumorContext {
1048 RumorContext {
1049 sender: keys.public_key(),
1050 is_mine: false,
1051 conversation_id: "npub1test".to_string(),
1052 conversation_type: ConversationType::DirectMessage,
1053 }
1054 }
1055
1056 fn temp_dir() -> std::path::PathBuf {
1057 std::env::temp_dir().join("vector-rumor-test")
1058 }
1059
1060 #[test]
1065 fn test_text_message_dm() {
1066 let keys = test_keypair();
1067 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Hello world!", Tags::new());
1068 let ctx = dm_context(&keys);
1069 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1070
1071 match result {
1072 RumorProcessingResult::TextMessage(msg) => {
1073 assert_eq!(msg.content, "Hello world!");
1074 assert!(!msg.mine);
1075 assert!(msg.npub.is_none());
1076 assert!(msg.attachments.is_empty());
1077 }
1078 _ => panic!("Expected TextMessage"),
1079 }
1080 }
1081
1082 #[test]
1083 fn test_text_message_mine() {
1084 let keys = test_keypair();
1085 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "My own message", Tags::new());
1086 let ctx = RumorContext {
1087 sender: keys.public_key(),
1088 is_mine: true,
1089 conversation_id: "npub1test".to_string(),
1090 conversation_type: ConversationType::DirectMessage,
1091 };
1092 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1093
1094 match result {
1095 RumorProcessingResult::TextMessage(msg) => {
1096 assert!(msg.mine);
1097 }
1098 _ => panic!("Expected TextMessage"),
1099 }
1100 }
1101
1102 #[test]
1103 fn test_text_message_with_reply() {
1104 let keys = test_keypair();
1105 let t = tags(vec![
1106 Tag::custom(TagKind::e(), ["abc123def456".to_string(), String::new(), "reply".to_string()]),
1107 ]);
1108 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Reply text", t);
1109 let ctx = dm_context(&keys);
1110 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1111
1112 match result {
1113 RumorProcessingResult::TextMessage(msg) => {
1114 assert_eq!(msg.replied_to, "abc123def456");
1115 }
1116 _ => panic!("Expected TextMessage"),
1117 }
1118 }
1119
1120 #[test]
1121 fn test_text_message_with_ms_timestamp() {
1122 let keys = test_keypair();
1123 let t = tags(vec![custom_tag("ms", &["456"])]);
1124 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Precise time", t);
1125 let ctx = dm_context(&keys);
1126 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1127
1128 match result {
1129 RumorProcessingResult::TextMessage(msg) => {
1130 assert_eq!(msg.at, 1700000000 * 1000 + 456);
1131 }
1132 _ => panic!("Expected TextMessage"),
1133 }
1134 }
1135
1136 #[test]
1141 fn test_reaction() {
1142 let keys = test_keypair();
1143 let t = tags(vec![custom_tag("e", &["target_msg_id_hex"])]);
1144 let rumor = make_rumor(&keys, Kind::Reaction, "👍", t);
1145 let ctx = dm_context(&keys);
1146 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1147
1148 match result {
1149 RumorProcessingResult::Reaction(reaction) => {
1150 assert_eq!(reaction.emoji, "👍");
1151 assert_eq!(reaction.reference_id, "target_msg_id_hex");
1152 }
1153 _ => panic!("Expected Reaction"),
1154 }
1155 }
1156
1157 #[test]
1158 fn test_reaction_missing_e_tag() {
1159 let keys = test_keypair();
1160 let rumor = make_rumor(&keys, Kind::Reaction, "👍", Tags::new());
1161 let ctx = dm_context(&keys);
1162 let result = process_rumor(rumor, ctx, &temp_dir());
1163 assert!(result.is_err());
1164 }
1165
1166 #[test]
1171 fn test_edit_event() {
1172 let keys = test_keypair();
1173 let t = tags(vec![custom_tag("e", &["original_msg_id"])]);
1174 let rumor = make_rumor(&keys, Kind::from_u16(16), "Edited content", t);
1175 let ctx = dm_context(&keys);
1176 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1177
1178 match result {
1179 RumorProcessingResult::Edit { message_id, new_content, event, .. } => {
1180 assert_eq!(message_id, "original_msg_id");
1181 assert_eq!(new_content, "Edited content");
1182 assert_eq!(event.kind, event_kind::MESSAGE_EDIT);
1183 }
1184 _ => panic!("Expected Edit"),
1185 }
1186 }
1187
1188 #[test]
1193 fn test_typing_indicator_valid() {
1194 let keys = test_keypair();
1195 let future_ts = std::time::SystemTime::now()
1196 .duration_since(std::time::UNIX_EPOCH).unwrap()
1197 .as_secs() + 10;
1198 let t = tags(vec![
1199 Tag::identifier("vector"),
1200 Tag::expiration(Timestamp::from_secs(future_ts)),
1201 ]);
1202 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1203 let ctx = dm_context(&keys);
1204 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1205
1206 match result {
1207 RumorProcessingResult::TypingIndicator { until, .. } => {
1208 assert_eq!(until, future_ts);
1209 }
1210 _ => panic!("Expected TypingIndicator"),
1211 }
1212 }
1213
1214 #[test]
1215 fn test_typing_indicator_expired() {
1216 let keys = test_keypair();
1217 let t = tags(vec![
1218 Tag::identifier("vector"),
1219 Tag::expiration(Timestamp::from_secs(1000000000)),
1220 ]);
1221 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1222 let ctx = dm_context(&keys);
1223 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1224
1225 assert!(matches!(result, RumorProcessingResult::Ignored));
1226 }
1227
1228 #[test]
1233 fn test_leave_request() {
1234 let keys = test_keypair();
1235 let t = tags(vec![Tag::identifier("vector")]);
1236 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "leave", t);
1237 let ctx = dm_context(&keys);
1238 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1239
1240 match result {
1241 RumorProcessingResult::LeaveRequest { member_pubkey, .. } => {
1242 assert!(!member_pubkey.is_empty());
1243 assert!(member_pubkey.starts_with("npub1"));
1244 }
1245 _ => panic!("Expected LeaveRequest"),
1246 }
1247 }
1248
1249 #[test]
1254 fn test_pivx_payment() {
1255 let keys = test_keypair();
1256 let t = tags(vec![
1257 Tag::identifier("pivx-payment"),
1258 custom_tag("gift-code", &["ABC12"]),
1259 custom_tag("amount", &["100000000"]),
1260 custom_tag("address", &["DTest123"]),
1261 ]);
1262 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "pivx-payment", t);
1263 let ctx = dm_context(&keys);
1264 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1265
1266 match result {
1267 RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, .. } => {
1268 assert_eq!(gift_code, "ABC12");
1269 assert!((amount_piv - 1.0).abs() < f64::EPSILON);
1270 assert_eq!(address, Some("DTest123".to_string()));
1271 }
1272 _ => panic!("Expected PivxPayment"),
1273 }
1274 }
1275
1276 #[test]
1281 fn test_webxdc_peer_advertisement() {
1282 let keys = test_keypair();
1283 let t = tags(vec![
1284 custom_tag("webxdc-topic", &["topic123"]),
1285 custom_tag("webxdc-node-addr", &["addr456"]),
1286 ]);
1287 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1288 let ctx = dm_context(&keys);
1289 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1290
1291 match result {
1292 RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr, .. } => {
1293 assert_eq!(topic_id, "topic123");
1294 assert_eq!(node_addr, "addr456");
1295 }
1296 _ => panic!("Expected WebxdcPeerAdvertisement"),
1297 }
1298 }
1299
1300 #[test]
1301 fn test_webxdc_peer_advertisement_own_ignored() {
1302 let keys = test_keypair();
1303 let t = tags(vec![
1304 custom_tag("webxdc-topic", &["topic123"]),
1305 custom_tag("webxdc-node-addr", &["addr456"]),
1306 ]);
1307 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1308 let ctx = RumorContext {
1309 sender: keys.public_key(),
1310 is_mine: true,
1311 conversation_id: "npub1test".to_string(),
1312 conversation_type: ConversationType::DirectMessage,
1313 };
1314 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1315 assert!(matches!(result, RumorProcessingResult::Ignored));
1316 }
1317
1318 #[test]
1319 fn test_webxdc_peer_left() {
1320 let keys = test_keypair();
1321 let t = tags(vec![custom_tag("webxdc-topic", &["topic123"])]);
1322 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-left", t);
1323 let ctx = dm_context(&keys);
1324 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1325
1326 match result {
1327 RumorProcessingResult::WebxdcPeerLeft { topic_id, .. } => {
1328 assert_eq!(topic_id, "topic123");
1329 }
1330 _ => panic!("Expected WebxdcPeerLeft"),
1331 }
1332 }
1333
1334 #[test]
1339 fn test_unknown_kind() {
1340 let keys = test_keypair();
1341 let rumor = make_rumor(&keys, Kind::from_u16(65535), "Mystery event", Tags::new());
1342 let ctx = dm_context(&keys);
1343 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1344
1345 match result {
1346 RumorProcessingResult::UnknownEvent(event) => {
1347 assert_eq!(event.kind, 65535);
1348 assert_eq!(event.content, "Mystery event");
1349 }
1350 _ => panic!("Expected UnknownEvent"),
1351 }
1352 }
1353
1354 #[test]
1359 fn test_file_attachment() {
1360 let keys = test_keypair();
1361 let ox_hash = "deadbeef".repeat(8); let t = tags(vec![
1363 custom_tag("decryption-key", &["aabbccdd"]),
1364 custom_tag("decryption-nonce", &["11223344"]),
1365 custom_tag("ox", &[&ox_hash]),
1366 custom_tag("file-type", &["image/jpeg"]),
1367 custom_tag("name", &["photo.jpg"]),
1368 custom_tag("size", &["12345"]),
1369 ]);
1370 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1371 let ctx = dm_context(&keys);
1372 let dir = temp_dir();
1373 let result = process_rumor(rumor, ctx, &dir).unwrap();
1374
1375 match result {
1376 RumorProcessingResult::FileAttachment(msg) => {
1377 assert_eq!(msg.attachments.len(), 1);
1378 let att = &msg.attachments[0];
1379 assert_eq!(att.key, "aabbccdd");
1380 assert_eq!(att.nonce, "11223344");
1381 assert_eq!(att.extension, "jpg");
1382 assert_eq!(att.name, "photo.jpg");
1383 assert_eq!(att.size, 12345);
1384 assert!(!att.downloaded);
1385 }
1386 _ => panic!("Expected FileAttachment"),
1387 }
1388 }
1389
1390 #[test]
1391 fn test_file_attachment_hostile_path_basis_rejected() {
1392 let keys = test_keypair();
1393 let dir = temp_dir();
1394 let ctx = || dm_context(&keys);
1395
1396 let t = tags(vec![
1400 custom_tag("decryption-key", &["aabbccdd"]),
1401 custom_tag("decryption-nonce", &["11223344"]),
1402 custom_tag("ox", &["../../../etc/passwd"]),
1403 custom_tag("file-type", &["image/jpeg"]),
1404 custom_tag("name", &["x.jpg"]),
1405 ]);
1406 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/x.jpg", t);
1407 let expected_id = crate::crypto::attachment_identity_basis(None, "11223344", "https://blossom.example/x.jpg");
1408 match process_rumor(rumor, ctx(), &dir).unwrap() {
1409 RumorProcessingResult::FileAttachment(msg) => {
1410 let att = &msg.attachments[0];
1411 assert!(!att.path.contains(".."), "traversal basis must not reach the path: {}", att.path);
1412 assert_eq!(att.id, expected_id, "id falls back to the nonce+url digest");
1413 }
1414 _ => panic!("Expected FileAttachment"),
1415 }
1416
1417 let t = tags(vec![
1419 custom_tag("decryption-key", &["aabbccdd"]),
1420 custom_tag("decryption-nonce", &["../../../etc/cron.d/evil"]),
1421 custom_tag("file-type", &["image/jpeg"]),
1422 ]);
1423 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/y.jpg", t);
1424 assert!(process_rumor(rumor, ctx(), &dir).is_err());
1425 }
1426
1427 #[test]
1428 fn test_file_attachment_empty_hash_rejected() {
1429 let keys = test_keypair();
1430 let t = tags(vec![
1431 custom_tag("decryption-key", &["aabbccdd"]),
1432 custom_tag("decryption-nonce", &["11223344"]),
1433 custom_tag("file-type", &["image/jpeg"]),
1434 ]);
1435 let empty_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1436 let rumor = make_rumor(&keys, Kind::from_u16(15), &format!("https://blossom.example/{}", empty_hash), t);
1437 let ctx = dm_context(&keys);
1438 let result = process_rumor(rumor, ctx, &temp_dir());
1439 assert!(result.is_err());
1440 }
1441
1442 #[test]
1443 fn test_file_attachment_with_image_meta() {
1444 let keys = test_keypair();
1445 let ox_hash = "a".repeat(64);
1446 let t = tags(vec![
1447 custom_tag("decryption-key", &["aabbccdd"]),
1448 custom_tag("decryption-nonce", &["11223344"]),
1449 custom_tag("ox", &[&ox_hash]),
1450 custom_tag("file-type", &["image/png"]),
1451 custom_tag("thumbhash", &["base64data"]),
1452 custom_tag("dim", &["1920x1080"]),
1453 custom_tag("size", &["5000"]),
1454 ]);
1455 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/aaa.png", t);
1456 let ctx = dm_context(&keys);
1457 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1458
1459 match result {
1460 RumorProcessingResult::FileAttachment(msg) => {
1461 let att = &msg.attachments[0];
1462 let meta = att.img_meta.as_ref().unwrap();
1463 assert_eq!(meta.width, 1920);
1464 assert_eq!(meta.height, 1080);
1465 assert_eq!(meta.thumbhash, "base64data");
1466 }
1467 _ => panic!("Expected FileAttachment"),
1468 }
1469 }
1470
1471 #[test]
1477 fn test_file_attachment_thumb_tag_is_read() {
1478 let keys = test_keypair();
1479 let ox_hash = "b".repeat(64);
1480 let t = tags(vec![
1481 custom_tag("decryption-key", &["aabbccdd"]),
1482 custom_tag("decryption-nonce", &["11223344"]),
1483 custom_tag("ox", &[&ox_hash]),
1484 custom_tag("file-type", &["image/png"]),
1485 custom_tag("thumb", &["realwiretag"]),
1486 custom_tag("dim", &["800x600"]),
1487 custom_tag("size", &["5000"]),
1488 ]);
1489 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/bbb.png", t);
1490 let ctx = dm_context(&keys);
1491 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1492
1493 match result {
1494 RumorProcessingResult::FileAttachment(msg) => {
1495 let meta = msg.attachments[0].img_meta.as_ref()
1496 .expect("img_meta must be populated from the `thumb` tag");
1497 assert_eq!(meta.thumbhash, "realwiretag");
1498 assert_eq!(meta.width, 800);
1499 assert_eq!(meta.height, 600);
1500 }
1501 _ => panic!("Expected FileAttachment"),
1502 }
1503 }
1504
1505 #[test]
1510 fn test_extract_hash_from_blossom_url() {
1511 let hash = "a".repeat(64);
1512 let url = format!("https://blossom.example/{}.jpg", hash);
1513 assert_eq!(extract_hash_from_blossom_url(&url), Some(hash));
1514
1515 assert_eq!(extract_hash_from_blossom_url("https://example.com/short"), None);
1516 assert_eq!(extract_hash_from_blossom_url("https://example.com/not-hex-at-all-but-exactly-sixty-four-characters-long-string-here!"), None);
1517 }
1518
1519 #[test]
1520 fn test_unknown_app_specific_ignored() {
1521 let keys = test_keypair();
1522 let t = tags(vec![Tag::identifier("some-other-app")]);
1523 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "unknown-content", t);
1524 let ctx = dm_context(&keys);
1525 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1526 assert!(matches!(result, RumorProcessingResult::Ignored));
1527 }
1528}