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