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