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 wrapper_event_id: Some(opened.wrapper_id.to_hex()),
54 ..Default::default()
55 }
56}
57
58fn author_is_banned_here(channel_id: &str, author: &PublicKey) -> bool {
62 let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
63 return false;
64 };
65 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
66 !banned.is_empty() && banned.contains(&author.to_hex())
67}
68
69pub enum ChatPersist {
74 New(Message),
76 Updated { message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
80 Removed(String),
82}
83
84pub fn apply_chat_to_state(state: &mut ChatState, event: &ChatEvent, channel_id: &str, my_pubkey: &PublicKey) -> Option<ChatPersist> {
91 if author_is_banned_here(channel_id, &event.opened().author) {
98 return None;
99 }
100 match event {
101 ChatEvent::Message { opened, reply_to, emoji } => {
102 let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey);
103 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
106 return None;
107 }
108 state.ensure_community_chat(channel_id);
109 state.add_message_to_chat(channel_id, msg.clone());
113 Some(ChatPersist::New(msg))
114 }
115 ChatEvent::Reaction { opened, target, emoji, emoji_url, .. } => {
116 let target_id = crate::simd::hex::bytes_to_hex_32(target);
117 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == channel_id) {
121 return None;
122 }
123 let reaction = Reaction {
124 id: opened.rumor_id.to_hex(),
125 reference_id: target_id.clone(),
126 author_id: opened.author.to_hex(),
127 emoji: emoji.clone(),
128 emoji_url: emoji_url.clone(),
129 };
130 let (_c, added) = state.add_reaction_to_message(&target_id, reaction)?;
131 added.then(|| state.find_message(&target_id).map(|(_c, m)| ChatPersist::Updated { message: m, edit_event: None }))?
132 }
133 ChatEvent::Edit { opened, target, new_content } => {
134 if crate::db::events::event_exists(&opened.rumor_id.to_hex()).unwrap_or(false) {
139 return None;
140 }
141 let target_id = crate::simd::hex::bytes_to_hex_32(target);
142 let editor_npub = opened.author.to_bech32().ok()?;
144 if !matches!(state.find_message(&target_id), Some((chat, m)) if chat.id == channel_id && m.npub.as_deref() == Some(editor_npub.as_str())) {
145 return None;
146 }
147 let edited_at = opened.at_ms;
150 let (_c, message) = state.update_message(&target_id, |m| m.apply_edit(new_content.clone(), edited_at, Vec::new()))?;
151 let edit_event = crate::stored_event::StoredEventBuilder::new()
153 .id(opened.rumor_id.to_hex())
154 .kind(crate::stored_event::event_kind::MESSAGE_EDIT)
155 .content(new_content.clone())
156 .reference_id(Some(target_id.clone()))
157 .created_at(edited_at / 1000)
158 .mine(opened.author == *my_pubkey)
159 .npub(opened.author.to_bech32().ok())
160 .build();
161 Some(ChatPersist::Updated { message, edit_event: Some(Box::new(edit_event)) })
162 }
163 ChatEvent::Delete { opened, target, .. } => {
164 let target_id = crate::simd::hex::bytes_to_hex_32(target);
165 let own = matches!(state.find_message(&target_id), Some((_, m)) if m.npub.as_deref() == opened.author.to_bech32().ok().as_deref());
168 own.then(|| state.remove_message(&target_id).map(|_| ChatPersist::Removed(target_id)))?
169 }
170 ChatEvent::Typing { .. } | ChatEvent::Webxdc { .. } => None,
171 }
172}
173
174pub async fn persist_chat_event(
180 event: &ChatEvent,
181 channel_id: &str,
182 my_pubkey: &PublicKey,
183 session: &crate::state::SessionGuard,
184) -> Option<ChatPersist> {
185 let outcome = {
186 let mut st = crate::state::STATE.lock().await;
187 if !session.is_valid() {
189 return None;
190 }
191 apply_chat_to_state(&mut st, event, channel_id, my_pubkey)
192 }?;
193 let outcome = match outcome {
197 ChatPersist::New(mut m) => {
198 if !m.replied_to.is_empty() {
199 let _ = crate::db::events::populate_reply_context(&mut m).await;
200 }
201 ChatPersist::New(m)
202 }
203 o => o,
204 };
205 if !session.is_valid() {
207 return None;
208 }
209 persist_chat(channel_id, &outcome).await;
210 Some(outcome)
211}
212
213pub async fn persist_chat(channel_id: &str, outcome: &ChatPersist) {
217 match outcome {
218 ChatPersist::New(m) => {
219 let _ = crate::db::events::save_message(channel_id, m).await;
220 }
221 ChatPersist::Updated { message, edit_event } => match edit_event {
224 Some(ev) => {
225 let mut ev = (**ev).clone();
226 if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
229 ev.chat_id = cid;
230 }
231 let _ = crate::db::events::save_event(&ev).await;
232 }
233 None => {
234 let _ = crate::db::events::save_message(channel_id, message).await;
235 }
236 },
237 ChatPersist::Removed(id) => {
238 let _ = crate::db::events::delete_event(id).await;
239 }
240 }
241}
242
243#[derive(Debug, Clone)]
245pub enum DispatchedV2 {
246 Chat { channel_id: String, event: Box<ChatEvent> },
252 Typing { channel_id: String, npub: String },
254 Presence { npub: String, joined: bool },
256 Control { community_id: String },
261 Rekey { community_id: String },
265 Dissolved { community_id: String },
268 Ignored,
271 NotOurs,
273}
274
275pub fn dispatch_wrap(
281 wrap: &nostr_sdk::Event,
282 community: &CommunityV2,
283 _my_pubkey: &PublicKey,
284 handler: &dyn InboundEventHandler,
285) -> DispatchedV2 {
286 for ch in &community.channels {
288 if ch.private && ch.key.is_none() {
291 continue;
292 }
293 let (secret, epoch) = community.channel_secret(ch);
294 let group = super::derive::channel_group_key(&secret, &ch.id, epoch);
295 if wrap.pubkey != group.pk() {
296 continue;
297 }
298 let Ok(event) = chat::open_chat_event(wrap, &group, &ch.id, epoch) else {
299 return DispatchedV2::NotOurs;
300 };
301 let channel_id = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
302 return dispatch_chat_event(event, &channel_id, handler);
303 }
304
305 let gb = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
307 if wrap.pubkey == gb.pk() {
308 if let Ok(opened) = stream::open_wrap(wrap, &gb) {
309 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
310 return dispatch_guestbook(&ev.entry, community, handler);
311 }
312 }
313 return DispatchedV2::Ignored;
314 }
315
316 if wrap.pubkey == super::realtime::control_author(community) {
320 return DispatchedV2::Control { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
321 }
322
323 if super::realtime::rekey_authors(community).iter().any(|p| *p == wrap.pubkey) {
328 return DispatchedV2::Rekey { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
329 }
330
331 if wrap.pubkey == super::derive::dissolved_group_key(community.id()).pk() {
334 if super::dissolution::verify_dissolved(wrap, &community.identity) {
335 return DispatchedV2::Dissolved { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
336 }
337 return DispatchedV2::Ignored;
338 }
339
340 DispatchedV2::NotOurs
341}
342
343fn dispatch_chat_event(event: ChatEvent, channel_id: &str, handler: &dyn InboundEventHandler) -> DispatchedV2 {
344 match event {
345 ChatEvent::Typing { opened } => {
349 if author_is_banned_here(channel_id, &opened.author) {
350 return DispatchedV2::Ignored;
351 }
352 let npub = opened.author.to_bech32().unwrap_or_default();
353 let until = opened.at_ms / 1000 + 30;
354 handler.on_community_typing(channel_id, &npub, until);
355 DispatchedV2::Typing { channel_id: channel_id.to_string(), npub }
356 }
357 ChatEvent::Webxdc { .. } => DispatchedV2::Ignored,
358 event => DispatchedV2::Chat { channel_id: channel_id.to_string(), event: Box::new(event) },
361 }
362}
363
364fn dispatch_guestbook(ev: &GuestbookEntry, community: &CommunityV2, handler: &dyn InboundEventHandler) -> DispatchedV2 {
365 if let GuestbookEntry::Join { member, .. } | GuestbookEntry::Leave { member, .. } = ev {
368 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
369 let banned = crate::db::community::get_community_banlist(&cid_hex).unwrap_or_default();
370 if banned.contains(&member.to_hex()) {
371 return DispatchedV2::Ignored;
372 }
373 }
374 let chat_id = community
377 .channels
378 .first()
379 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0))
380 .unwrap_or_default();
381 match ev {
382 GuestbookEntry::Join { member, at_ms, invited_by } => {
383 let npub = member.to_bech32().unwrap_or_default();
384 let (by, label) = match invited_by {
385 Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
386 None => (None, None),
387 };
388 handler.on_community_presence(&chat_id, &npub, true, "", at_ms / 1000, by, label);
389 DispatchedV2::Presence { npub, joined: true }
390 }
391 GuestbookEntry::Leave { member, at_ms } => {
392 let npub = member.to_bech32().unwrap_or_default();
393 handler.on_community_presence(&chat_id, &npub, false, "", at_ms / 1000, None, None);
394 DispatchedV2::Presence { npub, joined: false }
395 }
396 GuestbookEntry::Kick { .. } | GuestbookEntry::Snapshot { .. } => DispatchedV2::Ignored,
398 }
399}
400
401#[cfg(test)]
402mod tests {
403 use super::*;
404 use super::super::service;
405 use crate::community::transport::memory::MemoryRelay;
406 use crate::community::transport::Transport;
407 use nostr_sdk::prelude::Keys;
408 use std::sync::Mutex;
409
410 #[derive(Default)]
412 struct Recorder {
413 messages: Mutex<Vec<(String, Message)>>,
414 updates: Mutex<Vec<(String, String)>>,
415 removed: Mutex<Vec<(String, String)>>,
416 presence: Mutex<Vec<(String, bool)>>,
417 }
418 impl InboundEventHandler for Recorder {
419 fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
420 self.messages.lock().unwrap().push((chat_id.to_string(), msg.clone()));
421 }
422 fn on_community_update(&self, chat_id: &str, target: &str, _msg: &Message) {
423 self.updates.lock().unwrap().push((chat_id.to_string(), target.to_string()));
424 }
425 fn on_community_removed(&self, chat_id: &str, target: &str) {
426 self.removed.lock().unwrap().push((chat_id.to_string(), target.to_string()));
427 }
428 fn on_community_presence(&self, _c: &str, npub: &str, joined: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
429 self.presence.lock().unwrap().push((npub.to_string(), joined));
430 }
431 }
432
433 fn init() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
434 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
435 crate::db::close_database();
436 crate::db::clear_id_caches();
437 let tmp = tempfile::tempdir().unwrap();
438 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(90_000);
439 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
440 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
441 let mut acct = String::from("npub1");
442 let mut v = n as usize;
443 for _ in 0..58 {
444 acct.push(B[v % 32] as char);
445 v = v / 32 + 7;
446 }
447 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
448 crate::db::set_app_data_dir(tmp.path().to_path_buf());
449 crate::db::set_current_account(acct.clone()).unwrap();
450 crate::db::init_database(&acct).unwrap();
451 let _ = crate::state::take_nostr_client();
452 let me = Keys::generate();
453 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
454 crate::state::set_my_public_key(me.public_key());
455 (tmp, guard, me)
456 }
457
458 #[tokio::test]
459 async fn a_received_message_wrap_opens_then_fires_from_the_persist_outcome() {
460 use nostr_sdk::prelude::Timestamp;
461 let (_tmp, _guard, me) = init();
462 let relay = MemoryRelay::new();
463 let community = service::create_community(&relay, "In", vec!["wss://r".into()], None).await.unwrap();
464 let general = community.channels[0].id;
465 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
466
467 let member = Keys::generate();
470 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
471 let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "ping", None, &[], vec![], 5_000);
472 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
473
474 let rec = Recorder::default();
477 let dispatched = dispatch_wrap(&wrap, &community, &me.public_key(), &rec);
478 assert!(rec.messages.lock().unwrap().is_empty(), "no optimistic message callback");
479 let DispatchedV2::Chat { channel_id, event } = dispatched else {
480 panic!("a chat wrap dispatches as Chat");
481 };
482 assert_eq!(channel_id, cid);
483
484 let session = crate::state::SessionGuard::capture();
485 let outcome = persist_chat_event(&event, &channel_id, &me.public_key(), &session).await;
486 let Some(ChatPersist::New(msg)) = outcome else {
487 panic!("the first delivery persists as New");
488 };
489 assert_eq!(msg.content, "ping");
490 assert!(!msg.mine, "authored by the other member");
491
492 let (rewrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(6), false).unwrap();
495 assert_ne!(rewrap.id, wrap.id, "a re-wrap is a distinct outer event");
496 let DispatchedV2::Chat { event: dup, .. } = dispatch_wrap(&rewrap, &community, &me.public_key(), &rec) else {
497 panic!("the re-wrap still opens");
498 };
499 assert!(
500 persist_chat_event(&dup, &channel_id, &me.public_key(), &session).await.is_none(),
501 "a re-wrapped duplicate yields no outcome (nothing re-fires)"
502 );
503 }
504
505 #[tokio::test]
506 async fn a_guestbook_join_wrap_fires_presence() {
507 let (_tmp, _guard, me) = init();
508 let relay = MemoryRelay::new();
509 let community = service::create_community(&relay, "GB", vec!["wss://r".into()], None).await.unwrap();
511 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
512 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb.pk_hex()], ..Default::default() };
513 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
514
515 let rec = Recorder::default();
516 for w in &wraps {
517 dispatch_wrap(w, &community, &me.public_key(), &rec);
518 }
519 let pres = rec.presence.lock().unwrap();
520 assert_eq!(pres.len(), 1, "the owner's genesis Join fires one presence");
521 assert!(pres[0].1, "it's a join");
522 assert_eq!(pres[0].0, me.public_key().to_bech32().unwrap());
523 }
524
525 #[tokio::test]
526 async fn v2_chat_events_persist_into_the_shared_store() {
527 let (_tmp, _guard, me) = init();
528 let relay = MemoryRelay::new();
529 let community = service::create_community(&relay, "Persist", vec!["wss://r".into()], None).await.unwrap();
530 let general = community.channels[0].id;
531 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
532 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
533 let me_hex = me.public_key().to_hex();
534
535 let msg_id = service::send_message(&relay, &community, &general, "persist me").await.unwrap();
536 service::send_reaction(&relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
537
538 assert!(crate::db::events::event_exists(&msg_id).unwrap(), "the send echo persisted the message row");
541 let reacted = {
542 let st = crate::state::STATE.lock().await;
543 st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🔥")).unwrap_or(false)
544 };
545 assert!(reacted, "the send echo aggregated the reaction onto the stored message");
546
547 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
550 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
551 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
552 events.sort_by_key(|e| e.opened().at_ms);
553 assert!(!events.is_empty());
554 for ev in &events {
555 let outcome = {
556 let mut st = crate::state::STATE.lock().await;
557 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
558 };
559 assert!(outcome.is_none(), "the relay echo of an already-echoed send dedups");
560 }
561 }
562
563 #[tokio::test]
564 async fn a_v2_edit_persists_as_a_folded_edit_event() {
565 let (_tmp, _guard, me) = init();
566 let relay = MemoryRelay::new();
567 let community = service::create_community(&relay, "Edit", vec!["wss://r".into()], None).await.unwrap();
568 let general = community.channels[0].id;
569 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
570 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
571
572 let msg_id = service::send_message(&relay, &community, &general, "original").await.unwrap();
573 service::send_edit(&relay, &community, &general, &msg_id, "edited!").await.unwrap();
574
575 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
577 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
578 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
579 events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
580 for ev in &events {
581 let outcome = {
582 let mut st = crate::state::STATE.lock().await;
583 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
584 };
585 if let Some(o) = outcome {
586 persist_chat(&cid, &o).await;
587 }
588 }
589
590 let content = {
591 let st = crate::state::STATE.lock().await;
592 st.find_message(&msg_id).map(|(_, m)| m.content)
593 };
594 assert_eq!(content.as_deref(), Some("edited!"), "the edit applied to the stored message");
595 let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
596 assert!(crate::db::events::event_exists(&edit_id).unwrap(), "the MESSAGE_EDIT event is persisted (folds on reload)");
597 }
598
599 #[tokio::test]
600 async fn a_reaction_after_a_rekey_aggregates_onto_a_prior_epoch_message() {
601 use nostr_sdk::prelude::Timestamp;
607 let (_tmp, _guard, me) = init();
608 let relay = MemoryRelay::new();
609 let community = service::create_community(&relay, "Rekeyed", vec!["wss://r".into()], None).await.unwrap();
610 let general = community.channels[0].id;
611 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
612 let session = crate::state::SessionGuard::capture();
613
614 let member = Keys::generate();
616 let g0 = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
617 let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "before the rekey", None, &[], vec![], 5_000);
618 let msg_id = msg.id.unwrap().to_hex();
619 let (mw, _) = chat::seal_chat_rumor(&msg, &g0, &member, Timestamp::from_secs(5), false).unwrap();
620 let ev = chat::open_chat_event(&mw, &g0, &general, community.root_epoch).unwrap();
621 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
622
623 let next = crate::community::Epoch(community.root_epoch.0 + 1);
626 let g1 = super::super::derive::channel_group_key(&community.community_root, &general, next);
627 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);
628 let (rw, _) = chat::seal_chat_rumor(&reaction, &g1, &member, Timestamp::from_secs(6), false).unwrap();
629 let rev = chat::open_chat_event(&rw, &g1, &general, next).unwrap();
630 let outcome = persist_chat_event(&rev, &cid, &me.public_key(), &session).await;
631 assert!(matches!(outcome, Some(ChatPersist::Updated { .. })), "the cross-epoch reaction updates the target");
632
633 let reacted = {
634 let st = crate::state::STATE.lock().await;
635 st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🎉")).unwrap_or(false)
636 };
637 assert!(reacted, "the epoch-1 reaction aggregated onto the epoch-0 message");
638 }
639
640 #[tokio::test]
641 async fn a_v2_message_with_an_imeta_attachment_surfaces_as_an_attachment() {
642 use nostr_sdk::prelude::Timestamp;
643 let (_tmp, _guard, me) = init();
644 let relay = MemoryRelay::new();
645 let community = service::create_community(&relay, "Files", vec!["wss://r".into()], None).await.unwrap();
646 let general = community.channels[0].id;
647 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
648 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
649 let session = crate::state::SessionGuard::capture();
650
651 let attachment = crate::types::Attachment {
654 id: "a".repeat(64),
655 key: "0".repeat(64),
656 nonce: "1".repeat(32),
657 extension: "png".into(),
658 name: "photo.png".into(),
659 url: "https://blossom.example/abc".into(),
660 path: String::new(),
661 size: 4096,
662 img_meta: None,
663 downloading: false,
664 downloaded: false,
665 webxdc_topic: None,
666 group_id: None,
667 original_hash: Some("b".repeat(64)),
668 scheme_version: None,
669 mls_filename: None,
670 };
671 let imeta = crate::community::attachments::attachment_to_imeta(&attachment);
672 let member = Keys::generate();
673 let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "here's a file", None, &[], vec![imeta], 5_000);
674 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
675 let ev = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
676
677 let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
678 let Some(ChatPersist::New(msg)) = outcome else {
679 panic!("the file message persists as new");
680 };
681 assert_eq!(msg.attachments.len(), 1, "the imeta tag parsed into one attachment");
682 let att = &msg.attachments[0];
683 assert!(att.url.contains("blossom.example"), "attachment url carried through: {}", att.url);
684 assert_eq!(att.extension, "png", "extension carried through");
685 }
686
687 #[tokio::test]
688 async fn a_banned_members_every_chat_event_is_dropped_on_sight() {
689 use nostr_sdk::prelude::Timestamp;
693 let (_tmp, _guard, me) = init();
694 let relay = MemoryRelay::new();
695 let community = service::create_community(&relay, "BanGate", vec!["wss://r".into()], None).await.unwrap();
696 let general = community.channels[0].id;
697 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
698 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
699 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
700 let session = crate::state::SessionGuard::capture();
701 let rogue = Keys::generate();
702
703 let m1 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "pre-ban", None, &[], vec![], 5_000);
705 let m1_id = m1.id.unwrap().to_hex();
706 let (w1, _) = chat::seal_chat_rumor(&m1, &group, &rogue, Timestamp::from_secs(5), false).unwrap();
707 let ev = chat::open_chat_event(&w1, &group, &general, community.root_epoch).unwrap();
708 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
709
710 crate::db::community::set_community_banlist(&cid_hex, &[rogue.public_key().to_hex()], 1_000).unwrap();
712
713 let m2 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "post-ban", None, &[], vec![], 6_000);
715 let (w2, _) = chat::seal_chat_rumor(&m2, &group, &rogue, Timestamp::from_secs(6), false).unwrap();
716 let ev = chat::open_chat_event(&w2, &group, &general, community.root_epoch).unwrap();
717 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned message is dropped");
718
719 let edit = chat::build_edit_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, "rewritten", 7_000);
720 let (we, _) = chat::seal_chat_rumor(&edit, &group, &rogue, Timestamp::from_secs(7), false).unwrap();
721 let ev = chat::open_chat_event(&we, &group, &general, community.root_epoch).unwrap();
722 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned edit is dropped");
723
724 let del = chat::build_delete_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, super::super::kind::MESSAGE, 8_000);
725 let (wd, _) = chat::seal_chat_rumor(&del, &group, &rogue, Timestamp::from_secs(8), false).unwrap();
726 let ev = chat::open_chat_event(&wd, &group, &general, community.root_epoch).unwrap();
727 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a banned delete is dropped");
728 assert!(
729 crate::state::STATE.lock().await.find_message(&m1_id).is_some(),
730 "their pre-ban message survives their own post-ban delete"
731 );
732
733 let rec = Recorder::default();
735 let typ = chat::build_typing_rumor(rogue.public_key(), &general, community.root_epoch, 9_000);
736 let (wt, _) = chat::seal_chat_rumor(&typ, &group, &rogue, Timestamp::from_secs(9), true).unwrap();
737 assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
738 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
739 let join = guestbook::build_join_rumor(rogue.public_key(), None, 10_000);
740 let (wj, _) = guestbook::seal_guestbook_rumor(&join, &gb, &rogue, Timestamp::from_secs(10)).unwrap();
741 assert!(matches!(dispatch_wrap(&wj, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
742 assert!(rec.presence.lock().unwrap().is_empty(), "no presence callback for a banned join");
743
744 let innocent = Keys::generate();
746 let m3 = chat::build_message_rumor(innocent.public_key(), &general, community.root_epoch, "innocent", None, &[], vec![], 11_000);
747 let (w3, _) = chat::seal_chat_rumor(&m3, &group, &innocent, Timestamp::from_secs(11), false).unwrap();
748 let ev = chat::open_chat_event(&w3, &group, &general, community.root_epoch).unwrap();
749 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await, Some(ChatPersist::New(_))));
750 }
751
752 #[tokio::test]
753 async fn an_armada_threaded_reply_persists_and_fires_as_an_inline_reply() {
754 use nostr_sdk::prelude::Timestamp;
755 let (_tmp, _guard, me) = init();
756 let relay = MemoryRelay::new();
757 let community = service::create_community(&relay, "Thread", vec!["wss://r".into()], None).await.unwrap();
758 let general = community.channels[0].id;
759 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
760 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
761 let session = crate::state::SessionGuard::capture();
762
763 let member = Keys::generate();
766 let root = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "thread root", None, &[], vec![], 5_000);
767 let root_id = root.id.unwrap().to_hex();
768 let (rw, _) = chat::seal_chat_rumor(&root, &group, &member, Timestamp::from_secs(5), false).unwrap();
769 let reply = chat::build_comment_rumor(
770 member.public_key(),
771 &general,
772 community.root_epoch,
773 "thread reply",
774 &root_id,
775 super::super::kind::MESSAGE,
776 &member.public_key().to_hex(),
777 None,
778 &[],
779 6_000,
780 );
781 let reply_id = reply.id.unwrap().to_hex();
782 let (tw, _) = chat::seal_chat_rumor(&reply, &group, &member, Timestamp::from_secs(6), false).unwrap();
783
784 for w in [&rw, &tw] {
785 let ev = chat::open_chat_event(w, &group, &general, community.root_epoch).unwrap();
786 let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
787 assert!(matches!(outcome, Some(ChatPersist::New(_))), "both persist as new messages");
788 }
789 let held = {
792 let st = crate::state::STATE.lock().await;
793 st.find_message(&reply_id).map(|(_, m)| m)
794 }
795 .expect("the threaded reply is resident");
796 assert_eq!(held.replied_to, root_id, "the immediate parent is the reply context");
797 assert_eq!(held.content, "thread reply");
798
799 let del = chat::build_delete_rumor(member.public_key(), &general, community.root_epoch, &reply_id, super::super::kind::COMMENT, 7_000);
801 let (dw, _) = chat::seal_chat_rumor(&del, &group, &member, Timestamp::from_secs(7), false).unwrap();
802 let ev = chat::open_chat_event(&dw, &group, &general, community.root_epoch).unwrap();
803 let outcome = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
804 assert!(matches!(outcome, Some(ChatPersist::Removed(id)) if id == reply_id), "the author's delete removes their thread reply");
805 }
806
807 #[tokio::test]
808 async fn an_edit_replay_never_refires() {
809 use nostr_sdk::prelude::Timestamp;
810 let (_tmp, _guard, me) = init();
811 let relay = MemoryRelay::new();
812 let community = service::create_community(&relay, "EditReplay", vec!["wss://r".into()], None).await.unwrap();
813 let general = community.channels[0].id;
814 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
815 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
816 let session = crate::state::SessionGuard::capture();
817
818 let member = Keys::generate();
820 let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "v1 text", None, &[], vec![], 5_000);
821 let msg_id = msg.id.unwrap().to_hex();
822 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
823 let edit = chat::build_edit_rumor(member.public_key(), &general, community.root_epoch, &msg_id, "v2 text", 6_000);
824 let (ew, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(6), false).unwrap();
825 for w in [&mw, &ew] {
826 if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
827 let _ = persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
828 }
829 }
830
831 let (replay, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(7), false).unwrap();
834 assert_ne!(replay.id, ew.id, "a re-wrap is a distinct outer event");
835 let ev = chat::open_chat_event(&replay, &group, &general, community.root_epoch).unwrap();
836 assert!(
837 persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(),
838 "a replayed edit yields no outcome (no handler re-fire)"
839 );
840 }
841
842 #[tokio::test]
843 async fn a_forged_edit_from_a_non_author_is_ignored() {
844 use nostr_sdk::prelude::Timestamp;
848 let (_tmp, _guard, me) = init();
849 let relay = MemoryRelay::new();
850 let community = service::create_community(&relay, "EditGuard", vec!["wss://r".into()], None).await.unwrap();
851 let general = community.channels[0].id;
852 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
853 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
854 let session = crate::state::SessionGuard::capture();
855
856 let author = Keys::generate();
858 let msg = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, "original", None, &[], vec![], 5_000);
859 let msg_id = msg.id.unwrap().to_hex();
860 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &author, Timestamp::from_secs(5), false).unwrap();
861 let ev = chat::open_chat_event(&mw, &group, &general, community.root_epoch).unwrap();
862 persist_chat_event(&ev, &cid, &me.public_key(), &session).await;
863
864 let stranger = Keys::generate();
866 let edit = chat::build_edit_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, "TAMPERED", 6_000);
867 let (ew, _) = chat::seal_chat_rumor(&edit, &group, &stranger, Timestamp::from_secs(6), false).unwrap();
868 let ev = chat::open_chat_event(&ew, &group, &general, community.root_epoch).unwrap();
869 assert!(persist_chat_event(&ev, &cid, &me.public_key(), &session).await.is_none(), "a forged edit yields no outcome");
870
871 let content = {
872 let st = crate::state::STATE.lock().await;
873 st.find_message(&msg_id).map(|(_, m)| m.content)
874 };
875 assert_eq!(content.as_deref(), Some("original"), "the message content is unchanged by the forged edit");
876 }
877
878 #[tokio::test]
879 async fn a_reaction_cannot_be_injected_across_channels() {
880 use nostr_sdk::prelude::Timestamp;
886 let (_tmp, _guard, me) = init();
887 let relay = MemoryRelay::new();
888 let mut community = service::create_community(&relay, "TwoChan", vec!["wss://r".into()], None).await.unwrap();
889 let chan_a = community.channels[0].id;
890 let chan_b = service::create_public_channel(&relay, &community, "b").await.unwrap();
891 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
892 let a_hex = crate::simd::hex::bytes_to_hex_32(&chan_a.0);
893 let session = crate::state::SessionGuard::capture();
894
895 let author = Keys::generate();
897 let gb = super::super::derive::channel_group_key(&community.community_root, &chan_b, community.root_epoch);
898 let msg = chat::build_message_rumor(author.public_key(), &chan_b, community.root_epoch, "in B", None, &[], vec![], 5_000);
899 let msg_id = msg.id.unwrap().to_hex();
900 let (mw, _) = chat::seal_chat_rumor(&msg, &gb, &author, Timestamp::from_secs(5), false).unwrap();
901 let bev = chat::open_chat_event(&mw, &gb, &chan_b, community.root_epoch).unwrap();
902 persist_chat_event(&bev, &crate::simd::hex::bytes_to_hex_32(&chan_b.0), &me.public_key(), &session).await;
903
904 let ga = super::super::derive::channel_group_key(&community.community_root, &chan_a, community.root_epoch);
906 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);
907 let (rw, _) = chat::seal_chat_rumor(&reaction, &ga, &author, Timestamp::from_secs(6), false).unwrap();
908 let aev = chat::open_chat_event(&rw, &ga, &chan_a, community.root_epoch).unwrap();
909 let outcome = persist_chat_event(&aev, &a_hex, &me.public_key(), &session).await;
911 assert!(outcome.is_none(), "a cross-channel reaction is dropped");
912 let reacted = {
913 let st = crate::state::STATE.lock().await;
914 st.find_message(&msg_id).map(|(_, m)| !m.reactions.is_empty()).unwrap_or(false)
915 };
916 assert!(!reacted, "the channel-B message gained no reaction from the channel-A injection");
917 }
918
919 #[tokio::test]
920 async fn a_forged_delete_from_a_non_author_is_ignored() {
921 use nostr_sdk::prelude::Timestamp;
922 let (_tmp, _guard, me) = init();
923 let relay = MemoryRelay::new();
924 let community = service::create_community(&relay, "Forge", vec!["wss://r".into()], None).await.unwrap();
925 let general = community.channels[0].id;
926 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
927 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
928
929 let msg_id = service::send_message(&relay, &community, &general, "mine").await.unwrap();
931 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
932 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
933 for w in &wraps {
934 if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
935 let mut st = crate::state::STATE.lock().await;
936 apply_chat_to_state(&mut st, &ev, &cid, &me.public_key());
937 }
938 }
939
940 let stranger = nostr_sdk::prelude::Keys::generate();
942 let del = chat::build_delete_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, super::super::kind::MESSAGE, 9_000);
943 let (wrap, _) = chat::seal_chat_rumor(&del, &group, &stranger, Timestamp::from_secs(9), false).unwrap();
944 let event = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
945
946 let outcome = {
947 let mut st = crate::state::STATE.lock().await;
948 apply_chat_to_state(&mut st, &event, &cid, &me.public_key())
949 };
950 assert!(outcome.is_none(), "a forged delete from a non-author yields no removal");
951 let survives = {
952 let st = crate::state::STATE.lock().await;
953 st.find_message(&msg_id).is_some()
954 };
955 assert!(survives, "the message survives the forged delete (live view + DB stay consistent)");
956 }
957
958 #[tokio::test]
959 async fn a_foreign_wrap_is_not_ours() {
960 let (_tmp, _guard, me) = init();
961 let relay = MemoryRelay::new();
962 let community = service::create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
963
964 let stranger = super::super::derive::channel_group_key(&[0x99u8; 32], &community.channels[0].id, community.root_epoch);
967 let rumor = chat::build_message_rumor(me.public_key(), &community.channels[0].id, community.root_epoch, "not yours", None, &[], vec![], 1_000);
968 let (wrap, _) = chat::seal_chat_rumor(&rumor, &stranger, &me, nostr_sdk::prelude::Timestamp::from_secs(1), false).unwrap();
969
970 let rec = Recorder::default();
971 assert!(matches!(dispatch_wrap(&wrap, &community, &me.public_key(), &rec), DispatchedV2::NotOurs));
972 assert!(rec.messages.lock().unwrap().is_empty());
973 }
974}