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