1use nostr_sdk::prelude::PublicKey;
12use nostr_sdk::prelude::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 let attachments = attachments_from_tags(opened.rumor.tags.iter(), &crate::db::get_download_dir());
41 Message {
42 id: opened.rumor_id.to_hex(),
43 content: super::super::attachments::strip_attachment_urls(&opened.rumor.content, &attachments),
45 replied_to,
46 replied_to_npub,
47 at: opened.at_ms,
48 mine: opened.author == *my_pubkey,
49 npub: opened.author.to_bech32().ok(),
50 attachments,
51 emoji_tags: emoji
52 .iter()
53 .map(|(shortcode, url)| EmojiTag { shortcode: shortcode.clone(), url: url.clone() })
54 .collect(),
55 addressed_bots: crate::bot_interface::addressed_bots(opened.rumor.tags.iter()),
56 wrapper_event_id: Some(opened.wrapper_id.to_hex()),
57 expiration: chat::message_expiration(&opened.rumor),
59 ..Default::default()
60 }
61}
62
63fn author_is_banned_here(channel_id: &str, author: &PublicKey) -> bool {
67 let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
68 return false;
69 };
70 crate::db::community::is_author_banned(&cid_hex, author)
71}
72
73fn moderation_delete_authorized(
78 channel_id: &str,
79 deleter: &PublicKey,
80 author: &PublicKey,
81 tags: &nostr_sdk::prelude::Tags,
82) -> bool {
83 let Ok(Some(cid_hex)) = crate::db::community::community_id_for_channel(channel_id) else {
84 return false;
85 };
86 if crate::db::community::get_community_dissolved(&cid_hex).unwrap_or(false) {
87 return false;
88 }
89 let Some(owner_hex) = crate::community::moderation::owner_hex(&cid_hex) else {
90 return false; };
92 let deleter_hex = deleter.to_hex();
93 let citation = crate::community::edition::AuthorityCitation::from_tags(tags);
94 if !super::service::citation_is_synced(&cid_hex, &owner_hex, &deleter_hex, citation.as_ref()) {
95 return false;
96 }
97 let roster = crate::db::community::get_community_roles(&cid_hex).unwrap_or_default();
98 crate::community::moderation::can_hide(Some(&owner_hex), &roster, &deleter_hex, &author.to_hex())
99}
100
101pub enum ChatPersist {
106 New(Message),
108 Updated { message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
112 Removed(String),
114 ReactionRemoved { reaction_id: String, message: Message },
118}
119
120pub fn apply_chat_to_state(state: &mut ChatState, event: &ChatEvent, channel_id: &str, my_pubkey: &PublicKey) -> Option<ChatPersist> {
127 if author_is_banned_here(channel_id, &event.opened().author) {
134 return None;
135 }
136 match event {
137 ChatEvent::Message { opened, reply_to, emoji } => {
138 let msg = chat_message_to_message(opened, reply_to, emoji, my_pubkey);
139 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
142 return None;
143 }
144 state.ensure_community_chat(channel_id);
145 state.add_message_to_chat(channel_id, &msg);
149 Some(ChatPersist::New(msg))
150 }
151 ChatEvent::Reaction { opened, target, emoji, emoji_url, .. } => {
152 let target_id = crate::simd::hex::bytes_to_hex_32(target);
153 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == channel_id) {
157 return None;
158 }
159 let reaction = Reaction {
160 id: opened.rumor_id.to_hex(),
161 reference_id: target_id.clone(),
162 author_id: opened.author.to_bech32().unwrap_or_else(|_| opened.author.to_hex()),
166 emoji: emoji.clone(),
167 emoji_url: emoji_url.clone(),
168 };
169 let (_c, added) = state.add_reaction_to_message(&target_id, reaction)?;
170 added.then(|| state.find_message(&target_id).map(|(_c, m)| ChatPersist::Updated { message: m, edit_event: None }))?
171 }
172 ChatEvent::Edit { opened, target, new_content, emoji } => {
173 if crate::db::events::event_exists(&opened.rumor_id.to_hex()).unwrap_or(false) {
178 return None;
179 }
180 let target_id = crate::simd::hex::bytes_to_hex_32(target);
181 let editor_npub = opened.author.to_bech32().ok()?;
183 if !matches!(state.find_message(&target_id), Some((chat, m)) if chat.id == channel_id && m.npub.as_deref() == Some(editor_npub.as_str())) {
184 return None;
185 }
186 let edited_at = opened.at_ms;
191 let emoji_tags: Vec<crate::types::EmojiTag> = emoji
192 .iter()
193 .map(|(shortcode, url)| crate::types::EmojiTag { shortcode: shortcode.clone(), url: url.clone() })
194 .collect();
195 let (_c, message) = state.update_message(&target_id, |m| m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone()))?;
196 let edit_event = crate::stored_event::StoredEventBuilder::new()
199 .id(opened.rumor_id.to_hex())
200 .kind(crate::stored_event::event_kind::MESSAGE_EDIT)
201 .content(new_content.clone())
202 .tags(emoji.iter().map(|(s, u)| vec!["emoji".to_string(), s.clone(), u.clone()]).collect())
203 .reference_id(Some(target_id.clone()))
204 .created_at(edited_at / 1000)
205 .mine(opened.author == *my_pubkey)
206 .npub(opened.author.to_bech32().ok())
207 .build();
208 Some(ChatPersist::Updated { message, edit_event: Some(Box::new(edit_event)) })
209 }
210 ChatEvent::Delete { opened, target, .. } => {
211 let target_id = crate::simd::hex::bytes_to_hex_32(target);
212 if let Some((_chat, message_id, author_npub, _)) = state.find_reaction(&target_id) {
217 let reactor_ok = PublicKey::parse(&author_npub).map(|pk| pk == opened.author).unwrap_or(false);
218 if !reactor_ok {
219 return None;
220 }
221 return state
222 .remove_reaction_from_message(&message_id, &target_id)
223 .map(|(_cid, message)| ChatPersist::ReactionRemoved { reaction_id: target_id, message });
224 }
225 let resident = state.find_message(&target_id).and_then(|(_, m)| m.npub.clone());
231 let author_npub = match resident {
232 Some(n) => Some(n),
233 None => crate::db::events::event_author(&target_id).ok().flatten(),
234 };
235 let author = author_npub.as_deref().and_then(|n| PublicKey::parse(n).ok())?;
236 let authorized = author == opened.author
237 || moderation_delete_authorized(channel_id, &opened.author, &author, &opened.rumor.tags);
238 if !authorized {
239 return None;
240 }
241 let _ = state.remove_message(&target_id);
244 Some(ChatPersist::Removed(target_id))
245 }
246 ChatEvent::Typing { .. } | ChatEvent::Webxdc { .. } => None,
247 }
248}
249
250pub async fn persist_chat_event(
256 event: &ChatEvent,
257 channel_id: &str,
258 my_pubkey: &PublicKey,
259) -> Option<ChatPersist> {
260 let outcome = {
261 let mut st = crate::state::STATE.lock().await;
262 apply_chat_to_state(&mut st, event, channel_id, my_pubkey)
263 }?;
264 let outcome = match outcome {
268 ChatPersist::New(mut m) => {
269 if !m.replied_to.is_empty() {
270 let _ = crate::db::events::populate_reply_context(&mut m).await;
271 }
272 crate::db::attachments::verify_message_attachments(&mut m, Some(channel_id)).await;
276 ChatPersist::New(m)
277 }
278 o => o,
279 };
280 persist_chat(channel_id, &outcome).await;
282 match (&outcome, event) {
286 (ChatPersist::Removed(target), _) => {
287 super::service::spawn_pin_duty(channel_id, target, None);
288 }
289 (ChatPersist::Updated { .. }, ChatEvent::Edit { opened, target, .. }) => {
290 let target_hex = crate::simd::hex::bytes_to_hex_32(target);
291 super::service::spawn_pin_duty(channel_id, &target_hex, Some(opened.clone()));
292 }
293 _ => {}
294 }
295 Some(outcome)
296}
297
298pub async fn persist_chat(channel_id: &str, outcome: &ChatPersist) {
302 match outcome {
303 ChatPersist::New(m) => {
304 let _ = crate::db::events::save_message(channel_id, m).await;
305 }
306 ChatPersist::Updated { message, edit_event } => match edit_event {
309 Some(ev) => {
310 let mut ev = (**ev).clone();
311 if let Ok(cid) = crate::db::id_cache::get_or_create_chat_id(channel_id) {
314 ev.chat_id = cid;
315 }
316 let _ = crate::db::events::save_event(&ev).await;
317 }
318 None => {
319 let _ = crate::db::events::save_message(channel_id, message).await;
320 }
321 },
322 ChatPersist::Removed(id) => {
323 let _ = crate::db::events::delete_event(id).await;
324 }
325 ChatPersist::ReactionRemoved { reaction_id, message } => {
326 let _ = crate::db::events::delete_event(reaction_id).await;
327 let _ = crate::db::events::save_message(channel_id, message).await;
328 }
329 }
330}
331
332#[derive(Debug, Clone)]
334pub enum DispatchedV2 {
335 Chat { channel_id: String, event: Box<ChatEvent> },
341 Typing { channel_id: String, npub: String },
343 Presence { npub: String, joined: bool },
345 Kick { target: PublicKey },
349 Control { community_id: String },
354 Rekey { community_id: String },
358 Dissolved { community_id: String },
361 Ignored,
364 NotOurs,
366}
367
368pub fn dispatch_wrap(
374 wrap: &nostr_sdk::prelude::Event,
375 community: &CommunityV2,
376 my_pubkey: &PublicKey,
377 handler: &dyn InboundEventHandler,
378) -> DispatchedV2 {
379 for ch in &community.channels {
381 if ch.private && ch.key.is_none() {
384 continue;
385 }
386 let (secret, epoch) = community.channel_secret(ch);
387 let group = super::derive::channel_group_key(&secret, &ch.id, epoch);
388 if wrap.pubkey != group.pk() {
389 continue;
390 }
391 let Ok(event) = chat::open_chat_event(wrap, &group, &ch.id, epoch) else {
392 return DispatchedV2::NotOurs;
393 };
394 let channel_id = crate::simd::hex::bytes_to_hex_32(&ch.id.0);
395 return dispatch_chat_event(event, &channel_id, my_pubkey, handler);
396 }
397
398 let gb = super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
400 if wrap.pubkey == gb.pk() {
401 if let Ok(opened) = stream::open_wrap(wrap, &gb) {
402 if let Ok(ev) = guestbook::parse_guestbook_event(&opened) {
403 return dispatch_guestbook(&ev, community, handler);
404 }
405 }
406 return DispatchedV2::Ignored;
407 }
408
409 if wrap.pubkey == super::realtime::control_author(community) {
413 return DispatchedV2::Control { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
414 }
415
416 if super::realtime::rekey_authors(community).contains(&wrap.pubkey) {
421 return DispatchedV2::Rekey { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
422 }
423
424 if wrap.pubkey == super::derive::dissolved_group_key(community.id()).pk() {
427 if super::dissolution::verify_dissolved(wrap, &community.identity) {
428 return DispatchedV2::Dissolved { community_id: crate::simd::hex::bytes_to_hex_32(&community.id().0) };
429 }
430 return DispatchedV2::Ignored;
431 }
432
433 DispatchedV2::NotOurs
434}
435
436fn dispatch_chat_event(event: ChatEvent, channel_id: &str, my_pubkey: &PublicKey, handler: &dyn InboundEventHandler) -> DispatchedV2 {
437 match event {
438 ChatEvent::Typing { opened } => {
442 if opened.author == *my_pubkey || author_is_banned_here(channel_id, &opened.author) {
447 return DispatchedV2::Ignored;
448 }
449 let npub = opened.author.to_bech32().unwrap_or_default();
450 let until = opened.at_ms / 1000 + 30;
451 handler.on_community_typing(channel_id, &npub, until);
452 DispatchedV2::Typing { channel_id: channel_id.to_string(), npub }
453 }
454 ChatEvent::Webxdc { opened } => {
458 if opened.author == *my_pubkey || author_is_banned_here(channel_id, &opened.author) {
459 return DispatchedV2::Ignored;
460 }
461 let Some((topic_id, node_addr)) = crate::webxdc::parse_peer_signal(&opened.rumor.content) else {
462 return DispatchedV2::Ignored;
463 };
464 let npub = opened.author.to_bech32().unwrap_or_default();
465 handler.on_community_webxdc(
466 channel_id,
467 &npub,
468 &topic_id,
469 node_addr.as_deref(),
470 &opened.rumor_id.to_hex(),
471 opened.at_ms / 1000,
472 );
473 DispatchedV2::Ignored
474 }
475 event => DispatchedV2::Chat { channel_id: channel_id.to_string(), event: Box::new(event) },
478 }
479}
480
481fn dispatch_guestbook(ev: &guestbook::GuestbookEvent, community: &CommunityV2, handler: &dyn InboundEventHandler) -> DispatchedV2 {
482 let suppressed = match &ev.entry {
490 GuestbookEntry::Join { member, .. } | GuestbookEntry::Leave { member, .. } => {
491 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
492 crate::db::community::is_author_banned(&cid_hex, member)
493 }
494 _ => false,
495 };
496 let event_id = crate::simd::hex::bytes_to_hex_32(&ev.rumor_id);
500 let chat_id = community
508 .primary_channel()
509 .map(|c| crate::simd::hex::bytes_to_hex_32(&c.id.0));
510 match &ev.entry {
511 GuestbookEntry::Join { member, at_ms, invited_by } => {
512 let npub = member.to_bech32().unwrap_or_default();
513 let (by, label) = match invited_by {
514 Some((c, l)) => (Some(c.as_str()), Some(l.as_str())),
515 None => (None, None),
516 };
517 if let (false, Some(chat)) = (suppressed, chat_id.as_deref()) {
518 handler.on_community_presence(chat, &npub, true, &event_id, at_ms / 1000, by, label);
519 }
520 DispatchedV2::Presence { npub: npub.clone(), joined: true }
521 }
522 GuestbookEntry::Leave { member, at_ms } => {
523 let npub = member.to_bech32().unwrap_or_default();
524 if let (false, Some(chat)) = (suppressed, chat_id.as_deref()) {
525 handler.on_community_presence(chat, &npub, false, &event_id, at_ms / 1000, None, None);
526 }
527 DispatchedV2::Presence { npub: npub.clone(), joined: false }
528 }
529 GuestbookEntry::Kick { target, .. } => DispatchedV2::Kick { target: *target },
532 GuestbookEntry::Snapshot { .. } => DispatchedV2::Ignored,
533 }
534}
535
536#[cfg(test)]
537mod tests {
538 use super::*;
539 use super::super::service;
540 use crate::community::transport::memory::MemoryRelay;
541 use crate::community::transport::Transport;
542 use nostr_sdk::prelude::Keys;
543 use std::sync::Mutex;
544
545 #[derive(Default)]
547 struct Recorder {
548 messages: Mutex<Vec<(String, Message)>>,
549 updates: Mutex<Vec<(String, String)>>,
550 removed: Mutex<Vec<(String, String)>>,
551 presence: Mutex<Vec<(String, bool)>>,
552 typing: Mutex<Vec<(String, String)>>,
553 }
554 impl InboundEventHandler for Recorder {
555 fn on_community_message(&self, chat_id: &str, msg: &Message, _is_new: bool) {
556 self.messages.lock().unwrap().push((chat_id.to_string(), msg.clone()));
557 }
558 fn on_community_update(&self, chat_id: &str, target: &str, _msg: &Message) {
559 self.updates.lock().unwrap().push((chat_id.to_string(), target.to_string()));
560 }
561 fn on_community_removed(&self, chat_id: &str, target: &str) {
562 self.removed.lock().unwrap().push((chat_id.to_string(), target.to_string()));
563 }
564 fn on_community_presence(&self, _c: &str, npub: &str, joined: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
565 self.presence.lock().unwrap().push((npub.to_string(), joined));
566 }
567 fn on_community_typing(&self, chat_id: &str, npub: &str, _until: u64) {
568 self.typing.lock().unwrap().push((chat_id.to_string(), npub.to_string()));
569 }
570 }
571
572 fn init() -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Keys) {
573 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
574 crate::db::close_database();
575 crate::db::clear_id_caches();
576 let tmp = tempfile::tempdir().unwrap();
577 static N: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(90_000);
578 let n = N.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
579 const B: &[u8] = b"qpzry9x8gf2tvdw0s3jn54khce6mua7l";
580 let mut acct = String::from("npub1");
581 let mut v = n as usize;
582 for _ in 0..58 {
583 acct.push(B[v % 32] as char);
584 v = v / 32 + 7;
585 }
586 std::fs::create_dir_all(tmp.path().join(&acct)).unwrap();
587 crate::db::set_app_data_dir(crate::db::shared_test_data_dir().to_path_buf());
588 crate::db::set_current_account(acct.clone()).unwrap();
589 crate::db::init_database(&acct).unwrap();
590 let _ = crate::state::take_nostr_client();
591 let me = Keys::generate();
592 crate::state::MY_SECRET_KEY.store_from_keys(&me, &[]);
593 crate::state::set_my_public_key(me.public_key());
594 (tmp, guard, me)
595 }
596
597 #[tokio::test]
598 async fn a_received_message_wrap_opens_then_fires_from_the_persist_outcome() {
599 use nostr_sdk::prelude::Timestamp;
600 let (_tmp, _guard, me) = init();
601 let relay = MemoryRelay::new();
602 let community = service::create_community(&relay, "In", vec!["wss://r".into()], None).await.unwrap();
603 let general = community.channels[0].id;
604 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
605
606 let member = Keys::generate();
609 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
610 let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "ping", None, &[], vec![], 5_000);
611 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
612
613 let rec = Recorder::default();
616 let dispatched = dispatch_wrap(&wrap, &community, &me.public_key(), &rec);
617 assert!(rec.messages.lock().unwrap().is_empty(), "no optimistic message callback");
618 let DispatchedV2::Chat { channel_id, event } = dispatched else {
619 panic!("a chat wrap dispatches as Chat");
620 };
621 assert_eq!(channel_id, cid);
622
623 let outcome = persist_chat_event(&event, &channel_id, &me.public_key()).await;
624 let Some(ChatPersist::New(msg)) = outcome else {
625 panic!("the first delivery persists as New");
626 };
627 assert_eq!(msg.content, "ping");
628 assert!(!msg.mine, "authored by the other member");
629
630 let (rewrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(6), false).unwrap();
633 assert_ne!(rewrap.id, wrap.id, "a re-wrap is a distinct outer event");
634 let DispatchedV2::Chat { event: dup, .. } = dispatch_wrap(&rewrap, &community, &me.public_key(), &rec) else {
635 panic!("the re-wrap still opens");
636 };
637 assert!(
638 persist_chat_event(&dup, &channel_id, &me.public_key()).await.is_none(),
639 "a re-wrapped duplicate yields no outcome (nothing re-fires)"
640 );
641 }
642
643 #[tokio::test]
644 async fn a_guestbook_join_wrap_fires_presence() {
645 let (_tmp, _guard, me) = init();
646 let relay = MemoryRelay::new();
647 let community = service::create_community(&relay, "GB", vec!["wss://r".into()], None).await.unwrap();
649 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
650 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![gb.pk_hex()], ..Default::default() };
651 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
652
653 let rec = Recorder::default();
654 for w in &wraps {
655 dispatch_wrap(w, &community, &me.public_key(), &rec);
656 }
657 let pres = rec.presence.lock().unwrap();
658 assert_eq!(pres.len(), 1, "the owner's genesis Join fires one presence");
659 assert!(pres[0].1, "it's a join");
660 assert_eq!(pres[0].0, me.public_key().to_bech32().unwrap());
661 }
662
663 #[tokio::test]
664 async fn v2_chat_events_persist_into_the_shared_store() {
665 let (_tmp, _guard, me) = init();
666 let relay = MemoryRelay::new();
667 let community = service::create_community(&relay, "Persist", vec!["wss://r".into()], None).await.unwrap();
668 let general = community.channels[0].id;
669 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
670 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
671 let me_hex = me.public_key().to_hex();
672
673 let msg_id = service::send_message(&relay, &community, &general, "persist me").await.unwrap();
674 service::send_reaction(&relay, &community, &general, &msg_id, &me_hex, super::super::kind::MESSAGE, "🔥", None).await.unwrap();
675
676 assert!(crate::db::events::event_exists(&msg_id).unwrap(), "the send echo persisted the message row");
679 let reacted = {
680 let st = crate::state::STATE.lock().await;
681 st.find_message(&msg_id).map(|(_, m)| m.reactions.iter().any(|r| r.emoji == "🔥")).unwrap_or(false)
682 };
683 assert!(reacted, "the send echo aggregated the reaction onto the stored message");
684
685 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
688 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
689 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
690 events.sort_by_key(|e| e.opened().at_ms);
691 assert!(!events.is_empty());
692 for ev in &events {
693 let outcome = {
694 let mut st = crate::state::STATE.lock().await;
695 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
696 };
697 assert!(outcome.is_none(), "the relay echo of an already-echoed send dedups");
698 }
699 }
700
701 #[tokio::test]
702 async fn a_v2_edit_persists_as_a_folded_edit_event() {
703 let (_tmp, _guard, me) = init();
704 let relay = MemoryRelay::new();
705 let community = service::create_community(&relay, "Edit", vec!["wss://r".into()], None).await.unwrap();
706 let general = community.channels[0].id;
707 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
708 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
709
710 let msg_id = service::send_message(&relay, &community, &general, "original").await.unwrap();
711 service::send_edit(&relay, &community, &general, &msg_id, "edited!", &[]).await.unwrap();
712
713 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
715 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
716 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
717 events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
718 for ev in &events {
719 let outcome = {
720 let mut st = crate::state::STATE.lock().await;
721 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
722 };
723 if let Some(o) = outcome {
724 persist_chat(&cid, &o).await;
725 }
726 }
727
728 let content = {
729 let st = crate::state::STATE.lock().await;
730 st.find_message(&msg_id).map(|(_, m)| m.content)
731 };
732 assert_eq!(content.as_deref(), Some("edited!"), "the edit applied to the stored message");
733 let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
734 assert!(crate::db::events::event_exists(&edit_id).unwrap(), "the MESSAGE_EDIT event is persisted (folds on reload)");
735 }
736
737 #[tokio::test]
742 async fn an_edit_carries_its_custom_emoji_to_peers_and_across_restart() {
743 let (_tmp, _guard, me) = init();
744 let relay = MemoryRelay::new();
745 let community = service::create_community(&relay, "EditEmoji", vec!["wss://r".into()], None).await.unwrap();
746 let general = community.channels[0].id;
747 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
748 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
749
750 let msg_id = service::send_message(&relay, &community, &general, "plain").await.unwrap();
751 service::send_edit(
752 &relay, &community, &general, &msg_id,
753 "now with :vectorlove:",
754 &[("vectorlove", "https://cdn.example/vectorlove.webp")],
755 ).await.unwrap();
756
757 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
758 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
759 let mut events: Vec<ChatEvent> = wraps.iter().filter_map(|w| chat::open_chat_event(w, &group, &general, community.root_epoch).ok()).collect();
760 events.sort_by_key(|e| (!matches!(e, ChatEvent::Message { .. }), e.opened().at_ms));
761
762 let wire_emoji = events.iter().find_map(|e| match e {
763 ChatEvent::Edit { emoji, .. } => Some(emoji.clone()),
764 _ => None,
765 }).expect("the edit decodes");
766 assert_eq!(
767 wire_emoji,
768 vec![("vectorlove".to_string(), "https://cdn.example/vectorlove.webp".to_string())],
769 "the 3302 rumor carries the NIP-30 pair on the wire"
770 );
771
772 for ev in &events {
773 let outcome = {
774 let mut st = crate::state::STATE.lock().await;
775 apply_chat_to_state(&mut st, ev, &cid, &me.public_key())
776 };
777 if let Some(o) = outcome {
778 persist_chat(&cid, &o).await;
779 }
780 }
781
782 let folded = {
783 let st = crate::state::STATE.lock().await;
784 st.find_message(&msg_id).map(|(_, m)| m.emoji_tags.clone())
785 }.expect("message resident");
786 assert_eq!(folded.len(), 1, "the fold adopted the edit's emoji set");
787 assert_eq!(folded[0].shortcode, "vectorlove");
788 assert_eq!(folded[0].url, "https://cdn.example/vectorlove.webp");
789
790 let edit_id = events.iter().find_map(|e| matches!(e, ChatEvent::Edit { .. }).then(|| e.opened().rumor_id.to_hex())).unwrap();
791 let (_wrap, stored_tags) = crate::db::events::get_event_wrap_context(&edit_id)
792 .unwrap()
793 .expect("the MESSAGE_EDIT row is persisted");
794 let restored = crate::types::EmojiTag::extract_from_stored(&stored_tags);
795 assert_eq!(restored.len(), 1, "the MESSAGE_EDIT row carries the tags the reload fold reads");
796 assert_eq!(restored[0].shortcode, "vectorlove");
797 }
798
799 #[tokio::test]
800 async fn a_reaction_after_a_rekey_aggregates_onto_a_prior_epoch_message() {
801 use nostr_sdk::prelude::Timestamp;
807 let (_tmp, _guard, me) = init();
808 let relay = MemoryRelay::new();
809 let community = service::create_community(&relay, "Rekeyed", vec!["wss://r".into()], None).await.unwrap();
810 let general = community.channels[0].id;
811 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
812
813 let member = Keys::generate();
815 let g0 = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
816 let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "before the rekey", None, &[], vec![], 5_000);
817 let msg_id = msg.id.unwrap().to_hex();
818 let (mw, _) = chat::seal_chat_rumor(&msg, &g0, &member, Timestamp::from_secs(5), false).unwrap();
819 let ev = chat::open_chat_event(&mw, &g0, &general, community.root_epoch).unwrap();
820 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
821
822 let next = crate::community::Epoch(community.root_epoch.0 + 1);
825 let g1 = super::super::derive::channel_group_key(&community.community_root, &general, next);
826 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);
827 let (rw, _) = chat::seal_chat_rumor(&reaction, &g1, &member, Timestamp::from_secs(6), false).unwrap();
828 let rev = chat::open_chat_event(&rw, &g1, &general, next).unwrap();
829 let outcome = persist_chat_event(&rev, &cid, &me.public_key()).await;
830 assert!(matches!(outcome, Some(ChatPersist::Updated { .. })), "the cross-epoch reaction updates the target");
831
832 let reaction_author = {
833 let st = crate::state::STATE.lock().await;
834 st.find_message(&msg_id)
835 .and_then(|(_, m)| m.reactions.iter().find(|r| r.emoji == "🎉").map(|r| r.author_id.clone()))
836 };
837 let author = reaction_author.expect("the epoch-1 reaction aggregated onto the epoch-0 message");
838 assert_eq!(author, member.public_key().to_bech32().unwrap(), "reaction author is stored as bech32");
841 }
842
843 #[tokio::test]
844 async fn an_un_react_removes_the_reaction_for_receivers_and_only_for_its_reactor() {
845 use nostr_sdk::prelude::Timestamp;
846 let (_tmp, _guard, me) = init();
847 let relay = MemoryRelay::new();
848 let community = service::create_community(&relay, "UnReact", vec!["wss://r".into()], None).await.unwrap();
849 let general = community.channels[0].id;
850 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
851 let (secret, epoch) = community.channel_secret(&community.channels[0]);
852 let group = super::super::derive::channel_group_key(&secret, &general, epoch);
853
854 let member = Keys::generate();
856 let msg = chat::build_message_rumor(member.public_key(), &general, epoch, "react to me", None, &[], vec![], 5_000);
857 let msg_id = msg.id.unwrap().to_hex();
858 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
859 let ev = chat::open_chat_event(&mw, &group, &general, epoch).unwrap();
860 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
861
862 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);
863 let reaction_id = reaction.id.unwrap().to_hex();
864 let (rw, _) = chat::seal_chat_rumor(&reaction, &group, &member, Timestamp::from_secs(6), false).unwrap();
865 let rev = chat::open_chat_event(&rw, &group, &general, epoch).unwrap();
866 assert!(matches!(persist_chat_event(&rev, &cid, &me.public_key()).await, Some(ChatPersist::Updated { .. })));
867
868 let outsider = Keys::generate();
870 let forged = chat::build_delete_rumor(outsider.public_key(), &general, epoch, &reaction_id, super::super::kind::MESSAGE, 7_000, None);
871 let (fw, _) = chat::seal_chat_rumor(&forged, &group, &outsider, Timestamp::from_secs(7), false).unwrap();
872 let fev = chat::open_chat_event(&fw, &group, &general, epoch).unwrap();
873 assert!(persist_chat_event(&fev, &cid, &me.public_key()).await.is_none(), "only the reactor revokes their reaction");
874
875 let revoke = chat::build_delete_rumor(member.public_key(), &general, epoch, &reaction_id, super::super::kind::MESSAGE, 8_000, None);
878 let (vw, _) = chat::seal_chat_rumor(&revoke, &group, &member, Timestamp::from_secs(8), false).unwrap();
879 let vev = chat::open_chat_event(&vw, &group, &general, epoch).unwrap();
880 assert!(matches!(persist_chat_event(&vev, &cid, &me.public_key()).await, Some(ChatPersist::ReactionRemoved { .. })));
881 let (has_reaction, parent_alive) = {
882 let st = crate::state::STATE.lock().await;
883 (
884 st.find_reaction(&reaction_id).is_some(),
885 st.find_message(&msg_id).is_some(),
886 )
887 };
888 assert!(!has_reaction, "the chip is gone from STATE");
889 assert!(parent_alive, "the parent message survives an un-react");
890 assert!(!crate::db::events::event_exists(&reaction_id).unwrap(), "the kind-7 row is deleted");
891 }
892
893 #[tokio::test]
894 async fn a_guestbook_join_fires_presence_with_its_real_rumor_id() {
895 use nostr_sdk::prelude::Timestamp;
896 use std::sync::Mutex as StdMutex;
897 let (_tmp, _guard, me) = init();
898 let relay = MemoryRelay::new();
899 let community = service::create_community(&relay, "Pres", vec!["wss://r".into()], None).await.unwrap();
900
901 #[derive(Default)]
902 struct Capture(StdMutex<Vec<(String, bool, String)>>);
903 impl InboundEventHandler for Capture {
904 fn on_community_presence(&self, _chat_id: &str, npub: &str, joined: bool, event_id: &str, _at: u64, _by: Option<&str>, _label: Option<&str>) {
905 self.0.lock().unwrap().push((npub.into(), joined, event_id.into()));
906 }
907 }
908
909 let member = Keys::generate();
910 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
911 let rumor = guestbook::build_join_rumor(member.public_key(), None, 5_000);
912 let (wrap, _) = super::super::guestbook::seal_guestbook_rumor(&rumor, &gb, &member, Timestamp::from_secs(5)).unwrap();
913 let expected_id = rumor.id.unwrap().to_hex();
914
915 let cap = Capture::default();
916 let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
917 assert!(matches!(out, DispatchedV2::Presence { joined: true, .. }));
918 let seen = cap.0.lock().unwrap().clone();
919 assert_eq!(seen.len(), 1);
920 assert_eq!(seen[0].0, member.public_key().to_bech32().unwrap());
921 assert_eq!(seen[0].2, expected_id, "presence carries the join's own rumor id");
924 }
925
926 #[tokio::test]
927 async fn a_guestbook_kick_dispatches_its_target_and_raises_no_presence_line() {
928 use nostr_sdk::prelude::Timestamp;
931 use std::sync::Mutex as StdMutex;
932 let (_tmp, _guard, me) = init();
933 let relay = MemoryRelay::new();
934 let community = service::create_community(&relay, "Kicks", vec!["wss://r".into()], None).await.unwrap();
935
936 #[derive(Default)]
937 struct Capture(StdMutex<usize>);
938 impl InboundEventHandler for Capture {
939 fn on_community_presence(&self, _c: &str, _n: &str, _j: bool, _e: &str, _a: u64, _b: Option<&str>, _l: Option<&str>) {
940 *self.0.lock().unwrap() += 1;
941 }
942 }
943
944 let target = Keys::generate();
945 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
946 let rumor = guestbook::build_kick_rumor(me.public_key(), target.public_key(), None, 5_000);
947 let (wrap, _) = super::super::guestbook::seal_guestbook_rumor(&rumor, &gb, &me, Timestamp::from_secs(5)).unwrap();
948
949 let cap = Capture::default();
950 let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
951 match out {
952 DispatchedV2::Kick { target: t } => assert_eq!(t, target.public_key(), "the kick names its target"),
953 other => panic!("a Kick must reach the store, got {other:?}"),
954 }
955 assert_eq!(*cap.0.lock().unwrap(), 0, "a kick raises no presence line");
957 }
958
959 #[tokio::test]
960 async fn a_webxdc_peer_ad_fires_the_shared_handler_and_own_echo_drops() {
961 use nostr_sdk::prelude::Timestamp;
962 use std::sync::Mutex as StdMutex;
963 let (_tmp, _guard, me) = init();
964 let relay = MemoryRelay::new();
965 let community = service::create_community(&relay, "XDC", vec!["wss://r".into()], None).await.unwrap();
966 let general = community.channels[0].id;
967
968 #[derive(Default)]
969 struct Capture(StdMutex<Vec<(String, String, Option<String>)>>);
970 impl InboundEventHandler for Capture {
971 fn on_community_webxdc(&self, _chat_id: &str, npub: &str, topic_id: &str, node_addr: Option<&str>, _event_id: &str, _created_at: u64) {
972 self.0.lock().unwrap().push((npub.into(), topic_id.into(), node_addr.map(String::from)));
973 }
974 }
975
976 let topic = "B".repeat(52);
977 let content = crate::webxdc::peer_signal_content(&topic, Some("iroh:node/xyz"));
978 let (secret, epoch) = community.channel_secret(&community.channels[0]);
979 let group = super::super::derive::channel_group_key(&secret, &general, epoch);
980
981 let peer = Keys::generate();
983 let rumor = chat::build_webxdc_rumor(peer.public_key(), &general, epoch, &content, vec![], 5_000);
984 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &peer, Timestamp::from_secs(5), false).unwrap();
985 let cap = Capture::default();
986 let out = dispatch_wrap(&wrap, &community, &me.public_key(), &cap);
987 assert!(matches!(out, DispatchedV2::Ignored), "fired inline, nothing to persist v2-side");
988 let seen = cap.0.lock().unwrap().clone();
989 assert_eq!(seen.len(), 1);
990 assert_eq!(seen[0].0, peer.public_key().to_bech32().unwrap());
991 assert_eq!(seen[0].1, topic);
992 assert_eq!(seen[0].2.as_deref(), Some("iroh:node/xyz"));
993
994 let own = chat::build_webxdc_rumor(me.public_key(), &general, epoch, &content, vec![], 6_000);
996 let (own_wrap, _) = chat::seal_chat_rumor(&own, &group, &me, Timestamp::from_secs(6), false).unwrap();
997 let cap2 = Capture::default();
998 dispatch_wrap(&own_wrap, &community, &me.public_key(), &cap2);
999 assert!(cap2.0.lock().unwrap().is_empty(), "own-device echo drops");
1000 }
1001
1002 #[tokio::test]
1003 async fn a_v2_message_with_an_imeta_attachment_surfaces_as_an_attachment() {
1004 use nostr_sdk::prelude::Timestamp;
1005 let (_tmp, _guard, me) = init();
1006 let relay = MemoryRelay::new();
1007 let community = service::create_community(&relay, "Files", vec!["wss://r".into()], None).await.unwrap();
1008 let general = community.channels[0].id;
1009 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1010 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1011
1012 let attachment = crate::types::Attachment {
1015 id: "a".repeat(64),
1016 key: "0".repeat(64),
1017 nonce: "1".repeat(32),
1018 extension: "png".into(),
1019 name: "photo.png".into(),
1020 url: "https://blossom.example/abc".into(),
1021 path: String::new(),
1022 size: 4096,
1023 img_meta: None,
1024 downloading: false,
1025 downloaded: false,
1026 webxdc_topic: None,
1027 group_id: None,
1028 original_hash: Some("b".repeat(64)),
1029 fallback_urls: Vec::new(),
1030 };
1031 let imeta = crate::community::attachments::attachment_to_imeta(&attachment);
1032 let member = Keys::generate();
1033 let rumor = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "here's a file", None, &[], vec![imeta], 5_000);
1034 let (wrap, _) = chat::seal_chat_rumor(&rumor, &group, &member, Timestamp::from_secs(5), false).unwrap();
1035 let ev = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
1036
1037 let outcome = persist_chat_event(&ev, &cid, &me.public_key()).await;
1038 let Some(ChatPersist::New(msg)) = outcome else {
1039 panic!("the file message persists as new");
1040 };
1041 assert_eq!(msg.attachments.len(), 1, "the imeta tag parsed into one attachment");
1042 let att = &msg.attachments[0];
1043 assert!(att.url.contains("blossom.example"), "attachment url carried through: {}", att.url);
1044 assert_eq!(att.extension, "png", "extension carried through");
1045 }
1046
1047 #[tokio::test]
1048 async fn a_banned_members_every_chat_event_is_dropped_on_sight() {
1049 use nostr_sdk::prelude::Timestamp;
1053 let (_tmp, _guard, me) = init();
1054 let relay = MemoryRelay::new();
1055 let community = service::create_community(&relay, "BanGate", vec!["wss://r".into()], None).await.unwrap();
1056 let general = community.channels[0].id;
1057 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1058 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1059 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1060 let rogue = Keys::generate();
1061
1062 let m1 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "pre-ban", None, &[], vec![], 5_000);
1064 let m1_id = m1.id.unwrap().to_hex();
1065 let (w1, _) = chat::seal_chat_rumor(&m1, &group, &rogue, Timestamp::from_secs(5), false).unwrap();
1066 let ev = chat::open_chat_event(&w1, &group, &general, community.root_epoch).unwrap();
1067 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
1068
1069 crate::db::community::set_community_banlist(&cid_hex, &[rogue.public_key().to_hex()], 1_000).unwrap();
1071
1072 let m2 = chat::build_message_rumor(rogue.public_key(), &general, community.root_epoch, "post-ban", None, &[], vec![], 6_000);
1074 let (w2, _) = chat::seal_chat_rumor(&m2, &group, &rogue, Timestamp::from_secs(6), false).unwrap();
1075 let ev = chat::open_chat_event(&w2, &group, &general, community.root_epoch).unwrap();
1076 assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a banned message is dropped");
1077
1078 let edit = chat::build_edit_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, "rewritten", &[], 7_000);
1079 let (we, _) = chat::seal_chat_rumor(&edit, &group, &rogue, Timestamp::from_secs(7), false).unwrap();
1080 let ev = chat::open_chat_event(&we, &group, &general, community.root_epoch).unwrap();
1081 assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a banned edit is dropped");
1082
1083 let del = chat::build_delete_rumor(rogue.public_key(), &general, community.root_epoch, &m1_id, super::super::kind::MESSAGE, 8_000, None);
1084 let (wd, _) = chat::seal_chat_rumor(&del, &group, &rogue, Timestamp::from_secs(8), false).unwrap();
1085 let ev = chat::open_chat_event(&wd, &group, &general, community.root_epoch).unwrap();
1086 assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a banned delete is dropped");
1087 assert!(
1088 crate::state::STATE.lock().await.find_message(&m1_id).is_some(),
1089 "their pre-ban message survives their own post-ban delete"
1090 );
1091
1092 let rec = Recorder::default();
1094 let typ = chat::build_typing_rumor(rogue.public_key(), &general, community.root_epoch, 9_000);
1095 let (wt, _) = chat::seal_chat_rumor(&typ, &group, &rogue, Timestamp::from_secs(9), true).unwrap();
1096 assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Ignored));
1097 let gb = super::super::derive::guestbook_group_key(&community.community_root, community.id(), community.root_epoch);
1102 let join = guestbook::build_join_rumor(rogue.public_key(), None, 10_000);
1103 let (wj, _) = guestbook::seal_guestbook_rumor(&join, &gb, &rogue, Timestamp::from_secs(10)).unwrap();
1104 assert!(matches!(dispatch_wrap(&wj, &community, &me.public_key(), &rec), DispatchedV2::Presence { joined: true, .. }));
1105 assert!(rec.presence.lock().unwrap().is_empty(), "no presence callback for a banned join");
1106
1107 let innocent = Keys::generate();
1109 let m3 = chat::build_message_rumor(innocent.public_key(), &general, community.root_epoch, "innocent", None, &[], vec![], 11_000);
1110 let (w3, _) = chat::seal_chat_rumor(&m3, &group, &innocent, Timestamp::from_secs(11), false).unwrap();
1111 let ev = chat::open_chat_event(&w3, &group, &general, community.root_epoch).unwrap();
1112 assert!(matches!(persist_chat_event(&ev, &cid, &me.public_key()).await, Some(ChatPersist::New(_))));
1113 }
1114
1115 #[tokio::test]
1116 async fn my_own_typing_from_another_client_never_paints_me_as_typing() {
1117 use nostr_sdk::prelude::Timestamp;
1118 let (_tmp, _guard, me) = init();
1119 let relay = MemoryRelay::new();
1120 let community = service::create_community(&relay, "Typing", vec!["wss://r".into()], None).await.unwrap();
1121 let general = community.channels[0].id;
1122 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1123 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1124 let rec = Recorder::default();
1125
1126 let mine = chat::build_typing_rumor(me.public_key(), &general, community.root_epoch, 5_000);
1129 let (wm, _) = chat::seal_chat_rumor(&mine, &group, &me, Timestamp::from_secs(5), true).unwrap();
1130 assert!(
1131 matches!(dispatch_wrap(&wm, &community, &me.public_key(), &rec), DispatchedV2::Ignored),
1132 "our own typing signal is not someone typing at us"
1133 );
1134 assert!(rec.typing.lock().unwrap().is_empty(), "no typing callback for our own signal");
1135
1136 let peer = Keys::generate();
1138 let theirs = chat::build_typing_rumor(peer.public_key(), &general, community.root_epoch, 6_000);
1139 let (wt, _) = chat::seal_chat_rumor(&theirs, &group, &peer, Timestamp::from_secs(6), true).unwrap();
1140 assert!(matches!(dispatch_wrap(&wt, &community, &me.public_key(), &rec), DispatchedV2::Typing { .. }));
1141 let got = rec.typing.lock().unwrap();
1142 assert_eq!(got.len(), 1, "exactly the peer's signal fired");
1143 assert_eq!(got[0].0, cid);
1144 assert_eq!(got[0].1, peer.public_key().to_bech32().unwrap());
1145 }
1146
1147 #[tokio::test]
1148 async fn an_armada_threaded_reply_persists_and_fires_as_an_inline_reply() {
1149 use nostr_sdk::prelude::Timestamp;
1150 let (_tmp, _guard, me) = init();
1151 let relay = MemoryRelay::new();
1152 let community = service::create_community(&relay, "Thread", vec!["wss://r".into()], None).await.unwrap();
1153 let general = community.channels[0].id;
1154 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1155 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1156
1157 let member = Keys::generate();
1160 let root = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "thread root", None, &[], vec![], 5_000);
1161 let root_id = root.id.unwrap().to_hex();
1162 let (rw, _) = chat::seal_chat_rumor(&root, &group, &member, Timestamp::from_secs(5), false).unwrap();
1163 let reply = chat::build_comment_rumor(
1164 member.public_key(),
1165 &general,
1166 community.root_epoch,
1167 "thread reply",
1168 &root_id,
1169 super::super::kind::MESSAGE,
1170 &member.public_key().to_hex(),
1171 None,
1172 &[],
1173 6_000,
1174 );
1175 let reply_id = reply.id.unwrap().to_hex();
1176 let (tw, _) = chat::seal_chat_rumor(&reply, &group, &member, Timestamp::from_secs(6), false).unwrap();
1177
1178 for w in [&rw, &tw] {
1179 let ev = chat::open_chat_event(w, &group, &general, community.root_epoch).unwrap();
1180 let outcome = persist_chat_event(&ev, &cid, &me.public_key()).await;
1181 assert!(matches!(outcome, Some(ChatPersist::New(_))), "both persist as new messages");
1182 }
1183 let held = {
1186 let st = crate::state::STATE.lock().await;
1187 st.find_message(&reply_id).map(|(_, m)| m)
1188 }
1189 .expect("the threaded reply is resident");
1190 assert_eq!(held.replied_to, root_id, "the immediate parent is the reply context");
1191 assert_eq!(held.content, "thread reply");
1192
1193 let del = chat::build_delete_rumor(member.public_key(), &general, community.root_epoch, &reply_id, super::super::kind::COMMENT, 7_000, None);
1195 let (dw, _) = chat::seal_chat_rumor(&del, &group, &member, Timestamp::from_secs(7), false).unwrap();
1196 let ev = chat::open_chat_event(&dw, &group, &general, community.root_epoch).unwrap();
1197 let outcome = persist_chat_event(&ev, &cid, &me.public_key()).await;
1198 assert!(matches!(outcome, Some(ChatPersist::Removed(id)) if id == reply_id), "the author's delete removes their thread reply");
1199 }
1200
1201 #[tokio::test]
1202 async fn an_edit_replay_never_refires() {
1203 use nostr_sdk::prelude::Timestamp;
1204 let (_tmp, _guard, me) = init();
1205 let relay = MemoryRelay::new();
1206 let community = service::create_community(&relay, "EditReplay", vec!["wss://r".into()], None).await.unwrap();
1207 let general = community.channels[0].id;
1208 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1209 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1210
1211 let member = Keys::generate();
1213 let msg = chat::build_message_rumor(member.public_key(), &general, community.root_epoch, "v1 text", None, &[], vec![], 5_000);
1214 let msg_id = msg.id.unwrap().to_hex();
1215 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &member, Timestamp::from_secs(5), false).unwrap();
1216 let edit = chat::build_edit_rumor(member.public_key(), &general, community.root_epoch, &msg_id, "v2 text", &[], 6_000);
1217 let (ew, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(6), false).unwrap();
1218 for w in [&mw, &ew] {
1219 if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
1220 let _ = persist_chat_event(&ev, &cid, &me.public_key()).await;
1221 }
1222 }
1223
1224 let (replay, _) = chat::seal_chat_rumor(&edit, &group, &member, Timestamp::from_secs(7), false).unwrap();
1227 assert_ne!(replay.id, ew.id, "a re-wrap is a distinct outer event");
1228 let ev = chat::open_chat_event(&replay, &group, &general, community.root_epoch).unwrap();
1229 assert!(
1230 persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(),
1231 "a replayed edit yields no outcome (no handler re-fire)"
1232 );
1233 }
1234
1235 #[tokio::test]
1236 async fn a_forged_edit_from_a_non_author_is_ignored() {
1237 use nostr_sdk::prelude::Timestamp;
1241 let (_tmp, _guard, me) = init();
1242 let relay = MemoryRelay::new();
1243 let community = service::create_community(&relay, "EditGuard", vec!["wss://r".into()], None).await.unwrap();
1244 let general = community.channels[0].id;
1245 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1246 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1247
1248 let author = Keys::generate();
1250 let msg = chat::build_message_rumor(author.public_key(), &general, community.root_epoch, "original", None, &[], vec![], 5_000);
1251 let msg_id = msg.id.unwrap().to_hex();
1252 let (mw, _) = chat::seal_chat_rumor(&msg, &group, &author, Timestamp::from_secs(5), false).unwrap();
1253 let ev = chat::open_chat_event(&mw, &group, &general, community.root_epoch).unwrap();
1254 persist_chat_event(&ev, &cid, &me.public_key()).await;
1255
1256 let stranger = Keys::generate();
1258 let edit = chat::build_edit_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, "TAMPERED", &[], 6_000);
1259 let (ew, _) = chat::seal_chat_rumor(&edit, &group, &stranger, Timestamp::from_secs(6), false).unwrap();
1260 let ev = chat::open_chat_event(&ew, &group, &general, community.root_epoch).unwrap();
1261 assert!(persist_chat_event(&ev, &cid, &me.public_key()).await.is_none(), "a forged edit yields no outcome");
1262
1263 let content = {
1264 let st = crate::state::STATE.lock().await;
1265 st.find_message(&msg_id).map(|(_, m)| m.content)
1266 };
1267 assert_eq!(content.as_deref(), Some("original"), "the message content is unchanged by the forged edit");
1268 }
1269
1270 #[tokio::test]
1271 async fn a_reaction_cannot_be_injected_across_channels() {
1272 use nostr_sdk::prelude::Timestamp;
1278 let (_tmp, _guard, me) = init();
1279 let relay = MemoryRelay::new();
1280 let mut community = service::create_community(&relay, "TwoChan", vec!["wss://r".into()], None).await.unwrap();
1281 let chan_a = community.channels[0].id;
1282 let chan_b = service::create_public_channel(&relay, &community, "b").await.unwrap();
1283 community = crate::db::community::load_community_v2(community.id()).unwrap().unwrap();
1284 let a_hex = crate::simd::hex::bytes_to_hex_32(&chan_a.0);
1285
1286 let author = Keys::generate();
1288 let gb = super::super::derive::channel_group_key(&community.community_root, &chan_b, community.root_epoch);
1289 let msg = chat::build_message_rumor(author.public_key(), &chan_b, community.root_epoch, "in B", None, &[], vec![], 5_000);
1290 let msg_id = msg.id.unwrap().to_hex();
1291 let (mw, _) = chat::seal_chat_rumor(&msg, &gb, &author, Timestamp::from_secs(5), false).unwrap();
1292 let bev = chat::open_chat_event(&mw, &gb, &chan_b, community.root_epoch).unwrap();
1293 persist_chat_event(&bev, &crate::simd::hex::bytes_to_hex_32(&chan_b.0), &me.public_key()).await;
1294
1295 let ga = super::super::derive::channel_group_key(&community.community_root, &chan_a, community.root_epoch);
1297 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);
1298 let (rw, _) = chat::seal_chat_rumor(&reaction, &ga, &author, Timestamp::from_secs(6), false).unwrap();
1299 let aev = chat::open_chat_event(&rw, &ga, &chan_a, community.root_epoch).unwrap();
1300 let outcome = persist_chat_event(&aev, &a_hex, &me.public_key()).await;
1302 assert!(outcome.is_none(), "a cross-channel reaction is dropped");
1303 let reacted = {
1304 let st = crate::state::STATE.lock().await;
1305 st.find_message(&msg_id).map(|(_, m)| !m.reactions.is_empty()).unwrap_or(false)
1306 };
1307 assert!(!reacted, "the channel-B message gained no reaction from the channel-A injection");
1308 }
1309
1310 #[tokio::test]
1311 async fn a_forged_delete_from_a_non_author_is_ignored() {
1312 use nostr_sdk::prelude::Timestamp;
1313 let (_tmp, _guard, me) = init();
1314 let relay = MemoryRelay::new();
1315 let community = service::create_community(&relay, "Forge", vec!["wss://r".into()], None).await.unwrap();
1316 let general = community.channels[0].id;
1317 let cid = crate::simd::hex::bytes_to_hex_32(&general.0);
1318 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1319
1320 let msg_id = service::send_message(&relay, &community, &general, "mine").await.unwrap();
1322 let q = crate::community::transport::Query { kinds: vec![stream::KIND_WRAP], authors: vec![group.pk_hex()], ..Default::default() };
1323 let wraps = relay.fetch(&q, &community.relays).await.unwrap();
1324 for w in &wraps {
1325 if let Ok(ev) = chat::open_chat_event(w, &group, &general, community.root_epoch) {
1326 let mut st = crate::state::STATE.lock().await;
1327 apply_chat_to_state(&mut st, &ev, &cid, &me.public_key());
1328 }
1329 }
1330
1331 let stranger = nostr_sdk::prelude::Keys::generate();
1333 let del = chat::build_delete_rumor(stranger.public_key(), &general, community.root_epoch, &msg_id, super::super::kind::MESSAGE, 9_000, None);
1334 let (wrap, _) = chat::seal_chat_rumor(&del, &group, &stranger, Timestamp::from_secs(9), false).unwrap();
1335 let event = chat::open_chat_event(&wrap, &group, &general, community.root_epoch).unwrap();
1336
1337 let outcome = {
1338 let mut st = crate::state::STATE.lock().await;
1339 apply_chat_to_state(&mut st, &event, &cid, &me.public_key())
1340 };
1341 assert!(outcome.is_none(), "a forged delete from a non-author yields no removal");
1342 let survives = {
1343 let st = crate::state::STATE.lock().await;
1344 st.find_message(&msg_id).is_some()
1345 };
1346 assert!(survives, "the message survives the forged delete (live view + DB stay consistent)");
1347 }
1348
1349 struct ModerationBed {
1353 community: super::super::community::CommunityV2,
1354 general: crate::community::ChannelId,
1355 chat_id: String,
1356 group: super::super::derive::GroupKey,
1357 admin: Keys,
1358 admin_citation: crate::community::edition::AuthorityCitation,
1359 }
1360
1361 async fn moderation_bed(relay: &MemoryRelay, name: &str) -> ModerationBed {
1362 let community = service::create_community(relay, name, vec!["wss://r".into()], None).await.unwrap();
1363 let general = community.channels[0].id;
1364 let chat_id = crate::simd::hex::bytes_to_hex_32(&general.0);
1365 let group = super::super::derive::channel_group_key(&community.community_root, &general, community.root_epoch);
1366 let cid_hex = crate::simd::hex::bytes_to_hex_32(&community.id().0);
1367
1368 let admin = Keys::generate();
1369 service::grant_admin(relay, &community, &admin.public_key()).await.unwrap();
1370
1371 let view = service::fetch_authority(relay, &community).await;
1374 crate::db::community::set_community_roles(&cid_hex, &view.roles, 1_000).unwrap();
1375
1376 let entity_id = super::super::derive::grant_locator(community.id(), &admin.public_key().to_bytes());
1378 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
1379 let (version, edition_hash) = crate::db::community::get_edition_head(&cid_hex, &entity_hex)
1380 .unwrap()
1381 .expect("the owner's own grant publish stores its head");
1382 let admin_citation = crate::community::edition::AuthorityCitation { entity_id, version, edition_hash };
1383
1384 ModerationBed { community, general, chat_id, group, admin, admin_citation }
1385 }
1386
1387 async fn post_as(bed: &ModerationBed, author: &Keys, body: &str, at: u64, me: &PublicKey) -> String {
1389 use nostr_sdk::prelude::Timestamp;
1390 let rumor = chat::build_message_rumor(author.public_key(), &bed.general, bed.community.root_epoch, body, None, &[], vec![], at);
1391 let (wrap, _) = chat::seal_chat_rumor(&rumor, &bed.group, author, Timestamp::from_secs(at / 1000), false).unwrap();
1392 let event = chat::open_chat_event(&wrap, &bed.group, &bed.general, bed.community.root_epoch).unwrap();
1393 let id = rumor.id.unwrap().to_hex();
1394 let mut st = crate::state::STATE.lock().await;
1395 apply_chat_to_state(&mut st, &event, &bed.chat_id, me);
1396 id
1397 }
1398
1399 async fn delete_as(
1401 bed: &ModerationBed,
1402 actor: &Keys,
1403 target: &str,
1404 citation: Option<&crate::community::edition::AuthorityCitation>,
1405 at: u64,
1406 me: &PublicKey,
1407 ) -> Option<ChatPersist> {
1408 use nostr_sdk::prelude::Timestamp;
1409 let del = chat::build_delete_rumor(actor.public_key(), &bed.general, bed.community.root_epoch, target, super::super::kind::MESSAGE, at, citation);
1410 let (wrap, _) = chat::seal_chat_rumor(&del, &bed.group, actor, Timestamp::from_secs(at / 1000), false).unwrap();
1411 let event = chat::open_chat_event(&wrap, &bed.group, &bed.general, bed.community.root_epoch).unwrap();
1412 let mut st = crate::state::STATE.lock().await;
1413 apply_chat_to_state(&mut st, &event, &bed.chat_id, me)
1414 }
1415
1416 #[tokio::test]
1417 async fn an_admins_moderation_delete_removes_a_members_message() {
1418 let (_tmp, _guard, me) = init();
1419 let relay = MemoryRelay::new();
1420 let bed = moderation_bed(&relay, "Mod").await;
1421 let member = Keys::generate();
1422
1423 let victim = post_as(&bed, &member, "spam", 1_000, &me.public_key()).await;
1424 let outcome = delete_as(&bed, &bed.admin, &victim, Some(&bed.admin_citation), 2_000, &me.public_key()).await;
1425
1426 assert!(matches!(outcome, Some(ChatPersist::Removed(ref id)) if *id == victim), "MANAGE_MESSAGES + outrank removes it");
1427 assert!(crate::state::STATE.lock().await.find_message(&victim).is_none());
1428 }
1429
1430 #[tokio::test]
1431 async fn an_admin_cannot_moderation_delete_the_owners_message() {
1432 let (_tmp, _guard, me) = init();
1436 let relay = MemoryRelay::new();
1437 let bed = moderation_bed(&relay, "Sacred").await;
1438
1439 let owners_message = post_as(&bed, &me, "the owner speaks", 1_000, &me.public_key()).await;
1440 let outcome = delete_as(&bed, &bed.admin, &owners_message, Some(&bed.admin_citation), 2_000, &me.public_key()).await;
1441
1442 assert!(outcome.is_none(), "an admin never outranks the owner");
1443 assert!(crate::state::STATE.lock().await.find_message(&owners_message).is_some());
1444 }
1445
1446 #[tokio::test]
1447 async fn a_moderation_delete_without_a_synced_citation_is_refused() {
1448 let (_tmp, _guard, me) = init();
1451 let relay = MemoryRelay::new();
1452 let bed = moderation_bed(&relay, "Uncited").await;
1453 let member = Keys::generate();
1454
1455 let victim = post_as(&bed, &member, "spam", 1_000, &me.public_key()).await;
1456 assert!(delete_as(&bed, &bed.admin, &victim, None, 2_000, &me.public_key()).await.is_none());
1457 assert!(crate::state::STATE.lock().await.find_message(&victim).is_some());
1458
1459 assert!(delete_as(&bed, &bed.admin, &victim, Some(&bed.admin_citation), 3_000, &me.public_key()).await.is_some());
1462 }
1463
1464 #[tokio::test]
1465 async fn a_roleless_member_cannot_moderation_delete_anyone() {
1466 let (_tmp, _guard, me) = init();
1467 let relay = MemoryRelay::new();
1468 let bed = moderation_bed(&relay, "Roleless").await;
1469 let (member, rando) = (Keys::generate(), Keys::generate());
1470
1471 let victim = post_as(&bed, &member, "hello", 1_000, &me.public_key()).await;
1472 let outcome = delete_as(&bed, &rando, &victim, Some(&bed.admin_citation), 2_000, &me.public_key()).await;
1475
1476 assert!(outcome.is_none(), "no MANAGE_MESSAGES, no removal");
1477 assert!(crate::state::STATE.lock().await.find_message(&victim).is_some());
1478 }
1479
1480 #[tokio::test]
1481 async fn a_member_still_deletes_their_own_message_uncited() {
1482 let (_tmp, _guard, me) = init();
1484 let relay = MemoryRelay::new();
1485 let bed = moderation_bed(&relay, "Self").await;
1486 let member = Keys::generate();
1487
1488 let mine = post_as(&bed, &member, "oops", 1_000, &me.public_key()).await;
1489 let outcome = delete_as(&bed, &member, &mine, None, 2_000, &me.public_key()).await;
1490
1491 assert!(matches!(outcome, Some(ChatPersist::Removed(ref id)) if *id == mine));
1492 }
1493
1494 #[tokio::test]
1495 async fn a_foreign_wrap_is_not_ours() {
1496 let (_tmp, _guard, me) = init();
1497 let relay = MemoryRelay::new();
1498 let community = service::create_community(&relay, "X", vec!["wss://r".into()], None).await.unwrap();
1499
1500 let stranger = super::super::derive::channel_group_key(&[0x99u8; 32], &community.channels[0].id, community.root_epoch);
1503 let rumor = chat::build_message_rumor(me.public_key(), &community.channels[0].id, community.root_epoch, "not yours", None, &[], vec![], 1_000);
1504 let (wrap, _) = chat::seal_chat_rumor(&rumor, &stranger, &me, nostr_sdk::prelude::Timestamp::from_secs(1), false).unwrap();
1505
1506 let rec = Recorder::default();
1507 assert!(matches!(dispatch_wrap(&wrap, &community, &me.public_key(), &rec), DispatchedV2::NotOurs));
1508 assert!(rec.messages.lock().unwrap().is_empty());
1509 }
1510}