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
390 .find(TagKind::Custom(Cow::Borrowed("thumbhash")))
391 .or_else(|| rumor.tags.find(TagKind::Custom(Cow::Borrowed("thumb"))))
392 .and_then(|tag| tag.content())
393 .map(|s| s.to_string());
394
395 let dimensions_opt = rumor.tags
396 .find(TagKind::Custom(Cow::Borrowed("dim")))
397 .and_then(|tag| tag.content())
398 .and_then(|s| {
399 let parts: Vec<&str> = s.split('x').collect();
400 if parts.len() == 2 {
401 let width = parts[0].parse::<u32>().ok()?;
402 let height = parts[1].parse::<u32>().ok()?;
403 Some((width, height))
404 } else {
405 None
406 }
407 });
408
409 match (thumbhash_opt, dimensions_opt) {
410 (Some(thumbhash), Some((width, height))) => {
411 Some(ImageMetadata {
412 thumbhash,
413 width,
414 height,
415 })
416 },
417 _ => None
418 }
419 };
420
421 let mime_type = rumor.tags
423 .find(TagKind::Custom(Cow::Borrowed("file-type")))
424 .and_then(|tag| tag.content())
425 .ok_or("Missing file-type tag")?;
426 let mime_extension = extension_from_mime(mime_type);
427
428 let file_name = rumor.tags
430 .find(TagKind::Custom(Cow::Borrowed("name")))
431 .and_then(|tag| tag.content())
432 .map(|s| sanitize_filename(s))
433 .unwrap_or_default();
434
435 let extension = if !file_name.is_empty() {
438 file_name.rsplit('.').next()
439 .filter(|e| !e.is_empty() && *e != file_name)
440 .map(|e| e.to_lowercase())
441 .unwrap_or(mime_extension)
442 } else {
443 mime_extension
444 };
445
446 let reported_size = rumor.tags
448 .find(TagKind::Custom(Cow::Borrowed("size")))
449 .and_then(|tag| tag.content())
450 .and_then(|s| s.parse::<u64>().ok())
451 .unwrap_or(0);
452
453 let valid_path_basis =
460 |s: &str| !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit());
461 let original_file_hash = original_file_hash.filter(|h| valid_path_basis(h));
462 if !valid_path_basis(&decryption_nonce) {
463 return Err("Invalid decryption-nonce tag".to_string());
464 }
465 let file_hash = crate::crypto::attachment_identity_basis(
466 original_file_hash.as_deref(),
467 &decryption_nonce,
468 &content_url,
469 );
470 let hash_file_path = download_dir.join(format!("{}.{}", file_hash, extension));
471 let downloaded = false;
476 let file_path = hash_file_path.to_string_lossy().to_string();
477
478 let replied_to = extract_reply_reference(&rumor);
480
481 let ms_timestamp = extract_millisecond_timestamp(&rumor);
483
484 let webxdc_topic = rumor.tags
488 .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
489 .and_then(|tag| tag.content())
490 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
491 .map(|s| s.to_string());
492
493 let attachment = Attachment {
495 id: file_hash.clone(),
496 key: decryption_key,
497 nonce: decryption_nonce,
498 extension: extension.to_string(),
499 name: file_name,
500 url: content_url,
501 path: file_path,
502 size: reported_size,
503 img_meta,
504 downloading: false,
505 downloaded,
506 webxdc_topic,
507 group_id: None, original_hash: original_file_hash, scheme_version: None, mls_filename: None, };
512
513 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
514 let npub = context.author_npub(&rumor.pubkey);
516
517 let expiration = extract_nip40_expiration(&rumor);
519 let msg = Message {
520 expiration,
521 id: rumor.id.to_hex(),
522 content: String::new(),
523 replied_to,
524 replied_to_content: None, replied_to_npub: None,
526 replied_to_has_attachment: None,
527 replied_to_attachment_extension: None,
528 preview_metadata: None,
529 at: ms_timestamp,
530 attachments: vec![attachment],
531 reactions: Vec::new(),
532 mine: context.is_mine,
533 pending: false,
534 failed: false,
535 npub,
536 wrapper_event_id: None, edited: false,
538 edit_history: None,
539 emoji_tags,
540 addressed_bots: crate::bot_interface::addressed_bots(rumor.tags.iter()),
541 };
542
543 Ok(RumorProcessingResult::FileAttachment(msg))
544}
545
546fn unique_event_ref(rumor: &RumorEvent) -> Option<String> {
556 let mut matches = rumor.tags.iter().filter(|t| t.kind() == TagKind::e());
557 let first = matches.next()?;
558 if matches.next().is_some() {
559 return None;
560 }
561 first.content().map(|s| s.to_string())
562}
563
564fn extract_nip40_expiration(rumor: &RumorEvent) -> Option<u64> {
567 rumor.tags.iter().find_map(|tag| {
568 let s = tag.as_slice();
569 if s.len() >= 2 && s[0] == "expiration" {
570 s[1].parse::<u64>().ok()
571 } else {
572 None
573 }
574 })
575}
576
577fn process_deletion(
578 rumor: RumorEvent,
579 _context: RumorContext,
580) -> Result<RumorProcessingResult, String> {
581 let target_event_id = unique_event_ref(&rumor)
582 .ok_or("Deletion target tag missing or ambiguous")?;
583 Ok(RumorProcessingResult::DeletionRequest { target_event_id })
584}
585
586fn is_renderable_reaction(content: &str) -> bool {
591 if content.is_empty() || content == "+" || content == "-" {
593 return true;
594 }
595 if let Some(inner) = content.strip_prefix(':').and_then(|s| s.strip_suffix(':')) {
598 if !inner.is_empty()
599 && inner.len() <= 48
600 && inner.chars().all(|c| c.is_ascii_alphanumeric() || matches!(c, '_' | '~' | '-' | '+'))
601 {
602 return true;
603 }
604 }
605 content.chars().count() <= 12
609 && !content.chars().any(char::is_whitespace)
610 && !content.contains("://")
611}
612
613fn process_reaction(
617 rumor: RumorEvent,
618 _context: RumorContext,
619) -> Result<RumorProcessingResult, String> {
620 let reference_id = unique_event_ref(&rumor)
621 .ok_or("Reaction reference tag missing or ambiguous")?;
622
623 if !is_renderable_reaction(&rumor.content) {
626 return Ok(RumorProcessingResult::Ignored);
627 }
628
629 let emoji_url = if rumor.content.starts_with(':') && rumor.content.ends_with(':')
632 && rumor.content.len() >= 3
633 {
634 let sc = &rumor.content[1..rumor.content.len() - 1];
635 rumor.tags.iter().find_map(|tag| {
636 let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
637 if parts.len() >= 3 && parts[0] == "emoji" && parts[1] == sc {
638 Some(parts[2].to_string())
639 } else {
640 None
641 }
642 })
643 } else {
644 None
645 };
646
647 let reaction = Reaction {
648 id: rumor.id.to_hex(),
649 reference_id,
650 author_id: rumor.pubkey.to_bech32().unwrap_or_else(|_| rumor.pubkey.to_hex()),
651 emoji: rumor.content,
652 emoji_url,
653 };
654
655 Ok(RumorProcessingResult::Reaction(reaction))
656}
657
658fn process_edit_event(
662 rumor: RumorEvent,
663 context: RumorContext,
664) -> Result<RumorProcessingResult, String> {
665 let message_id = unique_event_ref(&rumor)
666 .ok_or("Edit reference tag missing or ambiguous")?;
667
668 let edited_at = extract_millisecond_timestamp(&rumor);
669
670 let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
673
674 let tags: Vec<Vec<String>> = rumor.tags.iter()
675 .map(|tag| {
676 tag.as_slice().iter().map(|s| s.to_string()).collect()
677 })
678 .collect();
679
680 let event = StoredEventBuilder::new()
681 .id(rumor.id.to_hex())
682 .kind(event_kind::MESSAGE_EDIT)
683 .content(rumor.content.clone())
684 .tags(tags)
685 .reference_id(Some(message_id.clone()))
686 .created_at(rumor.created_at.as_secs())
687 .mine(context.is_mine)
688 .npub(rumor.pubkey.to_bech32().ok())
689 .build();
690
691 Ok(RumorProcessingResult::Edit {
692 message_id,
693 new_content: rumor.content,
694 edited_at,
695 emoji_tags,
696 event,
697 })
698}
699
700fn process_app_specific(
702 rumor: RumorEvent,
703 context: RumorContext,
704) -> Result<RumorProcessingResult, String> {
705 if is_typing_indicator(&rumor) {
707 let expiry_tag = rumor.tags
708 .find(TagKind::Expiration)
709 .ok_or("Typing indicator missing expiration tag")?;
710
711 let expiry_timestamp: u64 = expiry_tag.content()
712 .ok_or("Expiration tag has no content")?
713 .parse()
714 .map_err(|_| "Invalid expiration timestamp")?;
715
716 let current_timestamp = std::time::SystemTime::now()
717 .duration_since(std::time::UNIX_EPOCH)
718 .map_err(|e| format!("System time error: {}", e))?
719 .as_secs();
720
721 if expiry_timestamp <= current_timestamp || expiry_timestamp > current_timestamp + 30 {
722 return Ok(RumorProcessingResult::Ignored);
723 }
724
725 let profile_id = rumor.pubkey.to_bech32()
726 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
727
728 return Ok(RumorProcessingResult::TypingIndicator {
729 profile_id,
730 until: expiry_timestamp,
731 });
732 }
733
734 if is_leave_request(&rumor) {
736 let member_pubkey = rumor.pubkey.to_bech32()
737 .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
738
739 return Ok(RumorProcessingResult::LeaveRequest {
740 event_id: rumor.id.to_hex(),
741 member_pubkey,
742 });
743 }
744
745 if is_pivx_payment(&rumor) {
747 let gift_code = rumor.tags
748 .find(TagKind::Custom(Cow::Borrowed("gift-code")))
749 .and_then(|tag| tag.content())
750 .ok_or("PIVX payment missing gift-code tag")?
751 .to_string();
752
753 let amount_str = rumor.tags
754 .find(TagKind::Custom(Cow::Borrowed("amount")))
755 .and_then(|tag| tag.content())
756 .unwrap_or("0");
757 let amount_piv = amount_str.parse::<u64>().unwrap_or(0) as f64 / 100_000_000.0;
758
759 let address = rumor.tags
760 .find(TagKind::Custom(Cow::Borrowed("address")))
761 .and_then(|tag| tag.content())
762 .map(|s| s.to_string());
763
764 let message_id = rumor.id.to_hex();
765
766 let tags: Vec<Vec<String>> = rumor.tags.iter()
767 .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
768 .collect();
769
770 let event = StoredEventBuilder::new()
771 .id(&message_id)
772 .kind(event_kind::APPLICATION_SPECIFIC)
773 .chat_id(0) .content(&rumor.content)
775 .tags(tags)
776 .created_at(rumor.created_at.as_secs())
777 .mine(context.is_mine)
778 .npub(Some(rumor.pubkey.to_bech32().unwrap_or_default()))
779 .build();
780
781 return Ok(RumorProcessingResult::PivxPayment {
782 gift_code,
783 amount_piv,
784 address,
785 message_id,
786 event,
787 });
788 }
789
790 if is_wallpaper_change(&rumor) {
794 let url = rumor.tags
799 .find(TagKind::Custom(Cow::Borrowed("url")))
800 .and_then(|tag| tag.content())
801 .unwrap_or_default()
802 .to_string();
803 let decryption_key = rumor.tags
804 .find(TagKind::Custom(Cow::Borrowed("decryption-key")))
805 .and_then(|tag| tag.content())
806 .unwrap_or_default()
807 .to_string();
808 let decryption_nonce = rumor.tags
809 .find(TagKind::Custom(Cow::Borrowed("decryption-nonce")))
810 .and_then(|tag| tag.content())
811 .unwrap_or_default()
812 .to_string();
813 let plaintext_hash = rumor.tags
814 .find(TagKind::Custom(Cow::Borrowed("x")))
815 .and_then(|tag| tag.content())
816 .map(|s| s.to_string());
817 let mime = rumor.tags
818 .find(TagKind::Custom(Cow::Borrowed("m")))
819 .and_then(|tag| tag.content())
820 .map(|s| s.to_string());
821 let blur = rumor.tags
822 .find(TagKind::Custom(Cow::Borrowed("blur")))
823 .and_then(|tag| tag.content())
824 .and_then(|s| s.parse::<u32>().ok())
825 .map(|n| n.min(30) as u8);
826 let dim = rumor.tags
827 .find(TagKind::Custom(Cow::Borrowed("dim")))
828 .and_then(|tag| tag.content())
829 .and_then(|s| s.parse::<u32>().ok())
830 .map(|n| n.min(100) as u8);
831
832 return Ok(RumorProcessingResult::WallpaperChanged {
833 sender_npub: rumor.pubkey.to_bech32().unwrap_or_default(),
834 created_at: rumor.created_at.as_secs(),
835 url,
836 decryption_key,
837 decryption_nonce,
838 plaintext_hash,
839 mime,
840 blur,
841 dim,
842 event_id: rumor.id.to_hex(),
843 });
844 }
845
846 if is_webxdc_peer_advertisement(&rumor) {
848 log_info!("[WEBXDC] Found peer advertisement rumor, is_mine={}, sender={}",
849 context.is_mine,
850 rumor.pubkey.to_bech32().unwrap_or_else(|_| "unknown".to_string()));
851
852 if context.is_mine {
853 log_info!("[WEBXDC] Ignoring our own peer advertisement");
854 return Ok(RumorProcessingResult::Ignored);
855 }
856
857 log_info!("[WEBXDC] Detected peer advertisement in rumor from another device");
858
859 let topic_id = rumor.tags
860 .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
861 .and_then(|tag| tag.content())
862 .ok_or("Peer advertisement missing webxdc-topic tag")?
863 .to_string();
864
865 let node_addr = rumor.tags
866 .find(TagKind::Custom(Cow::Borrowed("webxdc-node-addr")))
867 .and_then(|tag| tag.content())
868 .ok_or("Peer advertisement missing webxdc-node-addr tag")?
869 .to_string();
870
871 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
872 return Ok(RumorProcessingResult::WebxdcPeerAdvertisement {
873 event_id: rumor.id.to_hex(),
874 topic_id,
875 node_addr,
876 sender_npub,
877 created_at: rumor.created_at.as_secs(),
878 });
879 }
880
881 if is_webxdc_peer_left(&rumor) {
883 if context.is_mine {
884 return Ok(RumorProcessingResult::Ignored);
885 }
886
887 log_info!("[WEBXDC] Detected peer-left signal from another device");
888
889 let topic_id = rumor.tags
890 .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
891 .and_then(|tag| tag.content())
892 .ok_or("Peer-left missing webxdc-topic tag")?
893 .to_string();
894
895 let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
896 return Ok(RumorProcessingResult::WebxdcPeerLeft {
897 event_id: rumor.id.to_hex(),
898 topic_id,
899 sender_npub,
900 created_at: rumor.created_at.as_secs(),
901 });
902 }
903
904 Ok(RumorProcessingResult::Ignored)
906}
907
908fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool {
910 rumor.content == "peer-advertisement"
911 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-topic"))).is_some()
912 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-node-addr"))).is_some()
913}
914
915fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool {
917 rumor.content == "peer-left"
918 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-topic"))).is_some()
919}
920
921fn is_pivx_payment(rumor: &RumorEvent) -> bool {
923 rumor.tags
924 .find(TagKind::d())
925 .and_then(|tag| tag.content())
926 .map(|content| content == "pivx-payment")
927 .unwrap_or(false)
928 && rumor.tags.find(TagKind::Custom(Cow::Borrowed("gift-code"))).is_some()
929}
930
931fn extract_millisecond_timestamp(rumor: &RumorEvent) -> u64 {
940 let ms_tag = rumor.tags
941 .find(TagKind::Custom(Cow::Borrowed("ms")))
942 .and_then(|t| t.content());
943 resolve_message_timestamp(rumor.created_at.as_secs(), ms_tag)
944}
945
946pub fn resolve_message_timestamp(created_at_secs: u64, ms_tag: Option<&str>) -> u64 {
957 const FUTURE_GRACE_MS: u64 = 5 * 60 * 1000;
958 let base = created_at_secs.saturating_mul(1000);
959 let at = match ms_tag.and_then(|s| s.parse::<u64>().ok()) {
960 Some(offset) if offset <= 999 => base.saturating_add(offset),
961 _ => base,
962 };
963 let now_ms = std::time::SystemTime::now()
964 .duration_since(std::time::UNIX_EPOCH)
965 .map(|d| d.as_millis() as u64)
966 .unwrap_or(u64::MAX);
967 if at > now_ms.saturating_add(FUTURE_GRACE_MS) { now_ms } else { at }
968}
969
970fn extract_reply_reference(rumor: &RumorEvent) -> String {
975 match rumor.tags.find(TagKind::e()) {
976 Some(tag) => {
977 if tag.is_reply() {
980 tag.content().unwrap_or("").to_string()
981 } else {
982 let slice = tag.as_slice();
983 if slice.get(3).map(|s| s == "reply").unwrap_or(false) {
984 tag.content().unwrap_or("").to_string()
985 } else {
986 String::new()
987 }
988 }
989 }
990 None => String::new(),
991 }
992}
993
994fn is_typing_indicator(rumor: &RumorEvent) -> bool {
996 let has_vector_tag = rumor.tags
997 .find(TagKind::d())
998 .and_then(|tag| tag.content())
999 .map(|content| content == "vector")
1000 .unwrap_or(false);
1001
1002 let is_typing_content = rumor.content == "typing";
1003
1004 has_vector_tag && is_typing_content
1005}
1006
1007fn is_wallpaper_change(rumor: &RumorEvent) -> bool {
1009 rumor.tags
1010 .find(TagKind::d())
1011 .and_then(|tag| tag.content())
1012 .map(|content| content == "vector-wallpaper")
1013 .unwrap_or(false)
1014}
1015
1016fn is_leave_request(rumor: &RumorEvent) -> bool {
1018 let has_vector_tag = rumor.tags
1019 .find(TagKind::d())
1020 .and_then(|tag| tag.content())
1021 .map(|content| content == "vector")
1022 .unwrap_or(false);
1023
1024 let is_leave_content = rumor.content == "leave";
1025
1026 has_vector_tag && is_leave_content
1027}
1028
1029#[cfg(test)]
1030mod tests {
1031 use super::*;
1032
1033 #[test]
1037 fn ms_resolver_applies_offset_enforces_sub_second_and_clamps_future() {
1038 assert_eq!(resolve_message_timestamp(1500, Some("242")), 1_500_242);
1040 assert_eq!(resolve_message_timestamp(1500, None), 1_500_000);
1042 assert_eq!(resolve_message_timestamp(1500, Some("4242")), 1_500_000);
1044 assert_eq!(resolve_message_timestamp(1500, Some("nope")), 1_500_000);
1045 let now = std::time::SystemTime::now()
1047 .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1048 let clamped = resolve_message_timestamp(253_402_300_800, Some("5"));
1049 assert!(clamped <= (now + 3600) * 1000, "implausible-future ms must clamp to ~now");
1050 }
1051
1052 #[test]
1056 fn ambiguous_target_is_rejected_for_reaction_edit_delete() {
1057 let keys = test_keypair();
1058 let two_e = || tags(vec![
1059 Tag::custom(TagKind::e(), ["aa".repeat(32)]),
1060 Tag::custom(TagKind::e(), ["bb".repeat(32)]),
1061 ]);
1062 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", two_e()), dm_context(&keys), &temp_dir()).is_err());
1063 assert!(process_rumor(make_rumor(&keys, Kind::EventDeletion, "", two_e()), dm_context(&keys), &temp_dir()).is_err());
1064 assert!(process_rumor(make_rumor(&keys, Kind::from(event_kind::MESSAGE_EDIT), "edited", two_e()), dm_context(&keys), &temp_dir()).is_err());
1065 let one_e = tags(vec![Tag::custom(TagKind::e(), ["aa".repeat(32)])]);
1066 assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", one_e), dm_context(&keys), &temp_dir()).is_ok());
1067 }
1068
1069 fn test_keypair() -> Keys {
1070 Keys::generate()
1071 }
1072
1073 fn tags(items: Vec<Tag>) -> Tags {
1075 let mut t = Tags::new();
1076 for item in items {
1077 t.push(item);
1078 }
1079 t
1080 }
1081
1082 fn custom_tag(key: &str, values: &[&str]) -> Tag {
1084 let owned: Vec<String> = values.iter().map(|s| s.to_string()).collect();
1085 Tag::custom(TagKind::custom(key.to_string()), owned)
1086 }
1087
1088 fn make_rumor(keys: &Keys, kind: Kind, content: &str, t: Tags) -> RumorEvent {
1089 RumorEvent {
1090 id: EventId::all_zeros(),
1091 kind,
1092 content: content.to_string(),
1093 tags: t,
1094 created_at: Timestamp::from_secs(1700000000),
1095 pubkey: keys.public_key(),
1096 }
1097 }
1098
1099 fn dm_context(keys: &Keys) -> RumorContext {
1100 RumorContext {
1101 sender: keys.public_key(),
1102 is_mine: false,
1103 conversation_id: "npub1test".to_string(),
1104 conversation_type: ConversationType::DirectMessage,
1105 }
1106 }
1107
1108 fn temp_dir() -> std::path::PathBuf {
1109 std::env::temp_dir().join("vector-rumor-test")
1110 }
1111
1112 #[test]
1117 fn test_text_message_dm() {
1118 let keys = test_keypair();
1119 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Hello world!", Tags::new());
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.content, "Hello world!");
1126 assert!(!msg.mine);
1127 assert!(msg.npub.is_none());
1128 assert!(msg.attachments.is_empty());
1129 }
1130 _ => panic!("Expected TextMessage"),
1131 }
1132 }
1133
1134 #[test]
1135 fn test_text_message_mine() {
1136 let keys = test_keypair();
1137 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "My own message", Tags::new());
1138 let ctx = RumorContext {
1139 sender: keys.public_key(),
1140 is_mine: true,
1141 conversation_id: "npub1test".to_string(),
1142 conversation_type: ConversationType::DirectMessage,
1143 };
1144 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1145
1146 match result {
1147 RumorProcessingResult::TextMessage(msg) => {
1148 assert!(msg.mine);
1149 }
1150 _ => panic!("Expected TextMessage"),
1151 }
1152 }
1153
1154 #[test]
1155 fn test_text_message_with_reply() {
1156 let keys = test_keypair();
1157 let t = tags(vec![
1158 Tag::custom(TagKind::e(), ["abc123def456".to_string(), String::new(), "reply".to_string()]),
1159 ]);
1160 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Reply text", t);
1161 let ctx = dm_context(&keys);
1162 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1163
1164 match result {
1165 RumorProcessingResult::TextMessage(msg) => {
1166 assert_eq!(msg.replied_to, "abc123def456");
1167 }
1168 _ => panic!("Expected TextMessage"),
1169 }
1170 }
1171
1172 #[test]
1173 fn test_text_message_with_ms_timestamp() {
1174 let keys = test_keypair();
1175 let t = tags(vec![custom_tag("ms", &["456"])]);
1176 let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Precise time", t);
1177 let ctx = dm_context(&keys);
1178 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1179
1180 match result {
1181 RumorProcessingResult::TextMessage(msg) => {
1182 assert_eq!(msg.at, 1700000000 * 1000 + 456);
1183 }
1184 _ => panic!("Expected TextMessage"),
1185 }
1186 }
1187
1188 #[test]
1193 fn test_reaction() {
1194 let keys = test_keypair();
1195 let t = tags(vec![custom_tag("e", &["target_msg_id_hex"])]);
1196 let rumor = make_rumor(&keys, Kind::Reaction, "👍", t);
1197 let ctx = dm_context(&keys);
1198 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1199
1200 match result {
1201 RumorProcessingResult::Reaction(reaction) => {
1202 assert_eq!(reaction.emoji, "👍");
1203 assert_eq!(reaction.reference_id, "target_msg_id_hex");
1204 }
1205 _ => panic!("Expected Reaction"),
1206 }
1207 }
1208
1209 #[test]
1210 fn test_reaction_missing_e_tag() {
1211 let keys = test_keypair();
1212 let rumor = make_rumor(&keys, Kind::Reaction, "👍", Tags::new());
1213 let ctx = dm_context(&keys);
1214 let result = process_rumor(rumor, ctx, &temp_dir());
1215 assert!(result.is_err());
1216 }
1217
1218 #[test]
1219 fn junk_reaction_content_is_dropped_clean_ones_kept() {
1220 for ok in ["👍", "+", "-", "", "👨\u{200d}👩\u{200d}👧\u{200d}👦", ":thugamy:"] {
1222 assert!(is_renderable_reaction(ok), "{ok:?} should be renderable");
1223 }
1224 for junk in [
1226 ":thugamy:https://image.nostr.build/ccc22.png",
1227 "https://example.com/x.png",
1228 "lorem ipsum dolor",
1229 ] {
1230 assert!(!is_renderable_reaction(junk), "{junk:?} should be dropped");
1231 }
1232 assert!(!is_renderable_reaction(&"x".repeat(64)));
1233
1234 let keys = test_keypair();
1236 let t = tags(vec![custom_tag("e", &["target"])]);
1237 let rumor = make_rumor(&keys, Kind::Reaction, ":thugamy:https://image.nostr.build/ccc22.png", t);
1238 let result = process_rumor(rumor, dm_context(&keys), &temp_dir()).unwrap();
1239 assert!(matches!(result, RumorProcessingResult::Ignored), "junk reaction should be Ignored");
1240 }
1241
1242 #[test]
1247 fn test_edit_event() {
1248 let keys = test_keypair();
1249 let t = tags(vec![custom_tag("e", &["original_msg_id"])]);
1250 let rumor = make_rumor(&keys, Kind::from_u16(16), "Edited content", t);
1251 let ctx = dm_context(&keys);
1252 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1253
1254 match result {
1255 RumorProcessingResult::Edit { message_id, new_content, event, .. } => {
1256 assert_eq!(message_id, "original_msg_id");
1257 assert_eq!(new_content, "Edited content");
1258 assert_eq!(event.kind, event_kind::MESSAGE_EDIT);
1259 }
1260 _ => panic!("Expected Edit"),
1261 }
1262 }
1263
1264 #[test]
1269 fn test_typing_indicator_valid() {
1270 let keys = test_keypair();
1271 let future_ts = std::time::SystemTime::now()
1272 .duration_since(std::time::UNIX_EPOCH).unwrap()
1273 .as_secs() + 10;
1274 let t = tags(vec![
1275 Tag::identifier("vector"),
1276 Tag::expiration(Timestamp::from_secs(future_ts)),
1277 ]);
1278 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1279 let ctx = dm_context(&keys);
1280 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1281
1282 match result {
1283 RumorProcessingResult::TypingIndicator { until, .. } => {
1284 assert_eq!(until, future_ts);
1285 }
1286 _ => panic!("Expected TypingIndicator"),
1287 }
1288 }
1289
1290 #[test]
1291 fn test_typing_indicator_expired() {
1292 let keys = test_keypair();
1293 let t = tags(vec![
1294 Tag::identifier("vector"),
1295 Tag::expiration(Timestamp::from_secs(1000000000)),
1296 ]);
1297 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1298 let ctx = dm_context(&keys);
1299 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1300
1301 assert!(matches!(result, RumorProcessingResult::Ignored));
1302 }
1303
1304 #[test]
1309 fn test_leave_request() {
1310 let keys = test_keypair();
1311 let t = tags(vec![Tag::identifier("vector")]);
1312 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "leave", t);
1313 let ctx = dm_context(&keys);
1314 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1315
1316 match result {
1317 RumorProcessingResult::LeaveRequest { member_pubkey, .. } => {
1318 assert!(!member_pubkey.is_empty());
1319 assert!(member_pubkey.starts_with("npub1"));
1320 }
1321 _ => panic!("Expected LeaveRequest"),
1322 }
1323 }
1324
1325 #[test]
1330 fn test_pivx_payment() {
1331 let keys = test_keypair();
1332 let t = tags(vec![
1333 Tag::identifier("pivx-payment"),
1334 custom_tag("gift-code", &["ABC12"]),
1335 custom_tag("amount", &["100000000"]),
1336 custom_tag("address", &["DTest123"]),
1337 ]);
1338 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "pivx-payment", t);
1339 let ctx = dm_context(&keys);
1340 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1341
1342 match result {
1343 RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, .. } => {
1344 assert_eq!(gift_code, "ABC12");
1345 assert!((amount_piv - 1.0).abs() < f64::EPSILON);
1346 assert_eq!(address, Some("DTest123".to_string()));
1347 }
1348 _ => panic!("Expected PivxPayment"),
1349 }
1350 }
1351
1352 #[test]
1357 fn test_webxdc_peer_advertisement() {
1358 let keys = test_keypair();
1359 let t = tags(vec![
1360 custom_tag("webxdc-topic", &["topic123"]),
1361 custom_tag("webxdc-node-addr", &["addr456"]),
1362 ]);
1363 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1364 let ctx = dm_context(&keys);
1365 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1366
1367 match result {
1368 RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr, .. } => {
1369 assert_eq!(topic_id, "topic123");
1370 assert_eq!(node_addr, "addr456");
1371 }
1372 _ => panic!("Expected WebxdcPeerAdvertisement"),
1373 }
1374 }
1375
1376 #[test]
1377 fn test_webxdc_peer_advertisement_own_ignored() {
1378 let keys = test_keypair();
1379 let t = tags(vec![
1380 custom_tag("webxdc-topic", &["topic123"]),
1381 custom_tag("webxdc-node-addr", &["addr456"]),
1382 ]);
1383 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1384 let ctx = RumorContext {
1385 sender: keys.public_key(),
1386 is_mine: true,
1387 conversation_id: "npub1test".to_string(),
1388 conversation_type: ConversationType::DirectMessage,
1389 };
1390 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1391 assert!(matches!(result, RumorProcessingResult::Ignored));
1392 }
1393
1394 #[test]
1395 fn test_webxdc_peer_left() {
1396 let keys = test_keypair();
1397 let t = tags(vec![custom_tag("webxdc-topic", &["topic123"])]);
1398 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-left", t);
1399 let ctx = dm_context(&keys);
1400 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1401
1402 match result {
1403 RumorProcessingResult::WebxdcPeerLeft { topic_id, .. } => {
1404 assert_eq!(topic_id, "topic123");
1405 }
1406 _ => panic!("Expected WebxdcPeerLeft"),
1407 }
1408 }
1409
1410 #[test]
1415 fn test_unknown_kind() {
1416 let keys = test_keypair();
1417 let rumor = make_rumor(&keys, Kind::from_u16(65535), "Mystery event", Tags::new());
1418 let ctx = dm_context(&keys);
1419 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1420
1421 match result {
1422 RumorProcessingResult::UnknownEvent(event) => {
1423 assert_eq!(event.kind, 65535);
1424 assert_eq!(event.content, "Mystery event");
1425 }
1426 _ => panic!("Expected UnknownEvent"),
1427 }
1428 }
1429
1430 #[test]
1435 fn test_file_attachment() {
1436 let keys = test_keypair();
1437 let ox_hash = "deadbeef".repeat(8); let t = tags(vec![
1439 custom_tag("decryption-key", &["aabbccdd"]),
1440 custom_tag("decryption-nonce", &["11223344"]),
1441 custom_tag("ox", &[&ox_hash]),
1442 custom_tag("file-type", &["image/jpeg"]),
1443 custom_tag("name", &["photo.jpg"]),
1444 custom_tag("size", &["12345"]),
1445 ]);
1446 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1447 let ctx = dm_context(&keys);
1448 let dir = temp_dir();
1449 let result = process_rumor(rumor, ctx, &dir).unwrap();
1450
1451 match result {
1452 RumorProcessingResult::FileAttachment(msg) => {
1453 assert_eq!(msg.attachments.len(), 1);
1454 let att = &msg.attachments[0];
1455 assert_eq!(att.key, "aabbccdd");
1456 assert_eq!(att.nonce, "11223344");
1457 assert_eq!(att.extension, "jpg");
1458 assert_eq!(att.name, "photo.jpg");
1459 assert_eq!(att.size, 12345);
1460 assert!(!att.downloaded);
1461 }
1462 _ => panic!("Expected FileAttachment"),
1463 }
1464 }
1465
1466 #[test]
1467 fn test_file_attachment_hostile_path_basis_rejected() {
1468 let keys = test_keypair();
1469 let dir = temp_dir();
1470 let ctx = || dm_context(&keys);
1471
1472 let t = tags(vec![
1476 custom_tag("decryption-key", &["aabbccdd"]),
1477 custom_tag("decryption-nonce", &["11223344"]),
1478 custom_tag("ox", &["../../../etc/passwd"]),
1479 custom_tag("file-type", &["image/jpeg"]),
1480 custom_tag("name", &["x.jpg"]),
1481 ]);
1482 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/x.jpg", t);
1483 let expected_id = crate::crypto::attachment_identity_basis(None, "11223344", "https://blossom.example/x.jpg");
1484 match process_rumor(rumor, ctx(), &dir).unwrap() {
1485 RumorProcessingResult::FileAttachment(msg) => {
1486 let att = &msg.attachments[0];
1487 assert!(!att.path.contains(".."), "traversal basis must not reach the path: {}", att.path);
1488 assert_eq!(att.id, expected_id, "id falls back to the nonce+url digest");
1489 }
1490 _ => panic!("Expected FileAttachment"),
1491 }
1492
1493 let t = tags(vec![
1495 custom_tag("decryption-key", &["aabbccdd"]),
1496 custom_tag("decryption-nonce", &["../../../etc/cron.d/evil"]),
1497 custom_tag("file-type", &["image/jpeg"]),
1498 ]);
1499 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/y.jpg", t);
1500 assert!(process_rumor(rumor, ctx(), &dir).is_err());
1501 }
1502
1503 #[test]
1504 fn test_file_attachment_empty_hash_rejected() {
1505 let keys = test_keypair();
1506 let t = tags(vec![
1507 custom_tag("decryption-key", &["aabbccdd"]),
1508 custom_tag("decryption-nonce", &["11223344"]),
1509 custom_tag("file-type", &["image/jpeg"]),
1510 ]);
1511 let empty_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1512 let rumor = make_rumor(&keys, Kind::from_u16(15), &format!("https://blossom.example/{}", empty_hash), t);
1513 let ctx = dm_context(&keys);
1514 let result = process_rumor(rumor, ctx, &temp_dir());
1515 assert!(result.is_err());
1516 }
1517
1518 #[test]
1519 fn test_file_attachment_with_image_meta() {
1520 let keys = test_keypair();
1521 let ox_hash = "a".repeat(64);
1522 let t = tags(vec![
1523 custom_tag("decryption-key", &["aabbccdd"]),
1524 custom_tag("decryption-nonce", &["11223344"]),
1525 custom_tag("ox", &[&ox_hash]),
1526 custom_tag("file-type", &["image/png"]),
1527 custom_tag("thumbhash", &["base64data"]),
1528 custom_tag("dim", &["1920x1080"]),
1529 custom_tag("size", &["5000"]),
1530 ]);
1531 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/aaa.png", t);
1532 let ctx = dm_context(&keys);
1533 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1534
1535 match result {
1536 RumorProcessingResult::FileAttachment(msg) => {
1537 let att = &msg.attachments[0];
1538 let meta = att.img_meta.as_ref().unwrap();
1539 assert_eq!(meta.width, 1920);
1540 assert_eq!(meta.height, 1080);
1541 assert_eq!(meta.thumbhash, "base64data");
1542 }
1543 _ => panic!("Expected FileAttachment"),
1544 }
1545 }
1546
1547 #[test]
1553 fn test_file_attachment_thumb_tag_is_read() {
1554 let keys = test_keypair();
1555 let ox_hash = "b".repeat(64);
1556 let t = tags(vec![
1557 custom_tag("decryption-key", &["aabbccdd"]),
1558 custom_tag("decryption-nonce", &["11223344"]),
1559 custom_tag("ox", &[&ox_hash]),
1560 custom_tag("file-type", &["image/png"]),
1561 custom_tag("thumb", &["realwiretag"]),
1562 custom_tag("dim", &["800x600"]),
1563 custom_tag("size", &["5000"]),
1564 ]);
1565 let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/bbb.png", t);
1566 let ctx = dm_context(&keys);
1567 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1568
1569 match result {
1570 RumorProcessingResult::FileAttachment(msg) => {
1571 let meta = msg.attachments[0].img_meta.as_ref()
1572 .expect("img_meta must be populated from the `thumb` tag");
1573 assert_eq!(meta.thumbhash, "realwiretag");
1574 assert_eq!(meta.width, 800);
1575 assert_eq!(meta.height, 600);
1576 }
1577 _ => panic!("Expected FileAttachment"),
1578 }
1579 }
1580
1581 #[test]
1586 fn test_extract_hash_from_blossom_url() {
1587 let hash = "a".repeat(64);
1588 let url = format!("https://blossom.example/{}.jpg", hash);
1589 assert_eq!(extract_hash_from_blossom_url(&url), Some(hash));
1590
1591 assert_eq!(extract_hash_from_blossom_url("https://example.com/short"), None);
1592 assert_eq!(extract_hash_from_blossom_url("https://example.com/not-hex-at-all-but-exactly-sixty-four-characters-long-string-here!"), None);
1593 }
1594
1595 #[test]
1596 fn test_unknown_app_specific_ignored() {
1597 let keys = test_keypair();
1598 let t = tags(vec![Tag::identifier("some-other-app")]);
1599 let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "unknown-content", t);
1600 let ctx = dm_context(&keys);
1601 let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1602 assert!(matches!(result, RumorProcessingResult::Ignored));
1603 }
1604}