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