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