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