Skip to main content

vector_core/
event_handler.rs

1//! Event handler — gift wrap receive, unwrap, process, commit pipeline.
2//!
3//! Two-phase architecture:
4//! - **Phase 1** (`prepare_event`): Parallel-safe — dedup, unwrap, process_rumor
5//! - **Phase 2** (`commit_prepared_event`): Sequential — save DB, update STATE, emit
6//!
7//! Platform-specific behavior (notifications) handled by `InboundEventHandler` trait.
8
9use nostr_sdk::prelude::*;
10
11use crate::rumor::{RumorProcessingResult, RumorEvent, RumorContext, ConversationType, process_rumor};
12use crate::types::Message;
13use crate::state::WRAPPER_ID_CACHE;
14
15/// Platform-specific callbacks for inbound event processing.
16///
17/// Same pattern as SendCallback/ProfileSyncHandler — trait with default no-ops.
18/// Platforms implement only the hooks they need.
19pub trait InboundEventHandler: Send + Sync {
20    /// A DM text message was received and committed to STATE + DB.
21    fn on_dm_received(&self, _chat_id: &str, _msg: &Message, _is_new: bool) {}
22
23    /// A DM file attachment was received and committed to STATE + DB.
24    fn on_file_received(&self, _chat_id: &str, _msg: &Message, _is_new: bool) {}
25
26    /// A reaction was received and applied to a message.
27    fn on_reaction_received(&self, _chat_id: &str, _msg: &Message) {}
28
29    /// A previously-stored message was deleted by its sender (Layer 2
30    /// cooperative hide via NIP-09 over NIP-17). Frontend drops the row.
31    fn on_message_deleted(&self, _chat_id: &str, _message_id: &str) {}
32
33    /// A Community invite was received over a gift wrap and the local user was
34    /// joined (member-view Community persisted). Platform refreshes the Community
35    /// subscription so messages start flowing, and surfaces the new Community in the UI.
36    fn on_community_invite(&self, _community_id: &str) {}
37
38    // --- Community realtime (Concord channel events; `chat_id` is the channel id hex) ---
39
40    /// A new Community channel message was received, ingested into STATE, and persisted.
41    fn on_community_message(&self, _chat_id: &str, _msg: &Message, _is_new: bool) {}
42
43    /// A reaction or edit was applied to an existing Community message. `target_id` is the
44    /// affected message; `msg` is the live-updated view.
45    fn on_community_update(&self, _chat_id: &str, _target_id: &str, _msg: &Message) {}
46
47    /// A Community message was removed (cooperative delete / moderation tombstone).
48    fn on_community_removed(&self, _chat_id: &str, _target_id: &str) {}
49
50    /// A join/leave presence announcement. `created_at` is the authenticated inner timestamp;
51    /// `invited_by`/`invited_label` carry invite attribution when present.
52    #[allow(clippy::too_many_arguments)]
53    fn on_community_presence(
54        &self,
55        _chat_id: &str,
56        _npub: &str,
57        _joined: bool,
58        _event_id: &str,
59        _created_at: u64,
60        _invited_by: Option<&str>,
61        _invited_label: Option<&str>,
62    ) {}
63
64    /// A Community typing indicator (ephemeral). `until` is the unix-secs the typer stops being active.
65    fn on_community_typing(&self, _chat_id: &str, _npub: &str, _until: u64) {}
66
67    /// A WebXDC realtime peer signal. `node_addr` = `Some` advertises an Iroh node, `None` = peer-left.
68    #[allow(clippy::too_many_arguments)]
69    fn on_community_webxdc(
70        &self,
71        _chat_id: &str,
72        _npub: &str,
73        _topic_id: &str,
74        _node_addr: Option<&str>,
75        _event_id: &str,
76        _created_at: u64,
77    ) {}
78
79    /// A WebXDC realtime peer signal in a DM. `contact` is the DM's npub; `node_addr` =
80    /// `Some` advertises an Iroh node, `None` = peer-left. Clients without Iroh ignore it.
81    #[allow(clippy::too_many_arguments)]
82    fn on_webxdc_signal(
83        &self,
84        _contact: &str,
85        _npub: &str,
86        _topic_id: &str,
87        _node_addr: Option<&str>,
88        _event_id: &str,
89        _created_at: u64,
90    ) {}
91
92    /// The local user was removed from a Community (kick / ban / a leave authored on another device).
93    /// Local data is torn down (epoch keys retained); the platform surfaces it + refreshes subs.
94    fn on_community_self_removed(&self, _community_id: &str) {}
95
96    /// A Private channel is now readable — its key arrived, by rekey delivery or
97    /// by a grant's key vend. `channel_id` is the 32-byte hex id.
98    ///
99    /// The inverse of the silent-mute failure: a bot granted access can act the
100    /// moment it can actually read, rather than polling for a channel it has no
101    /// way to know it is waiting on.
102    ///
103    /// `backfilled` counts the messages that predate the grant and were pulled
104    /// into LOCAL STATE before this fired. They are history, not delivery: none
105    /// of them reach [`on_message`](Self::on_message) — read them explicitly
106    /// (`get_messages_before`) and decide what deserves acting on.
107    fn on_channel_keyed(&self, _community_id: &str, _channel_id: &str, _backfilled: usize) {}
108
109    /// The realtime Community subscription REGISTERED: healthy relays deliver
110    /// live channel events from this moment (AUTH-gating relays join as their
111    /// stream auth completes in the background). Fires once per
112    /// [`listen`](crate::VectorCore::listen), after the startup refresh commits —
113    /// even when `communities` is 0, so "subscribed to nothing" and "still
114    /// connecting" are distinguishable. Community messages sent before this are
115    /// only recoverable by a later sync; DMs sent since `listen` began still
116    /// reach the handler.
117    fn on_subscription_ready(&self, _communities: usize) {}
118
119    /// A Community's control plane was refreshed in realtime (banlist/roles/metadata/mode change,
120    /// or a re-founding followed). The platform re-reads display state.
121    fn on_community_refreshed(&self, _community_id: &str) {}
122
123    /// A Community was DISSOLVED by its owner (CORD-02 §9): sealed read-only, held keys still open
124    /// history but nothing new is honored. The platform surfaces the grave.
125    fn on_community_dissolved(&self, _community_id: &str) {}
126
127    /// Bulk-sync persist intercept: return `true` to take ownership of persisting a committed
128    /// DM/file message — the commit then SKIPS its per-message save AND its wrapper-ledger
129    /// write (`wrapper` = the gift-wrap's `(id_bytes, created_at)`; the owner MUST commit it
130    /// in the same transaction as the message row, or the message loses crash/drop
131    /// recoverability). Streaming sync loops (see [`BatchingPersist`]) buffer here and drain
132    /// many messages into one transaction. Default `false` keeps the per-message save.
133    fn buffer_persist(&self, _chat_id: &str, _msg: &Message, _wrapper: Option<([u8; 32], u64)>) -> bool { false }
134}
135
136/// No-op handler for CLI/tests.
137pub struct NoOpEventHandler;
138impl InboundEventHandler for NoOpEventHandler {}
139
140/// For catch-up syncs: forwards to `inner` only what is live rather than
141/// history. Mini App peer signals always: a signal is live state, and a sync
142/// that opens one ledgers its wrapper, so the live subscription will never
143/// deliver it again. With [`from`](Self::from), also DMs and files sent since
144/// that moment: sent while the sync ran, they can reach us through it alone.
145pub struct LiveOnly<'a> {
146    inner: &'a dyn InboundEventHandler,
147    live_from_ms: Option<u64>,
148}
149
150impl<'a> LiveOnly<'a> {
151    /// Signals only.
152    pub fn signals(inner: &'a dyn InboundEventHandler) -> Self {
153        LiveOnly { inner, live_from_ms: None }
154    }
155
156    /// Signals, and messages sent at or after `ms` (unix milliseconds).
157    pub fn from(inner: &'a dyn InboundEventHandler, ms: u64) -> Self {
158        LiveOnly { inner, live_from_ms: Some(ms) }
159    }
160
161    fn is_live(&self, msg: &Message) -> bool {
162        self.live_from_ms.is_some_and(|from| msg.at >= from)
163    }
164}
165
166impl InboundEventHandler for LiveOnly<'_> {
167    fn on_community_webxdc(&self, chat_id: &str, npub: &str, topic_id: &str, node_addr: Option<&str>, event_id: &str, created_at: u64) {
168        self.inner.on_community_webxdc(chat_id, npub, topic_id, node_addr, event_id, created_at)
169    }
170
171    fn on_webxdc_signal(&self, contact: &str, npub: &str, topic_id: &str, node_addr: Option<&str>, event_id: &str, created_at: u64) {
172        self.inner.on_webxdc_signal(contact, npub, topic_id, node_addr, event_id, created_at)
173    }
174
175    fn on_dm_received(&self, chat_id: &str, msg: &Message, is_new: bool) {
176        if self.is_live(msg) {
177            self.inner.on_dm_received(chat_id, msg, is_new)
178        }
179    }
180
181    fn on_file_received(&self, chat_id: &str, msg: &Message, is_new: bool) {
182        if self.is_live(msg) {
183            self.inner.on_file_received(chat_id, msg, is_new)
184        }
185    }
186}
187
188/// One deferred DM persist: the message, its chat, and its gift-wrap ledger entry — the
189/// ledger row commits in the same flush transaction as the message row (see
190/// `save_messages_batch_multi`), so a lost batch leaves the wrapper unledgered.
191struct BufferedDm {
192    chat_id: String,
193    msg: Message,
194    wrapper: Option<([u8; 32], u64)>,
195}
196
197/// Wraps any handler for a bulk-sync drain loop: every callback delegates to the inner
198/// handler, but committed messages BUFFER here instead of saving one transaction each —
199/// the loop calls [`BatchingPersist::flush`] periodically and at stream end to land them
200/// in batched transactions (`save_messages_batch_multi`).
201///
202/// Deferral is recoverable because the wrapper ledger (the negentropy fingerprint set)
203/// rides the flush transaction: a message lost to a crash, a stale-session drop, or a
204/// failed flush leaves its wrapper unledgered, so the next reconciliation re-delivers it.
205pub struct BatchingPersist<'a> {
206    inner: &'a dyn InboundEventHandler,
207    buf: std::sync::Mutex<Vec<BufferedDm>>,
208    /// The account whose messages are in the buffer.
209    ///
210    /// Held rather than resolved at flush time: this outlives the calls that
211    /// fill it, and a flush reached after a swap would otherwise write one
212    /// account's inbox into another's. Holding it means the buffer always
213    /// drains where it was filled, from any caller, bound or not.
214    session: std::sync::Arc<crate::db::Session>,
215}
216
217impl<'a> BatchingPersist<'a> {
218    pub fn new(inner: &'a dyn InboundEventHandler) -> Self {
219        Self {
220            inner,
221            buf: std::sync::Mutex::new(Vec::new()),
222            session: crate::db::current_session(),
223        }
224    }
225
226    /// How many messages are waiting — the loop's flush-threshold probe.
227    pub fn buffered(&self) -> usize {
228        self.buf.lock().map(|b| b.len()).unwrap_or(0)
229    }
230
231    /// Drain the buffer into batched transactions (grouped by chat, arrival order kept),
232    /// against the account that filled it.
233    pub async fn flush(&self) -> usize {
234        self.try_flush().await.unwrap_or(0)
235    }
236
237    /// [`Self::flush`], but a persist failure is distinguishable from "nothing
238    /// to write" — callers that gate follow-on effects on the ledger actually
239    /// holding the batch (reconcile-cursor births) need the difference: an
240    /// advance over an unledgered batch skips those events forever.
241    pub async fn try_flush(&self) -> Result<usize, String> {
242        crate::db::with_session(self.session.clone(), async move {
243            let mut drained: Vec<BufferedDm> = match self.buf.lock() {
244                Ok(mut b) => b.drain(..).collect(),
245                Err(_) => return Ok(0),
246            };
247            if drained.is_empty() {
248                return Ok(0);
249            }
250            // A deletion may have landed (live subscription or this stream) while an entry sat
251            // buffered: its delete_event no-ops on the not-yet-written row, so persisting the
252            // buffered copy would resurrect a deleted message. Keyed on the POSITIVE deletion
253            // tombstone, never STATE absence — the LRU evicts old messages from STATE, and
254            // archive-synced history is exactly that tail (an evicted message must still
255            // persist). A dropped entry's wrapper stays unledgered and re-delivers next sync,
256            // where the DB dedup sees the (still-deleted) state cleanly.
257            drained.retain(|e| !crate::state::was_message_deleted(&e.msg.id));
258            if drained.is_empty() {
259                return Ok(0);
260            }
261            // Group by chat preserving first-seen chat order + per-chat arrival order.
262            let mut groups: Vec<crate::db::events::ChatBatch<'_>> = Vec::new();
263            for e in &drained {
264                match groups.iter_mut().find(|(c, _)| c == &e.chat_id) {
265                    Some((_, v)) => v.push((&e.msg, e.wrapper)),
266                    None => groups.push((e.chat_id.clone(), vec![(&e.msg, e.wrapper)])),
267                }
268            }
269            match crate::db::events::save_messages_batch_multi(&groups).await {
270                Ok(n) => Ok(n),
271                Err(e) => {
272                    crate::log_warn!("[Sync] batched persist failed ({} msgs): {}", drained.len(), e);
273                    Err(e)
274                }
275            }
276        })
277        .await
278    }
279}
280
281impl InboundEventHandler for BatchingPersist<'_> {
282    fn buffer_persist(&self, chat_id: &str, msg: &Message, wrapper: Option<([u8; 32], u64)>) -> bool {
283        match self.buf.lock() {
284            Ok(mut b) => {
285                b.push(BufferedDm { chat_id: chat_id.to_string(), msg: msg.clone(), wrapper });
286                true
287            }
288            // Poisoned lock: fall back to the commit's own per-message save.
289            Err(_) => false,
290        }
291    }
292
293    fn on_dm_received(&self, chat_id: &str, msg: &Message, is_new: bool) {
294        self.inner.on_dm_received(chat_id, msg, is_new)
295    }
296    fn on_file_received(&self, chat_id: &str, msg: &Message, is_new: bool) {
297        self.inner.on_file_received(chat_id, msg, is_new)
298    }
299    fn on_reaction_received(&self, chat_id: &str, msg: &Message) {
300        self.inner.on_reaction_received(chat_id, msg)
301    }
302    fn on_message_deleted(&self, chat_id: &str, message_id: &str) {
303        // The deletion's delete_event no-ops when the target is still buffered here —
304        // purge it (unledgered wrapper → re-delivers → dedups against the deleted state)
305        // so the flush can't resurrect a deleted message.
306        if let Ok(mut b) = self.buf.lock() {
307            b.retain(|e| e.msg.id != message_id);
308        }
309        self.inner.on_message_deleted(chat_id, message_id)
310    }
311    fn on_community_invite(&self, community_id: &str) {
312        self.inner.on_community_invite(community_id)
313    }
314    fn on_community_message(&self, chat_id: &str, msg: &Message, is_new: bool) {
315        self.inner.on_community_message(chat_id, msg, is_new)
316    }
317    fn on_community_update(&self, chat_id: &str, target_id: &str, msg: &Message) {
318        self.inner.on_community_update(chat_id, target_id, msg)
319    }
320    fn on_community_removed(&self, chat_id: &str, target_id: &str) {
321        self.inner.on_community_removed(chat_id, target_id)
322    }
323    fn on_community_presence(
324        &self,
325        chat_id: &str,
326        npub: &str,
327        joined: bool,
328        event_id: &str,
329        created_at: u64,
330        invited_by: Option<&str>,
331        invited_label: Option<&str>,
332    ) {
333        self.inner.on_community_presence(chat_id, npub, joined, event_id, created_at, invited_by, invited_label)
334    }
335    fn on_community_typing(&self, chat_id: &str, npub: &str, until: u64) {
336        self.inner.on_community_typing(chat_id, npub, until)
337    }
338    fn on_community_webxdc(
339        &self,
340        chat_id: &str,
341        npub: &str,
342        topic_id: &str,
343        node_addr: Option<&str>,
344        event_id: &str,
345        created_at: u64,
346    ) {
347        self.inner.on_community_webxdc(chat_id, npub, topic_id, node_addr, event_id, created_at)
348    }
349    fn on_webxdc_signal(
350        &self,
351        contact: &str,
352        npub: &str,
353        topic_id: &str,
354        node_addr: Option<&str>,
355        event_id: &str,
356        created_at: u64,
357    ) {
358        self.inner.on_webxdc_signal(contact, npub, topic_id, node_addr, event_id, created_at)
359    }
360    fn on_community_self_removed(&self, community_id: &str) {
361        self.inner.on_community_self_removed(community_id)
362    }
363    fn on_community_refreshed(&self, community_id: &str) {
364        self.inner.on_community_refreshed(community_id)
365    }
366    fn on_community_dissolved(&self, community_id: &str) {
367        self.inner.on_community_dissolved(community_id)
368    }
369}
370
371/// Result of Phase 1 (prepare_event) — everything needed for sequential commit.
372#[allow(clippy::large_enum_variant)] // boxing a public variant's payload is an API break
373pub enum PreparedEvent {
374    /// Fully processed DM rumor — ready for state commit.
375    Processed {
376        result: RumorProcessingResult,
377        contact: String,
378        sender: PublicKey,
379        is_mine: bool,
380        wrapper_event_id: String,
381        wrapper_event_id_bytes: [u8; 32],
382        wrapper_created_at: u64,
383        /// Time spent on ECDH + ChaCha20Poly1305 decryption (nanoseconds)
384        unwrap_ns: u64,
385        /// Time spent on rumor parsing (nanoseconds)
386        parse_ns: u64,
387    },
388    /// Community invite bundle (kind 3304) — parked for explicit user consent.
389    CommunityInvite {
390        invite: crate::community::invite::CommunityInvite,
391        /// Inviter's npub (bech32) — shown in the pending-invite UI.
392        inviter: String,
393        is_mine: bool,
394        wrapper_event_id_bytes: [u8; 32],
395        wrapper_created_at: u64,
396        /// Inner rumor `created_at` (seconds) — the real send time. Unlike the outer
397        /// wrapper, which NIP-59 backdates up to 2 days, this is honest; the tombstone
398        /// supersession test needs it so a re-invite isn't misread as older than a decline.
399        rumor_created_at: u64,
400        /// Sender-declared NIP-40 expiry (unix secs); 0 = none, so permanent.
401        expires_at: u64,
402    },
403    /// Concord v2 Direct Invite (inner kind 3313) — parked for explicit consent.
404    /// Carries the raw bundle JSON (parked verbatim; the accept path re-parses it).
405    CommunityInviteV2 {
406        bundle_json: String,
407        community_id: String,
408        /// Inviter's npub (hex) — the proven seal signer.
409        inviter: String,
410        is_mine: bool,
411        wrapper_event_id_bytes: [u8; 32],
412        wrapper_created_at: u64,
413        /// Inner rumor `created_at` (seconds) — the real send time (see the v1 variant).
414        rumor_created_at: u64,
415        /// Sender-declared NIP-40 expiry (unix secs); 0 = none, so permanent.
416        expires_at: u64,
417    },
418    /// Duplicate event — just persist wrapper for negentropy.
419    DedupSkip {
420        wrapper_id_bytes: [u8; 32],
421        wrapper_created_at: u64,
422    },
423    /// Error during unwrap/processing — persist wrapper for negentropy.
424    ErrorSkip {
425        wrapper_id_bytes: [u8; 32],
426        wrapper_created_at: u64,
427    },
428}
429
430/// Phase 1: Prepare an event for commit (parallel-safe, no state mutation).
431///
432/// Performs dedup check, gift wrap decryption, and rumor parsing.
433/// Safe to call from multiple tokio tasks concurrently.
434pub async fn prepare_event(
435    event: Event,
436    _client: &Client,
437    my_public_key: PublicKey,
438) -> PreparedEvent {
439    if let Some(skip) = dedup_skip(&event).await {
440        return skip;
441    }
442    let unwrap_start = web_time::Instant::now();
443    let unwrapped = crate::signer::unwrap_gift_wrap(&event).await;
444    finish_prepare(&event, unwrapped, unwrap_start.elapsed().as_nanos() as u64, my_public_key)
445}
446
447/// Wraps opened per vault read on bulk paths. It bounds how long the key stays
448/// materialised: two ECDHs per wrap, about 1.5 ms per batch on a desktop core.
449pub const UNWRAP_BATCH: usize = 16;
450
451/// [`prepare_event`] for many events, one result per event in order. The wraps that pass
452/// dedup are opened together, under one vault read for a local key.
453pub async fn prepare_events(events: &[Event], my_public_key: PublicKey) -> Vec<PreparedEvent> {
454    let mut out: Vec<Option<PreparedEvent>> = Vec::with_capacity(events.len());
455    for event in events {
456        out.push(dedup_skip(event).await);
457    }
458    let todo: Vec<&Event> = events.iter().zip(&out).filter(|(_, o)| o.is_none()).map(|(e, _)| e).collect();
459    if !todo.is_empty() {
460        let unwrap_start = web_time::Instant::now();
461        let unwrapped = crate::signer::unwrap_gift_wraps(&todo).await;
462        let unwrap_ns = unwrap_start.elapsed().as_nanos() as u64 / todo.len() as u64;
463        let mut unwrapped = unwrapped.into_iter();
464        for (event, slot) in events.iter().zip(out.iter_mut()) {
465            if slot.is_none() {
466                let u = unwrapped.next().unwrap_or_else(|| Err("missing unwrap result".into()));
467                *slot = Some(finish_prepare(event, u, unwrap_ns, my_public_key));
468            }
469        }
470    }
471    out.into_iter().flatten().collect()
472}
473
474/// `Some(DedupSkip)` when this wrap has been seen before.
475async fn dedup_skip(event: &Event) -> Option<PreparedEvent> {
476    let wrapper_created_at = event.created_at.as_secs();
477    let wrapper_event_id_bytes: [u8; 32] = event.id.to_bytes();
478    let skip = || Some(PreparedEvent::DedupSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at });
479
480    // Dedup: in-memory cache first, then DB fallback
481    {
482        let cache = WRAPPER_ID_CACHE.lock().await;
483        if cache.contains(&wrapper_event_id_bytes) {
484            return skip();
485        }
486    }
487
488    if let Ok(true) = crate::db::events::wrapper_event_exists(&event.id.to_hex()) {
489        return skip();
490    }
491
492    // The persistent ledger too, not just the events table: a DELETED message has no
493    // events row, so without this a wrap re-served after a restart (relays ignore
494    // NIP-09 freely) re-processes cleanly and resurrects the message.
495    if crate::db::wrappers::processed_wrapper_exists(&wrapper_event_id_bytes) {
496        return skip();
497    }
498    None
499}
500
501/// Everything after the unwrap: classify the rumor and parse it.
502fn finish_prepare(
503    event: &Event,
504    unwrapped: Result<UnwrappedGift, String>,
505    unwrap_ns: u64,
506    my_public_key: PublicKey,
507) -> PreparedEvent {
508    let wrapper_created_at = event.created_at.as_secs();
509    let wrapper_event_id_bytes: [u8; 32] = event.id.to_bytes();
510    let wrapper_event_id = event.id.to_hex();
511
512    let (rumor, sender) = match unwrapped {
513        Ok(UnwrappedGift { rumor, sender }) => (rumor, sender),
514        Err(_) => return PreparedEvent::ErrorSkip {
515            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
516        },
517    };
518
519    // Inner rumor send time (seconds). The outer wrapper's `created_at` is NIP-59
520    // backdated up to 2 days, so it can't order an invite against a decline tombstone.
521    let rumor_created_at = rumor.created_at.as_secs();
522
523    let is_mine = sender == my_public_key;
524    let contact = if is_mine {
525        rumor.tags.public_keys().next()
526            .and_then(|pk| pk.to_bech32().ok())
527            .unwrap_or_else(|| sender.to_bech32().unwrap_or_default())
528    } else {
529        sender.to_bech32().unwrap_or_default()
530    };
531
532    // Skip NIP-17 group messages (multiple p-tags) — Vector DMs are 1:1
533    if rumor.tags.public_keys().count() > 1 {
534        return PreparedEvent::ErrorSkip {
535            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
536        };
537    }
538
539    // Community invite (carrier) — a join, not a chat message. Recognized before
540    // process_rumor so it never lands as an UnknownEvent in the DM thread.
541    if rumor.kind == Kind::Custom(crate::stored_event::event_kind::COMMUNITY_INVITE_BUNDLE) {
542        return match crate::community::invite::parse_invite_rumor(rumor.kind, &rumor.content) {
543            Some(invite) => PreparedEvent::CommunityInvite {
544                invite, inviter: contact.clone(), is_mine, wrapper_event_id_bytes, wrapper_created_at, rumor_created_at,
545                expires_at: crate::community::invite::expiration_secs(&rumor.tags).unwrap_or(0),
546            },
547            None => PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at },
548        };
549    }
550
551    // Concord v2 Direct Invite (inner kind 3313) — the v2 join carrier. `from_bundle_json`
552    // validates the owner commitment + bounds; we park the canonical re-serialized bundle.
553    if rumor.kind == Kind::Custom(crate::community::v2::kind::DIRECT_INVITE) {
554        return match crate::community::v2::invite::CommunityInvite::from_bundle_json(&rumor.content)
555            .ok()
556            .and_then(|b| serde_json::to_string(&b).ok().map(|j| (b.community_id, j)))
557        {
558            Some((community_id, bundle_json)) => PreparedEvent::CommunityInviteV2 {
559                community_id,
560                bundle_json,
561                inviter: contact.clone(), // the seal signer's npub (bech32), like the v1 arm
562                is_mine,
563                wrapper_event_id_bytes,
564                wrapper_created_at,
565                rumor_created_at,
566                expires_at: crate::community::invite::expiration_secs(&rumor.tags).unwrap_or(0),
567            },
568            None => PreparedEvent::ErrorSkip { wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at },
569        };
570    }
571
572    // Build RumorEvent for processing
573    let Some(rumor_id) = rumor.id else {
574        return PreparedEvent::ErrorSkip {
575            wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
576        };
577    };
578
579    let rumor_event = RumorEvent {
580        id: rumor_id,
581        kind: rumor.kind,
582        content: rumor.content,
583        tags: rumor.tags,
584        created_at: rumor.created_at,
585        pubkey: rumor.pubkey,
586    };
587    let rumor_context = RumorContext {
588        sender,
589        is_mine,
590        conversation_id: contact.clone(),
591        conversation_type: ConversationType::DirectMessage,
592    };
593
594    let parse_start = web_time::Instant::now();
595    let download_dir = crate::db::get_download_dir();
596    match process_rumor(rumor_event, rumor_context, &download_dir) {
597        Ok(result) => {
598            let parse_ns = parse_start.elapsed().as_nanos() as u64;
599            PreparedEvent::Processed {
600                result, contact, sender, is_mine,
601                wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at,
602                unwrap_ns, parse_ns,
603            }
604        }
605        Err(e) => {
606            log_warn!("[EventHandler] Failed to process rumor: {}", e);
607            PreparedEvent::ErrorSkip {
608                wrapper_id_bytes: wrapper_event_id_bytes, wrapper_created_at,
609            }
610        }
611    }
612}
613
614// ============================================================================
615// Phase 2: Commit — sequential state mutation, DB save, emit
616// ============================================================================
617
618/// Phase 2: commit a prepared event (sequential — not parallel-safe).
619/// Saves to DB, updates STATE, emits to frontend, calls handler hooks.
620/// Returns true if a new displayable message was committed.
621///
622/// Session-safety: captures the generation at the first line. If a swap
623/// occurred between `prepare_event()` and here (e.g. long-running
624/// negentropy fetch queued events for commit), bail before any STATE /
625/// DB write. Centralized so individual spawn sites (sync.rs fetch_messages,
626/// archive task, sync_dms, subscription_handler) don't have to wrap.
627/// Direct Invites live 24 hours BY DESIGN, and the RECIPIENT enforces it: a
628/// sender that omits the NIP-40 tag (older clients, other implementations)
629/// must not mint a permanent invite, and an archive sync that resurrects a
630/// months-old wrap must not park a ghost (a re-founded community's stale
631/// invite has no held id for the exists-guard to match). The sender's
632/// declared deadline is honored when EARLIER; the rumor-age lifetime is the
633/// ceiling either way. One hour of slack absorbs sender clock skew.
634pub const DIRECT_INVITE_LIFETIME_SECS: u64 = 24 * 3600 + 3600;
635
636/// Park the Private-Channel keys a CATCH-UP bundle carries that we don't hold.
637///
638/// This is delivery, never authority: nothing is adopted here. The bundle's
639/// self-certification is checked (a forged community binding never parks), the
640/// keys are stashed, and [`judge_channel_key_vend`] rules on them against our own
641/// fold after the next control follow. A vend that races its Grant therefore
642/// parks quietly instead of being dropped, which is what makes an admin's grant
643/// actually reach a member who is already in the community.
644///
645/// `inviter` is the seal author's npub (bech32) — recorded so the judge can
646/// require an entitled vendor.
647fn park_catch_up_channel_keys(
648    community_id: &str,
649    bundle_json: &str,
650    inviter: &str,
651) {
652    use crate::community::v2::invite::CommunityInvite;
653    let Ok(bundle) = CommunityInvite::from_bundle_json(bundle_json) else { return };
654    // (1) Self-cert: the bundle cannot claim to be this community while naming a
655    // different owner. Cheap, and it keeps garbage out of the park table.
656    let (Some(cid), Some(owner), Some(salt)) = (
657        crate::simd::hex::hex_to_bytes_32_checked(&bundle.community_id),
658        crate::simd::hex::hex_to_bytes_32_checked(&bundle.owner),
659        crate::simd::hex::hex_to_bytes_32_checked(&bundle.owner_salt),
660    ) else {
661        return;
662    };
663    if !crate::community::v2::derive::verify_community_id(&crate::community::CommunityId(cid), &owner, &salt) {
664        log_warn!("[community] catch-up bundle failed self-certification — dropped");
665        return;
666    }
667    let Ok(sender) = PublicKey::parse(inviter) else { return };
668    let sender_hex = sender.to_hex();
669    // The commit path's guard was captured before an await; re-check before the
670    // writes below or a swap mid-commit parks account A's channel key into
671    // account B's database.
672    let held = crate::db::community::load_community_v2(&crate::community::CommunityId(cid)).ok().flatten();
673    let mut parked_any = false;
674    for grant in &bundle.channels {
675        let Some(chan) = crate::simd::hex::hex_to_bytes_32_checked(&grant.id) else { continue };
676        let Some(key) = crate::simd::hex::hex_to_bytes_32_checked(&grant.key) else { continue };
677        // Only park what we LACK: a key at or below the held epoch is already
678        // superseded, and a public channel's "key" is just the root.
679        if let Some(c) = held.as_ref().and_then(|h| h.channel(&crate::community::ChannelId(chan))) {
680            if !c.private || (c.key.is_some() && grant.epoch <= c.epoch.0) {
681                continue;
682            }
683        }
684        if let Err(e) = crate::db::community::park_channel_key(community_id, &grant.id, grant.epoch, &key, &sender_hex) {
685            log_warn!("[community] parking a vended channel key failed: {e}");
686            continue;
687        }
688        parked_any = true;
689    }
690    // Judge it now rather than whenever the next edition happens by. A vend that
691    // lands AFTER its Grant folded changes no control state, so without a nudge
692    // there is nothing left to trigger the re-judge.
693    if parked_any {
694        crate::community::v2::realtime::enqueue_follow(&crate::community::CommunityId(cid));
695    }
696}
697
698fn expired_invite(expires_at: u64, rumor_created_at: u64) -> bool {
699    let now = nostr_sdk::prelude::Timestamp::now().as_secs();
700    if expires_at != 0 && expires_at <= now {
701        return true;
702    }
703    rumor_created_at.saturating_add(DIRECT_INVITE_LIFETIME_SECS) <= now
704}
705
706#[cfg(test)]
707mod revoked_reaction_tests {
708    use super::*;
709
710    #[tokio::test]
711    async fn a_revoked_reaction_is_refused_under_any_wrapper() {
712        let id = "7".repeat(64);
713        let reaction = crate::types::Reaction {
714            id: id.clone(),
715            reference_id: "message".into(),
716            author_id: "author".into(),
717            emoji: "+".into(),
718            emoji_url: None,
719        };
720        crate::state::note_message_deleted(&id);
721        assert!(!commit_reaction(reaction, "contact", false, "any-wrapper", &NoOpEventHandler).await);
722    }
723}
724
725#[cfg(test)]
726mod invite_expiry_tests {
727    use super::*;
728
729    fn now() -> u64 {
730        nostr_sdk::prelude::Timestamp::now().as_secs()
731    }
732
733    #[test]
734    fn declared_deadline_is_honored() {
735        assert!(expired_invite(now() - 10, now()));
736        assert!(!expired_invite(now() + 3600, now()));
737    }
738
739    #[test]
740    fn tagless_invites_die_at_the_recipient_lifetime() {
741        // Fresh, no tag: parks.
742        assert!(!expired_invite(0, now() - 3600));
743        // A day-and-slack old, no tag: never parks — the class the archive
744        // recovery resurrected (months-old invite to a re-founded community).
745        assert!(expired_invite(0, now() - DIRECT_INVITE_LIFETIME_SECS));
746        assert!(expired_invite(0, now() - 90 * 24 * 3600));
747    }
748
749    #[test]
750    fn lifetime_caps_a_generous_declared_deadline() {
751        // Sender promised a week — the recipient's 24h ceiling still wins.
752        assert!(expired_invite(now() + 7 * 24 * 3600, now() - DIRECT_INVITE_LIFETIME_SECS));
753    }
754}
755
756pub async fn commit_prepared_event(
757    prepared: PreparedEvent,
758    is_new: bool,
759    handler: &dyn InboundEventHandler,
760) -> bool {
761    let session = crate::db::current_session();
762    if !session.is_live() {
763        return false;
764    }
765    match prepared {
766        PreparedEvent::Processed { result, contact, sender, is_mine, wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, .. } => {
767            // Cache wrapper for session dedup
768            {
769                let mut cache = WRAPPER_ID_CACHE.lock().await;
770                cache.insert(wrapper_event_id_bytes);
771            }
772
773            // Blocked check — drop content from blocked contacts (wrapper still ledgered so
774            // the dropped content never re-syncs)
775            if !is_mine {
776                let blocked = {
777                    let state = crate::state::STATE.lock().await;
778                    state.get_profile(&contact).is_some_and(|p| p.flags.is_blocked())
779                };
780                if blocked {
781                    let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
782                    return false;
783                }
784            }
785
786            // Persist for cross-session dedup + negentropy — EXCEPT message rumors: the
787            // ledger IS the negentropy fingerprint set, and a batching handler may defer the
788            // message's save, so its wrapper must never be ledgered before its row lands (a
789            // ledgered-but-unpersisted message reads as "have" and is never re-delivered).
790            // Message wrappers ledger inside commit_dm_message / the batch-flush transaction.
791            if !matches!(result, RumorProcessingResult::TextMessage(_) | RumorProcessingResult::FileAttachment(_)) {
792                let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
793            }
794
795            match result {
796                RumorProcessingResult::TextMessage(mut msg) => {
797                    msg.wrapper_event_id = Some(wrapper_event_id.clone());
798                    commit_dm_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, handler, false).await
799                }
800                RumorProcessingResult::FileAttachment(mut msg) => {
801                    msg.wrapper_event_id = Some(wrapper_event_id.clone());
802                    // If the sender's client (e.g. 0xChat) didn't ship `size` in
803                    // the imeta tag, probe the URL via Content-Length so the
804                    // frontend's auto-download gate has accurate metadata to
805                    // decide on.
806                    //
807                    // Skip for self-echoes (is_mine): we just uploaded these
808                    // files, the local Attachment.size is authoritative, and
809                    // probing our own blossom URL right after upload is a
810                    // correlation-fingerprint privacy regression.
811                    //
812                    // Each probe is bounded by a 3s outer timeout so a slow or
813                    // dead server can't stall the inbound rumor pipeline. If
814                    // the probe times out, we ship size=0 and the frontend
815                    // falls back to a manual "Click to Download" affordance.
816                    if !is_mine {
817                        for att in &mut msg.attachments {
818                            if att.size == 0
819                                && (att.url.starts_with("https://") || att.url.starts_with("http://"))
820                            {
821                                if let Ok(Some(size)) = crate::rt::time::timeout(
822                                    std::time::Duration::from_secs(3),
823                                    crate::net::get_remote_file_size(&att.url),
824                                ).await {
825                                    att.size = size;
826                                }
827                            }
828                        }
829                    }
830                    // A claim whose bytes are already on disk verifies at ARRIVAL,
831                    // not at tap: the box renders downloaded from its first paint and
832                    // no bytes are re-fetched. The ledger probe is an indexed lookup;
833                    // the content hash only runs when a plausible candidate exists.
834                    crate::db::attachments::verify_message_attachments(&mut msg, None).await;
835                    commit_dm_message(msg, &contact, is_mine, is_new, &wrapper_event_id, wrapper_event_id_bytes, wrapper_created_at, handler, true).await
836                }
837                RumorProcessingResult::Reaction(reaction) => {
838                    commit_reaction(reaction, &contact, is_mine, &wrapper_event_id, handler).await
839                }
840                RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, mut event } => {
841                    commit_edit(&mut event, &contact, &message_id, &new_content, edited_at, emoji_tags, &wrapper_event_id).await
842                }
843                RumorProcessingResult::TypingIndicator { profile_id, until } => {
844                    let active_typers = {
845                        let mut state = crate::state::STATE.lock().await;
846                        state.update_typing_and_get_active(&contact, &profile_id, until)
847                    };
848                    crate::traits::emit_event("typing-update", &serde_json::json!({
849                        "conversation_id": contact,
850                        "typers": active_typers,
851                    }));
852                    false
853                }
854                RumorProcessingResult::PivxPayment { gift_code, amount_piv, address, message_id, mut event } => {
855                    if crate::db::events::event_exists(&event.id).unwrap_or(false) {
856                        return false;
857                    }
858                    event.wrapper_event_id = Some(wrapper_event_id.clone());
859                    let ts = event.created_at;
860                    let _ = crate::db::events::save_pivx_payment_event(&contact, event).await;
861                    crate::traits::emit_event("pivx_payment_received", &serde_json::json!({
862                        "conversation_id": contact,
863                        "gift_code": gift_code, "amount_piv": amount_piv,
864                        "address": address, "message_id": message_id,
865                        "sender": sender.to_hex(), "is_mine": is_mine,
866                        "at": ts * 1000,
867                    }));
868                    true
869                }
870                RumorProcessingResult::UnknownEvent(mut event) => {
871                    event.wrapper_event_id = Some(wrapper_event_id.clone());
872                    // Store unknown events for forward compatibility
873                    if let Ok(chat_id) = crate::db::id_cache::get_or_create_chat_id(&contact) {
874                        event.chat_id = chat_id;
875                    }
876                    let _ = crate::db::events::save_event(&event).await;
877                    false
878                }
879                RumorProcessingResult::LeaveRequest { .. } => false,
880                RumorProcessingResult::WebxdcPeerAdvertisement { event_id, topic_id, node_addr, sender_npub, created_at } => {
881                    handler.on_webxdc_signal(&contact, &sender_npub, &topic_id, Some(&node_addr), &event_id, created_at);
882                    false
883                }
884                RumorProcessingResult::WebxdcPeerLeft { event_id, topic_id, sender_npub, created_at } => {
885                    handler.on_webxdc_signal(&contact, &sender_npub, &topic_id, None, &event_id, created_at);
886                    false
887                }
888                #[cfg(feature = "calls")]
889                RumorProcessingResult::CallSignal { call_id, signal, node_addr, sender_npub, created_at, video, media, .. } => {
890                    // Only the DM's other party may signal; a group member cannot ring us through a channel.
891                    if contact == sender_npub {
892                        crate::calls::session::on_signal(&sender_npub, &call_id, &signal, node_addr.as_deref(), created_at, video.as_deref(), media.as_deref()).await;
893                    }
894                    false
895                }
896                #[cfg(not(feature = "calls"))]
897                RumorProcessingResult::CallSignal { .. } => false,
898                RumorProcessingResult::WallpaperChanged {
899                    sender_npub, created_at, url, decryption_key, decryption_nonce,
900                    plaintext_hash, mime, blur, dim, event_id,
901                } => {
902                    let _ = crate::wallpaper::apply_received_wallpaper(
903                        &contact, &sender_npub, created_at, &url,
904                        &decryption_key, &decryption_nonce,
905                        plaintext_hash.as_deref(), mime.as_deref(),
906                        blur, dim,
907                        &event_id,
908                    ).await;
909                    // System event is saved inside apply_received_wallpaper.
910                    // Return true so the caller treats this as a stored event.
911                    true
912                }
913                RumorProcessingResult::DeletionRequest { target_event_id } => {
914                    // A deletion targets a message OR a reaction (both event ids,
915                    // never colliding). Try the message path; if it's not a known
916                    // message, treat it as a reaction revocation.
917                    if commit_deletion(&target_event_id, &contact, &sender, handler).await {
918                        true
919                    } else {
920                        commit_reaction_deletion(&target_event_id, &sender).await
921                    }
922                }
923                RumorProcessingResult::Ignored => false,
924            }
925        }
926        PreparedEvent::CommunityInvite { invite, inviter, is_mine, wrapper_event_id_bytes, wrapper_created_at, rumor_created_at, expires_at } => {
927            // Negentropy bookkeeping regardless of outcome (the outer wrapper id is
928            // attacker-controlled, so it can't be the join-idempotency key — see below).
929            {
930                let mut cache = WRAPPER_ID_CACHE.lock().await;
931                cache.insert(wrapper_event_id_bytes);
932            }
933            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
934
935            // Never park our own echoed invite.
936            if is_mine {
937                return false;
938            }
939
940            // Past the sender's deadline OR past the recipient-enforced 24h
941            // lifetime (a catch-up sync of a stale wrap, a relay that ignores
942            // expiry, a sender that never set the tag): never park it.
943            if expired_invite(expires_at, rumor_created_at) {
944                return false;
945            }
946
947            // Cap-check before touching the DB (a hostile bundle can declare an
948            // unbounded channel/relay list).
949            if let Err(e) = invite.validate() {
950                log_warn!("[community] invite rejected: {}", e);
951                return false;
952            }
953
954            // Idempotency on the INNER identity (community_id), NOT the wrapper id: a
955            // replayed bundle re-wrapped under fresh ephemeral keys must not re-notify
956            // or churn. If we already hold this Community, or already have it parked,
957            // drop silently.
958            let community_id = invite.community_id.clone();
959            // PUBLIC input: a signature-valid invite can still carry a malformed id, so decode through
960            // the SIMD-validated path (rejects non-hex / wrong length in-register).
961            let already_held = crate::community::CommunityId(
962                match crate::simd::hex::hex_to_bytes_32_checked(&community_id) {
963                    Some(b) => b,
964                    None => { log_warn!("[community] invite has malformed id"); return false; }
965                },
966            );
967            if crate::db::community::community_exists(&already_held).unwrap_or(false) {
968                return false;
969            }
970            if crate::db::community::pending_invite_exists(&community_id).unwrap_or(false) {
971                return false;
972            }
973
974            // Supersession: a decline/leave tombstone suppresses any invite no newer than the
975            // decision (so the un-deletable 3304 can't re-nag, and a sibling's decline propagated via
976            // the synced list silences this device too). A STRICTLY-newer invite falls through and
977            // parks — a deliberate re-invite resurfaces. Ordered on the inner rumor time, not the
978            // NIP-59-backdated wrapper (which would make a fresh re-invite look older than the decline).
979            if crate::community::list::tombstone_suppresses(&community_id, rumor_created_at) {
980                return false;
981            }
982
983            // Park for explicit consent — do NOT join, subscribe, or dial the bundle's
984            // relays here. The user accepts via the command layer.
985            let bundle_json = match invite.to_json() {
986                Ok(j) => j,
987                Err(e) => { log_warn!("[community] invite re-serialize failed: {}", e); return false; }
988            };
989            match crate::db::community::save_pending_invite(&community_id, &bundle_json, &inviter, expires_at as i64) {
990                Ok(true) => {
991                    handler.on_community_invite(&community_id);
992                    // Warm the community's first page in the background so a subsequent Accept opens
993                    // populated instead of paying the join sync. RAM-only + best-effort; promotion on
994                    // Join re-validates freshness. std::sync::Arc<crate::db::Session>'d so a mid-flight swap is a no-op.
995                    let invite_warm = invite.clone();
996                    let bg = crate::db::current_session();
997                    crate::rt::spawn(async move {
998                        if !bg.is_live() {
999                            return;
1000                        }
1001                        crate::community::service::preload_community(&invite_warm).await;
1002                    });
1003                }
1004                Ok(false) => {} // raced — already parked
1005                Err(e) => log_warn!("[community] invite park failed: {}", e),
1006            }
1007            false
1008        }
1009        PreparedEvent::CommunityInviteV2 { bundle_json, community_id, inviter, is_mine, wrapper_event_id_bytes, wrapper_created_at, rumor_created_at, expires_at } => {
1010            {
1011                let mut cache = WRAPPER_ID_CACHE.lock().await;
1012                cache.insert(wrapper_event_id_bytes);
1013            }
1014            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
1015
1016            // Never park our own echoed invite.
1017            if is_mine {
1018                return false;
1019            }
1020            // Past the sender's deadline or the 24h lifetime — see the v1 arm.
1021            if expired_invite(expires_at, rumor_created_at) {
1022                return false;
1023            }
1024            // Idempotency on the INNER community_id (already validated hex), not the
1025            // attacker-controlled wrapper id: already held, or already parked → drop.
1026            let held = match crate::simd::hex::hex_to_bytes_32_checked(&community_id) {
1027                Some(b) => crate::community::CommunityId(b),
1028                None => return false,
1029            };
1030            // Already a member? Then this is not an invitation but a CATCH-UP: a
1031            // grant's Private-Channel key vend (CORD-03 "delivered on grant"), or
1032            // an admin healing us forward. Park the keys we lack for the judge to
1033            // rule on after the next control fold — dropping it here is why a
1034            // granted member (or bot) stayed silently keyless.
1035            if crate::db::community::community_exists(&held).unwrap_or(false) {
1036                park_catch_up_channel_keys(&community_id, &bundle_json, &inviter);
1037                return false;
1038            }
1039            if crate::db::community::pending_invite_exists(&community_id).unwrap_or(false) {
1040                return false;
1041            }
1042            // Supersession: a decline/leave tombstone (protocol-agnostic, keyed on
1043            // community_id) suppresses a re-wrapped invite no newer than the decision,
1044            // so a declined/left community can't be re-nagged by a fresh ephemeral wrap.
1045            // Ordered on the inner rumor time, not the NIP-59-backdated wrapper.
1046            if crate::community::list::tombstone_suppresses(&community_id, rumor_created_at) {
1047                return false;
1048            }
1049            // Park for explicit consent — do NOT join/subscribe here. Accept via the command layer.
1050            match crate::db::community::save_pending_invite(&community_id, &bundle_json, &inviter, expires_at as i64) {
1051                Ok(true) => handler.on_community_invite(&community_id),
1052                Ok(false) => {} // raced — already parked
1053                Err(e) => log_warn!("[community] v2 invite park failed: {}", e),
1054            }
1055            false
1056        }
1057        PreparedEvent::DedupSkip { wrapper_id_bytes, wrapper_created_at } => {
1058            // Persist wrapper timestamp for negentropy backfill (skip no-op writes).
1059            // Guarded: a cache-hit skip can name a wrapper whose message is still sitting in
1060            // a batch buffer (deferred ledger) — inserting it here would mark the message
1061            // "have" before its row exists. Only touch the ledger when the wrapper is
1062            // already ledgered (timestamp backfill) or its row is verifiably persisted
1063            // (legacy pre-ledger events, whose wrapper lives only on the events row).
1064            if wrapper_created_at > 0 {
1065                if crate::db::wrappers::processed_wrapper_exists(&wrapper_id_bytes) {
1066                    let _ = crate::db::wrappers::update_wrapper_timestamp(&wrapper_id_bytes, wrapper_created_at);
1067                } else {
1068                    let wrapper_hex = crate::simd::hex::bytes_to_hex_32(&wrapper_id_bytes);
1069                    if crate::db::events::wrapper_event_exists(&wrapper_hex).unwrap_or(false) {
1070                        let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
1071                    }
1072                }
1073            }
1074            false
1075        }
1076        PreparedEvent::ErrorSkip { wrapper_id_bytes, wrapper_created_at } => {
1077            let _ = crate::db::wrappers::save_processed_wrapper(&wrapper_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17);
1078            false
1079        }
1080    }
1081}
1082
1083/// Commit a DM text or file message (shared logic for both).
1084///
1085/// Owns this message's wrapper-ledger write (`processed_wrappers` = the negentropy
1086/// fingerprint set): the wrapper is ledgered only once the message row is durably handled —
1087/// immediately after a successful save, inside the batch-flush transaction when a handler
1088/// defers, or right away when the message is a known duplicate. An unledgered wrapper is
1089/// re-delivered by the next reconciliation, which is what makes a dropped batch recoverable.
1090#[allow(clippy::too_many_arguments)]
1091async fn commit_dm_message(
1092    mut msg: Message,
1093    contact: &str,
1094    _is_mine: bool,
1095    is_new: bool,
1096    wrapper_event_id: &str,
1097    wrapper_event_id_bytes: [u8; 32],
1098    wrapper_created_at: u64,
1099    handler: &dyn InboundEventHandler,
1100    is_file: bool,
1101) -> bool {
1102    let ledger_wrapper = || {
1103        let _ = crate::db::wrappers::save_processed_wrapper(
1104            &wrapper_event_id_bytes, wrapper_created_at, crate::db::wrappers::TRANSPORT_NIP17,
1105        );
1106    };
1107    // A tombstoned (user-deleted) message must never re-commit, whatever wrap
1108    // carried it — a retry wrap this device never ledgered can still deliver
1109    // the deleted rumor. Ledger the wrap so it stops re-delivering.
1110    if crate::state::was_message_deleted(&msg.id) {
1111        ledger_wrapper();
1112        return false;
1113    }
1114
1115    // Dedup: check if message already in DB
1116    if let Ok(true) = crate::db::events::message_exists_in_db(&msg.id) {
1117        // Already in DB — try to backfill wrapper_event_id
1118        if let Ok(updated) = crate::db::events::update_wrapper_event_id(&msg.id, wrapper_event_id) {
1119            if !updated {
1120                let mut cache = WRAPPER_ID_CACHE.lock().await;
1121                cache.insert(wrapper_event_id_bytes);
1122            }
1123        }
1124        ledger_wrapper();
1125        return false;
1126    }
1127
1128    // Populate reply context
1129    if !msg.replied_to.is_empty() {
1130        let _ = crate::db::events::populate_reply_context(&mut msg).await;
1131    }
1132
1133    // Add to STATE (+ clear typing indicator for file senders)
1134    let added = {
1135        let mut state = crate::state::STATE.lock().await;
1136        let added = state.add_message_to_participant(contact, &msg);
1137        if is_file && added {
1138            state.update_typing_and_get_active(contact, contact, 0);
1139        }
1140        added
1141    };
1142
1143    // A banned author's message is stored but never SHOWN: the queries that build a
1144    // page already drop it, and painting it live was the one way it still reached the
1145    // screen. Gated at emission rather than at persist so an unban restores their
1146    // history — the row is still there, the filter simply stops matching.
1147    let hidden_author = msg
1148        .npub
1149        .as_deref()
1150        .and_then(|author| {
1151            let community = crate::db::community::community_id_for_channel(contact).ok().flatten()?;
1152            nostr_sdk::prelude::PublicKey::parse(author)
1153                .ok()
1154                .map(|pk| crate::db::community::is_author_banned(&community, &pk))
1155        })
1156        .unwrap_or(false);
1157
1158    if added && !hidden_author {
1159        // Emit to frontend
1160        crate::traits::emit_message_new(contact, &msg);
1161
1162        // Platform callback (notifications, badge, etc.)
1163        if is_file {
1164            handler.on_file_received(contact, &msg, is_new);
1165        } else {
1166            handler.on_dm_received(contact, &msg, is_new);
1167        }
1168
1169        // Save to DB — unless a bulk-sync handler owns batched persistence (the handler then
1170        // also owns the wrapper-ledger write, inside its flush transaction). On the immediate
1171        // path the wrapper ledgers only after a successful save: a failed save left unledgered
1172        // re-delivers on the next reconciliation instead of being lost.
1173        if !handler.buffer_persist(contact, &msg, Some((wrapper_event_id_bytes, wrapper_created_at)))
1174            && crate::db::events::save_message(contact, &msg).await.is_ok()
1175        {
1176            ledger_wrapper();
1177        }
1178    } else {
1179        // STATE-level duplicate: a same-session twin owns the row; this wrapper carried
1180        // nothing new, so ledger it now (parity with the old eager ledger).
1181        ledger_wrapper();
1182    }
1183
1184    added
1185}
1186
1187/// Commit a reaction event.
1188async fn commit_reaction(
1189    reaction: crate::types::Reaction,
1190    contact: &str,
1191    is_mine: bool,
1192    wrapper_event_id: &str,
1193    handler: &dyn InboundEventHandler,
1194) -> bool {
1195    // A revoked reaction can come back under a new wrapper id; its own id is the stable key.
1196    if crate::state::was_message_deleted(&reaction.id) {
1197        return false;
1198    }
1199
1200    // Add to STATE
1201    let msg_for_emit = {
1202        let mut state = crate::state::STATE.lock().await;
1203        if let Some((chat_id, was_added)) = state.add_reaction_to_message(&reaction.reference_id, reaction.clone()) {
1204            if was_added {
1205                state.find_message(&reaction.reference_id)
1206                    .map(|(_, msg)| (chat_id, msg))
1207            } else { None }
1208        } else { None }
1209    };
1210
1211    if let Some((chat_id, mut msg)) = msg_for_emit {
1212        crate::traits::emit_message_update(&chat_id, &reaction.reference_id, &mut msg).await;
1213        let _ = crate::db::events::save_message(&chat_id, &msg).await;
1214        handler.on_reaction_received(&chat_id, &msg);
1215    }
1216
1217    // Always save reaction event with wrapper for dedup
1218    if let Ok(chat_id) = crate::db::id_cache::get_chat_id_by_identifier(contact) {
1219        let _ = crate::db::events::save_reaction_event(
1220            &reaction, chat_id, None, is_mine, Some(wrapper_event_id.to_string())
1221        ).await;
1222    }
1223
1224    true
1225}
1226
1227/// Commit a message edit.
1228async fn commit_edit(
1229    event: &mut crate::stored_event::StoredEvent,
1230    contact: &str,
1231    message_id: &str,
1232    new_content: &str,
1233    edited_at: u64,
1234    emoji_tags: Vec<crate::types::EmojiTag>,
1235    wrapper_event_id: &str,
1236) -> bool {
1237    if crate::db::events::event_exists(&event.id).unwrap_or(false) {
1238        return false;
1239    }
1240    if let Ok(chat_id) = crate::db::id_cache::get_chat_id_by_identifier(contact) {
1241        event.chat_id = chat_id;
1242    }
1243    event.wrapper_event_id = Some(wrapper_event_id.to_string());
1244    let _ = crate::db::events::save_event(event).await;
1245
1246    let msg_for_emit = {
1247        let mut state = crate::state::STATE.lock().await;
1248        state.update_message_in_chat(contact, message_id, |msg| {
1249            msg.apply_edit(new_content.to_string(), edited_at, emoji_tags.clone());
1250        })
1251    };
1252    if let Some(mut msg) = msg_for_emit {
1253        crate::traits::emit_message_update(contact, message_id, &mut msg).await;
1254    }
1255    true
1256}
1257
1258/// Commit a NIP-09 cooperative deletion request.
1259///
1260/// Authorization: only the original message's author can delete it
1261/// (matches NIP-09's `event.pubkey == deletion.pubkey` rule applied to
1262/// the inner rumor). For DMs, that means either the sender is `MY` (we
1263/// deleted from another device) or the sender is the chat counterpart
1264/// who originally sent that message. Anyone else's deletion is silently
1265/// ignored.
1266///
1267/// On success: drops the message from in-memory STATE, removes the row
1268/// from the events table, and emits `message_removed` so the frontend
1269/// can fade the row out — same code path as failed-message cleanup.
1270async fn commit_deletion(
1271    target_event_id: &str,
1272    contact: &str,
1273    sender: &PublicKey,
1274    handler: &dyn InboundEventHandler,
1275) -> bool {
1276    // Look up the original. If not present locally there's nothing to
1277    // delete — the deletion notice arrived before the original (rare),
1278    // or we never had it. Either way, no-op.
1279    //
1280    // KNOWN LIMITATION: late-binding deletions are not handled. If
1281    // the deletion arrives BEFORE the original (cold sync, out-of-order
1282    // relay delivery), we drop the deletion silently here, and when the
1283    // original arrives later it shows up unhidden. A future enhancement
1284    // would persist a `pending_deletions` table keyed by target id and
1285    // apply queued deletions when the target is committed in
1286    // commit_dm_message. The common case (deletion arrives after the
1287    // original) works correctly today.
1288    //
1289    // For DM rumors the `npub` field is intentionally empty: the chat
1290    // is between two parties, so the author is implicit from `mine`
1291    // (me if true, chat counterpart if false). We derive the original
1292    // author from that, since the rumor pubkey isn't stored.
1293    let (mine, chat_id) = {
1294        let state = crate::state::STATE.lock().await;
1295        match state.find_message(target_event_id) {
1296            Some((chat, msg)) => (msg.mine, chat.id.clone()),
1297            None => return false,
1298        }
1299    };
1300
1301    // Authorization: deletion sender must match the original author.
1302    // For DMs:
1303    //   - mine == true:  original author == us (MY_PUBLIC_KEY).
1304    //                    Authorized if the deletion sender is also us
1305    //                    (i.e. came in via our own self-wrap from
1306    //                    another device, multi-device sync).
1307    //   - mine == false: original author == chat counterpart. Chat id
1308    //                    for a DM is the counterpart's npub, so we
1309    //                    parse it and compare against the deletion
1310    //                    sender.
1311    let authorized = if mine {
1312        match crate::state::my_public_key() {
1313            Some(my_pk) => *sender == my_pk,
1314            None => false,
1315        }
1316    } else {
1317        match nostr_sdk::prelude::PublicKey::from_bech32(&chat_id) {
1318            Ok(counterpart) => sender == &counterpart,
1319            Err(_) => false, // chat id wasn't an npub (shouldn't happen for DMs)
1320        }
1321    };
1322    if !authorized {
1323        eprintln!(
1324            "[NIP-17 cooperative-delete] unauthorized: sender {} not the author of target {} (mine={}, chat={})",
1325            sender.to_hex(), target_event_id, mine, chat_id
1326        );
1327        return false;
1328    }
1329
1330    // Drop from in-memory state.
1331    let removed = {
1332        let mut state = crate::state::STATE.lock().await;
1333        state.remove_message(target_event_id)
1334    };
1335    let removed_msg = match removed {
1336        Some((_chat_id, msg)) => msg,
1337        None => return false,
1338    };
1339    // Tombstone BEFORE the row delete: the target may sit unflushed in a sync batch buffer
1340    // (delete_event below would no-op) — the flush consults this and drops it. The durable
1341    // row keeps the refusal across restarts: the sender's NIP-09 may never land on every
1342    // relay, and this side must not resurrect what it already agreed to drop.
1343    crate::state::note_message_deleted(target_event_id);
1344    if let Err(e) = crate::db::events::add_message_tombstone(target_event_id) {
1345        crate::log_warn!("[NIP-17 cooperative-delete] tombstone write failed: {}", e);
1346    }
1347
1348    // Nuke any cached attachment files for this message — sender asked
1349    // for the message to disappear, and a downloaded file the receiver
1350    // never moved out of Vector's cache should go with it.
1351    //
1352    // Refcount filter: drop attachments still referenced by sibling
1353    // messages so we don't yank a cached file from messages that
1354    // still need it (Vector dedupes by SHA-256, so the same file
1355    // can back multiple messages). User-managed paths are also left
1356    // alone (canonicalize + starts_with check).
1357    let unique = crate::deletion::filter_unreferenced_attachments(
1358        target_event_id,
1359        removed_msg.attachments,
1360    ).await;
1361    crate::deletion::delete_cached_attachment_files_pub(&unique);
1362
1363    // Drop from the events table.
1364    if let Err(e) = crate::db::events::delete_event(target_event_id).await {
1365        eprintln!(
1366            "[NIP-17 cooperative-delete] DB delete failed for {}: {}",
1367            target_event_id, e
1368        );
1369    }
1370
1371    // Tell the frontend to fade the row out. Reuses the existing
1372    // message_removed event handled in main.js, so no new wiring.
1373    crate::traits::emit_event(
1374        "message_removed",
1375        &serde_json::json!({
1376            "id": target_event_id,
1377            "chat_id": &chat_id,
1378            "reason": "deleted-by-sender",
1379        }),
1380    );
1381
1382    handler.on_message_deleted(&chat_id, target_event_id);
1383    let _ = contact;
1384    true
1385}
1386
1387/// Apply a cooperative reaction revocation (NIP-09 k=7) from the reaction's
1388/// author. Removes the reaction from its parent message and drops the kind-7
1389/// row, then live-refreshes the parent's chips. Returns false if we don't hold
1390/// the reaction or the sender isn't its author.
1391async fn commit_reaction_deletion(target_reaction_id: &str, sender: &PublicKey) -> bool {
1392    let found = {
1393        let state = crate::state::STATE.lock().await;
1394        state.find_reaction(target_reaction_id)
1395    };
1396    let (chat_id, message_id, author_npub, _is_community) = match found {
1397        Some(v) => v,
1398        None => return false,
1399    };
1400
1401    // Authorization: only the reaction's own author may revoke it.
1402    let authorized = nostr_sdk::prelude::PublicKey::parse(&author_npub)
1403        .map(|pk| pk == *sender)
1404        .unwrap_or(false);
1405    if !authorized {
1406        eprintln!(
1407            "[reaction-delete] unauthorized: sender {} is not the author of reaction {}",
1408            sender.to_hex(), target_reaction_id
1409        );
1410        return false;
1411    }
1412
1413    crate::state::note_message_deleted(target_reaction_id);
1414    if let Err(e) = crate::db::events::add_message_tombstone(target_reaction_id) {
1415        crate::log_warn!("[reaction-delete] tombstone write failed: {}", e);
1416    }
1417
1418    let updated = {
1419        let mut state = crate::state::STATE.lock().await;
1420        state.remove_reaction_from_message(&message_id, target_reaction_id)
1421    };
1422    let mut message = match updated {
1423        Some((_cid, msg)) => msg,
1424        None => return false,
1425    };
1426
1427    // save_message is additive for reactions, so the kind-7 row must be
1428    // dropped explicitly or it resurrects on reload.
1429    if let Err(e) = crate::db::events::delete_event(target_reaction_id).await {
1430        eprintln!("[reaction-delete] DB delete failed for {}: {}", target_reaction_id, e);
1431    }
1432
1433    crate::traits::emit_message_update(&chat_id, &message_id, &mut message).await;
1434    true
1435}
1436
1437// ============================================================================
1438// Convenience: single-call event processing
1439// ============================================================================
1440
1441/// Process a single event through the full pipeline (prepare + commit).
1442///
1443/// Gets client and public key from globals. For callers that manage their
1444/// own notification loop but want the full vector-core processing pipeline.
1445pub async fn process_event(
1446    event: Event,
1447    is_new: bool,
1448    handler: &dyn InboundEventHandler,
1449) -> std::result::Result<bool, String> {
1450    let client = crate::state::nostr_client()
1451        .ok_or_else(|| "Nostr client not initialized".to_string())?;
1452    let my_pk = crate::state::my_public_key()
1453        .ok_or_else(|| "Public key not initialized".to_string())?;
1454    let prepared = prepare_event(event, &client, my_pk).await;
1455    Ok(commit_prepared_event(prepared, is_new, handler).await)
1456}
1457
1458#[cfg(test)]
1459mod signals_only_tests {
1460    use super::*;
1461    use std::sync::atomic::{AtomicUsize, Ordering};
1462
1463    #[derive(Default)]
1464    struct Counts {
1465        signals: AtomicUsize,
1466        messages: AtomicUsize,
1467    }
1468
1469    impl InboundEventHandler for Counts {
1470        fn on_webxdc_signal(&self, _: &str, _: &str, _: &str, _: Option<&str>, _: &str, _: u64) {
1471            self.signals.fetch_add(1, Ordering::Relaxed);
1472        }
1473        fn on_community_webxdc(&self, _: &str, _: &str, _: &str, _: Option<&str>, _: &str, _: u64) {
1474            self.signals.fetch_add(1, Ordering::Relaxed);
1475        }
1476        fn on_dm_received(&self, _: &str, _: &Message, _: bool) {
1477            self.messages.fetch_add(1, Ordering::Relaxed);
1478        }
1479        fn on_file_received(&self, _: &str, _: &Message, _: bool) {
1480            self.messages.fetch_add(1, Ordering::Relaxed);
1481        }
1482    }
1483
1484    #[test]
1485    fn a_catch_up_sync_passes_on_signals_and_nothing_else() {
1486        let inner = Counts::default();
1487        let sync = LiveOnly::signals(&inner);
1488        sync.on_webxdc_signal("npub1a", "npub1a", "T", Some("addr"), "e1", 1);
1489        sync.on_community_webxdc("chan", "npub1a", "T", None, "e2", 1);
1490        sync.on_dm_received("npub1a", &Message { at: u64::MAX, ..Default::default() }, true);
1491        assert_eq!(inner.signals.load(Ordering::Relaxed), 2);
1492        assert_eq!(inner.messages.load(Ordering::Relaxed), 0, "messages from a sync are history");
1493    }
1494
1495    #[test]
1496    fn a_startup_sync_passes_on_messages_sent_since_listening_began() {
1497        let inner = Counts::default();
1498        let sync = LiveOnly::from(&inner, 5_000);
1499        sync.on_dm_received("npub1a", &Message { at: 4_999, ..Default::default() }, true);
1500        assert_eq!(inner.messages.load(Ordering::Relaxed), 0, "before: history");
1501        sync.on_dm_received("npub1a", &Message { at: 5_000, ..Default::default() }, true);
1502        sync.on_file_received("npub1a", &Message { at: 6_000, ..Default::default() }, true);
1503        assert_eq!(inner.messages.load(Ordering::Relaxed), 2, "since: live");
1504    }
1505}