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