1use nostr_sdk::prelude::PublicKey;
12use nostr_sdk::ToBech32;
13
14use super::super::attachments::attachments_from_tags;
15use super::chat::{self, ChatEvent};
16use super::community::CommunityV2;
17use super::guestbook::{self, GuestbookEntry};
18use super::stream;
19use crate::event_handler::InboundEventHandler;
20use crate::state::ChatState;
21use crate::types::{EmojiTag, Message, Reaction};
22
23pub fn chat_message_to_message(
28 opened: &stream::OpenedStream,
29 reply_to: &Option<chat::ReplyRef>,
30 emoji: &[(String, String)],
31 my_pubkey: &PublicKey,
32) -> Message {
33 let (replied_to, replied_to_npub) = match reply_to {
34 Some(r) => (
35 crate::simd::hex::bytes_to_hex_32(&r.id),
36 r.author.and_then(|a| a.to_bech32().ok()),
37 ),
38 None => (String::new(), None),
39 };
40 Message {
41 id: opened.rumor_id.to_hex(),
42 content: opened.rumor.content.clone(),
43 replied_to,
44 replied_to_npub,
45 at: opened.at_ms,
46 mine: opened.author == *my_pubkey,
47 npub: opened.author.to_bech32().ok(),
48 attachments: attachments_from_tags(opened.rumor.tags.iter(), &crate::db::get_download_dir()),
49 emoji_tags: emoji
50 .iter()
51 .map(|(shortcode, url)| EmojiTag { shortcode: shortcode.clone(), url: url.clone() })
52 .collect(),
53 addressed_bots: crate::bot_interface::addressed_bots(opened.rumor.tags.iter()),
54 wrapper_event_id: Some(opened.wrapper_id.to_hex()),
55 expiration: chat::message_expiration(&opened.rumor),
57 ..Default::default()
58 }
59}
60
61fn author_is_banned_here(channel_id: &str, author: &PublicKey) -> bool {
65 let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
66 return false;
67 };
68 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
69 !banned.is_empty() && banned.contains(&author.to_hex())
70}
71
72pub enum ChatPersist {
77 New(Message),
79 Updated { message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
83 Removed(String),
85 ReactionRemoved { reaction_id: String, message: Message },
89}
90
91pub fn apply_chat_to_state(state: &mut ChatState, event: &ChatEvent, channel_id: &str, my_pubkey: &PublicKey) -> Option<ChatPersist> {
98 if author_is_banned_here(channel_id, &event.opened().author) {
105 return None;
106 }
107 match event {
108 ChatEvent::Message { opened, reply_to, emoji } => {
109 let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey);
110 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
113 return None;
114 }
115 state.ensure_community_chat(channel_id);
116 state.add_message_to_chat(channel_id, msg.clone());
120 Some(ChatPersist::New(msg))
121 }
122 ChatEvent::Reaction { opened, target, emoji, emoji_url, .. } => {
123 let target_id = crate::simd::hex::bytes_to_hex_32(target);
124 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == channel_id) {
128 return None;
129 }
130 let reaction = Reaction {
131 id: opened.rumor_id.to_hex(),
132 reference_id: target_id.clone(),
133 author_id: opened.author.to_bech32().unwrap_or_else(|_| opened.author.to_hex()),
137 emoji: emoji.clone(),
138 emoji_url: emoji_url.clone(),
139 };
140 let (_c, added) = state.add_reaction_to_message(&target_id, reaction)?;
141 added.then(|| state.find_message(&target_id).map(|(_c, m)| ChatPersist::Updated { message: m, edit_event: None }))?
142 }
143 ChatEvent::Edit { opened, target, new_content } => {
144 if crate::db::events::event_exists(&opened.rumor_id.to_hex()).unwrap_or(false) {
149 return None;
150 }
151 let target_id = crate::simd::hex::bytes_to_hex_32(target);
152 let editor_npub = opened.author.to_bech32().ok()?;
154 if !matches!(state.find_message(&target_id), Some((chat, m)) if chat.id == channel_id && m.npub.as_deref() == Some(editor_npub.as_str())) {
155 return None;
156 }
157 let edited_at = opened.at_ms;
160 let (_c, message) = state.update_message(&target_id, |m| m.apply_edit(new_content.clone(), edited_at, Vec::new()))?;
161 let edit_event = crate::stored_event::StoredEventBuilder::new()
163 .id(opened.rumor_id.to_hex())
164 .kind(crate::stored_event::event_kind::MESSAGE_EDIT)
165 .content(new_content.clone())
166 .reference_id(Some(target_id.clone()))
167 .created_at(edited_at / 1000)
168 .mine(opened.author == *my_pubkey)
169 .npub(opened.author.to_bech32().ok())
170 .build();
171 Some(ChatPersist::Updated { message, edit_event: Some(Box::new(edit_event)) })
172 }
173 ChatEvent::Delete { opened, target, .. } => {
174 let target_id = crate::simd::hex::bytes_to_hex_32(target);
175 if let Some((_chat, message_id, author_npub, _)) = state.find_reaction(&target_id) {
180 let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == opened.author).unwrap_or(false);
181 if !reactor_ok {
182 return None;
183 }
184 return state
185 .remove_reaction_from_message(&message_id, &target_id)
186 .map(|(_cid, message)| ChatPersist::ReactionRemoved { reaction_id: target_id, message });
187 }
188 let own = matches!(state.find_message(&target_id), Some((_, m)) if m.npub.as_deref() == opened.author.to_bech32().ok().as_deref());
191 own.then(|| state.remove_message(&target_id).map(|_| ChatPersist::Removed(target_id)))?
192 }
193 ChatEvent::Typing { .. } | ChatEvent::Webxdc { .. } => None,
194 }
195}
196
197pub async fn persist_chat_event(
203 event: &ChatEvent,
204 channel_id: &str,
205 my_pubkey: &PublicKey,
206 session: &crate::state::SessionGuard,
207) -> Option<ChatPersist> {
208 let outcome = {
209 let mut st = crate::state::STATE.lock().await;
210 if !session.is_valid() {
212 return None;
213 }
214 apply_chat_to_state(&mut st, event, channel_id, my_pubkey)
215 }?;
216 let outcome = match outcome {
220 ChatPersist::New(mut m) => {
221 if !m.replied_to.is_empty() {
222 let _ = crate::db::events::populate_reply_context(&mut m).await;
223 }
224 ChatPersist::New(m)
225 }
226 o => o,
227 };
228 if !session.is_valid() {
230 return None;
231 }
232 persist_chat(channel_id, &outcome).await;
233 Some(outcome)
234}
235
236pub async fn persist_chat(channel_id: &str, outcome: &ChatPersist) {
240 match outcome {
241 ChatPersist::New(m) => {
242 let _ = crate::db::events::save_message(channel_id, m).await;
243 }
244 ChatPersist::Updated { message, edit_event } => match edit_event {
247 Some(ev) => {
248 let mut ev = (**ev).clone();
249 if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
252 ev.chat_id = cid;
253 }
254 let _ = crate::db::events::save_event(&ev).await;
255 }
256 None => {
257 let _ = crate::db::events::save_message(channel_id, message).await;
258 }
259 },
260 ChatPersist::Removed(id) => {
261 let _ = crate::db::events::delete_event(id).await;
262 }
263 ChatPersist::ReactionRemoved { reaction_id, message } => {
264 let _ = crate::db::events::delete_event(reaction_id).await;
265 let _ = crate::db::events::save_message(channel_id, message).await;
266 }
267 }
268}
269
270#[derive(Debug, Clone)]
272pub enum DispatchedV2 {
273 Chat { channel_id: String, event: Box<ChatEvent> },
279 Typing { channel_id: String, npub: String },
281 Presence { npub: String, joined: bool },
283 Control { community_id: String },
288 Rekey { community_id: String },
292 Dissolved { community_id: String },
295 Ignored,
298 NotOurs,
300}
301
302pub fn dispatch_wrap(
308 wrap: &nostr_sdk::Event,
309 community: &CommunityV2,
310 my_pubkey: &PublicKey,
311 handler: &dyn InboundEventHandler,
312) -> DispatchedV2 {
313 for ch in &community.channels {
315 if ch.private && ch.key.is_none() {
318 continue;
319 }
320 let (secret, epoch) = community.channel_secret(ch);
321 let group = super::derive::channel_group_key(&secret, &ch.id, epoch);
322 if wrap.pubkey != group.pk() {
323 continue;
324 }
325 let Ok(event) = chat::open_chat_event(wrap, &group, &ch.id, epoch) else {
326 return DispatchedV2::NotOurs;
327 };
328 let channel_id = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
329 return dispatch_chat_event(event, &channel_id, my_pubkey, handler);
330 }
331
332 let gb = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
334 if wrap.pubkey == gb.pk() {
335 if let Ok(opened) = stream::open_wrap(wrap, &gb) {
336 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
337 return dispatch_guestbook(&ev, community, handler);
338 }
339 }
340 return DispatchedV2::Ignored;
341 }
342
343 if wrap.pubkey == super::realtime::control_author(community) {
347 return DispatchedV2::Control { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
348 }
349
350 if super::realtime::rekey_authors(community).iter().any(|p| *p == wrap.pubkey) {
355 return DispatchedV2::Rekey { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
356 }
357
358 if wrap.pubkey == super::derive::dissolved_group_key(community.id()).pk() {
361 if super::dissolution::verify_dissolved(wrap, &community.identity) {
362 return DispatchedV2::Dissolved { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
363 }
364 return DispatchedV2::Ignored;
365 }
366
367 DispatchedV2::NotOurs
368}
369
370fn dispatch_chat_event(event: ChatEvent, channel_id: &str, my_pubkey: &PublicKey, handler: &dyn InboundEventHandler) -> DispatchedV2 {
371 match event {
372 ChatEvent::Typing { opened } => {
376 if author_is_banned_here(channel_id, &opened.author) {
377 return DispatchedV2::Ignored;
378 }
379 let npub = opened.author.to_bech32().unwrap_or_default();
380 let until = opened.at_ms / 1000 + 30;
381 handler.on_community_typing(channel_id, &npub, until);
382 DispatchedV2::Typing { channel_id: channel_id.to_string(), npub }
383 }
384 ChatEvent::Webxdc { opened } => {
388 if opened.author == *my_pubkey || author_is_banned_here(channel_id, &opened.author) {
389 return DispatchedV2::Ignored;
390 }
391 let Some((topic_id, node_addr)) = crate::webxdc::parse_peer_signal(&opened.rumor.content) else {
392 return DispatchedV2::Ignored;
393 };
394 let npub = opened.author.to_bech32().unwrap_or_default();
395 handler.on_community_webxdc(
396 channel_id,
397 &npub,
398 &topic_id,
399 node_addr.as_deref(),
400 &opened.rumor_id.to_hex(),
401 opened.at_ms / 1000,
402 );
403 DispatchedV2::Ignored
404 }
405 event => DispatchedV2::Chat { channel_id: channel_id.to_string(), event: Box::new(event) },
408 }
409}
410
411fn dispatch_guestbook(ev: &guestbook::GuestbookEvent, community: &CommunityV2, handler: &dyn InboundEventHandler) -> DispatchedV2 {
412 if let GuestbookEntry::Join { member, .. } | GuestbookEntry::Leave { member, .. } = &ev.entry {
415 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
416 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
417 if banned.contains(&member.to_hex()) {
418 return DispatchedV2::Ignored;
419 }
420 }
421 let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
425 let chat_id = community
428 .primary_channel()
429 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
430 .unwrap_or_default();
431 match &ev.entry {
432 GuestbookEntry::Join { member, at_ms, invited_by } => {
433 let npub = member.to_bech32().unwrap_or_default();
434 let (by, label) = match invited_by {
435 Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
436 None => (None, None),
437 };
438 handler.on_community_presence(&chat_id, &npub, true, &event_id, at_ms / 1000, by, label);
439 DispatchedV2::Presence { npub: npub.clone(), joined: true }
440 }
441 GuestbookEntry::Leave { member, at_ms } => {
442 let npub = member.to_bech32().unwrap_or_default();
443 handler.on_community_presence(&chat_id, &npub, false, &event_id, at_ms / 1000, None, None);
444 DispatchedV2::Presence { npub: npub.clone(), joined: false }
445 }
446 GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => DispatchedV2::Ignored,
448 }
449}
450
451#[cfg(test)]
452mod tests {
453 use super::*;
454 use super::super::service;
455 use crate::community::transport::memory::MemoryRelay;
456 use crate::community::transport::Transport;
457 use nostr_sdk::prelude::Keys;
458 use std::sync::Mutex;
459
460 #[derive(Default)]
462 struct Recorder {
463 messages: Mutex<Vec<(String, Message)>>,
464 updates: Mutex<Vec<(String, String)>>,
465 removed: Mutex<Vec<(String, String)>>,
466 presence: Mutex<Vec<(String, bool)>>,
467 }
468 impl InboundEventHandler for Recorder {
469 fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
470 self.messages.lock().unwrap().push((chat_id.to_string(), msg.clone()));
471 }
472 fn on_community_update(&self, chat_id: &str, target: &str, _msg: &Message) {
473 self.updates.lock().unwrap().push((chat_id.to_string(), target.to_string()));
474 }
475 fn on_community_removed(&self, chat_id: &str, target: &str) {
476 self.removed.lock().unwrap().push((chat_id.to_string(), target.to_string()));
477 }
478 fn on_community_presence(&self, _c: &str, npub: &str, joined: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
479 self.presence.lock().unwrap().push((npub.to_string(), joined));
480 }
481 }
482
483 fn init() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
484 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
485 crate::db::close_database();
486 crate::db::clear_id_caches();
487 let tmp = tempfile::tempdir().unwrap();
488 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(90_000);
489 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
490 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
491 let mut acct = String::from("npub1");
492 let mut v = n as usize;
493 for _ in 0..58 {
494 acct.push(B[v % 32] as char);
495 v = v / 32 + 7;
496 }
497 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
498 crate::db::set_app_data_dir(tmp.path().to_path_buf());
499 crate::db::set_current_account(acct.clone()).unwrap();
500 crate::db::init_database(&acct).unwrap();
501 let _ = crate::state::take_nostr_client();
502 let me = Keys::generate();
503 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
504 crate::state::set_my_public_key(me.public_key());
505 (tmp, guard, me)
506 }
507
508 #[tokio::test]
509 async fn a_received_message_wrap_opens_then_fires_from_the_persist_outcome() {
510 use nostr_sdk::prelude::Timestamp;
511 let (_tmp, _guard, me) = init();
512 let relay = MemoryRelay::new();
513 let community = service::create_community(&relay, "In", vec!["wss://r".into()], None).await.unwrap();
514 let general = community.channels[0].id;
515 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
516
517 let member = Keys::generate();
520 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
521 let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "ping", None, &[], vec![], 5_000);
522 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
523
524 let rec = Recorder::default();
527 let dispatched = dispatch_wrap(&wrap, &community, &me.public_key(), &rec);
528 assert!(rec.messages.lock().unwrap().is_empty(), "no optimistic message callback");
529 let DispatchedV2::Chat { channel_id, event } = dispatched else {
530 panic!("a chat wrap dispatches as Chat");
531 };
532 assert_eq!(channel_id, cid);
533
534 let session = crate::state::SessionGuard::capture();
535 let outcome = persist_chat_event(&event, &channel_id, &me.public_key(), &session).await;
536 let Some(ChatPersist::New(msg)) = outcome else {
537 panic!("the first delivery persists as New");
538 };
539 assert_eq!(msg.content, "ping");
540 assert!(!msg.mine, "authored by the other member");
541
542 let (rewrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(6), false).unwrap();
545 assert_ne!(rewrap.id, wrap.id, "a re-wrap is a distinct outer event");
546 let DispatchedV2::Chat { event: dup, .. } = dispatch_wrap(&rewrap, &community, &me.public_key(), &rec) else {
547 panic!("the re-wrap still opens");
548 };
549 assert!(
550 persist_chat_event(&dup, &channel_id, &me.public_key(), &session).await.is_none(),
551 "a re-wrapped duplicate yields no outcome (nothing re-fires)"
552 );
553 }
554
555 #[tokio::test]
556 async fn a_guestbook_join_wrap_fires_presence() {
557 let (_tmp, _guard, me) = init();
558 let relay = MemoryRelay::new();
559 let community = service::create_community(&relay, "GB", vec!["wss://r".into()], None).await.unwrap();
561 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
562 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb.pk_hex()], ..Default::default() };
563 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
564
565 let rec = Recorder::default();
566 for w in &wraps {
567 dispatch_wrap(w, &community, &me.public_key(), &rec);
568 }
569 let pres = rec.presence.lock().unwrap();
570 assert_eq!(pres.len(), 1, "the owner's genesis Join fires one presence");
571 assert!(pres[0].1, "it's a join");
572 assert_eq!(pres[0].0, me.public_key().to_bech32().unwrap());
573 }
574
575 #[tokio::test]
576 async fn v2_chat_events_persist_into_the_shared_store() {
577 let (_tmp, _guard, me) = init();
578 let relay = MemoryRelay::new();
579 let community = service::create_community(&relay, "Persist", vec!["wss://r".into()], None).await.unwrap();
580 let general = community.channels[0].id;
581 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
582 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
583 let me_hex = me.public_key().to_hex();
584
585 let msg_id = service::send_message(&relay, &community, &general, "persist me").await.unwrap();
586 service::send_reaction(&relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
587
588 assert!(crate::db::events::event_exists(&msg_id).unwrap(), "the send echo persisted the message row");
591 let reacted = {
592 let st = crate::state::STATE.lock().await;
593 st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🔥")).unwrap_or(false)
594 };
595 assert!(reacted, "the send echo aggregated the reaction onto the stored message");
596
597 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
600 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
601 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
602 events.sort_by_key(|e| e.opened().at_ms);
603 assert!(!events.is_empty());
604 for ev in &events {
605 let outcome = {
606 let mut st = crate::state::STATE.lock().await;
607 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
608 };
609 assert!(outcome.is_none(), "the relay echo of an already-echoed send dedups");
610 }
611 }
612
613 #[tokio::test]
614 async fn a_v2_edit_persists_as_a_folded_edit_event() {
615 let (_tmp, _guard, me) = init();
616 let relay = MemoryRelay::new();
617 let community = service::create_community(&relay, "Edit", vec!["wss://r".into()], None).await.unwrap();
618 let general = community.channels[0].id;
619 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
620 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
621
622 let msg_id = service::send_message(&relay, &community, &general, "original").await.unwrap();
623 service::send_edit(&relay, &community, &general, &msg_id, "edited!").await.unwrap();
624
625 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
627 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
628 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
629 events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
630 for ev in &events {
631 let outcome = {
632 let mut st = crate::state::STATE.lock().await;
633 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
634 };
635 if let Some(o) = outcome {
636 persist_chat(&cid, &o).await;
637 }
638 }
639
640 let content = {
641 let st = crate::state::STATE.lock().await;
642 st.find_message(&msg_id).map(|(_, m)| m.content)
643 };
644 assert_eq!(content.as_deref(), Some("edited!"), "the edit applied to the stored message");
645 let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
646 assert!(crate::db::events::event_exists(&edit_id).unwrap(), "the MESSAGE_EDIT event is persisted (folds on reload)");
647 }
648
649 #[tokio::test]
650 async fn a_reaction_after_a_rekey_aggregates_onto_a_prior_epoch_message() {
651 use nostr_sdk::prelude::Timestamp;
657 let (_tmp, _guard, me) = init();
658 let relay = MemoryRelay::new();
659 let community = service::create_community(&relay, "Rekeyed", vec!["wss://r".into()], None).await.unwrap();
660 let general = community.channels[0].id;
661 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
662 let session = crate::state::SessionGuard::capture();
663
664 let member = Keys::generate();
666 let g0 = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
667 let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "before the rekey", None, &[], vec![], 5_000);
668 let msg_id = msg.id.unwrap().to_hex();
669 let (mw, _) = chat::seal_chat_rumor(&msg, &g0, &member, Timestamp::from_secs(5), false).unwrap();
670 let ev = chat::open_chat_event(&mw, &g0, &general, community.root_epoch).unwrap();
671 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
672
673 let next = crate::community::Epoch(community.root_epoch.0 + 1);
676 let g1 = super::super::derive::channel_group_key(&community.community_root, &general, next);
677 let reaction = chat::build_reaction_rumor(member.public_key(), &general, next, &msg_id, &member.public_key().to_hex(), super::super::kind::MESSAGE, "🎉", None, 6_000);
678 let (rw, _) = chat::seal_chat_rumor(&reaction, &g1, &member, Timestamp::from_secs(6), false).unwrap();
679 let rev = chat::open_chat_event(&rw, &g1, &general, next).unwrap();
680 let outcome = persist_chat_event(&rev, &cid, &me.public_key(), &session).await;
681 assert!(matches!(outcome, Some(ChatPersist::Updated { .. })), "the cross-epoch reaction updates the target");
682
683 let reaction_author = {
684 let st = crate::state::STATE.lock().await;
685 st.find_message(&msg_id)
686 .and_then(|(_, m)| m.reactions.iter().find(|r| r.emoji == "🎉").map(|r| r.author_id.clone()))
687 };
688 let author = reaction_author.expect("the epoch-1 reaction aggregated onto the epoch-0 message");
689 assert_eq!(author, member.public_key().to_bech32().unwrap(), "reaction author is stored as bech32");
692 }
693
694 #[tokio::test]
695 async fn an_un_react_removes_the_reaction_for_receivers_and_only_for_its_reactor() {
696 use nostr_sdk::prelude::Timestamp;
697 let (_tmp, _guard, me) = init();
698 let relay = MemoryRelay::new();
699 let community = service::create_community(&relay, "UnReact", vec!["wss://r".into()], None).await.unwrap();
700 let general = community.channels[0].id;
701 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
702 let session = crate::state::SessionGuard::capture();
703 let (secret, epoch) = community.channel_secret(&community.channels[0]);
704 let group = super::super::derive::channel_group_key(&secret, &general, epoch);
705
706 let member = Keys::generate();
708 let msg = chat::build_message_rumor(member.public_key(), &general, epoch, "react to me", None, &[], vec![], 5_000);
709 let msg_id = msg.id.unwrap().to_hex();
710 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
711 let ev = chat::open_chat_event(&mw, &group, &general, epoch).unwrap();
712 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
713
714 let reaction = chat::build_reaction_rumor(member.public_key(), &general, epoch, &msg_id, &member.public_key().to_hex(), super::super::kind::MESSAGE, "🔥", None, 6_000);
715 let reaction_id = reaction.id.unwrap().to_hex();
716 let (rw, _) = chat::seal_chat_rumor(&reaction, &group, &member, Timestamp::from_secs(6), false).unwrap();
717 let rev = chat::open_chat_event(&rw, &group, &general, epoch).unwrap();
718 assert!(matches!(persist_chat_event(&rev, &cid, &me.public_key(), &session).await, Some(ChatPersist::Updated { .. })));
719
720 let outsider = Keys::generate();
722 let forged = chat::build_delete_rumor(outsider.public_key(), &general, epoch, &reaction_id, super::super::kind::MESSAGE, 7_000);
723 let (fw, _) = chat::seal_chat_rumor(&forged, &group, &outsider, Timestamp::from_secs(7), false).unwrap();
724 let fev = chat::open_chat_event(&fw, &group, &general, epoch).unwrap();
725 assert!(persist_chat_event(&fev, &cid, &me.public_key(), &session).await.is_none(), "only the reactor revokes their reaction");
726
727 let revoke = chat::build_delete_rumor(member.public_key(), &general, epoch, &reaction_id, super::super::kind::MESSAGE, 8_000);
730 let (vw, _) = chat::seal_chat_rumor(&revoke, &group, &member, Timestamp::from_secs(8), false).unwrap();
731 let vev = chat::open_chat_event(&vw, &group, &general, epoch).unwrap();
732 assert!(matches!(persist_chat_event(&vev, &cid, &me.public_key(), &session).await, Some(ChatPersist::ReactionRemoved { .. })));
733 let (has_reaction, parent_alive) = {
734 let st = crate::state::STATE.lock().await;
735 (
736 st.find_reaction(&reaction_id).is_some(),
737 st.find_message(&msg_id).is_some(),
738 )
739 };
740 assert!(!has_reaction, "the chip is gone from STATE");
741 assert!(parent_alive, "the parent message survives an un-react");
742 assert!(!crate::db::events::event_exists(&reaction_id).unwrap(), "the kind-7 row is deleted");
743 }
744
745 #[tokio::test]
746 async fn a_guestbook_join_fires_presence_with_its_real_rumor_id() {
747 use nostr_sdk::prelude::Timestamp;
748 use std::sync::Mutex as StdMutex;
749 let (_tmp, _guard, me) = init();
750 let relay = MemoryRelay::new();
751 let community = service::create_community(&relay, "Pres", vec!["wss://r".into()], None).await.unwrap();
752
753 #[derive(Default)]
754 struct Capture(StdMutex<Vec<(String, bool, String)>>);
755 impl InboundEventHandler for Capture {
756 fn on_community_presence(&self, _chat_id: &str, npub: &str, joined: bool, event_id: &str, _at: u64, _by: Option<&str>, _label: Option<&str>) {
757 self.0.lock().unwrap().push((npub.into(), joined, event_id.into()));
758 }
759 }
760
761 let member = Keys::generate();
762 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
763 let rumor = guestbook::build_join_rumor(member.public_key(), None, 5_000);
764 let (wrap, _) = super::super::guestbook::seal_guestbook_rumor(&rumor, &gb, &member, Timestamp::from_secs(5)).unwrap();
765 let expected_id = rumor.id.unwrap().to_hex();
766
767 let cap = Capture::default();
768 let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
769 assert!(matches!(out, DispatchedV2::Presence { joined: true, .. }));
770 let seen = cap.0.lock().unwrap().clone();
771 assert_eq!(seen.len(), 1);
772 assert_eq!(seen[0].0, member.public_key().to_bech32().unwrap());
773 assert_eq!(seen[0].2, expected_id, "presence carries the join's own rumor id");
776 }
777
778 #[tokio::test]
779 async fn a_webxdc_peer_ad_fires_the_shared_handler_and_own_echo_drops() {
780 use nostr_sdk::prelude::Timestamp;
781 use std::sync::Mutex as StdMutex;
782 let (_tmp, _guard, me) = init();
783 let relay = MemoryRelay::new();
784 let community = service::create_community(&relay, "XDC", vec!["wss://r".into()], None).await.unwrap();
785 let general = community.channels[0].id;
786
787 #[derive(Default)]
788 struct Capture(StdMutex<Vec<(String, String, Option<String>)>>);
789 impl InboundEventHandler for Capture {
790 fn on_community_webxdc(&self, _chat_id: &str, npub: &str, topic_id: &str, node_addr: Option<&str>, _event_id: &str, _created_at: u64) {
791 self.0.lock().unwrap().push((npub.into(), topic_id.into(), node_addr.map(String::from)));
792 }
793 }
794
795 let topic = "B".repeat(52);
796 let content = crate::webxdc::peer_signal_content(&topic, Some("iroh:node/xyz"));
797 let (secret, epoch) = community.channel_secret(&community.channels[0]);
798 let group = super::super::derive::channel_group_key(&secret, &general, epoch);
799
800 let peer = Keys::generate();
802 let rumor = chat::build_webxdc_rumor(peer.public_key(), &general, epoch, &content, vec![], 5_000);
803 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &peer, Timestamp::from_secs(5), false).unwrap();
804 let cap = Capture::default();
805 let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
806 assert!(matches!(out, DispatchedV2::Ignored), "fired inline, nothing to persist v2-side");
807 let seen = cap.0.lock().unwrap().clone();
808 assert_eq!(seen.len(), 1);
809 assert_eq!(seen[0].0, peer.public_key().to_bech32().unwrap());
810 assert_eq!(seen[0].1, topic);
811 assert_eq!(seen[0].2.as_deref(), Some("iroh:node/xyz"));
812
813 let own = chat::build_webxdc_rumor(me.public_key(), &general, epoch, &content, vec![], 6_000);
815 let (own_wrap, _) = chat::seal_chat_rumor(&own, &group, &me, Timestamp::from_secs(6), false).unwrap();
816 let cap2 = Capture::default();
817 dispatch_wrap(&own_wrap, &community, &me.public_key(), &cap2);
818 assert!(cap2.0.lock().unwrap().is_empty(), "own-device echo drops");
819 }
820
821 #[tokio::test]
822 async fn a_v2_message_with_an_imeta_attachment_surfaces_as_an_attachment() {
823 use nostr_sdk::prelude::Timestamp;
824 let (_tmp, _guard, me) = init();
825 let relay = MemoryRelay::new();
826 let community = service::create_community(&relay, "Files", vec!["wss://r".into()], None).await.unwrap();
827 let general = community.channels[0].id;
828 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
829 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
830 let session = crate::state::SessionGuard::capture();
831
832 let attachment = crate::types::Attachment {
835 id: "a".repeat(64),
836 key: "0".repeat(64),
837 nonce: "1".repeat(32),
838 extension: "png".into(),
839 name: "photo.png".into(),
840 url: "https://blossom.example/abc".into(),
841 path: String::new(),
842 size: 4096,
843 img_meta: None,
844 downloading: false,
845 downloaded: false,
846 webxdc_topic: None,
847 group_id: None,
848 original_hash: Some("b".repeat(64)),
849 scheme_version: None,
850 mls_filename: None,
851 };
852 let imeta = crate::community::attachments::attachment_to_imeta(&attachment);
853 let member = Keys::generate();
854 let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "here's a file", None, &[], vec![imeta], 5_000);
855 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
856 let ev = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
857
858 let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
859 let Some(ChatPersist::New(msg)) = outcome else {
860 panic!("the file message persists as new");
861 };
862 assert_eq!(msg.attachments.len(), 1, "the imeta tag parsed into one attachment");
863 let att = &msg.attachments[0];
864 assert!(att.url.contains("blossom.example"), "attachment url carried through: {}", att.url);
865 assert_eq!(att.extension, "png", "extension carried through");
866 }
867
868 #[tokio::test]
869 async fn a_banned_members_every_chat_event_is_dropped_on_sight() {
870 use nostr_sdk::prelude::Timestamp;
874 let (_tmp, _guard, me) = init();
875 let relay = MemoryRelay::new();
876 let community = service::create_community(&relay, "BanGate", vec!["wss://r".into()], None).await.unwrap();
877 let general = community.channels[0].id;
878 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
879 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
880 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
881 let session = crate::state::SessionGuard::capture();
882 let rogue = Keys::generate();
883
884 let m1 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "pre-ban", None, &[], vec![], 5_000);
886 let m1_id = m1.id.unwrap().to_hex();
887 let (w1, _) = chat::seal_chat_rumor(&m1, &group, &rogue, Timestamp::from_secs(5), false).unwrap();
888 let ev = chat::open_chat_event(&w1, &group, &general, community.root_epoch).unwrap();
889 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
890
891 crate::db::community::set_community_banlist(&cid_hex, &[rogue.public_key().to_hex()], 1_000).unwrap();
893
894 let m2 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "post-ban", None, &[], vec![], 6_000);
896 let (w2, _) = chat::seal_chat_rumor(&m2, &group, &rogue, Timestamp::from_secs(6), false).unwrap();
897 let ev = chat::open_chat_event(&w2, &group, &general, community.root_epoch).unwrap();
898 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned message is dropped");
899
900 let edit = chat::build_edit_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, "rewritten", 7_000);
901 let (we, _) = chat::seal_chat_rumor(&edit, &group, &rogue, Timestamp::from_secs(7), false).unwrap();
902 let ev = chat::open_chat_event(&we, &group, &general, community.root_epoch).unwrap();
903 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned edit is dropped");
904
905 let del = chat::build_delete_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, super::super::kind::MESSAGE, 8_000);
906 let (wd, _) = chat::seal_chat_rumor(&del, &group, &rogue, Timestamp::from_secs(8), false).unwrap();
907 let ev = chat::open_chat_event(&wd, &group, &general, community.root_epoch).unwrap();
908 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned delete is dropped");
909 assert!(
910 crate::state::STATE.lock().await.find_message(&m1_id).is_some(),
911 "their pre-ban message survives their own post-ban delete"
912 );
913
914 let rec = Recorder::default();
916 let typ = chat::build_typing_rumor(rogue.public_key(), &general, community.root_epoch, 9_000);
917 let (wt, _) = chat::seal_chat_rumor(&typ, &group, &rogue, Timestamp::from_secs(9), true).unwrap();
918 assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
919 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
920 let join = guestbook::build_join_rumor(rogue.public_key(), None, 10_000);
921 let (wj, _) = guestbook::seal_guestbook_rumor(&join, &gb, &rogue, Timestamp::from_secs(10)).unwrap();
922 assert!(matches!(dispatch_wrap(&wj, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
923 assert!(rec.presence.lock().unwrap().is_empty(), "no presence callback for a banned join");
924
925 let innocent = Keys::generate();
927 let m3 = chat::build_message_rumor(innocent.public_key(), &general, community.root_epoch, "innocent", None, &[], vec![], 11_000);
928 let (w3, _) = chat::seal_chat_rumor(&m3, &group, &innocent, Timestamp::from_secs(11), false).unwrap();
929 let ev = chat::open_chat_event(&w3, &group, &general, community.root_epoch).unwrap();
930 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
931 }
932
933 #[tokio::test]
934 async fn an_armada_threaded_reply_persists_and_fires_as_an_inline_reply() {
935 use nostr_sdk::prelude::Timestamp;
936 let (_tmp, _guard, me) = init();
937 let relay = MemoryRelay::new();
938 let community = service::create_community(&relay, "Thread", vec!["wss://r".into()], None).await.unwrap();
939 let general = community.channels[0].id;
940 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
941 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
942 let session = crate::state::SessionGuard::capture();
943
944 let member = Keys::generate();
947 let root = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "thread root", None, &[], vec![], 5_000);
948 let root_id = root.id.unwrap().to_hex();
949 let (rw, _) = chat::seal_chat_rumor(&root, &group, &member, Timestamp::from_secs(5), false).unwrap();
950 let reply = chat::build_comment_rumor(
951 member.public_key(),
952 &general,
953 community.root_epoch,
954 "thread reply",
955 &root_id,
956 super::super::kind::MESSAGE,
957 &member.public_key().to_hex(),
958 None,
959 &[],
960 6_000,
961 );
962 let reply_id = reply.id.unwrap().to_hex();
963 let (tw, _) = chat::seal_chat_rumor(&reply, &group, &member, Timestamp::from_secs(6), false).unwrap();
964
965 for w in [&rw, &tw] {
966 let ev = chat::open_chat_event(w, &group, &general, community.root_epoch).unwrap();
967 let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
968 assert!(matches!(outcome, Some(ChatPersist::New(_))), "both persist as new messages");
969 }
970 let held = {
973 let st = crate::state::STATE.lock().await;
974 st.find_message(&reply_id).map(|(_, m)| m)
975 }
976 .expect("the threaded reply is resident");
977 assert_eq!(held.replied_to, root_id, "the immediate parent is the reply context");
978 assert_eq!(held.content, "thread reply");
979
980 let del = chat::build_delete_rumor(member.public_key(), &general, community.root_epoch, &reply_id, super::super::kind::COMMENT, 7_000);
982 let (dw, _) = chat::seal_chat_rumor(&del, &group, &member, Timestamp::from_secs(7), false).unwrap();
983 let ev = chat::open_chat_event(&dw, &group, &general, community.root_epoch).unwrap();
984 let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
985 assert!(matches!(outcome, Some(ChatPersist::Removed(id)) if id == reply_id), "the author's delete removes their thread reply");
986 }
987
988 #[tokio::test]
989 async fn an_edit_replay_never_refires() {
990 use nostr_sdk::prelude::Timestamp;
991 let (_tmp, _guard, me) = init();
992 let relay = MemoryRelay::new();
993 let community = service::create_community(&relay, "EditReplay", vec!["wss://r".into()], None).await.unwrap();
994 let general = community.channels[0].id;
995 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
996 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
997 let session = crate::state::SessionGuard::capture();
998
999 let member = Keys::generate();
1001 let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "v1 text", None, &[], vec![], 5_000);
1002 let msg_id = msg.id.unwrap().to_hex();
1003 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
1004 let edit = chat::build_edit_rumor(member.public_key(), &general, community.root_epoch, &msg_id, "v2 text", 6_000);
1005 let (ew, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(6), false).unwrap();
1006 for w in [&mw, &ew] {
1007 if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
1008 let _ = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
1009 }
1010 }
1011
1012 let (replay, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(7), false).unwrap();
1015 assert_ne!(replay.id, ew.id, "a re-wrap is a distinct outer event");
1016 let ev = chat::open_chat_event(&replay, &group, &general, community.root_epoch).unwrap();
1017 assert!(
1018 persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(),
1019 "a replayed edit yields no outcome (no handler re-fire)"
1020 );
1021 }
1022
1023 #[tokio::test]
1024 async fn a_forged_edit_from_a_non_author_is_ignored() {
1025 use nostr_sdk::prelude::Timestamp;
1029 let (_tmp, _guard, me) = init();
1030 let relay = MemoryRelay::new();
1031 let community = service::create_community(&relay, "EditGuard", vec!["wss://r".into()], None).await.unwrap();
1032 let general = community.channels[0].id;
1033 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1034 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1035 let session = crate::state::SessionGuard::capture();
1036
1037 let author = Keys::generate();
1039 let msg = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, "original", None, &[], vec![], 5_000);
1040 let msg_id = msg.id.unwrap().to_hex();
1041 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &author, Timestamp::from_secs(5), false).unwrap();
1042 let ev = chat::open_chat_event(&mw, &group, &general, community.root_epoch).unwrap();
1043 persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
1044
1045 let stranger = Keys::generate();
1047 let edit = chat::build_edit_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, "TAMPERED", 6_000);
1048 let (ew, _) = chat::seal_chat_rumor(&edit, &group, &stranger, Timestamp::from_secs(6), false).unwrap();
1049 let ev = chat::open_chat_event(&ew, &group, &general, community.root_epoch).unwrap();
1050 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a forged edit yields no outcome");
1051
1052 let content = {
1053 let st = crate::state::STATE.lock().await;
1054 st.find_message(&msg_id).map(|(_, m)| m.content)
1055 };
1056 assert_eq!(content.as_deref(), Some("original"), "the message content is unchanged by the forged edit");
1057 }
1058
1059 #[tokio::test]
1060 async fn a_reaction_cannot_be_injected_across_channels() {
1061 use nostr_sdk::prelude::Timestamp;
1067 let (_tmp, _guard, me) = init();
1068 let relay = MemoryRelay::new();
1069 let mut community = service::create_community(&relay, "TwoChan", vec!["wss://r".into()], None).await.unwrap();
1070 let chan_a = community.channels[0].id;
1071 let chan_b = service::create_public_channel(&relay, &community, "b").await.unwrap();
1072 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
1073 let a_hex = crate::simd::hex::bytes_to_hex_32(&chan_a.0);
1074 let session = crate::state::SessionGuard::capture();
1075
1076 let author = Keys::generate();
1078 let gb = super::super::derive::channel_group_key(&community.community_root, &chan_b, community.root_epoch);
1079 let msg = chat::build_message_rumor(author.public_key(), &chan_b, community.root_epoch, "in B", None, &[], vec![], 5_000);
1080 let msg_id = msg.id.unwrap().to_hex();
1081 let (mw, _) = chat::seal_chat_rumor(&msg, &gb, &author, Timestamp::from_secs(5), false).unwrap();
1082 let bev = chat::open_chat_event(&mw, &gb, &chan_b, community.root_epoch).unwrap();
1083 persist_chat_event(&bev, &crate::simd::hex::bytes_to_hex_32(&chan_b.0), &me.public_key(), &session).await;
1084
1085 let ga = super::super::derive::channel_group_key(&community.community_root, &chan_a, community.root_epoch);
1087 let reaction = chat::build_reaction_rumor(author.public_key(), &chan_a, community.root_epoch, &msg_id, &author.public_key().to_hex(), super::super::kind::MESSAGE, "💥", None, 6_000);
1088 let (rw, _) = chat::seal_chat_rumor(&reaction, &ga, &author, Timestamp::from_secs(6), false).unwrap();
1089 let aev = chat::open_chat_event(&rw, &ga, &chan_a, community.root_epoch).unwrap();
1090 let outcome = persist_chat_event(&aev, &a_hex, &me.public_key(), &session).await;
1092 assert!(outcome.is_none(), "a cross-channel reaction is dropped");
1093 let reacted = {
1094 let st = crate::state::STATE.lock().await;
1095 st.find_message(&msg_id).map(|(_, m)| !m.reactions.is_empty()).unwrap_or(false)
1096 };
1097 assert!(!reacted, "the channel-B message gained no reaction from the channel-A injection");
1098 }
1099
1100 #[tokio::test]
1101 async fn a_forged_delete_from_a_non_author_is_ignored() {
1102 use nostr_sdk::prelude::Timestamp;
1103 let (_tmp, _guard, me) = init();
1104 let relay = MemoryRelay::new();
1105 let community = service::create_community(&relay, "Forge", vec!["wss://r".into()], None).await.unwrap();
1106 let general = community.channels[0].id;
1107 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1108 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1109
1110 let msg_id = service::send_message(&relay, &community, &general, "mine").await.unwrap();
1112 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
1113 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
1114 for w in &wraps {
1115 if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
1116 let mut st = crate::state::STATE.lock().await;
1117 apply_chat_to_state(&mut st, &ev, &cid, &me.public_key());
1118 }
1119 }
1120
1121 let stranger = nostr_sdk::prelude::Keys::generate();
1123 let del = chat::build_delete_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, super::super::kind::MESSAGE, 9_000);
1124 let (wrap, _) = chat::seal_chat_rumor(&del, &group, &stranger, Timestamp::from_secs(9), false).unwrap();
1125 let event = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
1126
1127 let outcome = {
1128 let mut st = crate::state::STATE.lock().await;
1129 apply_chat_to_state(&mut st, &event, &cid, &me.public_key())
1130 };
1131 assert!(outcome.is_none(), "a forged delete from a non-author yields no removal");
1132 let survives = {
1133 let st = crate::state::STATE.lock().await;
1134 st.find_message(&msg_id).is_some()
1135 };
1136 assert!(survives, "the message survives the forged delete (live view + DB stay consistent)");
1137 }
1138
1139 #[tokio::test]
1140 async fn a_foreign_wrap_is_not_ours() {
1141 let (_tmp, _guard, me) = init();
1142 let relay = MemoryRelay::new();
1143 let community = service::create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
1144
1145 let stranger = super::super::derive::channel_group_key(&[0x99u8; 32], &community.channels[0].id, community.root_epoch);
1148 let rumor = chat::build_message_rumor(me.public_key(), &community.channels[0].id, community.root_epoch, "not yours", None, &[], vec![], 1_000);
1149 let (wrap, _) = chat::seal_chat_rumor(&rumor, &stranger, &me, nostr_sdk::prelude::Timestamp::from_secs(1), false).unwrap();
1150
1151 let rec = Recorder::default();
1152 assert!(matches!(dispatch_wrap(&wrap, &community, &me.public_key(), &rec), DispatchedV2::NotOurs));
1153 assert!(rec.messages.lock().unwrap().is_empty());
1154 }
1155}