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