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