Skip to main content

vector_core/
rumor.rs

1//! Rumor Processing Module
2//!
3//! Unified processing for the inner decrypted events of NIP-17 DMs. "Rumors" are
4//! the inner events; only the gift-wrap unwrapping happens before this.
5//!
6//! ## Architecture
7//!
8//! ```text
9//! Event → Protocol Handler (unwrap) → RumorEvent
10//!                                       ↓
11//!                             process_rumor() [SHARED]
12//!                                       ↓
13//!                             RumorProcessingResult
14//!                                       ↓
15//!                     Storage Handler (protocol-specific)
16//!                                       ↓
17//!                             Emit to UI [SHARED]
18//! ```
19//!
20//! ## Supported Rumor Types
21//!
22//! - **Text Messages**: `Kind::PrivateDirectMessage` - Plain text with optional replies
23//! - **File Attachments**: `Kind::from_u16(15)` - Encrypted files with metadata
24//! - **Reactions**: `Kind::Reaction` - Emoji reactions to messages
25//! - **Typing Indicators**: `Kind::ApplicationSpecificData` - Real-time typing status
26
27use 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/// Decrypted NIP-17 rumor event representation.
35#[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/// Context for processing a rumor
46///
47/// Provides the necessary context to process a rumor correctly,
48/// including who sent it and what conversation it belongs to.
49#[derive(Debug, Clone)]
50pub struct RumorContext {
51    /// The sender's public key
52    pub sender: PublicKey,
53    /// Whether this rumor is from ourselves
54    pub is_mine: bool,
55    /// The conversation ID (npub for DMs)
56    pub conversation_id: String,
57    /// The type of conversation
58    pub conversation_type: ConversationType,
59}
60
61/// Type of conversation — the transport-specific dimension the shared parser keys off (e.g. who the
62/// author is). `conversation_id` carries the address: an npub for a DM, a channel id for a Community.
63#[derive(Debug, Clone, PartialEq)]
64pub enum ConversationType {
65    /// Direct message (NIP-17) — 1:1, so the author is implied by the chat.
66    DirectMessage,
67    /// Concord community channel — a group, so each message records its real author.
68    Community,
69}
70
71impl RumorContext {
72    /// The author npub to stamp on a parsed message. A DM is 1:1 so the author is implied by the
73    /// chat (`None`); a Community message records its real author so the group can attribute it.
74    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/// Result of processing a rumor
83///
84/// Represents the different types of events that can result from
85/// processing a rumor. The caller is responsible for storing these
86/// results appropriately based on the conversation type.
87#[derive(Debug, Clone)]
88pub enum RumorProcessingResult {
89    /// A text message (with optional reply reference)
90    TextMessage(Message),
91    /// A file attachment message
92    FileAttachment(Message),
93    /// An emoji reaction to a message
94    Reaction(Reaction),
95    /// A typing indicator update
96    TypingIndicator {
97        profile_id: String,
98        until: u64,
99    },
100    /// A leave request from a group member (admin should auto-remove them)
101    LeaveRequest {
102        /// The event ID of the leave request (for deduplication)
103        event_id: String,
104        /// The pubkey of the member requesting to leave (npub)
105        member_pubkey: String,
106    },
107    /// A WebXDC peer advertisement for realtime channels
108    WebxdcPeerAdvertisement {
109        event_id: String,
110        topic_id: String,
111        node_addr: String,
112        sender_npub: String,
113        created_at: u64,
114    },
115    /// A WebXDC peer left signal (peer closed their Mini App)
116    WebxdcPeerLeft {
117        event_id: String,
118        topic_id: String,
119        sender_npub: String,
120        created_at: u64,
121    },
122    /// Unknown event type - stored for future compatibility
123    /// The frontend will render this as "Unknown Event" placeholder
124    UnknownEvent(StoredEvent),
125    /// A PIVX payment promo code sent in chat
126    PivxPayment {
127        /// The promo code (5-char Base58)
128        gift_code: String,
129        /// Amount in PIV
130        amount_piv: f64,
131        /// The PIVX address for balance checking (optional for older events)
132        address: Option<String>,
133        /// The message ID for this payment event
134        message_id: String,
135        /// The stored event for persistence
136        event: StoredEvent,
137    },
138    /// A per-DM wallpaper change. The encrypted Blossom file is referenced
139    /// by URL + decryption key in the tags; the caller is responsible for
140    /// the timestamp comparison (latest-write-wins against
141    /// `chat.wallpaper_ts`) and the download + decrypt step.
142    WallpaperChanged {
143        /// Sender's npub (whoever set the wallpaper).
144        sender_npub: String,
145        /// Rumor `created_at` (Unix seconds) — drives latest-write-wins.
146        created_at: u64,
147        /// Encrypted file URL on Blossom.
148        url: String,
149        /// Hex-encoded AES key.
150        decryption_key: String,
151        /// Hex-encoded AES nonce.
152        decryption_nonce: String,
153        /// Optional plaintext SHA-256 (for caller integrity check).
154        plaintext_hash: Option<String>,
155        /// Optional MIME hint (e.g. "image/png") — informs cache extension.
156        mime: Option<String>,
157        /// Blur (px, 0..=30). `None` falls back to the receiver's default.
158        blur: Option<u8>,
159        /// Brightness percent (0..=100). `None` falls back to default.
160        dim: Option<u8>,
161        /// The rumor ID, used as the system-event row id.
162        event_id: String,
163    },
164    /// Event was ignored (invalid, expired, or should not be stored)
165    Ignored,
166    /// A NIP-09 deletion request — sender asks live clients to drop a
167    /// previously-received message from local storage. Cooperative
168    /// delete-for-everyone signal that pairs with Vector's gift-wrap
169    /// nuke at the relay layer (see `vector_core::deletion`).
170    DeletionRequest {
171        /// Hex id of the rumor being deleted (target's `["e", ...]` tag).
172        target_event_id: String,
173    },
174    /// A message edit event
175    Edit {
176        /// The ID of the message being edited
177        message_id: String,
178        /// The new content
179        new_content: String,
180        /// Timestamp of the edit (milliseconds)
181        edited_at: u64,
182        /// NIP-30 custom-emoji tags resolved from the new content
183        emoji_tags: Vec<crate::types::EmojiTag>,
184        /// The stored event for persistence
185        event: StoredEvent,
186    },
187}
188
189/// Main rumor processor - protocol agnostic
190///
191/// This is the single entry point for processing all rumor types.
192/// It handles text messages, file attachments, reactions, and typing indicators
193/// in a unified way, regardless of the underlying protocol.
194///
195/// # Arguments
196///
197/// * `rumor` - The rumor event to process
198/// * `context` - Context about the rumor (sender, conversation, etc.)
199/// * `download_dir` - Directory for file attachment paths
200///
201/// # Returns
202///
203/// A `RumorProcessingResult` indicating what type of event was processed,
204/// or an error if processing failed.
205pub fn process_rumor(
206    rumor: RumorEvent,
207    context: RumorContext,
208    download_dir: &Path,
209) -> Result<RumorProcessingResult, String> {
210    match rumor.kind {
211        // Text messages — Kind 14 (NIP-17 DM chat message).
212        Kind::PrivateDirectMessage => {
213            process_text_message(rumor, context)
214        }
215        // File attachments
216        k if k.as_u16() == 15 => {
217            process_file_attachment(rumor, context, download_dir)
218        }
219        // Message edits
220        k if k.as_u16() == event_kind::MESSAGE_EDIT => {
221            process_edit_event(rumor, context)
222        }
223        // Emoji reactions
224        Kind::Reaction => {
225            process_reaction(rumor, context)
226        }
227        // Application-specific data (typing indicators, etc.)
228        Kind::ApplicationSpecificData => {
229            process_app_specific(rumor, context)
230        }
231        // NIP-09 cooperative deletion (Layer 2 of Vector's delete flow).
232        // The relay-layer wrap nuke happens via the retained ephemeral
233        // key in `vector_core::deletion`; this rumor tells live clients
234        // that already decrypted the original to drop it from local
235        // storage. Authorization (sender == original author) is
236        // verified at commit time, not parse time.
237        Kind::EventDeletion => {
238            process_deletion(rumor, context)
239        }
240        // Unknown or unsupported kind - store for future compatibility
241        _ => {
242            process_unknown_event(rumor, context)
243        }
244    }
245}
246
247/// Process an unknown event type
248///
249/// Creates a StoredEvent for unknown kinds so they can be stored
250/// and potentially displayed/processed in future versions.
251fn process_unknown_event(
252    rumor: RumorEvent,
253    context: RumorContext,
254) -> Result<RumorProcessingResult, String> {
255    // Convert tags to Vec<Vec<String>> format
256    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    // Extract reference_id from e-tag if present
263    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
282/// Process a text message rumor
283///
284/// Extracts text content, reply references, and millisecond-precision timestamps.
285fn process_text_message(
286    rumor: RumorEvent,
287    context: RumorContext,
288) -> Result<RumorProcessingResult, String> {
289    // Extract reply reference if present
290    let replied_to = extract_reply_reference(&rumor);
291
292    // Extract millisecond-precision timestamp
293    let ms_timestamp = extract_millisecond_timestamp(&rumor);
294
295    let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
296    // DM → None (1:1, implied by chat); Community → the real author.
297    let npub = context.author_npub(&rumor.pubkey);
298
299    // Create the message
300    let msg = Message {
301        id: rumor.id.to_hex(),
302        content: rumor.content,
303        replied_to,
304        replied_to_content: None, // Populated by get_message_views
305        replied_to_npub: None,
306        replied_to_has_attachment: None,
307        replied_to_attachment_extension: None,
308        preview_metadata: None,
309        at: ms_timestamp,
310        attachments: Vec::new(),
311        reactions: Vec::new(),
312        mine: context.is_mine,
313        pending: false,
314        failed: false,
315        npub,
316        wrapper_event_id: None, // Set by caller after processing
317        edited: false,
318        edit_history: None,
319        emoji_tags,
320    };
321
322    Ok(RumorProcessingResult::TextMessage(msg))
323}
324
325/// Extract SHA256 hash from a Blossom URL
326///
327/// Blossom URLs typically follow the format: https://server.com/<sha256hash>[.ext]
328pub fn extract_hash_from_blossom_url(url: &str) -> Option<String> {
329    let path = url.split('/').last()?;
330    let hash_part = path.split('.').next()?;
331    if hash_part.len() == 64 && hash_part.chars().all(|c| c.is_ascii_hexdigit()) {
332        Some(hash_part.to_string())
333    } else {
334        None
335    }
336}
337
338/// Process a file attachment rumor
339///
340/// Handles encrypted file metadata including:
341/// - Decryption keys and nonces
342/// - Original file hashes (for deduplication)
343/// - Image metadata (thumbhash, dimensions)
344/// - File extensions and mime types
345fn process_file_attachment(
346    rumor: RumorEvent,
347    context: RumorContext,
348    download_dir: &Path,
349) -> Result<RumorProcessingResult, String> {
350    // Extract decryption parameters
351    let decryption_key = rumor.tags
352        .find(TagKind::Custom(Cow::Borrowed("decryption-key")))
353        .and_then(|tag| tag.content())
354        .ok_or("Missing decryption-key tag")?
355        .to_string();
356
357    let decryption_nonce = rumor.tags
358        .find(TagKind::Custom(Cow::Borrowed("decryption-nonce")))
359        .and_then(|tag| tag.content())
360        .ok_or("Missing decryption-nonce tag")?
361        .to_string();
362
363    // Extract original file hash (ox tag) if present
364    let original_file_hash = rumor.tags
365        .find(TagKind::Custom(Cow::Borrowed("ox")))
366        .and_then(|tag| tag.content())
367        .map(|s| s.to_string());
368
369    // Extract content storage URL
370    let content_url = rumor.content.clone();
371
372    // Skip attachments with empty file hash - these are corrupted uploads
373    const EMPTY_FILE_HASH: &str = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
374    if content_url.contains(EMPTY_FILE_HASH) {
375        eprintln!("Skipping attachment with empty file hash in URL: {}", content_url);
376        return Err("Attachment contains empty file hash - skipping".to_string());
377    }
378
379    // Extract image metadata if provided
380    let img_meta: Option<ImageMetadata> = {
381        // The sender emits the thumbhash under the `thumb` tag (see
382        // sending.rs); accept `thumbhash` too for forward-compat. These names
383        // had diverged (send `thumb` / receive `thumbhash`), which silently
384        // dropped img_meta on every received image — so they rendered as
385        // generic file boxes with no thumbhash preview.
386        let thumbhash_opt = rumor.tags
387            .find(TagKind::Custom(Cow::Borrowed("thumb")))
388            .or_else(|| rumor.tags.find(TagKind::Custom(Cow::Borrowed("thumbhash"))))
389            .and_then(|tag| tag.content())
390            .map(|s| s.to_string());
391
392        let dimensions_opt = rumor.tags
393            .find(TagKind::Custom(Cow::Borrowed("dim")))
394            .and_then(|tag| tag.content())
395            .and_then(|s| {
396                let parts: Vec<&str> = s.split('x').collect();
397                if parts.len() == 2 {
398                    let width = parts[0].parse::<u32>().ok()?;
399                    let height = parts[1].parse::<u32>().ok()?;
400                    Some((width, height))
401                } else {
402                    None
403                }
404            });
405
406        match (thumbhash_opt, dimensions_opt) {
407            (Some(thumbhash), Some((width, height))) => {
408                Some(ImageMetadata {
409                    thumbhash,
410                    width,
411                    height,
412                })
413            },
414            _ => None
415        }
416    };
417
418    // Figure out the file extension: prefer the name tag's extension, fall back to MIME-derived
419    let mime_type = rumor.tags
420        .find(TagKind::Custom(Cow::Borrowed("file-type")))
421        .and_then(|tag| tag.content())
422        .ok_or("Missing file-type tag")?;
423    let mime_extension = extension_from_mime(mime_type);
424
425    // Extract filename from name tag (used for extension override and display name)
426    let file_name = rumor.tags
427        .find(TagKind::Custom(Cow::Borrowed("name")))
428        .and_then(|tag| tag.content())
429        .map(|s| sanitize_filename(s))
430        .unwrap_or_default();
431
432    // Use the extension from the original filename when available (more accurate than MIME for
433    // uncommon types like .sh, .toml, .rs, etc. which all map to application/octet-stream)
434    let extension = if !file_name.is_empty() {
435        file_name.rsplit('.').next()
436            .filter(|e| !e.is_empty() && *e != file_name)
437            .map(|e| e.to_lowercase())
438            .unwrap_or(mime_extension)
439    } else {
440        mime_extension
441    };
442
443    // Grab the reported file size
444    let reported_size = rumor.tags
445        .find(TagKind::Custom(Cow::Borrowed("size")))
446        .and_then(|tag| tag.content())
447        .and_then(|s| s.parse::<u64>().ok())
448        .unwrap_or(0);
449
450    // Determine identity, file path and download status via the shared basis
451    // rules (ox for dedup when present, else a nonce+url digest — see
452    // `attachment_identity_basis`). The basis is author-controlled and
453    // becomes an on-disk filename — require bounded plain hex before joining
454    // it into a path, mirroring the Community parser, so a crafted tag can't
455    // smuggle `../` traversal into `path`.
456    let valid_path_basis =
457        |s: &str| !s.is_empty() && s.len() <= 128 && s.bytes().all(|b| b.is_ascii_hexdigit());
458    let original_file_hash = original_file_hash.filter(|h| valid_path_basis(h));
459    if !valid_path_basis(&decryption_nonce) {
460        return Err("Invalid decryption-nonce tag".to_string());
461    }
462    let file_hash = crate::crypto::attachment_identity_basis(
463        original_file_hash.as_deref(),
464        &decryption_nonce,
465        &content_url,
466    );
467    let hash_file_path = download_dir.join(format!("{}.{}", file_hash, extension));
468    // Arrival never claims downloaded: an ox-named file proves nothing about
469    // content (the download path re-verifies by hash before reuse), and the
470    // honest pipeline never writes digest-named files at all — a file found
471    // under one could only be a foreign plant.
472    let downloaded = false;
473    let file_path = hash_file_path.to_string_lossy().to_string();
474
475    // Extract reply reference if present
476    let replied_to = extract_reply_reference(&rumor);
477
478    // Extract millisecond-precision timestamp
479    let ms_timestamp = extract_millisecond_timestamp(&rumor);
480
481    // Extract webxdc-topic for Mini Apps (realtime channel isolation).
482    // Bounded sanity (mirrors the Community parser): base32 alphabet only,
483    // 32-byte payload (52 chars); anything else is dropped, not propagated.
484    let webxdc_topic = rumor.tags
485        .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
486        .and_then(|tag| tag.content())
487        .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))
488        .map(|s| s.to_string());
489
490    // Create the attachment
491    let attachment = Attachment {
492        id: file_hash.clone(),
493        key: decryption_key,
494        nonce: decryption_nonce,
495        extension: extension.to_string(),
496        name: file_name,
497        url: content_url,
498        path: file_path,
499        size: reported_size,
500        img_meta,
501        downloading: false,
502        downloaded,
503        webxdc_topic,
504        group_id: None,       // Kind 15 attachments use explicit key/nonce
505        original_hash: original_file_hash, // ox tag value (original file hash)
506        scheme_version: None, // Kind 15 uses explicit encryption, not MIP-04
507        mls_filename: None,   // Kind 15 uses explicit encryption, not MIP-04
508    };
509
510    let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
511    // DM → None (1:1, implied by chat); Community → the real author.
512    let npub = context.author_npub(&rumor.pubkey);
513
514    // Create the message with attachment
515    let msg = Message {
516        id: rumor.id.to_hex(),
517        content: String::new(),
518        replied_to,
519        replied_to_content: None, // Populated by get_message_views
520        replied_to_npub: None,
521        replied_to_has_attachment: None,
522        replied_to_attachment_extension: None,
523        preview_metadata: None,
524        at: ms_timestamp,
525        attachments: vec![attachment],
526        reactions: Vec::new(),
527        mine: context.is_mine,
528        pending: false,
529        failed: false,
530        npub,
531        wrapper_event_id: None, // Set by caller after processing
532        edited: false,
533        edit_history: None,
534        emoji_tags,
535    };
536
537    Ok(RumorProcessingResult::FileAttachment(msg))
538}
539
540/// Process a NIP-09 deletion rumor (Layer 2 cooperative hide).
541///
542/// Extracts the target event id from the `["e", ...]` tag. Authorization
543/// (sender pubkey == original message author) is verified at commit
544/// time so callers can short-circuit without an extra DB hit at parse.
545/// The single `e`-tag target id, or `None` if absent OR ambiguous (multiple `e` tags). A reaction,
546/// edit, and deletion each act on ONE specific message, so a first-of-many match could route to the
547/// wrong target — reject ambiguity for every transport (shared hardening; honest senders emit exactly
548/// one `e` tag). This mirrors Concord's `unique_tag` discipline and extends it to DMs.
549fn unique_event_ref(rumor: &RumorEvent) -> Option<String> {
550    let mut matches = rumor.tags.iter().filter(|t| t.kind() == TagKind::e());
551    let first = matches.next()?;
552    if matches.next().is_some() {
553        return None;
554    }
555    first.content().map(|s| s.to_string())
556}
557
558fn process_deletion(
559    rumor: RumorEvent,
560    _context: RumorContext,
561) -> Result<RumorProcessingResult, String> {
562    let target_event_id = unique_event_ref(&rumor)
563        .ok_or("Deletion target tag missing or ambiguous")?;
564    Ok(RumorProcessingResult::DeletionRequest { target_event_id })
565}
566
567/// Process a reaction rumor
568///
569/// Extracts emoji reactions to messages.
570fn process_reaction(
571    rumor: RumorEvent,
572    _context: RumorContext,
573) -> Result<RumorProcessingResult, String> {
574    let reference_id = unique_event_ref(&rumor)
575        .ok_or("Reaction reference tag missing or ambiguous")?;
576
577    // NIP-30: pull the first `["emoji", shortcode, url]` tag whose
578    // shortcode matches the reaction content (`:shortcode:` form).
579    let emoji_url = if rumor.content.starts_with(':') && rumor.content.ends_with(':')
580        && rumor.content.len() >= 3
581    {
582        let sc = &rumor.content[1..rumor.content.len() - 1];
583        rumor.tags.iter().find_map(|tag| {
584            let parts: Vec<&str> = tag.as_slice().iter().map(|s| s.as_str()).collect();
585            if parts.len() >= 3 && parts[0] == "emoji" && parts[1] == sc {
586                Some(parts[2].to_string())
587            } else {
588                None
589            }
590        })
591    } else {
592        None
593    };
594
595    let reaction = Reaction {
596        id: rumor.id.to_hex(),
597        reference_id,
598        author_id: rumor.pubkey.to_bech32().unwrap_or_else(|_| rumor.pubkey.to_hex()),
599        emoji: rumor.content,
600        emoji_url,
601    };
602
603    Ok(RumorProcessingResult::Reaction(reaction))
604}
605
606/// Process a message edit rumor
607///
608/// Extracts the edited content and references the original message.
609fn process_edit_event(
610    rumor: RumorEvent,
611    context: RumorContext,
612) -> Result<RumorProcessingResult, String> {
613    let message_id = unique_event_ref(&rumor)
614        .ok_or("Edit reference tag missing or ambiguous")?;
615
616    let edited_at = extract_millisecond_timestamp(&rumor);
617
618    // NIP-30 custom-emoji tags ride the edit so a `:shortcode:` introduced (or
619    // kept) by the edit renders as its image rather than literal text.
620    let emoji_tags = crate::types::EmojiTag::extract_from_tags(rumor.tags.iter());
621
622    let tags: Vec<Vec<String>> = rumor.tags.iter()
623        .map(|tag| {
624            tag.as_slice().iter().map(|s| s.to_string()).collect()
625        })
626        .collect();
627
628    let event = StoredEventBuilder::new()
629        .id(rumor.id.to_hex())
630        .kind(event_kind::MESSAGE_EDIT)
631        .content(rumor.content.clone())
632        .tags(tags)
633        .reference_id(Some(message_id.clone()))
634        .created_at(rumor.created_at.as_secs())
635        .mine(context.is_mine)
636        .npub(rumor.pubkey.to_bech32().ok())
637        .build();
638
639    Ok(RumorProcessingResult::Edit {
640        message_id,
641        new_content: rumor.content,
642        edited_at,
643        emoji_tags,
644        event,
645    })
646}
647
648/// Process application-specific data (typing indicators, etc.)
649fn process_app_specific(
650    rumor: RumorEvent,
651    context: RumorContext,
652) -> Result<RumorProcessingResult, String> {
653    // Check if this is a typing indicator
654    if is_typing_indicator(&rumor) {
655        let expiry_tag = rumor.tags
656            .find(TagKind::Expiration)
657            .ok_or("Typing indicator missing expiration tag")?;
658
659        let expiry_timestamp: u64 = expiry_tag.content()
660            .ok_or("Expiration tag has no content")?
661            .parse()
662            .map_err(|_| "Invalid expiration timestamp")?;
663
664        let current_timestamp = std::time::SystemTime::now()
665            .duration_since(std::time::UNIX_EPOCH)
666            .map_err(|e| format!("System time error: {}", e))?
667            .as_secs();
668
669        if expiry_timestamp <= current_timestamp || expiry_timestamp > current_timestamp + 30 {
670            return Ok(RumorProcessingResult::Ignored);
671        }
672
673        let profile_id = rumor.pubkey.to_bech32()
674            .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
675
676        return Ok(RumorProcessingResult::TypingIndicator {
677            profile_id,
678            until: expiry_timestamp,
679        });
680    }
681
682    // Check if this is a leave request
683    if is_leave_request(&rumor) {
684        let member_pubkey = rumor.pubkey.to_bech32()
685            .map_err(|e| format!("Failed to convert pubkey to bech32: {}", e))?;
686
687        return Ok(RumorProcessingResult::LeaveRequest {
688            event_id: rumor.id.to_hex(),
689            member_pubkey,
690        });
691    }
692
693    // Check if this is a PIVX payment
694    if is_pivx_payment(&rumor) {
695        let gift_code = rumor.tags
696            .find(TagKind::Custom(Cow::Borrowed("gift-code")))
697            .and_then(|tag| tag.content())
698            .ok_or("PIVX payment missing gift-code tag")?
699            .to_string();
700
701        let amount_str = rumor.tags
702            .find(TagKind::Custom(Cow::Borrowed("amount")))
703            .and_then(|tag| tag.content())
704            .unwrap_or("0");
705        let amount_piv = amount_str.parse::<u64>().unwrap_or(0) as f64 / 100_000_000.0;
706
707        let address = rumor.tags
708            .find(TagKind::Custom(Cow::Borrowed("address")))
709            .and_then(|tag| tag.content())
710            .map(|s| s.to_string());
711
712        let message_id = rumor.id.to_hex();
713
714        let tags: Vec<Vec<String>> = rumor.tags.iter()
715            .map(|tag| tag.as_slice().iter().map(|s| s.to_string()).collect())
716            .collect();
717
718        let event = StoredEventBuilder::new()
719            .id(&message_id)
720            .kind(event_kind::APPLICATION_SPECIFIC)
721            .chat_id(0) // Will be set by caller
722            .content(&rumor.content)
723            .tags(tags)
724            .created_at(rumor.created_at.as_secs())
725            .mine(context.is_mine)
726            .npub(Some(rumor.pubkey.to_bech32().unwrap_or_default()))
727            .build();
728
729        return Ok(RumorProcessingResult::PivxPayment {
730            gift_code,
731            amount_piv,
732            address,
733            message_id,
734            event,
735        });
736    }
737
738    // Check if this is a wallpaper change. Tags carry the encrypted file
739    // ref; the caller decides whether this beats the chat's current
740    // `wallpaper_ts` and runs the download+decrypt step.
741    if is_wallpaper_change(&rumor) {
742        // A wallpaper rumor with no `url` is a removal tombstone — the sender
743        // cleared their wallpaper. The url/key/nonce are absent in that case,
744        // so they're optional here; the apply step treats an empty url as
745        // "revert to default theme".
746        let url = rumor.tags
747            .find(TagKind::Custom(Cow::Borrowed("url")))
748            .and_then(|tag| tag.content())
749            .unwrap_or_default()
750            .to_string();
751        let decryption_key = rumor.tags
752            .find(TagKind::Custom(Cow::Borrowed("decryption-key")))
753            .and_then(|tag| tag.content())
754            .unwrap_or_default()
755            .to_string();
756        let decryption_nonce = rumor.tags
757            .find(TagKind::Custom(Cow::Borrowed("decryption-nonce")))
758            .and_then(|tag| tag.content())
759            .unwrap_or_default()
760            .to_string();
761        let plaintext_hash = rumor.tags
762            .find(TagKind::Custom(Cow::Borrowed("x")))
763            .and_then(|tag| tag.content())
764            .map(|s| s.to_string());
765        let mime = rumor.tags
766            .find(TagKind::Custom(Cow::Borrowed("m")))
767            .and_then(|tag| tag.content())
768            .map(|s| s.to_string());
769        let blur = rumor.tags
770            .find(TagKind::Custom(Cow::Borrowed("blur")))
771            .and_then(|tag| tag.content())
772            .and_then(|s| s.parse::<u32>().ok())
773            .map(|n| n.min(30) as u8);
774        let dim = rumor.tags
775            .find(TagKind::Custom(Cow::Borrowed("dim")))
776            .and_then(|tag| tag.content())
777            .and_then(|s| s.parse::<u32>().ok())
778            .map(|n| n.min(100) as u8);
779
780        return Ok(RumorProcessingResult::WallpaperChanged {
781            sender_npub: rumor.pubkey.to_bech32().unwrap_or_default(),
782            created_at: rumor.created_at.as_secs(),
783            url,
784            decryption_key,
785            decryption_nonce,
786            plaintext_hash,
787            mime,
788            blur,
789            dim,
790            event_id: rumor.id.to_hex(),
791        });
792    }
793
794    // Check if this is a WebXDC peer advertisement
795    if is_webxdc_peer_advertisement(&rumor) {
796        log_info!("[WEBXDC] Found peer advertisement rumor, is_mine={}, sender={}",
797            context.is_mine,
798            rumor.pubkey.to_bech32().unwrap_or_else(|_| "unknown".to_string()));
799
800        if context.is_mine {
801            log_info!("[WEBXDC] Ignoring our own peer advertisement");
802            return Ok(RumorProcessingResult::Ignored);
803        }
804
805        log_info!("[WEBXDC] Detected peer advertisement in rumor from another device");
806
807        let topic_id = rumor.tags
808            .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
809            .and_then(|tag| tag.content())
810            .ok_or("Peer advertisement missing webxdc-topic tag")?
811            .to_string();
812
813        let node_addr = rumor.tags
814            .find(TagKind::Custom(Cow::Borrowed("webxdc-node-addr")))
815            .and_then(|tag| tag.content())
816            .ok_or("Peer advertisement missing webxdc-node-addr tag")?
817            .to_string();
818
819        let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
820        return Ok(RumorProcessingResult::WebxdcPeerAdvertisement {
821            event_id: rumor.id.to_hex(),
822            topic_id,
823            node_addr,
824            sender_npub,
825            created_at: rumor.created_at.as_secs(),
826        });
827    }
828
829    // Check if this is a WebXDC peer-left signal
830    if is_webxdc_peer_left(&rumor) {
831        if context.is_mine {
832            return Ok(RumorProcessingResult::Ignored);
833        }
834
835        log_info!("[WEBXDC] Detected peer-left signal from another device");
836
837        let topic_id = rumor.tags
838            .find(TagKind::Custom(Cow::Borrowed("webxdc-topic")))
839            .and_then(|tag| tag.content())
840            .ok_or("Peer-left missing webxdc-topic tag")?
841            .to_string();
842
843        let sender_npub = rumor.pubkey.to_bech32().unwrap_or_default();
844        return Ok(RumorProcessingResult::WebxdcPeerLeft {
845            event_id: rumor.id.to_hex(),
846            topic_id,
847            sender_npub,
848            created_at: rumor.created_at.as_secs(),
849        });
850    }
851
852    // Unknown application-specific data
853    Ok(RumorProcessingResult::Ignored)
854}
855
856/// Check if a rumor is a WebXDC peer advertisement
857fn is_webxdc_peer_advertisement(rumor: &RumorEvent) -> bool {
858    rumor.content == "peer-advertisement"
859        && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-topic"))).is_some()
860        && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-node-addr"))).is_some()
861}
862
863/// Check if a rumor is a WebXDC peer-left signal
864fn is_webxdc_peer_left(rumor: &RumorEvent) -> bool {
865    rumor.content == "peer-left"
866        && rumor.tags.find(TagKind::Custom(Cow::Borrowed("webxdc-topic"))).is_some()
867}
868
869/// Check if a rumor is a PIVX payment
870fn is_pivx_payment(rumor: &RumorEvent) -> bool {
871    rumor.tags
872        .find(TagKind::d())
873        .and_then(|tag| tag.content())
874        .map(|content| content == "pivx-payment")
875        .unwrap_or(false)
876        && rumor.tags.find(TagKind::Custom(Cow::Borrowed("gift-code"))).is_some()
877}
878
879// ============================================================================
880// Helper Functions
881// ============================================================================
882
883/// Extract millisecond-precision timestamp from rumor
884///
885/// Combines the rumor's created_at (seconds) with a custom "ms" tag
886/// to provide millisecond precision for accurate message ordering.
887fn extract_millisecond_timestamp(rumor: &RumorEvent) -> u64 {
888    let ms_tag = rumor.tags
889        .find(TagKind::Custom(Cow::Borrowed("ms")))
890        .and_then(|t| t.content());
891    resolve_message_timestamp(rumor.created_at.as_secs(), ms_tag)
892}
893
894/// Resolve a message's ordering timestamp (epoch ms) from its second-resolution `created_at` and an
895/// optional `ms` sub-second offset tag. ONE implementation for every transport — DMs and Concord share
896/// the exact ms convention AND the anti-abuse clamp.
897///
898/// - The `ms` tag is a 0..=999 sub-second offset (senders decompose `created_at = ms/1000`,
899///   `tag = ms%1000`); an out-of-range or unparseable tag is ignored, falling back to whole seconds.
900/// - The inner event escapes relay far-future clamping (DM rumors and Concord inners are both
901///   encrypted and never published bare), so a hostile sender could stamp `created_at` year-9999 to
902///   pin a message to the top forever. Clamp an implausible-future result back to receipt time; a few
903///   minutes' grace absorbs clock skew.
904pub fn resolve_message_timestamp(created_at_secs: u64, ms_tag: Option<&str>) -> u64 {
905    const FUTURE_GRACE_MS: u64 = 5 * 60 * 1000;
906    let base = created_at_secs.saturating_mul(1000);
907    let at = match ms_tag.and_then(|s| s.parse::<u64>().ok()) {
908        Some(offset) if offset <= 999 => base.saturating_add(offset),
909        _ => base,
910    };
911    let now_ms = std::time::SystemTime::now()
912        .duration_since(std::time::UNIX_EPOCH)
913        .map(|d| d.as_millis() as u64)
914        .unwrap_or(u64::MAX);
915    if at > now_ms.saturating_add(FUTURE_GRACE_MS) { now_ms } else { at }
916}
917
918/// Extract reply reference from rumor tags
919///
920/// Looks for an "e" tag with the "reply" marker to identify
921/// which message this rumor is replying to.
922fn extract_reply_reference(rumor: &RumorEvent) -> String {
923    match rumor.tags.find(TagKind::e()) {
924        Some(tag) => {
925            // Check via SDK method first, then fallback to manual marker check
926            // (Tag::custom may not set internal reply flag)
927            if tag.is_reply() {
928                tag.content().unwrap_or("").to_string()
929            } else {
930                let slice = tag.as_slice();
931                if slice.get(3).map(|s| s == "reply").unwrap_or(false) {
932                    tag.content().unwrap_or("").to_string()
933                } else {
934                    String::new()
935                }
936            }
937        }
938        None => String::new(),
939    }
940}
941
942/// Check if rumor is a typing indicator
943fn is_typing_indicator(rumor: &RumorEvent) -> bool {
944    let has_vector_tag = rumor.tags
945        .find(TagKind::d())
946        .and_then(|tag| tag.content())
947        .map(|content| content == "vector")
948        .unwrap_or(false);
949
950    let is_typing_content = rumor.content == "typing";
951
952    has_vector_tag && is_typing_content
953}
954
955/// Check if rumor is a wallpaper-change application-data event.
956fn is_wallpaper_change(rumor: &RumorEvent) -> bool {
957    rumor.tags
958        .find(TagKind::d())
959        .and_then(|tag| tag.content())
960        .map(|content| content == "vector-wallpaper")
961        .unwrap_or(false)
962}
963
964/// Check if rumor is a leave request
965fn is_leave_request(rumor: &RumorEvent) -> bool {
966    let has_vector_tag = rumor.tags
967        .find(TagKind::d())
968        .and_then(|tag| tag.content())
969        .map(|content| content == "vector")
970        .unwrap_or(false);
971
972    let is_leave_content = rumor.content == "leave";
973
974    has_vector_tag && is_leave_content
975}
976
977#[cfg(test)]
978mod tests {
979    use super::*;
980
981    /// One ms resolver for every transport: sub-second offset convention + future-clamp.
982    /// Without the clamp a year-9999 `created_at` would pin a message to the top forever — the shared
983    /// resolver enforces it for DMs too.
984    #[test]
985    fn ms_resolver_applies_offset_enforces_sub_second_and_clamps_future() {
986        // created_at seconds + a valid 0..=999 offset.
987        assert_eq!(resolve_message_timestamp(1500, Some("242")), 1_500_242);
988        // No tag → whole-second resolution.
989        assert_eq!(resolve_message_timestamp(1500, None), 1_500_000);
990        // Out-of-range offset (>999) or junk is ignored, never added.
991        assert_eq!(resolve_message_timestamp(1500, Some("4242")), 1_500_000);
992        assert_eq!(resolve_message_timestamp(1500, Some("nope")), 1_500_000);
993        // Far-future created_at (year ~9999) is clamped back to ~now — can't dominate ordering.
994        let now = std::time::SystemTime::now()
995            .duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
996        let clamped = resolve_message_timestamp(253_402_300_800, Some("5"));
997        assert!(clamped <= (now + 3600) * 1000, "implausible-future ms must clamp to ~now");
998    }
999
1000    /// The dup-`e` target reject moved from Concord's `open_message` into the SHARED parser, so it now
1001    /// guards BOTH transports: a reaction/edit/delete naming TWO targets is ambiguous and rejected
1002    /// (a single, unambiguous target parses fine).
1003    #[test]
1004    fn ambiguous_target_is_rejected_for_reaction_edit_delete() {
1005        let keys = test_keypair();
1006        let two_e = || tags(vec![
1007            Tag::custom(TagKind::e(), ["aa".repeat(32)]),
1008            Tag::custom(TagKind::e(), ["bb".repeat(32)]),
1009        ]);
1010        assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", two_e()), dm_context(&keys), &temp_dir()).is_err());
1011        assert!(process_rumor(make_rumor(&keys, Kind::EventDeletion, "", two_e()), dm_context(&keys), &temp_dir()).is_err());
1012        assert!(process_rumor(make_rumor(&keys, Kind::from(event_kind::MESSAGE_EDIT), "edited", two_e()), dm_context(&keys), &temp_dir()).is_err());
1013        let one_e = tags(vec![Tag::custom(TagKind::e(), ["aa".repeat(32)])]);
1014        assert!(process_rumor(make_rumor(&keys, Kind::Reaction, "🔥", one_e), dm_context(&keys), &temp_dir()).is_ok());
1015    }
1016
1017    fn test_keypair() -> Keys {
1018        Keys::generate()
1019    }
1020
1021    /// Build a Tags collection from Tag items
1022    fn tags(items: Vec<Tag>) -> Tags {
1023        let mut t = Tags::new();
1024        for item in items {
1025            t.push(item);
1026        }
1027        t
1028    }
1029
1030    /// Create a custom tag (e.g., ["ms", "456"])
1031    fn custom_tag(key: &str, values: &[&str]) -> Tag {
1032        let owned: Vec<String> = values.iter().map(|s| s.to_string()).collect();
1033        Tag::custom(TagKind::custom(key.to_string()), owned)
1034    }
1035
1036    fn make_rumor(keys: &Keys, kind: Kind, content: &str, t: Tags) -> RumorEvent {
1037        RumorEvent {
1038            id: EventId::all_zeros(),
1039            kind,
1040            content: content.to_string(),
1041            tags: t,
1042            created_at: Timestamp::from_secs(1700000000),
1043            pubkey: keys.public_key(),
1044        }
1045    }
1046
1047    fn dm_context(keys: &Keys) -> RumorContext {
1048        RumorContext {
1049            sender: keys.public_key(),
1050            is_mine: false,
1051            conversation_id: "npub1test".to_string(),
1052            conversation_type: ConversationType::DirectMessage,
1053        }
1054    }
1055
1056    fn temp_dir() -> std::path::PathBuf {
1057        std::env::temp_dir().join("vector-rumor-test")
1058    }
1059
1060    // ========================================================================
1061    // Text Message Tests
1062    // ========================================================================
1063
1064    #[test]
1065    fn test_text_message_dm() {
1066        let keys = test_keypair();
1067        let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Hello world!", Tags::new());
1068        let ctx = dm_context(&keys);
1069        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1070
1071        match result {
1072            RumorProcessingResult::TextMessage(msg) => {
1073                assert_eq!(msg.content, "Hello world!");
1074                assert!(!msg.mine);
1075                assert!(msg.npub.is_none());
1076                assert!(msg.attachments.is_empty());
1077            }
1078            _ => panic!("Expected TextMessage"),
1079        }
1080    }
1081
1082    #[test]
1083    fn test_text_message_mine() {
1084        let keys = test_keypair();
1085        let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "My own message", Tags::new());
1086        let ctx = RumorContext {
1087            sender: keys.public_key(),
1088            is_mine: true,
1089            conversation_id: "npub1test".to_string(),
1090            conversation_type: ConversationType::DirectMessage,
1091        };
1092        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1093
1094        match result {
1095            RumorProcessingResult::TextMessage(msg) => {
1096                assert!(msg.mine);
1097            }
1098            _ => panic!("Expected TextMessage"),
1099        }
1100    }
1101
1102    #[test]
1103    fn test_text_message_with_reply() {
1104        let keys = test_keypair();
1105        let t = tags(vec![
1106            Tag::custom(TagKind::e(), ["abc123def456".to_string(), String::new(), "reply".to_string()]),
1107        ]);
1108        let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Reply text", t);
1109        let ctx = dm_context(&keys);
1110        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1111
1112        match result {
1113            RumorProcessingResult::TextMessage(msg) => {
1114                assert_eq!(msg.replied_to, "abc123def456");
1115            }
1116            _ => panic!("Expected TextMessage"),
1117        }
1118    }
1119
1120    #[test]
1121    fn test_text_message_with_ms_timestamp() {
1122        let keys = test_keypair();
1123        let t = tags(vec![custom_tag("ms", &["456"])]);
1124        let rumor = make_rumor(&keys, Kind::PrivateDirectMessage, "Precise time", t);
1125        let ctx = dm_context(&keys);
1126        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1127
1128        match result {
1129            RumorProcessingResult::TextMessage(msg) => {
1130                assert_eq!(msg.at, 1700000000 * 1000 + 456);
1131            }
1132            _ => panic!("Expected TextMessage"),
1133        }
1134    }
1135
1136    // ========================================================================
1137    // Reaction Tests
1138    // ========================================================================
1139
1140    #[test]
1141    fn test_reaction() {
1142        let keys = test_keypair();
1143        let t = tags(vec![custom_tag("e", &["target_msg_id_hex"])]);
1144        let rumor = make_rumor(&keys, Kind::Reaction, "👍", t);
1145        let ctx = dm_context(&keys);
1146        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1147
1148        match result {
1149            RumorProcessingResult::Reaction(reaction) => {
1150                assert_eq!(reaction.emoji, "👍");
1151                assert_eq!(reaction.reference_id, "target_msg_id_hex");
1152            }
1153            _ => panic!("Expected Reaction"),
1154        }
1155    }
1156
1157    #[test]
1158    fn test_reaction_missing_e_tag() {
1159        let keys = test_keypair();
1160        let rumor = make_rumor(&keys, Kind::Reaction, "👍", Tags::new());
1161        let ctx = dm_context(&keys);
1162        let result = process_rumor(rumor, ctx, &temp_dir());
1163        assert!(result.is_err());
1164    }
1165
1166    // ========================================================================
1167    // Edit Tests
1168    // ========================================================================
1169
1170    #[test]
1171    fn test_edit_event() {
1172        let keys = test_keypair();
1173        let t = tags(vec![custom_tag("e", &["original_msg_id"])]);
1174        let rumor = make_rumor(&keys, Kind::from_u16(16), "Edited content", t);
1175        let ctx = dm_context(&keys);
1176        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1177
1178        match result {
1179            RumorProcessingResult::Edit { message_id, new_content, event, .. } => {
1180                assert_eq!(message_id, "original_msg_id");
1181                assert_eq!(new_content, "Edited content");
1182                assert_eq!(event.kind, event_kind::MESSAGE_EDIT);
1183            }
1184            _ => panic!("Expected Edit"),
1185        }
1186    }
1187
1188    // ========================================================================
1189    // Typing Indicator Tests
1190    // ========================================================================
1191
1192    #[test]
1193    fn test_typing_indicator_valid() {
1194        let keys = test_keypair();
1195        let future_ts = std::time::SystemTime::now()
1196            .duration_since(std::time::UNIX_EPOCH).unwrap()
1197            .as_secs() + 10;
1198        let t = tags(vec![
1199            Tag::identifier("vector"),
1200            Tag::expiration(Timestamp::from_secs(future_ts)),
1201        ]);
1202        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1203        let ctx = dm_context(&keys);
1204        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1205
1206        match result {
1207            RumorProcessingResult::TypingIndicator { until, .. } => {
1208                assert_eq!(until, future_ts);
1209            }
1210            _ => panic!("Expected TypingIndicator"),
1211        }
1212    }
1213
1214    #[test]
1215    fn test_typing_indicator_expired() {
1216        let keys = test_keypair();
1217        let t = tags(vec![
1218            Tag::identifier("vector"),
1219            Tag::expiration(Timestamp::from_secs(1000000000)),
1220        ]);
1221        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "typing", t);
1222        let ctx = dm_context(&keys);
1223        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1224
1225        assert!(matches!(result, RumorProcessingResult::Ignored));
1226    }
1227
1228    // ========================================================================
1229    // Leave Request Tests
1230    // ========================================================================
1231
1232    #[test]
1233    fn test_leave_request() {
1234        let keys = test_keypair();
1235        let t = tags(vec![Tag::identifier("vector")]);
1236        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "leave", t);
1237        let ctx = dm_context(&keys);
1238        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1239
1240        match result {
1241            RumorProcessingResult::LeaveRequest { member_pubkey, .. } => {
1242                assert!(!member_pubkey.is_empty());
1243                assert!(member_pubkey.starts_with("npub1"));
1244            }
1245            _ => panic!("Expected LeaveRequest"),
1246        }
1247    }
1248
1249    // ========================================================================
1250    // PIVX Payment Tests
1251    // ========================================================================
1252
1253    #[test]
1254    fn test_pivx_payment() {
1255        let keys = test_keypair();
1256        let t = tags(vec![
1257            Tag::identifier("pivx-payment"),
1258            custom_tag("gift-code", &["ABC12"]),
1259            custom_tag("amount", &["100000000"]),
1260            custom_tag("address", &["DTest123"]),
1261        ]);
1262        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "pivx-payment", t);
1263        let ctx = dm_context(&keys);
1264        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1265
1266        match result {
1267            RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, .. } => {
1268                assert_eq!(gift_code, "ABC12");
1269                assert!((amount_piv - 1.0).abs() < f64::EPSILON);
1270                assert_eq!(address, Some("DTest123".to_string()));
1271            }
1272            _ => panic!("Expected PivxPayment"),
1273        }
1274    }
1275
1276    // ========================================================================
1277    // WebXDC Tests
1278    // ========================================================================
1279
1280    #[test]
1281    fn test_webxdc_peer_advertisement() {
1282        let keys = test_keypair();
1283        let t = tags(vec![
1284            custom_tag("webxdc-topic", &["topic123"]),
1285            custom_tag("webxdc-node-addr", &["addr456"]),
1286        ]);
1287        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1288        let ctx = dm_context(&keys);
1289        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1290
1291        match result {
1292            RumorProcessingResult::WebxdcPeerAdvertisement { topic_id, node_addr, .. } => {
1293                assert_eq!(topic_id, "topic123");
1294                assert_eq!(node_addr, "addr456");
1295            }
1296            _ => panic!("Expected WebxdcPeerAdvertisement"),
1297        }
1298    }
1299
1300    #[test]
1301    fn test_webxdc_peer_advertisement_own_ignored() {
1302        let keys = test_keypair();
1303        let t = tags(vec![
1304            custom_tag("webxdc-topic", &["topic123"]),
1305            custom_tag("webxdc-node-addr", &["addr456"]),
1306        ]);
1307        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-advertisement", t);
1308        let ctx = RumorContext {
1309            sender: keys.public_key(),
1310            is_mine: true,
1311            conversation_id: "npub1test".to_string(),
1312            conversation_type: ConversationType::DirectMessage,
1313        };
1314        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1315        assert!(matches!(result, RumorProcessingResult::Ignored));
1316    }
1317
1318    #[test]
1319    fn test_webxdc_peer_left() {
1320        let keys = test_keypair();
1321        let t = tags(vec![custom_tag("webxdc-topic", &["topic123"])]);
1322        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "peer-left", t);
1323        let ctx = dm_context(&keys);
1324        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1325
1326        match result {
1327            RumorProcessingResult::WebxdcPeerLeft { topic_id, .. } => {
1328                assert_eq!(topic_id, "topic123");
1329            }
1330            _ => panic!("Expected WebxdcPeerLeft"),
1331        }
1332    }
1333
1334    // ========================================================================
1335    // Unknown Event Tests
1336    // ========================================================================
1337
1338    #[test]
1339    fn test_unknown_kind() {
1340        let keys = test_keypair();
1341        let rumor = make_rumor(&keys, Kind::from_u16(65535), "Mystery event", Tags::new());
1342        let ctx = dm_context(&keys);
1343        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1344
1345        match result {
1346            RumorProcessingResult::UnknownEvent(event) => {
1347                assert_eq!(event.kind, 65535);
1348                assert_eq!(event.content, "Mystery event");
1349            }
1350            _ => panic!("Expected UnknownEvent"),
1351        }
1352    }
1353
1354    // ========================================================================
1355    // File Attachment Tests
1356    // ========================================================================
1357
1358    #[test]
1359    fn test_file_attachment() {
1360        let keys = test_keypair();
1361        let ox_hash = "deadbeef".repeat(8); // 64 hex chars
1362        let t = tags(vec![
1363            custom_tag("decryption-key", &["aabbccdd"]),
1364            custom_tag("decryption-nonce", &["11223344"]),
1365            custom_tag("ox", &[&ox_hash]),
1366            custom_tag("file-type", &["image/jpeg"]),
1367            custom_tag("name", &["photo.jpg"]),
1368            custom_tag("size", &["12345"]),
1369        ]);
1370        let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/deadbeef.jpg", t);
1371        let ctx = dm_context(&keys);
1372        let dir = temp_dir();
1373        let result = process_rumor(rumor, ctx, &dir).unwrap();
1374
1375        match result {
1376            RumorProcessingResult::FileAttachment(msg) => {
1377                assert_eq!(msg.attachments.len(), 1);
1378                let att = &msg.attachments[0];
1379                assert_eq!(att.key, "aabbccdd");
1380                assert_eq!(att.nonce, "11223344");
1381                assert_eq!(att.extension, "jpg");
1382                assert_eq!(att.name, "photo.jpg");
1383                assert_eq!(att.size, 12345);
1384                assert!(!att.downloaded);
1385            }
1386            _ => panic!("Expected FileAttachment"),
1387        }
1388    }
1389
1390    #[test]
1391    fn test_file_attachment_hostile_path_basis_rejected() {
1392        let keys = test_keypair();
1393        let dir = temp_dir();
1394        let ctx = || dm_context(&keys);
1395
1396        // Traversal via ox: non-hex basis is ignored → the identity falls back
1397        // to the nonce+url digest (always clean hex) and never leaves the
1398        // download dir.
1399        let t = tags(vec![
1400            custom_tag("decryption-key", &["aabbccdd"]),
1401            custom_tag("decryption-nonce", &["11223344"]),
1402            custom_tag("ox", &["../../../etc/passwd"]),
1403            custom_tag("file-type", &["image/jpeg"]),
1404            custom_tag("name", &["x.jpg"]),
1405        ]);
1406        let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/x.jpg", t);
1407        let expected_id = crate::crypto::attachment_identity_basis(None, "11223344", "https://blossom.example/x.jpg");
1408        match process_rumor(rumor, ctx(), &dir).unwrap() {
1409            RumorProcessingResult::FileAttachment(msg) => {
1410                let att = &msg.attachments[0];
1411                assert!(!att.path.contains(".."), "traversal basis must not reach the path: {}", att.path);
1412                assert_eq!(att.id, expected_id, "id falls back to the nonce+url digest");
1413            }
1414            _ => panic!("Expected FileAttachment"),
1415        }
1416
1417        // Traversal via the nonce (no ox): hard reject, nothing to fall back to.
1418        let t = tags(vec![
1419            custom_tag("decryption-key", &["aabbccdd"]),
1420            custom_tag("decryption-nonce", &["../../../etc/cron.d/evil"]),
1421            custom_tag("file-type", &["image/jpeg"]),
1422        ]);
1423        let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/y.jpg", t);
1424        assert!(process_rumor(rumor, ctx(), &dir).is_err());
1425    }
1426
1427    #[test]
1428    fn test_file_attachment_empty_hash_rejected() {
1429        let keys = test_keypair();
1430        let t = tags(vec![
1431            custom_tag("decryption-key", &["aabbccdd"]),
1432            custom_tag("decryption-nonce", &["11223344"]),
1433            custom_tag("file-type", &["image/jpeg"]),
1434        ]);
1435        let empty_hash = "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855";
1436        let rumor = make_rumor(&keys, Kind::from_u16(15), &format!("https://blossom.example/{}", empty_hash), t);
1437        let ctx = dm_context(&keys);
1438        let result = process_rumor(rumor, ctx, &temp_dir());
1439        assert!(result.is_err());
1440    }
1441
1442    #[test]
1443    fn test_file_attachment_with_image_meta() {
1444        let keys = test_keypair();
1445        let ox_hash = "a".repeat(64);
1446        let t = tags(vec![
1447            custom_tag("decryption-key", &["aabbccdd"]),
1448            custom_tag("decryption-nonce", &["11223344"]),
1449            custom_tag("ox", &[&ox_hash]),
1450            custom_tag("file-type", &["image/png"]),
1451            custom_tag("thumbhash", &["base64data"]),
1452            custom_tag("dim", &["1920x1080"]),
1453            custom_tag("size", &["5000"]),
1454        ]);
1455        let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/aaa.png", t);
1456        let ctx = dm_context(&keys);
1457        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1458
1459        match result {
1460            RumorProcessingResult::FileAttachment(msg) => {
1461                let att = &msg.attachments[0];
1462                let meta = att.img_meta.as_ref().unwrap();
1463                assert_eq!(meta.width, 1920);
1464                assert_eq!(meta.height, 1080);
1465                assert_eq!(meta.thumbhash, "base64data");
1466            }
1467            _ => panic!("Expected FileAttachment"),
1468        }
1469    }
1470
1471    /// Guards the send/receive tag-name contract: the sender emits the
1472    /// thumbhash under `thumb` (sending.rs), so the receiver MUST read it from
1473    /// `thumb`. These had diverged (`thumb` vs `thumbhash`), silently dropping
1474    /// img_meta on every received image. The test above uses the `thumbhash`
1475    /// alias; this one uses the real wire tag.
1476    #[test]
1477    fn test_file_attachment_thumb_tag_is_read() {
1478        let keys = test_keypair();
1479        let ox_hash = "b".repeat(64);
1480        let t = tags(vec![
1481            custom_tag("decryption-key", &["aabbccdd"]),
1482            custom_tag("decryption-nonce", &["11223344"]),
1483            custom_tag("ox", &[&ox_hash]),
1484            custom_tag("file-type", &["image/png"]),
1485            custom_tag("thumb", &["realwiretag"]),
1486            custom_tag("dim", &["800x600"]),
1487            custom_tag("size", &["5000"]),
1488        ]);
1489        let rumor = make_rumor(&keys, Kind::from_u16(15), "https://blossom.example/bbb.png", t);
1490        let ctx = dm_context(&keys);
1491        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1492
1493        match result {
1494            RumorProcessingResult::FileAttachment(msg) => {
1495                let meta = msg.attachments[0].img_meta.as_ref()
1496                    .expect("img_meta must be populated from the `thumb` tag");
1497                assert_eq!(meta.thumbhash, "realwiretag");
1498                assert_eq!(meta.width, 800);
1499                assert_eq!(meta.height, 600);
1500            }
1501            _ => panic!("Expected FileAttachment"),
1502        }
1503    }
1504
1505    // ========================================================================
1506    // Helper Function Tests
1507    // ========================================================================
1508
1509    #[test]
1510    fn test_extract_hash_from_blossom_url() {
1511        let hash = "a".repeat(64);
1512        let url = format!("https://blossom.example/{}.jpg", hash);
1513        assert_eq!(extract_hash_from_blossom_url(&url), Some(hash));
1514
1515        assert_eq!(extract_hash_from_blossom_url("https://example.com/short"), None);
1516        assert_eq!(extract_hash_from_blossom_url("https://example.com/not-hex-at-all-but-exactly-sixty-four-characters-long-string-here!"), None);
1517    }
1518
1519    #[test]
1520    fn test_unknown_app_specific_ignored() {
1521        let keys = test_keypair();
1522        let t = tags(vec![Tag::identifier("some-other-app")]);
1523        let rumor = make_rumor(&keys, Kind::ApplicationSpecificData, "unknown-content", t);
1524        let ctx = dm_context(&keys);
1525        let result = process_rumor(rumor, ctx, &temp_dir()).unwrap();
1526        assert!(matches!(result, RumorProcessingResult::Ignored));
1527    }
1528}