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