1use nostr_sdk::prelude::{Event, PublicKey};
7use nostr_sdk::ToBech32;
8
9use super::envelope::{open_message_multi, OpenedMessage};
10use super::Channel;
11use crate::state::ChatState;
12use crate::stored_event::event_kind;
13use crate::types::Message;
14
15fn concord_rumor(
27 opened: &OpenedMessage,
28 kind: nostr_sdk::Kind,
29 my_pubkey: &PublicKey,
30) -> (crate::rumor::RumorEvent, crate::rumor::RumorContext) {
31 use crate::rumor::{ConversationType, RumorContext, RumorEvent};
32 (
33 RumorEvent {
34 id: opened.message_id,
35 kind,
36 content: opened.content.clone(),
37 tags: opened.tags.clone(),
38 created_at: opened.created_at,
39 pubkey: opened.author,
40 },
41 RumorContext {
42 sender: opened.author,
43 is_mine: opened.author == *my_pubkey,
44 conversation_id: opened.channel_id.to_hex(),
45 conversation_type: ConversationType::Community,
46 },
47 )
48}
49
50pub fn build_message(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Message {
51 use crate::rumor::{process_rumor, RumorProcessingResult};
52 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::PrivateDirectMessage, my_pubkey);
53 let mut msg = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
54 Ok(RumorProcessingResult::TextMessage(m)) => m,
55 _ => Message {
58 id: opened.message_id.to_hex(),
59 content: opened.content.clone(),
60 at: opened.ms.unwrap_or_else(|| opened.created_at.as_secs().saturating_mul(1000)),
61 mine: opened.author == *my_pubkey,
62 npub: opened.author.to_bech32().ok(),
63 ..Default::default()
64 },
65 };
66 msg.attachments = opened.attachments.clone();
69 msg.wrapper_event_id = Some(opened.wrapper_id.to_hex());
70 msg
71}
72
73pub fn ingest_message(
78 state: &mut ChatState,
79 opened: &OpenedMessage,
80 my_pubkey: &PublicKey,
81) -> Option<Message> {
82 let chat_id = opened.channel_id.to_hex();
83 let msg = build_message(opened, my_pubkey);
84 if crate::db::events::event_exists(&msg.id).unwrap_or(false) {
91 return None;
92 }
93 state.ensure_community_chat(&chat_id);
94 if state.add_message_to_chat(&chat_id, msg.clone()) {
95 Some(msg)
96 } else {
97 None
98 }
99}
100
101pub enum IncomingEvent {
106 NewMessage(Message),
107 Updated { target_id: String, message: Message, edit_event: Option<Box<crate::stored_event::StoredEvent>> },
112 Removed { target_id: String },
113 Presence { npub: String, joined: bool, event_id: String, created_at: u64, invited_by: Option<String>, invited_label: Option<String> },
120 Kicked { community_id: String },
125 SelfLeft { community_id: String },
131 WebxdcPeer {
136 npub: String,
137 topic_id: String,
138 node_addr: Option<String>,
140 event_id: String,
141 created_at: u64,
142 },
143 Typing { npub: String, until: u64 },
147}
148
149pub fn process_incoming(
155 state: &mut ChatState,
156 event: &Event,
157 channel: &Channel,
158 my_pubkey: &PublicKey,
159) -> Option<IncomingEvent> {
160 let outer_bytes = event.id.to_bytes();
166 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
167 || crate::db::wrappers::processed_wrapper_exists(&outer_bytes)
168 {
169 return None;
170 }
171 if channel.dissolved && event.kind.as_u16() != event_kind::COMMUNITY_DELETE {
179 return None;
180 }
181 let opened = match open_message_multi(event, &channel.id, &channel.read_epoch_keys()) {
185 Ok(o) => o,
186 Err(e) => {
187 crate::log_debug!("[community] inbound drop {}: {}", event.id.to_hex(), e);
188 return None;
189 }
190 };
191 if channel.banned.contains(&opened.author) {
194 crate::log_debug!("[community] dropped event from banned author {}", opened.author.to_hex());
195 return None;
196 }
197 let outcome = match opened.kind {
198 k if k == event_kind::COMMUNITY_MESSAGE => {
199 ingest_message(state, &opened, my_pubkey).map(IncomingEvent::NewMessage)
200 }
201 k if k == event_kind::COMMUNITY_REACTION => apply_reaction(state, &opened, my_pubkey),
202 k if k == event_kind::COMMUNITY_EDIT => apply_edit(state, &opened, my_pubkey),
203 k if k == event_kind::COMMUNITY_DELETE => apply_delete(state, &opened, channel, my_pubkey),
204 k if k == event_kind::COMMUNITY_PRESENCE => apply_presence(&opened, channel, my_pubkey),
205 k if k == event_kind::COMMUNITY_KICK => apply_kick(&opened, channel, my_pubkey),
206 k if k == event_kind::COMMUNITY_WEBXDC => apply_webxdc(&opened, my_pubkey),
207 k if k == event_kind::COMMUNITY_TYPING => apply_typing(&opened, my_pubkey),
208 _ => None,
209 };
210 if let Some(ref evt) = outcome {
216 if !matches!(evt, IncomingEvent::NewMessage(_) | IncomingEvent::Typing { .. }) {
217 let _ = crate::db::wrappers::save_processed_wrapper(
218 &outer_bytes, event.created_at.as_secs(), crate::db::wrappers::TRANSPORT_CONCORD,
219 );
220 }
221 }
222 outcome
223}
224
225fn apply_presence(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
229 let joined = opened.content != "leave";
231 if !joined && opened.author == *my_pubkey {
235 if let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) {
236 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
244 let join_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0);
245 if opened.created_at.as_secs().saturating_mul(1000) > join_ms {
246 return Some(IncomingEvent::SelfLeft { community_id: cid });
247 }
248 crate::log_debug!("[community] self-leave predates this join — rendering as history, not teardown");
249 }
250 }
251 let (invited_by, invited_label) = if joined {
252 serde_json::from_str::<serde_json::Value>(&opened.content)
253 .ok()
254 .map(|v| {
255 let by = v.get("by").and_then(|b| b.as_str())
259 .filter(|s| PublicKey::parse(s).is_ok())
260 .map(str::to_string);
261 let label = v.get("l").and_then(|l| l.as_str())
262 .map(|s| s.chars().take(48).collect::<String>())
263 .filter(|s| !s.is_empty());
264 (by, label)
265 })
266 .unwrap_or((None, None))
267 } else {
268 (None, None)
269 };
270 Some(IncomingEvent::Presence {
271 npub: opened.author.to_bech32().ok()?,
272 joined,
273 event_id: opened.message_id.to_hex(),
274 created_at: clamp_inner_secs(opened.created_at.as_secs()),
275 invited_by,
276 invited_label,
277 })
278}
279
280fn clamp_inner_secs(secs: u64) -> u64 {
284 let now = std::time::SystemTime::now()
285 .duration_since(std::time::UNIX_EPOCH)
286 .map(|d| d.as_secs())
287 .unwrap_or(0);
288 secs.min(now + 300)
289}
290
291fn apply_webxdc(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
297 if opened.author == *my_pubkey {
298 return None;
299 }
300 let v: serde_json::Value = serde_json::from_str(&opened.content).ok()?;
301 let topic_id = v.get("topic").and_then(|t| t.as_str())
302 .filter(|t| t.len() == 52 && t.bytes().all(|b| b.is_ascii_uppercase() || (b'2'..=b'7').contains(&b)))?
303 .to_string();
304 let node_addr = match v.get("op").and_then(|o| o.as_str())? {
305 "ad" => Some(
306 v.get("addr").and_then(|a| a.as_str())
307 .filter(|a| !a.is_empty() && a.len() <= 2048)?
308 .to_string(),
309 ),
310 "left" => None,
311 _ => return None,
312 };
313 Some(IncomingEvent::WebxdcPeer {
314 npub: opened.author.to_bech32().ok()?,
315 topic_id,
316 node_addr,
317 event_id: opened.message_id.to_hex(),
318 created_at: opened.created_at.as_secs(),
319 })
320}
321
322fn apply_typing(opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
327 if opened.author == *my_pubkey {
328 return None;
329 }
330 if opened.content != "typing" {
331 return None;
332 }
333 let now = std::time::SystemTime::now()
334 .duration_since(std::time::UNIX_EPOCH)
335 .map(|d| d.as_secs())
336 .unwrap_or(0);
337 Some(IncomingEvent::Typing {
338 npub: opened.author.to_bech32().ok()?,
339 until: now + 30,
340 })
341}
342
343fn apply_kick(opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
354 use crate::community::roles::Permissions;
355 let target = PublicKey::parse(opened.content.trim()).ok()?;
356 let target_hex = target.to_hex();
357 let kicker_hex = opened.author.to_hex();
358 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
359 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &kicker_hex, opened.citation.as_ref());
360 if !(pinned && channel.roster.can_act_on_member(&kicker_hex, owner_hex.as_deref(), &target_hex, Permissions::KICK)) {
361 crate::log_debug!("[community] dropped kick: {kicker_hex} not authorized to kick {target_hex}");
362 return None;
363 }
364 let cid_hex = crate::db::community::community_id_for_channel(&channel.id.to_hex()).ok().flatten()?;
365 let cid = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid_hex));
370 let join_ms = crate::db::community::community_created_at_ms(&cid).unwrap_or(0);
371 if opened.created_at.as_secs().saturating_mul(1000) <= join_ms {
374 crate::log_debug!("[community] dropped stale kick of {target_hex} (predates this join)");
375 return None;
376 }
377 if target == *my_pubkey {
378 return Some(IncomingEvent::Kicked { community_id: cid_hex });
379 }
380 Some(IncomingEvent::Presence {
382 npub: target.to_bech32().ok()?,
383 joined: false,
384 event_id: opened.message_id.to_hex(),
385 created_at: clamp_inner_secs(opened.created_at.as_secs()),
386 invited_by: None,
387 invited_label: None,
388 })
389}
390
391pub fn event_authenticates(event: &Event, channel: &Channel) -> bool {
404 if crate::db::events::wrapper_event_exists(&event.id.to_hex()).unwrap_or(false)
405 || crate::db::wrappers::processed_wrapper_exists(&event.id.to_bytes())
406 {
407 return true;
408 }
409 open_message_multi(event, &channel.id, &channel.read_epoch_keys()).is_ok()
410}
411
412pub fn process_channel_batch(
426 state: &mut ChatState,
427 events: &[Event],
428 channel: &Channel,
429 my_pubkey: &PublicKey,
430) -> Vec<IncomingEvent> {
431 let mut out = Vec::new();
432 for want_message in [true, false] {
435 for ev in events {
436 let is_message = ev.kind.as_u16() == event_kind::COMMUNITY_MESSAGE;
437 if is_message != want_message {
438 continue;
439 }
440 if let Some(evt) = process_incoming(state, ev, channel, my_pubkey) {
441 out.push(evt);
442 }
443 }
444 }
445 out
446}
447
448fn apply_reaction(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
452 use crate::rumor::{process_rumor, RumorProcessingResult};
453 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::Reaction, my_pubkey);
454 let reaction = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
455 Ok(RumorProcessingResult::Reaction(r)) => r,
456 _ => return None,
457 };
458 let target_id = reaction.reference_id.clone();
459 let expected_chat = opened.channel_id.to_hex();
464 if !matches!(state.find_message(&target_id), Some((chat, _)) if chat.id == expected_chat) {
465 return None;
466 }
467 let (_chat_id, was_added) = state.add_reaction_to_message(&target_id, reaction)?;
468 if !was_added {
469 return None;
470 }
471 let (_chat, message) = state.find_message(&target_id)?;
472 Some(IncomingEvent::Updated { target_id, message, edit_event: None })
473}
474
475fn apply_edit(state: &mut ChatState, opened: &OpenedMessage, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
479 use crate::rumor::{process_rumor, RumorProcessingResult};
480 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::from(event_kind::MESSAGE_EDIT), my_pubkey);
481 let (target_id, new_content, edited_at, emoji_tags, edit_event) = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
482 Ok(RumorProcessingResult::Edit { message_id, new_content, edited_at, emoji_tags, event }) => (message_id, new_content, edited_at, emoji_tags, event),
483 _ => return None,
484 };
485 let editor_npub = opened.author.to_bech32().ok()?;
488 let target_author = state.find_message(&target_id).and_then(|(_, m)| m.npub)?;
489 if target_author != editor_npub {
490 crate::log_debug!("[community] dropped edit from non-author of {}", target_id);
491 return None;
492 }
493 let (_chat_id, message) = state.update_message(&target_id, |m| {
496 m.apply_edit(new_content.clone(), edited_at, emoji_tags.clone());
497 })?;
498 Some(IncomingEvent::Updated { target_id, message, edit_event: Some(Box::new(edit_event)) })
501}
502
503fn actor_authority_pinned(
513 channel: &Channel,
514 owner_hex: Option<&str>,
515 actor_hex: &str,
516 citation: Option<&super::edition::AuthorityCitation>,
517) -> bool {
518 if owner_hex == Some(actor_hex) {
519 return true; }
521 if citation.is_none() {
522 return false; }
524 let Ok(Some(cid)) = crate::db::community::community_id_for_channel(&channel.id.to_hex()) else {
525 return false; };
527 let cid_bytes = crate::simd::hex::hex_to_bytes_32(&cid);
528 let actor_bytes = crate::simd::hex::hex_to_bytes_32(actor_hex);
529 let grant_hex = crate::simd::hex::bytes_to_hex_32(&super::derive::grant_locator(
530 &crate::community::CommunityId(cid_bytes),
531 &actor_bytes,
532 ));
533 let head: Vec<super::roster::EntityHead> = crate::db::community::get_edition_head(&cid, &grant_hex)
534 .ok()
535 .flatten()
536 .map(|(version, self_hash)| super::roster::EntityHead { entity_hex: grant_hex.clone(), version, self_hash, inner_id: [0u8; 32], citation: None })
537 .into_iter()
538 .collect();
539 super::roster::authority_citation_satisfied(&head, owner_hex, actor_hex, &grant_hex, citation)
540}
541
542fn apply_delete(state: &mut ChatState, opened: &OpenedMessage, channel: &Channel, my_pubkey: &PublicKey) -> Option<IncomingEvent> {
543 use crate::community::roles::Permissions;
544 use crate::rumor::{process_rumor, RumorProcessingResult};
545 let (rumor, ctx) = concord_rumor(opened, nostr_sdk::Kind::EventDeletion, my_pubkey);
549 let target_id = match process_rumor(rumor, ctx, &crate::db::get_download_dir()) {
550 Ok(RumorProcessingResult::DeletionRequest { target_event_id }) => target_event_id,
551 _ => return None,
552 };
553 let deleter = opened.author;
554 let deleter_hex = deleter.to_hex();
555 let owner_hex = channel.protected.first().map(|pk| pk.to_hex());
556 let pinned = actor_authority_pinned(channel, owner_hex.as_deref(), &deleter_hex, opened.citation.as_ref());
559
560 let target_author = state
563 .find_message(&target_id)
564 .and_then(|(_, m)| m.npub.clone())
565 .and_then(|n| PublicKey::parse(&n).ok());
566
567 if let Some(author) = target_author {
568 if author == deleter {
570 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
571 }
572 if pinned && !channel.dissolved && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES) {
578 return state.remove_message(&target_id).map(|_| IncomingEvent::Removed { target_id });
579 }
580 crate::log_debug!("[community] dropped delete: {deleter_hex} not authorized to remove {target_id}");
581 return None;
582 }
583
584 if let Ok(Some(author_npub)) = crate::db::events::event_author(&target_id) {
588 if let Ok(author) = PublicKey::parse(&author_npub) {
589 let ok = author == deleter
593 || (pinned
594 && !channel.dissolved
595 && channel.roster.can_act_on_member(&deleter_hex, owner_hex.as_deref(), &author.to_hex(), Permissions::MANAGE_MESSAGES));
596 if ok {
597 return Some(IncomingEvent::Removed { target_id });
598 }
599 crate::log_debug!("[community] dropped out-of-window delete: {deleter_hex} not authorized over {target_id}");
600 return None;
601 }
602 }
603
604 None
612}
613
614pub fn route_incoming(
620 state: &mut ChatState,
621 event: &Event,
622 routes: &std::collections::HashMap<String, Channel>,
623 my_pubkey: &PublicKey,
624) -> Option<IncomingEvent> {
625 let pseudonym = event.tags.iter().find_map(|t| {
626 let s = t.as_slice();
627 (s.len() >= 2 && s[0] == "z").then(|| s[1].clone())
628 })?;
629 let channel = routes.get(&pseudonym)?;
630 process_incoming(state, event, channel, my_pubkey)
631}
632
633#[cfg(test)]
634mod tests {
635 use super::*;
636 use crate::community::derive::channel_pseudonym;
637 use std::collections::HashMap;
638 use crate::community::envelope::{build_inner_full, build_inner_typed, open_message, seal_message, seal_with_signed_inner};
639 use crate::community::edition::AuthorityCitation;
640 use crate::community::{Channel, ChannelId, ChannelKey, Epoch};
641 use crate::state::ChatState;
642 use nostr_sdk::prelude::{Keys, Tag};
643
644 fn db_roster_channel(
650 owner: &Keys,
651 admin: &PublicKey,
652 ) -> (tempfile::TempDir, std::sync::MutexGuard<'static, ()>, Channel, AuthorityCitation) {
653 use crate::community::roles::{CommunityRoles, MemberGrant, Role};
654 use nostr_sdk::{JsonUtil, ToBech32};
655 let guard = crate::db::DB_TEST_GUARD.lock().unwrap_or_else(|e| e.into_inner());
656 crate::db::close_database();
657 let tmp = tempfile::tempdir().unwrap();
658 let account = owner.public_key().to_bech32().unwrap();
659 std::fs::create_dir_all(tmp.path().join(&account)).unwrap();
660 crate::db::set_app_data_dir(tmp.path().to_path_buf());
661 crate::db::set_current_account(account.clone()).unwrap();
662 crate::db::init_database(&account).unwrap();
663 crate::state::MY_SECRET_KEY.store_from_keys(owner, &[]);
664 crate::state::set_my_public_key(owner.public_key());
665
666 let mut community = crate::community::Community::create("HQ", "general", vec!["r".into()]);
667 let cid = community.id.to_hex();
668 community.owner_attestation = Some(
669 crate::community::owner::build_owner_attestation_unsigned(owner.public_key(), &cid)
670 .sign_with_keys(owner)
671 .unwrap()
672 .as_json(),
673 );
674 crate::db::community::save_community(&community).unwrap();
675
676 let role = Role::admin("a".repeat(64));
679 let roster = CommunityRoles {
680 grants: vec![MemberGrant { member: admin.to_hex(), role_ids: vec![role.role_id.clone()] }],
681 roles: vec![role],
682 };
683 crate::db::community::set_community_roles(&cid, &roster, 0).unwrap();
684 let entity_id = crate::community::derive::grant_locator(&community.id, &admin.to_bytes());
685 let entity_hex = crate::simd::hex::bytes_to_hex_32(&entity_id);
686 let hash = [0x5Au8; 32];
687 crate::db::community::set_edition_head(&cid, &entity_hex, 1, &hash).unwrap();
688
689 let reloaded = crate::db::community::load_community(&community.id).unwrap().unwrap();
690 let channel = reloaded.channels[0].clone();
691 (tmp, guard, channel, AuthorityCitation { entity_id, version: 1, edition_hash: hash })
692 }
693
694 fn seal_hide(channel: &Channel, author: &Keys, target: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
696 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
697 let inner = build_inner_full(
698 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_DELETE, "", ms, Some(target), &[], &extra,
699 )
700 .sign_with_keys(author)
701 .unwrap();
702 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
703 }
704
705 fn ingest_msg_in(state: &mut ChatState, channel: &Channel, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
707 let outer = seal_message(author, &channel.key, &channel.id, channel.epoch, content, ms).unwrap();
708 match process_incoming(state, &outer, channel, &viewer.public_key()) {
709 Some(IncomingEvent::NewMessage(m)) => m.id,
710 _ => panic!("expected a new message"),
711 }
712 }
713
714 fn opened_from(author: &Keys, content: &str, ms: u64) -> OpenedMessage {
715 let key = ChannelKey([0x33u8; 32]);
716 let chan = ChannelId([0x44u8; 32]);
717 let outer = seal_message(author, &key, &chan, Epoch(0), content, ms).unwrap();
718 open_message(&outer, &key, &chan, Epoch(0)).unwrap()
719 }
720
721 fn test_channel() -> Channel {
722 Channel { id: ChannelId([0x44u8; 32]), key: ChannelKey([0x33u8; 32]), epoch: Epoch(0), name: "t".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false }
723 }
724
725 fn seal_typed(author: &Keys, kind: u16, content: &str, ms: u64, target: &str) -> Event {
727 let c = test_channel();
728 let inner = build_inner_typed(author.public_key(), &c.id, c.epoch, kind, content, ms, Some(target), &[])
729 .sign_with_keys(author)
730 .unwrap();
731 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
732 }
733
734 fn ingest_msg(state: &mut ChatState, author: &Keys, content: &str, ms: u64, viewer: &Keys) -> String {
735 let c = test_channel();
736 let outer = seal_message(author, &c.key, &c.id, c.epoch, content, ms).unwrap();
737 match process_incoming(state, &outer, &c, &viewer.public_key()) {
738 Some(IncomingEvent::NewMessage(m)) => m.id,
739 _ => panic!("expected a new message"),
740 }
741 }
742
743 #[test]
744 fn inbound_reaction_applies_to_target_and_dedups() {
745 use crate::stored_event::event_kind;
746 let mut state = ChatState::new();
747 let alice = Keys::generate();
748 let bob = Keys::generate();
749 let target = ingest_msg(&mut state, &alice, "hi", 1, &bob);
750
751 let react = seal_typed(&bob, event_kind::COMMUNITY_REACTION, "🔥", 2, &target);
752 match process_incoming(&mut state, &react, &test_channel(), &bob.public_key()) {
753 Some(IncomingEvent::Updated { target_id, message, edit_event: None }) => {
754 assert_eq!(target_id, target);
755 assert!(message.reactions.iter().any(|r| r.emoji == "🔥"), "reaction applied to target");
756 }
757 _ => panic!("expected a reaction update"),
758 }
759 assert!(process_incoming(&mut state, &react, &test_channel(), &bob.public_key()).is_none());
761 }
762
763 #[test]
764 fn reaction_cross_channel_is_rejected() {
765 use crate::stored_event::event_kind;
768 use crate::community::envelope::{build_inner_typed, seal_with_signed_inner};
769 let mut state = ChatState::new();
770 let alice = Keys::generate();
771 let bob = Keys::generate();
772 let chan_a = test_channel();
773 let chan_b = Channel {
774 id: ChannelId([0x55u8; 32]), key: ChannelKey([0x66u8; 32]), epoch: Epoch(0),
775 name: "b".into(), banned: Vec::new(), protected: Vec::new(),
776 roster: Default::default(), epoch_keys: Vec::new(), dissolved: false,
777 };
778 let target = ingest_msg_in(&mut state, &chan_a, &alice, "hi", 1, &bob);
780 let inner = build_inner_typed(
782 bob.public_key(), &chan_b.id, chan_b.epoch, event_kind::COMMUNITY_REACTION, "🔥", 2, Some(&target), &[],
783 ).sign_with_keys(&bob).unwrap();
784 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &chan_b.key, &chan_b.id, chan_b.epoch).unwrap();
785 assert!(
787 process_incoming(&mut state, &outer, &chan_b, &bob.public_key()).is_none(),
788 "a reaction sealed under another channel must not apply to this channel's message"
789 );
790 let (_c, msg) = state.find_message(&target).unwrap();
791 assert!(msg.reactions.is_empty(), "cross-channel reaction must not be applied");
792 }
793
794 #[test]
795 fn inbound_message_carries_multi_attachments() {
796 use crate::stored_event::event_kind;
797 use crate::community::attachments::attachment_to_imeta;
798 use crate::community::envelope::build_inner_full;
799 use crate::types::Attachment;
800 let mut state = ChatState::new();
801 let alice = Keys::generate();
802 let bob = Keys::generate();
803 let c = test_channel();
804
805 let mk = |n: &str, ext: &str| Attachment {
806 id: "x".into(), key: "0".repeat(64), nonce: format!("{:0<24}", n),
807 extension: ext.into(), name: n.into(), url: format!("https://b/{n}"),
808 path: String::new(), size: 9, img_meta: None, downloading: false, downloaded: false,
809 webxdc_topic: None, group_id: None, original_hash: Some("a".repeat(64)),
810 scheme_version: None, mls_filename: None,
811 };
812 let imetas = vec![attachment_to_imeta(&mk("a.png", "png")), attachment_to_imeta(&mk("b.txt", "txt"))];
813 let inner = build_inner_full(
814 alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_MESSAGE,
815 "caption", 5, None, &[], &imetas,
816 ).sign_with_keys(&alice).unwrap();
817 let outer = seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap();
818
819 match process_incoming(&mut state, &outer, &c, &bob.public_key()) {
820 Some(IncomingEvent::NewMessage(m)) => {
821 assert_eq!(m.content, "caption", "caption + attachments coexist in one event");
822 assert_eq!(m.attachments.len(), 2);
823 assert_eq!(m.attachments[0].name, "a.png");
824 assert_eq!(m.attachments[1].name, "b.txt");
825 assert!(m.attachments.iter().all(|a| a.group_id.is_none()));
826 }
827 _ => panic!("expected new message with attachments"),
828 }
829 }
830
831 #[test]
832 fn inbound_edit_only_honored_from_original_author() {
833 use crate::stored_event::event_kind;
834 let mut state = ChatState::new();
835 let alice = Keys::generate();
836 let target = ingest_msg(&mut state, &alice, "original", 1, &alice);
837
838 let edit = seal_typed(&alice, event_kind::COMMUNITY_EDIT, "edited!", 2, &target);
840 match process_incoming(&mut state, &edit, &test_channel(), &alice.public_key()) {
841 Some(IncomingEvent::Updated { message, edit_event, .. }) => {
842 assert_eq!(message.content, "edited!");
843 assert!(message.edited);
844 let ev = edit_event.expect("edit surfaces a MESSAGE_EDIT event to persist");
846 assert_eq!(ev.kind, event_kind::MESSAGE_EDIT);
847 assert_eq!(ev.reference_id.as_deref(), Some(target.as_str()));
848 assert_eq!(ev.content, "edited!");
849 }
850 _ => panic!("expected an edit update"),
851 }
852
853 let mallory = Keys::generate();
855 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_EDIT, "hijacked", 3, &target);
856 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
857 assert_eq!(state.find_message(&target).unwrap().1.content, "edited!");
858 }
859
860 #[test]
861 fn cooperative_delete_only_honored_from_original_author() {
862 use crate::stored_event::event_kind;
863 let mut state = ChatState::new();
864 let alice = Keys::generate();
865 let mallory = Keys::generate();
866 let target = ingest_msg(&mut state, &alice, "secret", 1, &alice);
867
868 let hijack = seal_typed(&mallory, event_kind::COMMUNITY_DELETE, "", 2, &target);
870 assert!(process_incoming(&mut state, &hijack, &test_channel(), &alice.public_key()).is_none());
871 assert!(state.find_message(&target).is_some(), "non-author delete must not remove");
872
873 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 3, &target);
875 match process_incoming(&mut state, &del, &test_channel(), &alice.public_key()) {
876 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
877 _ => panic!("expected a removal"),
878 }
879 assert!(state.find_message(&target).is_none(), "message gone after author delete");
880
881 let replay = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 4, &target);
883 assert!(process_incoming(&mut state, &replay, &test_channel(), &alice.public_key()).is_none());
884 }
885
886 #[test]
887 fn dissolved_community_still_honors_an_own_message_delete() {
888 use crate::stored_event::event_kind;
889 let mut state = ChatState::new();
890 let alice = Keys::generate();
891 let target = ingest_msg(&mut state, &alice, "alice's own message", 1, &alice);
892 let mut ch = test_channel();
893 ch.dissolved = true;
894 let del = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &target);
897 match process_incoming(&mut state, &del, &ch, &alice.public_key()) {
898 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
899 _ => panic!("a self-delete must be honored in a dissolved community"),
900 }
901 assert!(state.find_message(&target).is_none(), "own message scrubbed from the dead community");
902 }
903
904 #[test]
905 fn admin_moderation_hide_removes_any_message() {
906 let owner = Keys::generate();
907 let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
909 let alice = Keys::generate(); let mallory = Keys::generate(); let mut state = ChatState::new();
912 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
913
914 let hijack = seal_hide(&c, &mallory, &target, 2, None);
916 assert!(process_incoming(&mut state, &hijack, &c, &alice.public_key()).is_none());
917 assert!(state.find_message(&target).is_some(), "unprivileged hide rejected");
918
919 let hide = seal_hide(&c, &admin, &target, 3, Some(&cite));
921 match process_incoming(&mut state, &hide, &c, &alice.public_key()) {
922 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
923 _ => panic!("expected admin moderation-hide to remove the message"),
924 }
925 assert!(state.find_message(&target).is_none(), "admin hide removed the message");
926 }
927
928 #[test]
929 fn admin_hide_without_a_citation_is_dropped() {
930 let owner = Keys::generate();
934 let admin = Keys::generate();
935 let (_tmp, _guard, c, _cite) = db_roster_channel(&owner, &admin.public_key());
936 let alice = Keys::generate();
937 let mut state = ChatState::new();
938 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
939
940 let hide = seal_hide(&c, &admin, &target, 2, None); assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
942 assert!(state.find_message(&target).is_some(), "an uncited admin hide is dropped");
943 }
944
945 #[test]
946 fn hide_citing_an_unsynced_grant_version_is_dropped() {
947 let owner = Keys::generate();
951 let admin = Keys::generate();
952 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
953 let alice = Keys::generate();
954 let mut state = ChatState::new();
955 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
956
957 let ahead = AuthorityCitation { version: 2, ..cite };
959 let hide = seal_hide(&c, &admin, &target, 2, Some(&ahead));
960 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
961 assert!(state.find_message(&target).is_some(), "a hide citing an unsynced version is dropped");
962 }
963
964 #[test]
965 fn hide_with_a_forged_citation_hash_is_dropped() {
966 let owner = Keys::generate();
968 let admin = Keys::generate();
969 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
970 let alice = Keys::generate();
971 let mut state = ChatState::new();
972 let target = ingest_msg_in(&mut state, &c, &alice, "spicy take", 1, &alice);
973
974 let forged = AuthorityCitation { edition_hash: [0xEE; 32], ..cite };
975 let hide = seal_hide(&c, &admin, &target, 2, Some(&forged));
976 assert!(process_incoming(&mut state, &hide, &c, &alice.public_key()).is_none());
977 assert!(state.find_message(&target).is_some(), "a forged-hash citation is dropped");
978 }
979
980 #[test]
981 fn protected_owner_cannot_be_moderation_hidden_but_others_can() {
982 let owner = Keys::generate(); let admin = Keys::generate(); let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
985 let mut state = ChatState::new();
986
987 let owners_msg = ingest_msg_in(&mut state, &c, &owner, "owner speaks", 1, &owner);
989 let hide_owner = seal_hide(&c, &admin, &owners_msg, 2, Some(&cite));
990 assert!(process_incoming(&mut state, &hide_owner, &c, &owner.public_key()).is_none());
991 assert!(state.find_message(&owners_msg).is_some(), "owner's message is protected");
992
993 let member = Keys::generate();
995 let members_msg = ingest_msg_in(&mut state, &c, &member, "member speaks", 3, &owner);
996 let hide_member = seal_hide(&c, &admin, &members_msg, 4, Some(&cite));
997 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
998 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, members_msg),
999 _ => panic!("a non-protected member's message should be hideable"),
1000 }
1001 }
1002
1003 #[test]
1004 fn admin_hide_of_absent_target_defers_until_resident() {
1005 let owner = Keys::generate();
1013 let admin = Keys::generate();
1014 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1015 let mut state = ChatState::new();
1016 let absent_target = "f".repeat(64); let hide = seal_hide(&c, &admin, &absent_target, 1, Some(&cite));
1019 assert!(
1020 process_incoming(&mut state, &hide, &c, &Keys::generate().public_key()).is_none(),
1021 "a hide of an absent target defers (None) rather than falsely tombstoning + self-deduping",
1022 );
1023
1024 let mallory = Keys::generate();
1026 let hijack = seal_hide(&c, &mallory, &absent_target, 2, None);
1027 assert!(process_incoming(&mut state, &hijack, &c, &mallory.public_key()).is_none());
1028
1029 let uncited = seal_hide(&c, &admin, &absent_target, 3, None);
1032 assert!(
1033 process_incoming(&mut state, &uncited, &c, &Keys::generate().public_key()).is_none(),
1034 "an admin's uncited hide of an unknown target is dropped (pinned gates the author-unknown path)"
1035 );
1036 }
1037
1038 #[tokio::test]
1039 async fn out_of_window_hide_authorizes_against_db_author() {
1040 use crate::types::Message;
1041 use nostr_sdk::ToBech32;
1045 let owner = Keys::generate();
1046 let admin = Keys::generate(); let member = Keys::generate();
1048 let (_tmp, _guard, c, cite) = db_roster_channel(&owner, &admin.public_key());
1049
1050 let owner_msg = "a".repeat(64);
1052 let member_msg = "b".repeat(64);
1053 let mk = |id: &str, author: &Keys, at: u64| {
1054 let mut m = Message::default();
1055 m.id = id.to_string();
1056 m.npub = Some(author.public_key().to_bech32().unwrap());
1057 m.at = at;
1058 m
1059 };
1060 crate::db::events::save_message("chatoow", &mk(&owner_msg, &owner, 1)).await.unwrap();
1061 crate::db::events::save_message("chatoow", &mk(&member_msg, &member, 2)).await.unwrap();
1062
1063 let mut state = ChatState::new();
1064 let hide_owner = seal_hide(&c, &admin, &owner_msg, 3, Some(&cite));
1066 assert!(
1067 process_incoming(&mut state, &hide_owner, &c, &member.public_key()).is_none(),
1068 "owner's paged-out message must not be hideable by an admin"
1069 );
1070 let hide_member = seal_hide(&c, &admin, &member_msg, 4, Some(&cite));
1072 match process_incoming(&mut state, &hide_member, &c, &owner.public_key()) {
1073 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg),
1074 _ => panic!("admin should hide a member's paged-out message"),
1075 }
1076
1077 let member_msg2 = "c".repeat(64);
1081 crate::db::events::save_message("chatoow", &mk(&member_msg2, &member, 5)).await.unwrap();
1082 let mut sealed = c.clone();
1083 sealed.dissolved = true;
1084 let hide_sealed = seal_hide(&sealed, &admin, &member_msg2, 6, Some(&cite));
1085 assert!(
1086 process_incoming(&mut state, &hide_sealed, &sealed, &owner.public_key()).is_none(),
1087 "a dissolved community accepts no moderation-hide, resident or paged-out"
1088 );
1089 let self_del = seal_hide(&sealed, &member, &member_msg2, 7, None);
1090 match process_incoming(&mut state, &self_del, &sealed, &owner.public_key()) {
1091 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, member_msg2),
1092 _ => panic!("a self-delete of a paged-out message must survive the dissolved seal"),
1093 }
1094 crate::db::close_database();
1095 }
1096
1097 fn seal_kick(channel: &Channel, author: &Keys, target_hex: &str, ms: u64, citation: Option<&AuthorityCitation>) -> Event {
1099 let extra: Vec<Tag> = citation.iter().map(|c| c.to_tag()).collect();
1100 let inner = build_inner_full(
1101 author.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_KICK, target_hex, ms, None, &[], &extra,
1102 )
1103 .sign_with_keys(author)
1104 .unwrap();
1105 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1106 }
1107
1108 fn post_join_ms() -> u64 {
1111 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1112 (now + 5) * 1000
1113 }
1114
1115 #[test]
1116 fn cited_admin_kick_of_local_user_yields_self_removal() {
1117 let owner = Keys::generate();
1118 let admin = Keys::generate();
1119 let member = Keys::generate();
1120 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1121 let mut state = ChatState::new();
1122 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1124 match process_incoming(&mut state, &kick, &channel, &member.public_key()) {
1125 Some(IncomingEvent::Kicked { community_id }) => assert!(!community_id.is_empty()),
1126 _ => panic!("expected Kicked"),
1127 }
1128 crate::db::close_database();
1129 }
1130
1131 #[test]
1132 fn cited_admin_kick_of_other_member_is_a_leave() {
1133 use nostr_sdk::ToBech32;
1134 let owner = Keys::generate();
1135 let admin = Keys::generate();
1136 let member = Keys::generate();
1137 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1138 let mut state = ChatState::new();
1139 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), post_join_ms(), Some(&cite));
1142 match process_incoming(&mut state, &kick, &channel, &owner.public_key()) {
1143 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1144 assert!(!joined);
1145 assert_eq!(npub, member.public_key().to_bech32().unwrap());
1146 }
1147 _ => panic!("expected leave Presence"),
1148 }
1149 crate::db::close_database();
1150 }
1151
1152 #[test]
1153 fn uncited_kick_is_dropped() {
1154 let owner = Keys::generate();
1155 let admin = Keys::generate();
1156 let member = Keys::generate();
1157 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1158 let mut state = ChatState::new();
1159 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, None);
1160 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1161 "a non-owner kick without a citation is dropped");
1162 crate::db::close_database();
1163 }
1164
1165 #[test]
1166 fn unprivileged_kick_is_dropped() {
1167 let owner = Keys::generate();
1168 let admin = Keys::generate();
1169 let mallory = Keys::generate();
1170 let member = Keys::generate();
1171 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1172 let mut state = ChatState::new();
1173 let kick = seal_kick(&channel, &mallory, &member.public_key().to_hex(), 1, Some(&cite));
1176 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1177 "a kick from an unranked actor is dropped");
1178 crate::db::close_database();
1179 }
1180
1181 #[test]
1182 fn kick_of_owner_is_dropped() {
1183 let owner = Keys::generate();
1184 let admin = Keys::generate();
1185 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1186 let mut state = ChatState::new();
1187 let kick = seal_kick(&channel, &admin, &owner.public_key().to_hex(), post_join_ms(), Some(&cite));
1189 assert!(process_incoming(&mut state, &kick, &channel, &owner.public_key()).is_none(),
1190 "an admin cannot kick the owner");
1191 crate::db::close_database();
1192 }
1193
1194 #[test]
1195 fn stale_kick_predating_join_is_dropped() {
1196 let owner = Keys::generate();
1197 let admin = Keys::generate();
1198 let member = Keys::generate();
1199 let (_tmp, _g, channel, cite) = db_roster_channel(&owner, &admin.public_key());
1200 let mut state = ChatState::new();
1201 let kick = seal_kick(&channel, &admin, &member.public_key().to_hex(), 1, Some(&cite));
1204 assert!(process_incoming(&mut state, &kick, &channel, &member.public_key()).is_none(),
1205 "a kick older than the current join is dropped");
1206 crate::db::close_database();
1207 }
1208
1209 #[test]
1210 fn webxdc_signals_parse_ad_and_left_and_reject_garbage() {
1211 use crate::stored_event::event_kind;
1212 use nostr_sdk::ToBech32;
1213 let mut state = ChatState::new();
1214 let alice = Keys::generate();
1215 let c = test_channel();
1216 let viewer = Keys::generate();
1217 let mk = |content: &str, ms: u64| {
1218 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_WEBXDC, content, ms, None, &[])
1219 .sign_with_keys(&alice).unwrap();
1220 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1221 };
1222 let topic = crate::webxdc::mint_topic_id("game-hash", "sender");
1223
1224 let ad = serde_json::json!({ "op": "ad", "topic": topic, "addr": "BASE32NODEADDR" }).to_string();
1226 match process_incoming(&mut state, &mk(&ad, 1), &c, &viewer.public_key()) {
1227 Some(IncomingEvent::WebxdcPeer { npub, topic_id, node_addr, .. }) => {
1228 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "player is the inner author");
1229 assert_eq!(topic_id, topic);
1230 assert_eq!(node_addr.as_deref(), Some("BASE32NODEADDR"));
1231 }
1232 _ => panic!("expected a webxdc advertisement"),
1233 }
1234
1235 let left = serde_json::json!({ "op": "left", "topic": topic }).to_string();
1237 match process_incoming(&mut state, &mk(&left, 2), &c, &viewer.public_key()) {
1238 Some(IncomingEvent::WebxdcPeer { node_addr, .. }) => {
1239 assert!(node_addr.is_none(), "peer-left carries no addr");
1240 }
1241 _ => panic!("expected a webxdc peer-left"),
1242 }
1243
1244 assert!(
1246 process_incoming(&mut state, &mk(&ad, 3), &c, &alice.public_key()).is_none(),
1247 "own webxdc signal must be ignored"
1248 );
1249
1250 let bad_topic = serde_json::json!({ "op": "ad", "topic": "../../etc", "addr": "X" }).to_string();
1252 assert!(process_incoming(&mut state, &mk(&bad_topic, 4), &c, &viewer.public_key()).is_none());
1253 let bad_op = serde_json::json!({ "op": "explode", "topic": topic }).to_string();
1254 assert!(process_incoming(&mut state, &mk(&bad_op, 5), &c, &viewer.public_key()).is_none());
1255 let no_addr = serde_json::json!({ "op": "ad", "topic": topic }).to_string();
1256 assert!(process_incoming(&mut state, &mk(&no_addr, 6), &c, &viewer.public_key()).is_none());
1257 assert!(process_incoming(&mut state, &mk("not json", 7), &c, &viewer.public_key()).is_none());
1258 }
1259
1260 #[test]
1261 fn typing_indicator_parses_drops_own_echo_and_rejects_garbage() {
1262 use crate::stored_event::event_kind;
1263 use nostr_sdk::ToBech32;
1264 let mut state = ChatState::new();
1265 let alice = Keys::generate();
1266 let c = test_channel();
1267 let viewer = Keys::generate();
1268 let mk = |content: &str, ms: u64| {
1269 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_TYPING, content, ms, None, &[])
1270 .sign_with_keys(&alice).unwrap();
1271 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1272 };
1273 let now = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_secs();
1274
1275 match process_incoming(&mut state, &mk("typing", 1), &c, &viewer.public_key()) {
1278 Some(IncomingEvent::Typing { npub, until }) => {
1279 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "typer is the inner author");
1280 assert!(until >= now && until <= now + 31, "until is receiver-computed (~now + 30s)");
1281 }
1282 _ => panic!("expected a typing indicator"),
1283 }
1284
1285 assert!(
1287 process_incoming(&mut state, &mk("typing", 2), &c, &alice.public_key()).is_none(),
1288 "own typing signal must be ignored"
1289 );
1290
1291 assert!(process_incoming(&mut state, &mk("nope", 3), &c, &viewer.public_key()).is_none());
1293 }
1294
1295 #[test]
1296 fn presence_announcements_parse_join_and_leave() {
1297 use crate::stored_event::event_kind;
1298 let mut state = ChatState::new();
1299 let alice = Keys::generate();
1300 let c = test_channel();
1301 let viewer = Keys::generate();
1302 let mk = |content: &str, ms: u64| {
1303 let inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, content, ms, None, &[])
1304 .sign_with_keys(&alice).unwrap();
1305 seal_with_signed_inner(&Keys::generate(), &inner, &c.key, &c.id, c.epoch).unwrap()
1306 };
1307 match process_incoming(&mut state, &mk("join", 1), &c, &viewer.public_key()) {
1308 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1309 assert!(joined, "content 'join' → joined");
1310 assert_eq!(npub, alice.public_key().to_bech32().unwrap(), "announcer is the inner author");
1311 }
1312 _ => panic!("expected a join presence"),
1313 }
1314 match process_incoming(&mut state, &mk("leave", 2), &c, &viewer.public_key()) {
1315 Some(IncomingEvent::Presence { joined, invited_by, .. }) => {
1316 assert!(!joined, "content 'leave' → not joined");
1317 assert!(invited_by.is_none(), "a plain leave carries no attribution");
1318 }
1319 _ => panic!("expected a leave presence"),
1320 }
1321 let jean = Keys::generate().public_key().to_bech32().unwrap();
1324 let attributed = serde_json::json!({ "by": jean, "l": "Reddit" }).to_string();
1325 match process_incoming(&mut state, &mk(&attributed, 3), &c, &viewer.public_key()) {
1326 Some(IncomingEvent::Presence { joined, invited_by, invited_label, .. }) => {
1327 assert!(joined, "an attributed-join JSON is still a join");
1328 assert_eq!(invited_by.as_deref(), Some(jean.as_str()), "valid inviter npub surfaced");
1329 assert_eq!(invited_label.as_deref(), Some("Reddit"), "link label surfaced");
1330 }
1331 _ => panic!("expected an attributed join presence"),
1332 }
1333 let forged = serde_json::json!({ "by": "haha not an npub", "l": "x" }).to_string();
1335 match process_incoming(&mut state, &mk(&forged, 4), &c, &viewer.public_key()) {
1336 Some(IncomingEvent::Presence { invited_by, .. }) => assert!(invited_by.is_none(), "forged inviter dropped"),
1337 _ => panic!("expected a join presence"),
1338 }
1339 }
1340
1341 #[test]
1342 fn leave_presence_authored_by_local_npub_yields_self_left() {
1343 use crate::stored_event::event_kind;
1344 let owner = Keys::generate();
1347 let admin = Keys::generate();
1348 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1349 let mut state = ChatState::new();
1350 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1353 let cid_bytes = crate::community::CommunityId(crate::simd::hex::hex_to_bytes_32(&cid));
1354 let leave_ms = crate::db::community::community_created_at_ms(&cid_bytes).unwrap_or(0) + 10_000;
1355 let leave = {
1356 let inner = build_inner_typed(owner.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", leave_ms, None, &[])
1357 .sign_with_keys(&owner).unwrap();
1358 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1359 };
1360 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1361 Some(IncomingEvent::SelfLeft { community_id }) => assert!(!community_id.is_empty()),
1362 _ => panic!("expected SelfLeft"),
1363 }
1364 crate::db::close_database();
1365 }
1366
1367 #[test]
1368 fn leave_presence_authored_by_another_npub_stays_a_plain_leave() {
1369 use crate::stored_event::event_kind;
1370 use nostr_sdk::ToBech32;
1371 let owner = Keys::generate();
1373 let admin = Keys::generate();
1374 let other = Keys::generate();
1375 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1376 let mut state = ChatState::new();
1377 let leave = {
1378 let inner = build_inner_typed(other.public_key(), &channel.id, channel.epoch, event_kind::COMMUNITY_PRESENCE, "leave", 2, None, &[])
1379 .sign_with_keys(&other).unwrap();
1380 seal_with_signed_inner(&Keys::generate(), &inner, &channel.key, &channel.id, channel.epoch).unwrap()
1381 };
1382 match process_incoming(&mut state, &leave, &channel, &owner.public_key()) {
1384 Some(IncomingEvent::Presence { npub, joined, .. }) => {
1385 assert!(!joined);
1386 assert_eq!(npub, other.public_key().to_bech32().unwrap());
1387 }
1388 _ => panic!("expected plain leave Presence"),
1389 }
1390 crate::db::close_database();
1391 }
1392
1393 #[test]
1394 fn self_delete_still_applies_after_keep_keys_teardown() {
1395 let owner = Keys::generate();
1398 let admin = Keys::generate();
1399 let (_tmp, _g, channel, _cite) = db_roster_channel(&owner, &admin.public_key());
1400 let cid = crate::db::community::community_id_for_channel(&channel.id.to_hex()).unwrap().unwrap();
1401 let chan_hex = channel.id.to_hex();
1402 let epoch = channel.epoch.0;
1403
1404 let mut state = ChatState::new();
1406 let target = ingest_msg_in(&mut state, &channel, &owner, "mine", 1, &owner);
1407
1408 crate::db::community::delete_community_retain_keys(&cid).unwrap();
1410 let retained = crate::db::community::held_epoch_key(&cid, &chan_hex, epoch).unwrap()
1411 .expect("epoch key retained after keep-keys teardown");
1412 let mut rebuilt = channel.clone();
1413 rebuilt.key = ChannelKey(retained);
1414 rebuilt.epoch = Epoch(epoch);
1415
1416 let del = seal_hide(&rebuilt, &owner, &target, 2, None);
1418 match process_incoming(&mut state, &del, &rebuilt, &owner.public_key()) {
1419 Some(IncomingEvent::Removed { target_id }) => assert_eq!(target_id, target),
1420 _ => panic!("expected the self-delete to apply under the retained key"),
1421 }
1422 crate::db::close_database();
1423 }
1424
1425 #[test]
1426 fn banned_author_events_are_dropped_including_presence() {
1427 use crate::stored_event::event_kind;
1428 let mut state = ChatState::new();
1429 let alice = Keys::generate(); let bob = Keys::generate();
1431 let mut c = test_channel();
1432 c.banned = vec![alice.public_key()];
1433
1434 let spam = seal_message(&alice, &c.key, &c.id, c.epoch, "spam", 1).unwrap();
1436 assert!(process_incoming(&mut state, &spam, &c, &bob.public_key()).is_none(), "banned message dropped");
1437
1438 let pres_inner = build_inner_typed(alice.public_key(), &c.id, c.epoch, event_kind::COMMUNITY_PRESENCE, "join", 2, None, &[])
1440 .sign_with_keys(&alice).unwrap();
1441 let pres = seal_with_signed_inner(&Keys::generate(), &pres_inner, &c.key, &c.id, c.epoch).unwrap();
1442 assert!(process_incoming(&mut state, &pres, &c, &bob.public_key()).is_none(), "banned presence dropped");
1443
1444 let ok = seal_message(&bob, &c.key, &c.id, c.epoch, "hi", 3).unwrap();
1446 assert!(matches!(process_incoming(&mut state, &ok, &c, &bob.public_key()), Some(IncomingEvent::NewMessage(_))), "non-banned applied");
1447 }
1448
1449 #[test]
1450 fn cooperative_delete_applies_after_message_in_batch_order() {
1451 use crate::stored_event::event_kind;
1452 let mut state = ChatState::new();
1453 let alice = Keys::generate();
1454 let c = test_channel();
1455
1456 let msg_outer = seal_message(&alice, &c.key, &c.id, c.epoch, "bye", 1).unwrap();
1460 let opened = open_message(&msg_outer, &c.key, &c.id, c.epoch).unwrap();
1461 let inner_id = opened.message_id.to_hex();
1462 let del_outer = seal_typed(&alice, event_kind::COMMUNITY_DELETE, "", 2, &inner_id);
1463
1464 let applied = process_channel_batch(&mut state, &[del_outer, msg_outer], &c, &alice.public_key());
1465 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::NewMessage(_))));
1466 assert!(applied.iter().any(|e| matches!(e, IncomingEvent::Removed { .. })));
1467 assert!(state.find_message(&inner_id).is_none(), "delete applied despite arriving first");
1468 }
1469
1470 #[test]
1471 fn build_message_sets_mine_and_author() {
1472 let me = Keys::generate();
1473 let opened = opened_from(&me, "hello", 4242);
1474 let msg = build_message(&opened, &me.public_key());
1475 assert_eq!(msg.content, "hello");
1476 assert_eq!(msg.at, 4242);
1477 assert!(msg.mine, "author == me → mine");
1478 assert_eq!(msg.npub, me.public_key().to_bech32().ok());
1479 assert_eq!(msg.id, opened.message_id.to_hex());
1480
1481 let other_view = build_message(&opened, &Keys::generate().public_key());
1483 assert!(!other_view.mine);
1484 }
1485
1486 #[test]
1487 fn ingest_creates_community_chat_and_adds_message() {
1488 let mut state = ChatState::new();
1489 let alice = Keys::generate();
1490 let opened = opened_from(&alice, "gm", 1);
1491
1492 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some());
1493 let chat = state.chats.iter().find(|c| c.id == opened.channel_id.to_hex()).expect("chat");
1495 assert!(chat.is_community(), "channel chat must be ChatType::Community");
1496 }
1497
1498 #[test]
1499 fn process_incoming_ingests_valid_drops_foreign() {
1500 let mut state = ChatState::new();
1501 let alice = Keys::generate();
1502 let key = ChannelKey([0x33u8; 32]);
1503 let chan = ChannelId([0x44u8; 32]);
1504 let channel = Channel { id: chan, key: key.clone(), epoch: Epoch(0), name: "g".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false };
1505
1506 let outer = seal_message(&alice, &key, &chan, Epoch(0), "real", 1).unwrap();
1508 assert!(process_incoming(&mut state, &outer, &channel, &alice.public_key()).is_some());
1509 assert!(state.chats.iter().any(|c| c.is_community()));
1510
1511 let other_key = ChannelKey([0x99u8; 32]);
1513 let other_chan = ChannelId([0xaau8; 32]);
1514 let foreign = seal_message(&alice, &other_key, &other_chan, Epoch(0), "nope", 1).unwrap();
1515 assert!(process_incoming(&mut state, &foreign, &channel, &alice.public_key()).is_none());
1516 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1517 }
1518
1519 #[test]
1520 fn ingest_dedups_on_message_id() {
1521 let mut state = ChatState::new();
1522 let alice = Keys::generate();
1523 let opened = opened_from(&alice, "once", 1);
1524
1525 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_some(), "first add");
1526 assert!(ingest_message(&mut state, &opened, &alice.public_key()).is_none(), "duplicate not re-added");
1527 assert_eq!(state.chats.iter().filter(|c| c.is_community()).count(), 1);
1529 }
1530
1531 #[test]
1532 fn dedup_keys_on_inner_id_across_distinct_outer_events() {
1533 let mut state = ChatState::new();
1539 let alice = Keys::generate();
1540 let key = ChannelKey([0x33u8; 32]);
1541 let chan = ChannelId([0x44u8; 32]);
1542 let channel = Channel { id: chan, key: key.clone(), epoch: Epoch(0), name: "g".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false };
1543
1544 let outer_a = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1545 let outer_b = seal_message(&alice, &key, &chan, Epoch(0), "dup", 7).unwrap();
1546 assert_ne!(outer_a.id, outer_b.id, "distinct outer events (fresh ephemeral + nonce)");
1547
1548 assert!(process_incoming(&mut state, &outer_a, &channel, &alice.public_key()).is_some());
1549 assert!(
1550 process_incoming(&mut state, &outer_b, &channel, &alice.public_key()).is_none(),
1551 "same inner message id must dedup despite a different outer event"
1552 );
1553 }
1554
1555 #[test]
1556 fn route_incoming_routes_by_pseudonym() {
1557 let mut state = ChatState::new();
1558 let alice = Keys::generate();
1559 let key = ChannelKey([0x33u8; 32]);
1560 let chan = ChannelId([0x44u8; 32]);
1561 let channel = Channel { id: chan, key: key.clone(), epoch: Epoch(0), name: "g".into(), banned: Vec::new(), protected: Vec::new(), roster: Default::default(), epoch_keys: Vec::new(), dissolved: false };
1562
1563 let mut routes = HashMap::new();
1565 routes.insert(channel_pseudonym(&key, &chan, Epoch(0)).to_hex(), channel.clone());
1566
1567 let outer = seal_message(&alice, &key, &chan, Epoch(0), "routed", 1).unwrap();
1569 assert!(route_incoming(&mut state, &outer, &routes, &alice.public_key()).is_some());
1570
1571 let other_key = ChannelKey([0x55u8; 32]);
1573 let other_chan = ChannelId([0x66u8; 32]);
1574 let unrouted = seal_message(&alice, &other_key, &other_chan, Epoch(0), "x", 1).unwrap();
1575 assert!(route_incoming(&mut state, &unrouted, &routes, &alice.public_key()).is_none());
1576 }
1577
1578 #[test]
1579 fn ms_none_falls_back_to_created_at() {
1580 use nostr_sdk::prelude::{EventId, Timestamp, Tags};
1582 let author = Keys::generate();
1583 let opened = OpenedMessage {
1584 message_id: EventId::all_zeros(),
1585 author: author.public_key(),
1586 content: "no ms".into(),
1587 channel_id: ChannelId([1u8; 32]),
1588 epoch: Epoch(0),
1589 ms: None,
1590 created_at: Timestamp::from_secs(1500),
1591 kind: 3300,
1592 attachments: vec![],
1593 citation: None,
1594 wrapper_id: EventId::all_zeros(),
1595 tags: Tags::new(),
1596 };
1597 assert_eq!(build_message(&opened, &author.public_key()).at, 1_500_000);
1598 }
1599}